Feat(Chore, Fix): Admin Privilege
Added OIDC Web Page Flow Admin can add/reset password Admin can remove users'/members mfa/2fa, unlink account from oauth provider Chore: Text changes (Forgot Pass, CA)
This commit is contained in:
@@ -14,6 +14,12 @@ import {
|
||||
UserCheck,
|
||||
AlertTriangle,
|
||||
Trash2,
|
||||
ShieldOff,
|
||||
Smartphone,
|
||||
KeyRound,
|
||||
Link2,
|
||||
Unlink,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -50,7 +56,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api, User as ApiUser, SSHKey, ApiError } from "@/lib/api";
|
||||
import { api, User as ApiUser, SSHKey, ApiError, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
@@ -58,6 +64,10 @@ function formatDate(d: string | null) {
|
||||
return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function capitalize(s: string) {
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
|
||||
function isSuspended(status: string | undefined) {
|
||||
return status === "suspended" || status === "compliance_suspended";
|
||||
}
|
||||
@@ -124,11 +134,33 @@ export default function AdminUsersPage() {
|
||||
const [isSuspending, setIsSuspending] = useState(false);
|
||||
const [showSuspendConfirm, setShowSuspendConfirm] = useState(false);
|
||||
|
||||
// Force-verify email
|
||||
const [isVerifyingEmail, setIsVerifyingEmail] = useState(false);
|
||||
|
||||
// Hard delete
|
||||
const [showHardDelete, setShowHardDelete] = useState(false);
|
||||
const [hardDeleteConfirmEmail, setHardDeleteConfirmEmail] = useState("");
|
||||
const [isHardDeleting, setIsHardDeleting] = useState(false);
|
||||
|
||||
// MFA management
|
||||
const [userMfaMethods, setUserMfaMethods] = useState<AdminMfaMethod[]>([]);
|
||||
const [isMfaLoading, setIsMfaLoading] = useState(false);
|
||||
const [removingMfaId, setRemovingMfaId] = useState<string | null>(null);
|
||||
const [showRemoveAllMfa, setShowRemoveAllMfa] = useState(false);
|
||||
const [isRemovingAllMfa, setIsRemovingAllMfa] = useState(false);
|
||||
|
||||
// Linked accounts management
|
||||
const [userLinkedAccounts, setUserLinkedAccounts] = useState<AdminLinkedAccount[]>([]);
|
||||
const [totalAuthMethods, setTotalAuthMethods] = useState(0);
|
||||
const [unlinkingProvider, setUnlinkingProvider] = useState<string | null>(null);
|
||||
|
||||
// Admin password reset
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordConfirm, setNewPasswordConfirm] = useState("");
|
||||
const [passwordResetError, setPasswordResetError] = useState<string | null>(null);
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
|
||||
// ── Fetch users ─────────────────────────────────────────────────────────────
|
||||
const fetchUsers = useCallback(async (q: string, pg: number) => {
|
||||
setIsLoading(true);
|
||||
@@ -167,12 +199,24 @@ export default function AdminUsersPage() {
|
||||
const openUserDrawer = async (user: ApiUser) => {
|
||||
setSelectedUser(user);
|
||||
setUserSshKeys([]);
|
||||
setUserMfaMethods([]);
|
||||
setUserLinkedAccounts([]);
|
||||
setTotalAuthMethods(0);
|
||||
setIsDrawerLoading(true);
|
||||
try {
|
||||
const data = await api.admin.getUser(user.id);
|
||||
setUserSshKeys(data.ssh_keys);
|
||||
const [userData, mfaData, linkedData] = await Promise.allSettled([
|
||||
api.admin.getUser(user.id),
|
||||
api.admin.getUserMfa(user.id),
|
||||
api.admin.getUserLinkedAccounts(user.id),
|
||||
]);
|
||||
if (userData.status === "fulfilled") setUserSshKeys(userData.value.ssh_keys);
|
||||
if (mfaData.status === "fulfilled") setUserMfaMethods(mfaData.value.mfa_methods);
|
||||
if (linkedData.status === "fulfilled") {
|
||||
setUserLinkedAccounts(linkedData.value.linked_accounts);
|
||||
setTotalAuthMethods(linkedData.value.total_auth_methods);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — drawer still shows basic user info
|
||||
// Non-fatal
|
||||
} finally {
|
||||
setIsDrawerLoading(false);
|
||||
}
|
||||
@@ -270,6 +314,23 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Force-verify email ───────────────────────────────────────────────────────
|
||||
const handleVerifyEmail = async () => {
|
||||
if (!selectedUser) return;
|
||||
setIsVerifyingEmail(true);
|
||||
try {
|
||||
const data = await api.admin.adminVerifyUserEmail(selectedUser.id);
|
||||
const updated = { ...selectedUser, email_verified: data.user.email_verified, status: data.user.status };
|
||||
setSelectedUser(updated);
|
||||
setUsers((prev) => prev.map((u) => u.id === selectedUser.id ? { ...u, email_verified: data.user.email_verified, status: data.user.status } : u));
|
||||
toast({ title: "Email verified", description: `${selectedUser.email} is now verified and active.` });
|
||||
} catch (err) {
|
||||
toast({ variant: "destructive", title: "Failed to verify email", description: err instanceof ApiError ? err.message : "Something went wrong" });
|
||||
} finally {
|
||||
setIsVerifyingEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Hard delete user ─────────────────────────────────────────────────────────
|
||||
const handleHardDelete = async () => {
|
||||
if (!selectedUser) return;
|
||||
@@ -295,6 +356,108 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Remove single MFA method ─────────────────────────────────────────────────
|
||||
const handleRemoveMfaMethod = async (method: AdminMfaMethod) => {
|
||||
if (!selectedUser) return;
|
||||
setRemovingMfaId(method.id);
|
||||
try {
|
||||
const credentialId = method.type === "webauthn" ? method.id : undefined;
|
||||
await api.admin.removeUserMfa(selectedUser.id, method.type as "totp" | "webauthn", credentialId);
|
||||
// Refresh MFA methods list
|
||||
const mfaData = await api.admin.getUserMfa(selectedUser.id);
|
||||
setUserMfaMethods(mfaData.mfa_methods);
|
||||
toast({
|
||||
title: "MFA method removed",
|
||||
description: `${method.name} has been removed for ${selectedUser.email}. They can now re-enroll.`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to remove MFA method",
|
||||
description: err instanceof ApiError ? err.message : "Something went wrong",
|
||||
});
|
||||
} finally {
|
||||
setRemovingMfaId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Remove ALL MFA methods ───────────────────────────────────────────────────
|
||||
const handleRemoveAllMfa = async () => {
|
||||
if (!selectedUser) return;
|
||||
setIsRemovingAllMfa(true);
|
||||
try {
|
||||
await api.admin.removeUserMfa(selectedUser.id, "all");
|
||||
setUserMfaMethods([]);
|
||||
setShowRemoveAllMfa(false);
|
||||
toast({
|
||||
title: "All MFA methods removed",
|
||||
description: `All MFA methods for ${selectedUser.email} have been cleared. They can now re-enroll.`,
|
||||
});
|
||||
} catch (err) {
|
||||
setShowRemoveAllMfa(false);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to remove MFA methods",
|
||||
description: err instanceof ApiError ? err.message : "Something went wrong",
|
||||
});
|
||||
} finally {
|
||||
setIsRemovingAllMfa(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlinkProvider = async (account: AdminLinkedAccount) => {
|
||||
if (!selectedUser) return;
|
||||
setUnlinkingProvider(account.id);
|
||||
try {
|
||||
await api.admin.adminUnlinkUserProvider(selectedUser.id, account.provider_type);
|
||||
setUserLinkedAccounts((prev) => prev.filter((a) => a.id !== account.id));
|
||||
setTotalAuthMethods((prev) => Math.max(0, prev - 1));
|
||||
toast({
|
||||
title: "Provider unlinked",
|
||||
description: `${capitalize(account.provider_type)} has been unlinked from ${selectedUser.email}.`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to unlink provider",
|
||||
description: err instanceof ApiError ? err.message : "Something went wrong",
|
||||
});
|
||||
} finally {
|
||||
setUnlinkingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Admin password reset ─────────────────────────────────────────────────────
|
||||
const handlePasswordReset = async () => {
|
||||
if (!selectedUser) return;
|
||||
setPasswordResetError(null);
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setPasswordResetError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
setPasswordResetError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResettingPassword(true);
|
||||
try {
|
||||
await api.admin.adminSetUserPassword(selectedUser.id, newPassword);
|
||||
setShowPasswordReset(false);
|
||||
setNewPassword("");
|
||||
setNewPasswordConfirm("");
|
||||
toast({
|
||||
title: "Password updated",
|
||||
description: `Password has been set for ${selectedUser.email}. They can now log in with it.`,
|
||||
});
|
||||
} catch (err) {
|
||||
setPasswordResetError(err instanceof ApiError ? err.message : "Failed to set password");
|
||||
} finally {
|
||||
setIsResettingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter by role client-side
|
||||
const filteredUsers = users.filter((u) => {
|
||||
if (roleFilter === "all") return true;
|
||||
@@ -466,6 +629,28 @@ export default function AdminUsersPage() {
|
||||
<Ban className="w-4 h-4" />
|
||||
Account Access
|
||||
</h3>
|
||||
|
||||
{/* Unverified / inactive email block */}
|
||||
{(!selectedUser.email_verified || selectedUser.status === "inactive") && (
|
||||
<div className="space-y-2 pb-3 border-b">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedUser.status === "inactive"
|
||||
? "This account is inactive — the user has not verified their email and cannot log in, set up OAuth, or configure MFA."
|
||||
: "This user's email address is not verified."}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleVerifyEmail}
|
||||
disabled={isVerifyingEmail}
|
||||
className="text-blue-600 border-blue-300 hover:bg-blue-50"
|
||||
>
|
||||
{isVerifyingEmail ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <CheckCircle className="w-4 h-4 mr-2" />}
|
||||
Verify email & activate account
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSuspended(selectedUser.status) ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -532,6 +717,168 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── MFA Methods section ────────────────────────────────────────── */}
|
||||
{selectedUser.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 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>
|
||||
|
||||
{isMfaLoading ? (
|
||||
<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).
|
||||
The user will be able to re-enroll after removal.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Linked Accounts section ────────────────────────────────── */}
|
||||
{selectedUser.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>
|
||||
|
||||
{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 an OAuth provider to prevent sign-in via that provider.
|
||||
Cannot unlink if it is the user's only sign-in method.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Admin Password Reset section ──────────────────────────── */}
|
||||
{selectedUser.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">
|
||||
<Lock className="w-4 h-4" />
|
||||
Password
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Set a new password for this user. Use this when a user is locked out or needs a password added to their account.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => { setPasswordResetError(null); setNewPassword(""); setNewPasswordConfirm(""); setShowPasswordReset(true); }}
|
||||
>
|
||||
<Lock className="w-3 h-3 mr-1" />
|
||||
Set password
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Keys section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -680,6 +1027,37 @@ export default function AdminUsersPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Remove All MFA confirmation ───────────────────────────────────────── */}
|
||||
<Dialog open={showRemoveAllMfa} onOpenChange={setShowRemoveAllMfa}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-amber-600">
|
||||
<ShieldOff className="w-5 h-5" />
|
||||
Remove all MFA methods?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
All MFA methods for{" "}
|
||||
<strong>{selectedUser?.full_name || selectedUser?.email}</strong> will
|
||||
be removed. They will be able to re-enroll after this action. Use this
|
||||
when the user has lost access to their authenticator app or passkey.
|
||||
</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>
|
||||
|
||||
{/* ── Hard delete confirmation ──────────────────────────────────────────── */}
|
||||
<Dialog
|
||||
open={showHardDelete}
|
||||
@@ -728,6 +1106,71 @@ export default function AdminUsersPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* ── Admin password reset dialog ───────────────────────────────────── */}
|
||||
<Dialog
|
||||
open={showPasswordReset}
|
||||
onOpenChange={(open) => {
|
||||
setShowPasswordReset(open);
|
||||
if (!open) { setNewPassword(""); setNewPasswordConfirm(""); setPasswordResetError(null); }
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Lock className="w-5 h-5" />
|
||||
Set password for {selectedUser?.email}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
The user will be able to log in with this password immediately. This does not affect their existing OAuth logins.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2 space-y-3">
|
||||
{passwordResetError && (
|
||||
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{passwordResetError}</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-new-password">New password</Label>
|
||||
<Input
|
||||
id="admin-new-password"
|
||||
type="password"
|
||||
placeholder="Min. 8 characters"
|
||||
value={newPassword}
|
||||
onChange={(e) => { setNewPassword(e.target.value); setPasswordResetError(null); }}
|
||||
disabled={isResettingPassword}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="admin-new-password-confirm">Confirm password</Label>
|
||||
<Input
|
||||
id="admin-new-password-confirm"
|
||||
type="password"
|
||||
placeholder="Repeat new password"
|
||||
value={newPasswordConfirm}
|
||||
onChange={(e) => { setNewPasswordConfirm(e.target.value); setPasswordResetError(null); }}
|
||||
disabled={isResettingPassword}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && newPassword && newPasswordConfirm) handlePasswordReset(); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPasswordReset(false)}
|
||||
disabled={isResettingPassword}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePasswordReset}
|
||||
disabled={isResettingPassword || !newPassword || !newPasswordConfirm}
|
||||
>
|
||||
{isResettingPassword && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Set password
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user