feat(admin): add dedicated user management page

Extract user management functionality from MembersPage drawer into a
dedicated UserManagementPage at /org/members/:userId. The new page
provides a full-page interface with tabs for user details, security
settings (MFA methods), and access management (OAuth accounts, SSH keys).

This improves code organization by separating concerns and provides
better UX for user administration tasks.
This commit is contained in:
2026-04-08 16:45:57 +09:30
parent f3e0f806cc
commit e8987e28f7
3 changed files with 1042 additions and 750 deletions
+2
View File
@@ -43,6 +43,7 @@ import CLIGuidePage from "@/pages/user/CLIGuidePage";
// Organization pages
import OrgOverviewPage from "@/pages/org/OrgOverviewPage";
import MembersPage from "@/pages/org/MembersPage";
import UserManagementPage from "@/pages/admin/UserManagementPage";
import PoliciesPage from "@/pages/org/PoliciesPage";
import CompliancePage from "@/pages/org/CompliancePage";
import OrgAuditPage from "@/pages/org/OrgAuditPage";
@@ -198,6 +199,7 @@ function AppRoutes() {
{/* Organization management routes — org admins/owners only */}
<Route path="/org/members" element={<RequireAdmin><MembersPage /></RequireAdmin>} />
<Route path="/org/members/:userId" element={<RequireAdmin><UserManagementPage /></RequireAdmin>} />
<Route path="/org/departments" element={<RequireAdmin><DepartmentsPage /></RequireAdmin>} />
<Route path="/org/principals" element={<RequireAdmin><PrincipalsPage /></RequireAdmin>} />
<Route path="/org/api-keys" element={<RequireAdmin><ApiKeysPage /></RequireAdmin>} />
File diff suppressed because it is too large Load Diff
+8 -750
View File
@@ -27,7 +27,7 @@ import {
KeyRound,
Lock,
} from "lucide-react";
import { useParams } from "react-router-dom";
import { useParams, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -50,6 +50,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
@@ -57,21 +58,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
import { useToast } from "@/hooks/use-toast";
import { api, OrganizationMember, ApiError, OrgInvite, SSHKey, User as ApiUser, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api";
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
import { useAuth } from "@/contexts/AuthContext";
// ── Helpers ──────────────────────────────────────────────────────────────────
const getInitials = (name: string | null | undefined): string => {
if (!name) return "?";
return name
@@ -127,6 +118,7 @@ export default function MembersPage() {
const orgId = params.orgId || fallbackOrgId;
const { toast } = useToast();
const { user: currentUser } = useAuth();
const navigate = useNavigate();
// ── Member list ──────────────────────────────────────────────────────────────
const [members, setMembers] = useState<OrganizationMember[]>([]);
@@ -705,10 +697,10 @@ export default function MembersPage() {
const isOwner = (member.role || "").toLowerCase() === "owner";
const isSelf = member.user?.id === currentUser?.id;
return (
<button
<div
key={member.id}
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors"
onClick={() => openMemberDrawer(member)}
className="w-full flex items-center gap-4 p-4 text-left hover:bg-accent/50 transition-colors cursor-pointer"
onClick={() => navigate(`/org/members/${member.user_id}`)}
>
<Avatar className="w-10 h-10 flex-shrink-0">
<AvatarImage src={member.user?.avatar_url || undefined} />
@@ -768,7 +760,7 @@ export default function MembersPage() {
</DropdownMenu>
)}
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
</button>
</div>
);
})}
</div>
@@ -885,740 +877,6 @@ export default function MembersPage() {
</Tabs>
{/* ── User detail drawer ──────────────────────────────────────────────────── */}
<Sheet open={!!selectedMember} onOpenChange={(open) => { if (!open) closeDrawer(); }}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
{selectedMember && (
<>
<SheetHeader className="mb-4">
<SheetTitle className="flex items-center gap-3">
<Avatar className="w-9 h-9">
<AvatarImage src={selectedMember.user?.avatar_url || undefined} />
<AvatarFallback className="bg-primary text-primary-foreground text-sm">
{getInitials(selectedMember.user?.full_name)}
</AvatarFallback>
</Avatar>
<span>{selectedMember.user?.full_name || selectedMember.user?.email}</span>
</SheetTitle>
<SheetDescription>{selectedMember.user?.email}</SheetDescription>
</SheetHeader>
{isDrawerLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{/* Basic info */}
<div className="space-y-3 mb-6">
<div className="grid grid-cols-2 gap-2 text-sm">
<span className="text-muted-foreground">Status</span>
<span className="flex items-center gap-1">
{isSuspended(drawerStatus) ? (
<>
<Ban className="w-4 h-4 text-red-500" />
<span className="text-red-600 font-medium">
Suspended{drawerStatus === "compliance_suspended" ? " (compliance)" : ""}
</span>
</>
) : (
<>
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-green-600">Active</span>
</>
)}
</span>
<span className="text-muted-foreground">Joined</span>
<span>{formatDate(drawerCreatedAt)}</span>
{drawerActivated !== undefined && (
<>
<span className="text-muted-foreground">Activated</span>
<span className="flex items-center gap-1">
{drawerActivated === false ? (
<><XCircle className="w-4 h-4 text-amber-500" /> No</>
) : (
<><CheckCircle className="w-4 h-4 text-green-500" /> Yes</>
)}
</span>
</>
)}
<span className="text-muted-foreground">Last login</span>
<span>{formatDate(drawerLastLogin)}</span>
</div>
</div>
{/* Suspend / Unsuspend */}
{selectedMember.user?.id !== currentUser?.id && (
<div className="mb-6 p-4 border rounded-lg space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Ban className="w-4 h-4" />
Account Access
</h3>
{isSuspended(drawerStatus) ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{drawerStatus === "compliance_suspended"
? "This account is suspended due to MFA compliance. The user cannot request certificates."
: "This account is suspended. The user cannot request certificates."}
</p>
<Button
size="sm"
variant="outline"
onClick={handleUnsuspend}
disabled={isSuspending}
className="text-green-600 border-green-300 hover:bg-green-50"
>
{isSuspending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<UserCheck className="w-4 h-4 mr-2" />
)}
Restore account
</Button>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Suspending blocks this user from requesting SSH certificates.
</p>
<Button
size="sm"
variant="outline"
onClick={() => setShowSuspendConfirm(true)}
disabled={isSuspending}
className="text-red-600 border-red-300 hover:bg-red-50"
>
<Ban className="w-4 h-4 mr-2" />
Suspend account
</Button>
</div>
)}
</div>
)}
{/* Role management */}
{selectedMember.user?.id !== currentUser?.id && (
<div className="mb-6 p-4 border rounded-lg space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Shield className="w-4 h-4" />
Organization Role
</h3>
{(selectedMember.role || "").toLowerCase() === "owner" ? (
<p className="text-xs text-muted-foreground">
Owner role cannot be changed here. Transfer ownership from organization settings.
</p>
) : (
<div className="flex items-center gap-3">
<Select
value={(selectedMember.role || "member").toLowerCase()}
onValueChange={handleDrawerRoleChange}
disabled={isUpdatingRole}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
{isUpdatingRole && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
</div>
)}
</div>
)}
{/* Transfer Ownership — shown when current user is owner and target is not */}
{selectedMember.user?.id !== currentUser?.id &&
(selectedMember.role || "").toLowerCase() !== "owner" &&
members.some(
(m) => m.user?.id === currentUser?.id && (m.role || "").toLowerCase() === "owner"
) && (
<div className="mb-6 p-4 border rounded-lg space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Crown className="w-4 h-4" />
Transfer Ownership
</h3>
<p className="text-sm text-muted-foreground">
Make this member the new organization owner. You will be demoted to admin.
</p>
<Button
size="sm"
variant="outline"
onClick={() => setShowTransferOwnership(true)}
className="text-purple-600 border-purple-300 hover:bg-purple-50"
>
<Crown className="w-4 h-4 mr-2" />
Transfer ownership to this member
</Button>
</div>
)}
{/* Danger zone — Hard delete */}
{selectedMember.user?.id !== currentUser?.id && (
<div className="mb-6 p-4 border border-destructive/30 rounded-lg space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2 text-destructive">
<Trash2 className="w-4 h-4" />
Danger Zone
</h3>
<p className="text-sm text-muted-foreground">
Permanently delete this user account. This cannot be undone all SSH keys and certificates will be revoked.
</p>
<Button
size="sm"
variant="outline"
onClick={() => { setHardDeleteConfirmEmail(""); setShowHardDelete(true); }}
className="text-destructive border-destructive/40 hover:bg-destructive/10"
>
<Trash2 className="w-4 h-4 mr-2" />
Permanently delete account
</Button>
</div>
)}
{/* MFA Methods */}
{selectedMember.user?.id !== currentUser?.id && (
<div className="mb-6 p-4 border rounded-lg space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-2">
<ShieldOff className="w-4 h-4" />
MFA / 2FA Methods
</h3>
{userMfaMethods.length > 1 && (
<Button
size="sm"
variant="outline"
onClick={() => setShowRemoveAllMfa(true)}
className="text-red-600 border-red-300 hover:bg-red-50 text-xs"
>
<Trash2 className="w-3 h-3 mr-1" />
Remove all
</Button>
)}
</div>
{isDrawerLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
) : userMfaMethods.length === 0 ? (
<p className="text-sm text-muted-foreground">No MFA methods configured.</p>
) : (
<div className="space-y-2">
{userMfaMethods.map((method) => (
<div key={method.id} className="flex items-center justify-between p-3 border rounded-lg text-sm">
<div className="flex items-center gap-2 min-w-0">
{method.type === "totp" ? (
<Smartphone className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<KeyRound className="w-4 h-4 text-purple-500 flex-shrink-0" />
)}
<div className="min-w-0">
<p className="font-medium truncate">{method.name}</p>
{method.last_used_at && (
<p className="text-xs text-muted-foreground">
Last used: {formatDate(method.last_used_at)}
</p>
)}
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveMfaMethod(method)}
disabled={removingMfaId === method.id}
className="text-red-600 hover:bg-red-50 flex-shrink-0 ml-2"
title={`Remove ${method.name}`}
>
{removingMfaId === method.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
</div>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
Remove an MFA method if the user has lost access (e.g. lost phone or passkey). They can re-enroll after removal.
</p>
</div>
)}
{/* Linked OAuth Accounts */}
{selectedMember.user?.id !== currentUser?.id && (
<div className="mb-6 p-4 border rounded-lg space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Link2 className="w-4 h-4" />
Linked OAuth Accounts
</h3>
{isDrawerLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
) : userLinkedAccounts.length === 0 ? (
<p className="text-sm text-muted-foreground">No OAuth providers linked.</p>
) : (
<div className="space-y-2">
{userLinkedAccounts.map((account) => {
const isOnlyMethod = totalAuthMethods <= 1;
return (
<div key={account.id} className="flex items-center justify-between p-3 border rounded-lg text-sm">
<div className="flex items-center gap-2 min-w-0">
<Link2 className="w-4 h-4 text-blue-500 flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium capitalize">{account.provider_type}</p>
{account.email && (
<p className="text-xs text-muted-foreground truncate">{account.email}</p>
)}
{account.linked_at && (
<p className="text-xs text-muted-foreground">
Linked: {formatDate(account.linked_at)}
</p>
)}
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleUnlinkProvider(account)}
disabled={unlinkingProvider === account.id || isOnlyMethod}
className="text-red-600 hover:bg-red-50 flex-shrink-0 ml-2"
title={isOnlyMethod ? "Cannot unlink — this is the user's only sign-in method" : `Unlink ${account.provider_type}`}
>
{unlinkingProvider === account.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Unlink className="w-4 h-4" />
)}
</Button>
</div>
);
})}
</div>
)}
<p className="text-xs text-muted-foreground">
Unlink a provider to prevent sign-in via that provider. Cannot unlink if it is the user's only sign-in method.
</p>
</div>
)}
{/* Password Management */}
{selectedMember.user?.id !== currentUser?.id && (
<div className="mb-2 p-4 border rounded-lg space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Lock className="w-4 h-4" />
{detailUser?.has_password ? "Reset Password" : "Set Password"}
</h3>
<p className="text-sm text-muted-foreground">
{detailUser?.has_password
? "Override this user's current password. They will need to use the new password on next login."
: "This user has no password configured (sign-in via OIDC/OAuth only). Set one to enable email/password login."}
</p>
<form onSubmit={handleAdminSetPassword} className="space-y-2">
<Input
type="password"
placeholder="New password"
value={adminPwNew}
onChange={(e) => { setAdminPwNew(e.target.value); setAdminPwError(null); setAdminPwSuccess(false); }}
disabled={isSettingPw}
autoComplete="new-password"
/>
<Input
type="password"
placeholder="Confirm password"
value={adminPwConfirm}
onChange={(e) => { setAdminPwConfirm(e.target.value); setAdminPwError(null); setAdminPwSuccess(false); }}
disabled={isSettingPw}
autoComplete="new-password"
/>
{adminPwError && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertTriangle className="w-3 h-3 flex-shrink-0" />
{adminPwError}
</p>
)}
{adminPwSuccess && (
<p className="text-sm text-green-600 flex items-center gap-1">
<CheckCircle className="w-3 h-3 flex-shrink-0" />
Password updated successfully.
</p>
)}
<Button
type="submit"
size="sm"
variant="outline"
disabled={isSettingPw || !adminPwNew || !adminPwConfirm}
>
{isSettingPw ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Saving...</>
) : (
<><Lock className="w-4 h-4 mr-2" />{detailUser?.has_password ? "Reset password" : "Set password"}</>
)}
</Button>
</form>
</div>
)}
{/* SSH Keys */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Key className="w-4 h-4" />
SSH Keys
</h3>
<Button size="sm" variant="outline" onClick={() => setShowAddKey(true)}>
<Plus className="w-3 h-3 mr-1" />
Add key
</Button>
</div>
{userSshKeys.length === 0 ? (
<div className="text-center py-6 text-muted-foreground text-sm">
No SSH keys registered
</div>
) : (
<div className="space-y-2">
{userSshKeys.map((k) => (
<div key={k.id} className="p-3 border rounded-lg text-sm">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">
{k.description || <em className="text-muted-foreground">No description</em>}
</span>
{k.verified ? (
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">
<CheckCircle className="w-3 h-3 mr-1" />Verified
</Badge>
) : (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300">
Unverified
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground font-mono truncate">
{k.fingerprint ?? (k.public_key.slice(0, 64) + "")}
</div>
<div className="text-xs text-muted-foreground mt-1">
Added {formatDate(k.created_at)}
</div>
</div>
))}
</div>
)}
</div>
</>
)}
</>
)}
</SheetContent>
</Sheet>
{/* ── Admin add SSH key dialog ──────────────────────────────────────────── */}
<Dialog
open={showAddKey}
onOpenChange={(open) => {
setShowAddKey(open);
if (!open) { setAddKeyError(null); setAddKeyPublicKey(""); setAddKeyDescription(""); }
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add SSH Key for {selectedMember?.user?.email}</DialogTitle>
<DialogDescription>
Add an SSH public key on behalf of this user (admin action).
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{addKeyError && (
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{addKeyError}</div>
)}
<div className="space-y-2">
<Label>Public key</Label>
<Textarea
placeholder="ssh-ed25519 AAAA..."
value={addKeyPublicKey}
onChange={(e) => setAddKeyPublicKey(e.target.value)}
className="font-mono text-xs min-h-[80px]"
disabled={isAddingKey}
/>
</div>
<div className="space-y-2">
<Label>
Description <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
placeholder="Laptop key"
value={addKeyDescription}
onChange={(e) => setAddKeyDescription(e.target.value)}
disabled={isAddingKey}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddKey(false)} disabled={isAddingKey}>
Cancel
</Button>
<Button onClick={handleAddKey} disabled={isAddingKey || !addKeyPublicKey.trim()}>
{isAddingKey && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Add key
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Remove all MFA confirmation dialog ───────────────────────────── */}
<Dialog open={showRemoveAllMfa} onOpenChange={setShowRemoveAllMfa}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="w-5 h-5" />
Remove all MFA methods?
</DialogTitle>
<DialogDescription>
This will remove <strong>all</strong> MFA methods (TOTP and passkeys) for{" "}
<strong>{selectedMember?.user?.email}</strong>. They will be able to re-enroll after this action.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRemoveAllMfa(false)} disabled={isRemovingAllMfa}>
Cancel
</Button>
<Button variant="destructive" onClick={handleRemoveAllMfa} disabled={isRemovingAllMfa}>
{isRemovingAllMfa && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Remove all MFA
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Suspend confirmation dialog ───────────────────────────────────────── */}
<Dialog open={showSuspendConfirm} onOpenChange={setShowSuspendConfirm}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="w-5 h-5" />
Suspend account?
</DialogTitle>
<DialogDescription>
<strong>{selectedMember?.user?.full_name || selectedMember?.user?.email}</strong> will be
blocked from requesting SSH certificates. You can restore their access at any time.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowSuspendConfirm(false)}
disabled={isSuspending}
>
Cancel
</Button>
<Button variant="destructive" onClick={handleSuspend} disabled={isSuspending}>
{isSuspending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Suspend
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Change role dialog (row dropdown) ────────────────────────────────── */}
<Dialog open={!!changeRoleMember} onOpenChange={(o) => { if (!o) setChangeRoleMember(null); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Change role</DialogTitle>
<DialogDescription>
Update the role for{" "}
{changeRoleMember?.user?.full_name || changeRoleMember?.user?.email}
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Label className="mb-2 block">New role</Label>
<Select value={newRole} onValueChange={setNewRole} disabled={isChangingRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setChangeRoleMember(null)}
disabled={isChangingRole}
>
Cancel
</Button>
<Button onClick={handleChangeRole} disabled={isChangingRole}>
{isChangingRole && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Remove member confirmation ────────────────────────────────────────── */}
<Dialog open={!!removeMember} onOpenChange={(o) => { if (!o) setRemoveMember(null); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" />
Remove member?
</DialogTitle>
<DialogDescription>
<strong>{removeMember?.user?.full_name || removeMember?.user?.email}</strong> will lose
access to this organization immediately.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setRemoveMember(null)}
disabled={isRemoving}
>
Cancel
</Button>
<Button variant="destructive" onClick={handleRemoveMember} disabled={isRemoving}>
{isRemoving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Transfer ownership confirmation ───────────────────────────────── */}
<Dialog open={showTransferOwnership} onOpenChange={setShowTransferOwnership}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-purple-600">
<Crown className="w-5 h-5" />
Transfer ownership?
</DialogTitle>
<DialogDescription>
<strong>{selectedMember?.user?.full_name || selectedMember?.user?.email}</strong> will
become the new organization owner. You will be demoted to admin and lose the ability to
perform owner-only actions.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowTransferOwnership(false)}
disabled={isTransferringOwnership}
>
Cancel
</Button>
<Button
onClick={handleTransferOwnership}
disabled={isTransferringOwnership}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
{isTransferringOwnership && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Transfer ownership
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Hard delete confirmation ──────────────────────────────────────────── */}
<Dialog
open={showHardDelete}
onOpenChange={(open) => { setShowHardDelete(open); if (!open) setHardDeleteConfirmEmail(""); }}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="w-5 h-5" />
Permanently delete account?
</DialogTitle>
<DialogDescription>
This will <strong>permanently</strong> delete{" "}
<strong>{selectedMember?.user?.full_name || selectedMember?.user?.email}</strong>,
revoke all their SSH certificates, and remove all their SSH keys. This action
cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="py-2 space-y-2">
<Label className="text-sm">
Type <span className="font-mono font-semibold">{selectedMember?.user?.email}</span> to confirm
</Label>
<Input
value={hardDeleteConfirmEmail}
onChange={(e) => setHardDeleteConfirmEmail(e.target.value)}
placeholder={selectedMember?.user?.email ?? ""}
disabled={isHardDeleting}
className="font-mono"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowHardDelete(false)}
disabled={isHardDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleHardDelete}
disabled={isHardDeleting || hardDeleteConfirmEmail !== selectedMember?.user?.email}
>
{isHardDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Delete permanently
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Invite link dialog ────────────────────────────────────────────────── */}
<Dialog
open={!!inviteLink}
onOpenChange={(o) => { if (!o) { setInviteLink(null); setLinkCopied(false); } }}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ExternalLink className="w-4 h-4" />
Share this invite link
</DialogTitle>
<DialogDescription>
Email delivery is not configured. Share this link directly with{" "}
<strong>{inviteLinkEmail}</strong>.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="flex items-center gap-2 rounded-md border bg-muted px-3 py-2">
<span className="flex-1 text-xs text-muted-foreground break-all font-mono">
{inviteLink}
</span>
<Button
size="sm"
variant="ghost"
className="shrink-0"
onClick={() => {
if (inviteLink) {
navigator.clipboard.writeText(inviteLink);
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 2000);
}
}}
>
{linkCopied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
This link expires in 7 days. The recipient must already have an account or will be
prompted to create one.
</p>
</div>
<DialogFooter>
<Button onClick={() => { setInviteLink(null); setLinkCopied(false); }}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}