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:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user