mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 11:47:01 +00:00
refactor(frontend): migrate settings AlertDialog confirms to ConfirmModal (#953)
Four settings panels with per-row destructive confirms now share the §10 ConfirmModal chrome. Each per-row AlertDialog is replaced by a single parent-level ConfirmModal driven by per-target state, with the row's button calling setTarget(item). - UsersSection: Reset 2FA confirm (default variant) + Delete user confirm (destructive) - CloudBackupSection: Delete cloud snapshot confirm (destructive) - RegistriesSection: Delete registry confirm (destructive) - ApiTokensSection: Revoke token confirm (destructive) Drop the unused AlertCircle import in CloudBackupSection that the old AlertDialog header relied on.
This commit is contained in:
@@ -5,7 +5,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
import { ConfirmModal } from '@/components/ui/modal';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { copyToClipboard } from '@/lib/clipboard';
|
import { copyToClipboard } from '@/lib/clipboard';
|
||||||
@@ -60,6 +60,7 @@ export function ApiTokensSection() {
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [newToken, setNewToken] = useState<{ id: number; token: string } | null>(null);
|
const [newToken, setNewToken] = useState<{ id: number; token: string } | null>(null);
|
||||||
|
const [revokeTarget, setRevokeTarget] = useState<ApiTokenListItem | null>(null);
|
||||||
|
|
||||||
const [formName, setFormName] = useState('');
|
const [formName, setFormName] = useState('');
|
||||||
const [formScope, setFormScope] = useState('read-only');
|
const [formScope, setFormScope] = useState('read-only');
|
||||||
@@ -244,27 +245,14 @@ export function ApiTokensSection() {
|
|||||||
{SCOPE_LABELS[token.scope] || token.scope}
|
{SCOPE_LABELS[token.scope] || token.scope}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialog>
|
<Button
|
||||||
<AlertDialogTrigger asChild>
|
variant="ghost"
|
||||||
<Button variant="ghost" size="sm" className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground shrink-0">
|
size="sm"
|
||||||
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground shrink-0"
|
||||||
</Button>
|
onClick={() => setRevokeTarget(token)}
|
||||||
</AlertDialogTrigger>
|
>
|
||||||
<AlertDialogContent>
|
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
||||||
<AlertDialogHeader>
|
</Button>
|
||||||
<AlertDialogTitle>Revoke API token?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Revoking <strong>{token.name}</strong> will immediately invalidate it. Any pipelines or scripts using this token will stop working.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={() => handleRevoke(token.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
||||||
Revoke
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground tabular-nums">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground tabular-nums">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
@@ -282,6 +270,26 @@ export function ApiTokensSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={revokeTarget !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setRevokeTarget(null); }}
|
||||||
|
variant="destructive"
|
||||||
|
kicker="API TOKEN · REVOKE · IRREVERSIBLE"
|
||||||
|
title="Revoke API token"
|
||||||
|
confirmLabel="Revoke"
|
||||||
|
onConfirm={() => {
|
||||||
|
if (revokeTarget) {
|
||||||
|
const id = revokeTarget.id;
|
||||||
|
setRevokeTarget(null);
|
||||||
|
handleRevoke(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-stat-subtitle">
|
||||||
|
Invalidates <span className="font-medium text-stat-value">{revokeTarget?.name}</span> immediately. Any pipelines or scripts using this token will stop working.
|
||||||
|
</p>
|
||||||
|
</ConfirmModal>
|
||||||
</div>
|
</div>
|
||||||
</CapabilityGate>
|
</CapabilityGate>
|
||||||
</AdmiralGate>
|
</AdmiralGate>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
import { ConfirmModal } from '@/components/ui/modal';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { AdmiralGate } from './AdmiralGate';
|
import { AdmiralGate } from './AdmiralGate';
|
||||||
@@ -95,6 +95,7 @@ export function RegistriesSection() {
|
|||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [testingId, setTestingId] = useState<number | null>(null);
|
const [testingId, setTestingId] = useState<number | null>(null);
|
||||||
const [testingForm, setTestingForm] = useState(false);
|
const [testingForm, setTestingForm] = useState(false);
|
||||||
|
const [deleteRegistry, setDeleteRegistry] = useState<RegistryItem | null>(null);
|
||||||
|
|
||||||
const [formName, setFormName] = useState('');
|
const [formName, setFormName] = useState('');
|
||||||
const [formUrl, setFormUrl] = useState('');
|
const [formUrl, setFormUrl] = useState('');
|
||||||
@@ -422,32 +423,15 @@ export function RegistriesSection() {
|
|||||||
<Button variant="ghost" size="sm" onClick={() => startEdit(reg)} title="Edit">
|
<Button variant="ghost" size="sm" onClick={() => startEdit(reg)} title="Edit">
|
||||||
<Pencil className="w-4 h-4" strokeWidth={1.5} />
|
<Pencil className="w-4 h-4" strokeWidth={1.5} />
|
||||||
</Button>
|
</Button>
|
||||||
<AlertDialog>
|
<Button
|
||||||
<AlertDialogTrigger asChild>
|
variant="ghost"
|
||||||
<Button
|
size="sm"
|
||||||
variant="ghost"
|
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
||||||
size="sm"
|
title="Delete"
|
||||||
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
onClick={() => setDeleteRegistry(reg)}
|
||||||
title="Delete"
|
>
|
||||||
>
|
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
||||||
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
</Button>
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete registry?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Deleting <strong>{reg.name}</strong> will remove its stored credentials. Stacks using images from this registry will fail to pull until credentials are re-added.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={() => handleDelete(reg.id)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-xs text-stat-subtitle">
|
<div className="flex items-center gap-4 text-xs text-stat-subtitle">
|
||||||
@@ -468,6 +452,26 @@ export function RegistriesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={deleteRegistry !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setDeleteRegistry(null); }}
|
||||||
|
variant="destructive"
|
||||||
|
kicker="REGISTRY · DELETE · IRREVERSIBLE"
|
||||||
|
title="Delete registry"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteRegistry) {
|
||||||
|
const id = deleteRegistry.id;
|
||||||
|
setDeleteRegistry(null);
|
||||||
|
handleDelete(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-stat-subtitle">
|
||||||
|
Removes <span className="font-medium text-stat-value">{deleteRegistry?.name}</span> and its stored credentials. Stacks using images from this registry will fail to pull until credentials are re-added.
|
||||||
|
</p>
|
||||||
|
</ConfirmModal>
|
||||||
</div>
|
</div>
|
||||||
</CapabilityGate>
|
</CapabilityGate>
|
||||||
</AdmiralGate>
|
</AdmiralGate>
|
||||||
|
|||||||
@@ -5,21 +5,12 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||||
import {
|
import { ConfirmModal } from '@/components/ui/modal';
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from '@/components/ui/alert-dialog';
|
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { formatBytes } from '@/lib/utils';
|
import { formatBytes } from '@/lib/utils';
|
||||||
import { AdmiralGate } from '@/components/AdmiralGate';
|
import { AdmiralGate } from '@/components/AdmiralGate';
|
||||||
import { Cloud, CloudOff, RefreshCw, CheckCircle2, AlertCircle, Loader2, Trash2, Download } from 'lucide-react';
|
import { Cloud, CloudOff, RefreshCw, CheckCircle2, Loader2, Trash2, Download } from 'lucide-react';
|
||||||
import { SettingsPrimaryButton } from './SettingsActions';
|
import { SettingsPrimaryButton } from './SettingsActions';
|
||||||
import { useMastheadStats } from './MastheadStatsContext';
|
import { useMastheadStats } from './MastheadStatsContext';
|
||||||
|
|
||||||
@@ -521,25 +512,19 @@ export function CloudBackupSection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AlertDialog open={!!deleteKey} onOpenChange={open => !open && setDeleteKey(null)}>
|
<ConfirmModal
|
||||||
<AlertDialogContent>
|
open={!!deleteKey}
|
||||||
<AlertDialogHeader>
|
onOpenChange={open => !open && setDeleteKey(null)}
|
||||||
<AlertDialogTitle className="flex items-center gap-2">
|
variant="destructive"
|
||||||
<AlertCircle className="w-4 h-4 text-destructive" strokeWidth={1.5} />
|
kicker="CLOUD · DELETE · IRREVERSIBLE"
|
||||||
Delete cloud snapshot?
|
title="Delete cloud snapshot"
|
||||||
</AlertDialogTitle>
|
confirmLabel="Delete"
|
||||||
<AlertDialogDescription>
|
onConfirm={confirmDelete}
|
||||||
This permanently removes the archive from your bucket. The local SQLite copy is unaffected.
|
>
|
||||||
</AlertDialogDescription>
|
<p className="text-sm text-stat-subtitle">
|
||||||
</AlertDialogHeader>
|
Permanently removes the archive from your bucket. The local SQLite copy is unaffected.
|
||||||
<AlertDialogFooter>
|
</p>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
</ConfirmModal>
|
||||||
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
</AdmiralGate>
|
</AdmiralGate>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import {
|
import { ConfirmModal } from '@/components/ui/modal';
|
||||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
|
|
||||||
} from '@/components/ui/alert-dialog';
|
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { useAuth, type UserRole } from '@/context/AuthContext';
|
import { useAuth, type UserRole } from '@/context/AuthContext';
|
||||||
@@ -53,6 +50,10 @@ export function UsersSection() {
|
|||||||
const [formConfirmPassword, setFormConfirmPassword] = useState('');
|
const [formConfirmPassword, setFormConfirmPassword] = useState('');
|
||||||
const [formRole, setFormRole] = useState<UserRole>('viewer');
|
const [formRole, setFormRole] = useState<UserRole>('viewer');
|
||||||
|
|
||||||
|
// Per-row destructive confirms
|
||||||
|
const [resetMfaTarget, setResetMfaTarget] = useState<UserItem | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<UserItem | null>(null);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/users', { localOnly: true });
|
const res = await apiFetch('/users', { localOnly: true });
|
||||||
@@ -455,45 +456,23 @@ export function UsersSection() {
|
|||||||
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
|
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||||
</Button>
|
</Button>
|
||||||
{u.mfaEnabled && (
|
{u.mfaEnabled && (
|
||||||
<AlertDialog>
|
<Button
|
||||||
<AlertDialogTrigger asChild>
|
variant="ghost"
|
||||||
<Button variant="ghost" size="sm" title="Reset 2FA">
|
size="sm"
|
||||||
<ShieldOff className="w-3.5 h-3.5 text-warning" strokeWidth={1.5} />
|
title="Reset 2FA"
|
||||||
</Button>
|
onClick={() => setResetMfaTarget(u)}
|
||||||
</AlertDialogTrigger>
|
>
|
||||||
<AlertDialogContent>
|
<ShieldOff className="w-3.5 h-3.5 text-warning" strokeWidth={1.5} />
|
||||||
<AlertDialogHeader>
|
</Button>
|
||||||
<AlertDialogTitle>Reset two-factor authentication for "{u.username}"?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This removes the user's authenticator enrolment and backup codes. They will sign in with just their password on their next login and can re-enrol from their account settings. Use this when a user has lost access to their authenticator.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={() => handleResetMfa(u.id, u.username)}>Reset 2FA</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)}
|
)}
|
||||||
<AlertDialog>
|
<Button
|
||||||
<AlertDialogTrigger asChild>
|
variant="ghost"
|
||||||
<Button variant="ghost" size="sm" disabled={isSelf}>
|
size="sm"
|
||||||
<Trash2 className="w-3.5 h-3.5 text-destructive" strokeWidth={1.5} />
|
disabled={isSelf}
|
||||||
</Button>
|
onClick={() => setDeleteTarget(u)}
|
||||||
</AlertDialogTrigger>
|
>
|
||||||
<AlertDialogContent>
|
<Trash2 className="w-3.5 h-3.5 text-destructive" strokeWidth={1.5} />
|
||||||
<AlertDialogHeader>
|
</Button>
|
||||||
<AlertDialogTitle>Delete user "{u.username}"?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This action cannot be undone. The user will lose access immediately.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={() => handleDelete(u.id)}>Delete</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -503,6 +482,45 @@ export function UsersSection() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={resetMfaTarget !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setResetMfaTarget(null); }}
|
||||||
|
kicker="USERS · RESET 2FA"
|
||||||
|
title={`Reset 2FA for ${resetMfaTarget?.username ?? ''}`}
|
||||||
|
confirmLabel="Reset 2FA"
|
||||||
|
onConfirm={() => {
|
||||||
|
if (resetMfaTarget) {
|
||||||
|
const user = resetMfaTarget;
|
||||||
|
setResetMfaTarget(null);
|
||||||
|
handleResetMfa(user.id, user.username);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-stat-subtitle">
|
||||||
|
Removes the user's authenticator enrolment and backup codes. They will sign in with just their password on their next login and can re-enrol from their account settings. Use this when a user has lost access to their authenticator.
|
||||||
|
</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
|
||||||
|
variant="destructive"
|
||||||
|
kicker="USERS · DELETE · IRREVERSIBLE"
|
||||||
|
title={`Delete user "${deleteTarget?.username ?? ''}"`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteTarget) {
|
||||||
|
const id = deleteTarget.id;
|
||||||
|
setDeleteTarget(null);
|
||||||
|
handleDelete(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-stat-subtitle">
|
||||||
|
Removes the user immediately. They lose access right away.
|
||||||
|
</p>
|
||||||
|
</ConfirmModal>
|
||||||
</div>
|
</div>
|
||||||
</CapabilityGate>
|
</CapabilityGate>
|
||||||
</PaidGate>
|
</PaidGate>
|
||||||
|
|||||||
Reference in New Issue
Block a user