feat: complete account-bound profile switching and hardening (#78)
This commit is contained in:
31 files changed
+3612
-197
No files matched your search
@@ -18,7 +18,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
import { MoreVertical, Trash, RefreshCw, Box, Power } from 'lucide-react';
|
||||
import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint } from 'lucide-react';
|
||||
import { formatDistanceToNow, differenceInMinutes, differenceInHours, isBefore } from 'date-fns';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -28,6 +28,7 @@ interface CloudAccountCardProps {
|
||||
onRefresh: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onSwitch: (id: string) => void;
|
||||
onManageIdentity: (id: string) => void;
|
||||
isSelected?: boolean;
|
||||
onToggleSelection?: (id: string, selected: boolean) => void;
|
||||
isRefreshing?: boolean;
|
||||
@@ -40,6 +41,7 @@ export function CloudAccountCard({
|
||||
onRefresh,
|
||||
onDelete,
|
||||
onSwitch,
|
||||
onManageIdentity,
|
||||
isSelected = false,
|
||||
onToggleSelection,
|
||||
isRefreshing,
|
||||
@@ -59,6 +61,16 @@ export function CloudAccountCard({
|
||||
return 'text-red-500';
|
||||
};
|
||||
|
||||
const getQuotaBarColor = (percentage: number) => {
|
||||
if (percentage > 80) {
|
||||
return 'bg-emerald-500';
|
||||
}
|
||||
if (percentage > 20) {
|
||||
return 'bg-amber-500';
|
||||
}
|
||||
return 'bg-rose-500';
|
||||
};
|
||||
|
||||
const formatTimeRemaining = (dateStr: string) => {
|
||||
const targetDate = new Date(dateStr);
|
||||
if (Number.isNaN(targetDate.getTime())) {
|
||||
@@ -112,13 +124,13 @@ export function CloudAccountCard({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`flex flex-col overflow-hidden transition-all ${isSelected ? 'ring-primary border-primary/50 ring-2' : 'hover:border-primary/50'}`}
|
||||
className={`group flex h-full flex-col overflow-hidden border bg-card transition-all duration-200 hover:border-primary/40 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
|
||||
>
|
||||
<CardHeader className="group relative flex flex-row items-center gap-4 space-y-0 pb-2">
|
||||
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-2">
|
||||
{/* Selection Checkbox - Visible on hover or selected */}
|
||||
{onToggleSelection && (
|
||||
<div
|
||||
className={`absolute top-2 left-2 z-10 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} from-background rounded-full bg-gradient-to-br to-transparent p-2 transition-opacity`}
|
||||
className={`absolute top-2 left-2 z-10 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} rounded-full bg-background/90 p-2 transition-opacity`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
@@ -135,7 +147,7 @@ export function CloudAccountCard({
|
||||
className="bg-muted h-10 w-10 rounded-full border"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-full">
|
||||
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-full border">
|
||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||
</div>
|
||||
)}
|
||||
@@ -147,7 +159,7 @@ export function CloudAccountCard({
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Menu</span>
|
||||
</Button>
|
||||
@@ -164,6 +176,11 @@ export function CloudAccountCard({
|
||||
{t('cloud.card.refresh')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}>
|
||||
<Fingerprint className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.identityProfile')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(account.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
@@ -208,6 +225,7 @@ export function CloudAccountCard({
|
||||
size="sm"
|
||||
onClick={() => onSwitch(account.id)}
|
||||
disabled={isSwitching}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{isSwitching ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
@@ -224,7 +242,7 @@ export function CloudAccountCard({
|
||||
modelQuotas.map(([modelName, info]) => (
|
||||
<div
|
||||
key={modelName}
|
||||
className="hover:bg-muted/40 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-md px-1.5 py-1 text-sm transition-colors"
|
||||
className="hover:bg-muted/60 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-2 text-sm transition-colors hover:border-border/60"
|
||||
>
|
||||
<span className="text-muted-foreground min-w-0 truncate" title={modelName}>
|
||||
{modelName.replace('models/', '')}
|
||||
@@ -236,7 +254,7 @@ export function CloudAccountCard({
|
||||
>
|
||||
{getResetTimeLabel(info.resetTime)}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={`font-mono leading-none font-bold ${getQuotaColor(info.percentage)}`}
|
||||
>
|
||||
@@ -246,6 +264,12 @@ export function CloudAccountCard({
|
||||
{t('cloud.card.left')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted h-1.5 w-24 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColor(info.percentage)}`}
|
||||
style={{ width: `${Math.max(0, Math.min(100, info.percentage))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -258,7 +282,7 @@ export function CloudAccountCard({
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="bg-muted/50 text-muted-foreground justify-between p-2 px-4 text-xs">
|
||||
<CardFooter className="bg-muted/20 text-muted-foreground justify-between border-t p-2 px-4 text-xs">
|
||||
<span>
|
||||
{t('cloud.card.used')}{' '}
|
||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||
|
||||
+143
-102
@@ -11,6 +11,7 @@ import {
|
||||
startAuthFlow,
|
||||
} from '@/hooks/useCloudAccounts';
|
||||
import { CloudAccountCard } from '@/components/CloudAccountCard';
|
||||
import { IdentityProfileDialog } from '@/components/IdentityProfileDialog';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -62,6 +63,11 @@ export function CloudAccountList() {
|
||||
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [authCode, setAuthCode] = useState('');
|
||||
const [identityAccount, setIdentityAccount] = useState<CloudAccount | null>(null);
|
||||
const totalAccounts = accounts?.length || 0;
|
||||
const activeAccounts = accounts?.filter((account) => account.is_active).length || 0;
|
||||
const rateLimitedAccounts =
|
||||
accounts?.filter((account) => account.status === 'rate_limited').length || 0;
|
||||
|
||||
const handleAddAccount = (codeVal?: string) => {
|
||||
const codeToUse = codeVal || authCode;
|
||||
@@ -161,6 +167,11 @@ export function CloudAccountList() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageIdentity = (id: string) => {
|
||||
const target = (accounts || []).find((item) => item.id === id) || null;
|
||||
setIdentityAccount(target);
|
||||
};
|
||||
|
||||
const handleToggleAutoSwitch = (checked: boolean) => {
|
||||
setAutoSwitchMutation.mutate(
|
||||
{ enabled: checked },
|
||||
@@ -290,110 +301,128 @@ export function CloudAccountList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative space-y-6 pb-20">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('cloud.title')}</h2>
|
||||
<p className="text-muted-foreground">{t('cloud.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="bg-muted/20 flex items-center gap-2 rounded-md border p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={`h-4 w-4 ${autoSwitchEnabled ? 'fill-yellow-500 text-yellow-500' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<Label htmlFor="auto-switch" className="cursor-pointer text-sm font-medium">
|
||||
{t('cloud.autoSwitch')}
|
||||
</Label>
|
||||
<div className="space-y-5 pb-20">
|
||||
<div className="rounded-lg border bg-card p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t('cloud.title')}</h2>
|
||||
<p className="text-muted-foreground max-w-2xl">{t('cloud.description')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
||||
<div className="text-muted-foreground text-[11px] uppercase">{t('cloud.card.actions')}</div>
|
||||
<div className="text-base font-semibold">{totalAccounts}</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-switch"
|
||||
checked={!!autoSwitchEnabled}
|
||||
onCheckedChange={handleToggleAutoSwitch}
|
||||
disabled={isSettingsLoading || setAutoSwitchMutation.isPending}
|
||||
/>
|
||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
||||
<div className="text-muted-foreground text-[11px] uppercase">{t('cloud.card.active')}</div>
|
||||
<div className="text-base font-semibold text-emerald-600">{activeAccounts}</div>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
||||
<div className="text-muted-foreground text-[11px] uppercase">{t('cloud.card.rateLimited')}</div>
|
||||
<div className="text-base font-semibold text-rose-600">{rateLimitedAccounts}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" onClick={toggleSelectAll} title={t('cloud.batch.selectAll')}>
|
||||
<CheckSquare
|
||||
className={`mr-2 h-4 w-4 ${selectedIds.size > 0 && selectedIds.size === accounts?.length ? 'text-primary fill-primary/20' : ''}`}
|
||||
/>
|
||||
{t('cloud.batch.selectAll')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleForcePoll}
|
||||
title={t('cloud.checkQuota')}
|
||||
disabled={forcePollMutation.isPending}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${forcePollMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSyncLocal}
|
||||
disabled={syncMutation.isPending}
|
||||
title={t('cloud.syncFromIDE')}
|
||||
>
|
||||
<Download
|
||||
className={`mr-2 h-4 w-4 ${syncMutation.isPending ? 'animate-bounce' : ''}`}
|
||||
/>
|
||||
{t('cloud.syncFromIDE')}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('cloud.addAccount')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Button variant="outline" className="col-span-4" onClick={openAuthUrl}>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
{t('cloud.authDialog.openLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">{t('cloud.authDialog.authCode')}</Label>
|
||||
<Input
|
||||
id="code"
|
||||
placeholder={t('cloud.authDialog.placeholder')}
|
||||
value={authCode}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setAuthCode(e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('cloud.authDialog.instruction')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => handleAddAccount()}
|
||||
disabled={addMutation.isPending || !authCode}
|
||||
>
|
||||
{addMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('cloud.authDialog.verify')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-card p-3">
|
||||
<div className="bg-muted/50 flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap
|
||||
className={`h-4 w-4 ${autoSwitchEnabled ? 'fill-yellow-500 text-yellow-500' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<Label htmlFor="auto-switch" className="cursor-pointer text-sm font-medium">
|
||||
{t('cloud.autoSwitch')}
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-switch"
|
||||
checked={!!autoSwitchEnabled}
|
||||
onCheckedChange={handleToggleAutoSwitch}
|
||||
disabled={isSettingsLoading || setAutoSwitchMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" onClick={toggleSelectAll} title={t('cloud.batch.selectAll')} className="cursor-pointer">
|
||||
<CheckSquare
|
||||
className={`mr-2 h-4 w-4 ${selectedIds.size > 0 && selectedIds.size === accounts?.length ? 'text-primary fill-primary/20' : ''}`}
|
||||
/>
|
||||
{t('cloud.batch.selectAll')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleForcePoll}
|
||||
title={t('cloud.checkQuota')}
|
||||
disabled={forcePollMutation.isPending}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RefreshCcw
|
||||
className={`h-4 w-4 ${forcePollMutation.isPending ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSyncLocal}
|
||||
disabled={syncMutation.isPending}
|
||||
title={t('cloud.syncFromIDE')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download
|
||||
className={`mr-2 h-4 w-4 ${syncMutation.isPending ? 'animate-bounce' : ''}`}
|
||||
/>
|
||||
{t('cloud.syncFromIDE')}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('cloud.addAccount')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Button variant="outline" className="col-span-4" onClick={openAuthUrl}>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
{t('cloud.authDialog.openLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">{t('cloud.authDialog.authCode')}</Label>
|
||||
<Input
|
||||
id="code"
|
||||
placeholder={t('cloud.authDialog.placeholder')}
|
||||
value={authCode}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setAuthCode(e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('cloud.authDialog.instruction')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => handleAddAccount()}
|
||||
disabled={addMutation.isPending || !authCode}
|
||||
>
|
||||
{addMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('cloud.authDialog.verify')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{accounts?.map((account) => (
|
||||
<CloudAccountCard
|
||||
@@ -402,6 +431,7 @@ export function CloudAccountList() {
|
||||
onRefresh={handleRefresh}
|
||||
onDelete={handleDelete}
|
||||
onSwitch={handleSwitch}
|
||||
onManageIdentity={handleManageIdentity}
|
||||
isSelected={selectedIds.has(account.id)}
|
||||
onToggleSelection={toggleSelection}
|
||||
isRefreshing={
|
||||
@@ -417,15 +447,16 @@ export function CloudAccountList() {
|
||||
))}
|
||||
|
||||
{accounts?.length === 0 && (
|
||||
<div className="text-muted-foreground bg-muted/10 col-span-full rounded-lg border-2 border-dashed py-12 text-center">
|
||||
{t('cloud.list.noAccounts')}
|
||||
<div className="text-muted-foreground bg-muted/20 col-span-full rounded-lg border border-dashed py-14 text-center">
|
||||
<Cloud className="mx-auto mb-3 h-10 w-10 opacity-40" />
|
||||
<div className="text-sm">{t('cloud.list.noAccounts')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Batch Action Bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="bg-background animate-in fade-in slide-in-from-bottom-4 fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-4 rounded-full border px-6 py-2 shadow-xl">
|
||||
<div className="bg-card animate-in fade-in slide-in-from-bottom-4 fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-4 rounded-full border px-6 py-2 shadow-lg">
|
||||
<div className="flex items-center gap-2 border-r pr-4">
|
||||
<span className="text-sm font-semibold">
|
||||
{t('cloud.batch.selected', { count: selectedIds.size })}
|
||||
@@ -452,6 +483,16 @@ export function CloudAccountList() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IdentityProfileDialog
|
||||
account={identityAccount}
|
||||
open={Boolean(identityAccount)}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
setIdentityAccount(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { type JSX, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Fingerprint,
|
||||
FolderOpen,
|
||||
RotateCcw,
|
||||
Wand2,
|
||||
Trash2,
|
||||
History,
|
||||
ShieldCheck,
|
||||
Cpu,
|
||||
} from 'lucide-react';
|
||||
import type { CloudAccount } from '@/types/cloudAccount';
|
||||
import type { DeviceProfile, DeviceProfilesSnapshot, DeviceProfileVersion } from '@/types/account';
|
||||
import {
|
||||
bindCloudIdentityProfile,
|
||||
bindCloudIdentityProfileWithPayload,
|
||||
deleteCloudIdentityProfileRevision,
|
||||
getCloudIdentityProfiles,
|
||||
openCloudIdentityStorageFolder,
|
||||
previewGenerateCloudIdentityProfile,
|
||||
restoreCloudIdentityProfileRevision,
|
||||
restoreCloudBaselineProfile,
|
||||
} from '@/actions/cloud';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
|
||||
interface IdentityProfileDialogProps {
|
||||
account: CloudAccount | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function renderProfile(profile?: DeviceProfile): JSX.Element {
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs">
|
||||
N/A
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Array<{ label: string; value: string }> = [
|
||||
{ label: 'machineId', value: profile.machineId },
|
||||
{ label: 'macMachineId', value: profile.macMachineId },
|
||||
{ label: 'devDeviceId', value: profile.devDeviceId },
|
||||
{ label: 'sqmId', value: profile.sqmId },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex items-start justify-between gap-3">
|
||||
<span className="text-muted-foreground text-[11px] tracking-wide uppercase">
|
||||
{row.label}
|
||||
</span>
|
||||
<span className="max-w-[70%] text-right font-mono text-xs break-all">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sortHistory(history: DeviceProfileVersion[]): DeviceProfileVersion[] {
|
||||
return [...history].sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityProfileDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [snapshot, setSnapshot] = useState<DeviceProfilesSnapshot | null>(null);
|
||||
const [initialLoading, setInitialLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [actionKey, setActionKey] = useState<string | null>(null);
|
||||
const [previewProfile, setPreviewProfile] = useState<DeviceProfile | null>(null);
|
||||
const actionLockRef = useRef(false);
|
||||
|
||||
const history = useMemo(() => sortHistory(snapshot?.history || []), [snapshot?.history]);
|
||||
const showLoadingPlaceholder = initialLoading && !snapshot;
|
||||
|
||||
const refreshProfiles = async (options?: { silent?: boolean }) => {
|
||||
if (!account) {
|
||||
setSnapshot(null);
|
||||
return;
|
||||
}
|
||||
const silent = options?.silent === true;
|
||||
if (silent) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setInitialLoading(true);
|
||||
}
|
||||
try {
|
||||
const result = await getCloudIdentityProfiles({ accountId: account.id });
|
||||
setSnapshot(result);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }),
|
||||
description: formatError(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
if (silent) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
refreshProfiles();
|
||||
}, [open, account?.id]);
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<void>) => {
|
||||
if (actionLockRef.current) {
|
||||
return;
|
||||
}
|
||||
actionLockRef.current = true;
|
||||
setActionKey(key);
|
||||
try {
|
||||
await action();
|
||||
await refreshProfiles({ silent: true });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('cloud.toast.actionFailed', { defaultValue: 'Action failed' }),
|
||||
description: formatError(error),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
actionLockRef.current = false;
|
||||
setActionKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden p-0">
|
||||
<DialogHeader className="from-primary/10 via-background to-background border-b bg-gradient-to-r p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="bg-primary/15 text-primary rounded-xl border p-2.5">
|
||||
<Fingerprint className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate">{t('cloud.identity.title')}</DialogTitle>
|
||||
<DialogDescription className="truncate">{account.email}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
<History className="mr-1.5 h-3.5 w-3.5" />
|
||||
{history.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 space-y-6 overflow-y-auto p-6" aria-busy={refreshing}>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={actionKey === 'preview-generate'}
|
||||
className="h-auto cursor-pointer justify-start rounded-xl px-4 py-3"
|
||||
onClick={() => {
|
||||
runAction('preview-generate', async () => {
|
||||
const profile = await previewGenerateCloudIdentityProfile();
|
||||
setPreviewProfile(profile);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
{t('cloud.identity.generateAndBind')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={actionKey === 'capture-bind'}
|
||||
className="h-auto cursor-pointer justify-start rounded-xl px-4 py-3"
|
||||
onClick={() => {
|
||||
runAction('capture-bind', async () => {
|
||||
await bindCloudIdentityProfile({ accountId: account.id, mode: 'capture' });
|
||||
setPreviewProfile(null);
|
||||
toast({ title: t('cloud.identity.captureSuccess') });
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Fingerprint className="mr-2 h-4 w-4" />
|
||||
{t('cloud.identity.captureAndBind')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={actionKey === 'restore-original'}
|
||||
className="h-auto cursor-pointer justify-start rounded-xl px-4 py-3"
|
||||
onClick={() => {
|
||||
runAction('restore-original', async () => {
|
||||
await restoreCloudBaselineProfile({ accountId: account.id });
|
||||
toast({ title: t('cloud.identity.restoreOriginalSuccess') });
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('cloud.identity.restoreOriginal')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={actionKey === 'open-folder'}
|
||||
className="h-auto cursor-pointer justify-start rounded-xl px-4 py-3"
|
||||
onClick={() => {
|
||||
runAction('open-folder', async () => {
|
||||
await openCloudIdentityStorageFolder();
|
||||
toast({ title: t('cloud.identity.openFolderSuccess') });
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
{t('cloud.identity.openFolder')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{previewProfile ? (
|
||||
<Card className="border-primary/20 shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{t('cloud.identity.previewTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{renderProfile(previewProfile)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={actionKey === 'confirm-generate'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
runAction('confirm-generate', async () => {
|
||||
await bindCloudIdentityProfileWithPayload({
|
||||
accountId: account.id,
|
||||
profile: previewProfile,
|
||||
});
|
||||
setPreviewProfile(null);
|
||||
toast({ title: t('cloud.identity.generateSuccess') });
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('cloud.identity.confirm')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setPreviewProfile(null)}
|
||||
>
|
||||
{t('cloud.identity.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Cpu className="text-muted-foreground h-4 w-4" />
|
||||
{t('cloud.identity.currentStorage')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showLoadingPlaceholder ? (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('cloud.identity.loading')}
|
||||
</div>
|
||||
) : (
|
||||
renderProfile(snapshot?.currentStorage)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<ShieldCheck className="text-muted-foreground h-4 w-4" />
|
||||
{t('cloud.identity.accountBinding')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showLoadingPlaceholder ? (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('cloud.identity.loading')}
|
||||
</div>
|
||||
) : (
|
||||
renderProfile(snapshot?.boundProfile)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{t('cloud.identity.history')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="max-h-[38vh] space-y-3 overflow-y-auto pr-1">
|
||||
{showLoadingPlaceholder ? (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('cloud.identity.loading')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!showLoadingPlaceholder && history.length === 0 ? (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('cloud.identity.noHistory')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!showLoadingPlaceholder
|
||||
? history.map((version) => (
|
||||
<div key={version.id} className="bg-muted/20 rounded-xl border p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{version.label}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{new Date(version.createdAt * 1000).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
{version.isCurrent ? (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{t('cloud.identity.current')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{renderProfile(version.profile)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={version.isCurrent || actionKey === `restore-${version.id}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
runAction(`restore-${version.id}`, async () => {
|
||||
await restoreCloudIdentityProfileRevision({
|
||||
accountId: account.id,
|
||||
versionId: version.id,
|
||||
});
|
||||
toast({ title: t('cloud.identity.restoreVersionSuccess') });
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('cloud.identity.restore')}
|
||||
</Button>
|
||||
{!version.isCurrent ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actionKey === `delete-${version.id}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
runAction(`delete-${version.id}`, async () => {
|
||||
await deleteCloudIdentityProfileRevision({
|
||||
accountId: account.id,
|
||||
versionId: version.id,
|
||||
});
|
||||
toast({ title: t('cloud.identity.deleteVersionSuccess') });
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">
|
||||
{t('cloud.identity.baseline', { defaultValue: 'Reference Profile' })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showLoadingPlaceholder ? (
|
||||
<div className="text-muted-foreground text-xs">{t('cloud.identity.loading')}</div>
|
||||
) : (
|
||||
renderProfile(snapshot?.baseline)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t px-6 py-4">
|
||||
<Button variant="ghost" className="cursor-pointer" onClick={() => onOpenChange(false)}>
|
||||
{t('cloud.identity.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from '@/components/ui/toast';
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title ? <ToastTitle>{title}</ToastTitle> : null}
|
||||
{description ? <ToastDescription>{description}</ToastDescription> : null}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user