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:
@@ -13,6 +13,7 @@ import {
|
||||
ScrollText,
|
||||
Terminal,
|
||||
ShieldCheck,
|
||||
Key,
|
||||
} from "lucide-react";
|
||||
import { GatehouseLogo } from "@/components/branding/GatehouseLogo";
|
||||
import { NavLink } from "@/components/NavLink";
|
||||
@@ -57,8 +58,9 @@ const orgAdminNavItems = [
|
||||
|
||||
const adminNavItems = [
|
||||
{ title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck },
|
||||
{ title: "Org Audit Log", url: "/org/audit", icon: FileText },
|
||||
{ title: "System Logs", url: "/admin/audit", icon: ScrollText },
|
||||
{ title: "OIDC Clients", url: "/org/clients", icon: Key },
|
||||
{ title: "Org Audit Log", url: "/org/audit", icon: FileText },
|
||||
{ title: "System Logs", url: "/admin/audit", icon: ScrollText },
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
|
||||
@@ -18,6 +18,7 @@ interface TotpRemoveDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
isRequired?: boolean;
|
||||
hasPassword?: boolean;
|
||||
}
|
||||
|
||||
export function TotpRemoveDialog({
|
||||
@@ -25,6 +26,7 @@ export function TotpRemoveDialog({
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
isRequired = false,
|
||||
hasPassword = true,
|
||||
}: TotpRemoveDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -45,7 +47,7 @@ export function TotpRemoveDialog({
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
if (!password) {
|
||||
if (hasPassword && !password) {
|
||||
setError("Password is required to disable TOTP");
|
||||
return;
|
||||
}
|
||||
@@ -54,7 +56,7 @@ export function TotpRemoveDialog({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.totp.disable(password);
|
||||
await api.totp.disable(hasPassword ? password : null);
|
||||
|
||||
toast({
|
||||
title: "Two-factor authentication disabled",
|
||||
@@ -80,7 +82,7 @@ export function TotpRemoveDialog({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && password) {
|
||||
if (e.key === "Enter" && (!hasPassword || password)) {
|
||||
handleRemove();
|
||||
}
|
||||
};
|
||||
@@ -109,25 +111,30 @@ export function TotpRemoveDialog({
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password-confirm">Enter your password to confirm</Label>
|
||||
<Input
|
||||
id="password-confirm"
|
||||
type="password"
|
||||
placeholder="Your current password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
{hasPassword && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password-confirm">Enter your password to confirm</Label>
|
||||
<Input
|
||||
id="password-confirm"
|
||||
type="password"
|
||||
placeholder="Your current password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!hasPassword && error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
@@ -140,7 +147,7 @@ export function TotpRemoveDialog({
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleRemove}
|
||||
disabled={isLoading || !password}
|
||||
disabled={isLoading || (hasPassword && !password)}
|
||||
>
|
||||
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Remove TOTP
|
||||
|
||||
+95
-4
@@ -29,6 +29,10 @@ export interface User {
|
||||
org_role?: string;
|
||||
org_id?: string;
|
||||
activated?: boolean;
|
||||
// Auth method capabilities — present on /users/me response
|
||||
has_password?: boolean;
|
||||
totp_enabled?: boolean;
|
||||
linked_providers?: string[];
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
@@ -141,6 +145,34 @@ export interface WebAuthnLoginCompleteResponse {
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
// Admin MFA management types
|
||||
export interface AdminMfaMethod {
|
||||
/** Unique identifier: auth_method.id for TOTP, credential id for WebAuthn */
|
||||
id: string;
|
||||
/** 'totp' or 'webauthn' */
|
||||
type: 'totp' | 'webauthn';
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
device_type?: string;
|
||||
transports?: string[];
|
||||
verified: boolean;
|
||||
created_at: string | null;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
export interface AdminLinkedAccount {
|
||||
/** UUID of the AuthenticationMethod row */
|
||||
id: string;
|
||||
/** Provider name: 'google' | 'github' | 'microsoft' | 'oidc' */
|
||||
provider_type: string;
|
||||
/** Email address from the OAuth provider, if available */
|
||||
email: string | null;
|
||||
/** Display name from the OAuth provider, if available */
|
||||
name: string | null;
|
||||
/** ISO timestamp when the account was linked */
|
||||
linked_at: string | null;
|
||||
}
|
||||
|
||||
// External Auth Types
|
||||
export type ExternalProviderId = 'google' | 'github' | 'microsoft';
|
||||
|
||||
@@ -552,6 +584,10 @@ export const api = {
|
||||
unsuspendUser: (userId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ user: User }>(`/admin/users/${userId}/unsuspend`, { method: 'POST' }, true, requestConfig),
|
||||
|
||||
// Force-verify a user's email and activate their account (clears stale verification tokens)
|
||||
adminVerifyUserEmail: (userId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ user: User }>(`/admin/users/${userId}/verify-email`, { method: 'POST' }, true, requestConfig),
|
||||
|
||||
// Permanently delete a user — revokes certs, cascades DB delete, unrecoverable
|
||||
hardDeleteUser: (userId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ deleted_user_id: string; deleted_user_email: string; ssh_keys_deleted: number; certs_revoked: number }>(
|
||||
@@ -561,6 +597,61 @@ export const api = {
|
||||
requestConfig,
|
||||
),
|
||||
|
||||
// Get all MFA methods configured for a user (admin view)
|
||||
getUserMfa: (userId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ user: { id: string; email: string; full_name: string | null }; mfa_methods: AdminMfaMethod[] }>(
|
||||
`/admin/users/${userId}/mfa`,
|
||||
{},
|
||||
true,
|
||||
requestConfig,
|
||||
),
|
||||
|
||||
// Remove an MFA method for a user (admin action — use when user lost access)
|
||||
// method_type: 'totp' | 'webauthn' | 'all'
|
||||
// credentialId: optional WebAuthn credential ID to remove a single passkey
|
||||
removeUserMfa: (userId: string, methodType: 'totp' | 'webauthn' | 'all', credentialId?: string, requestConfig?: RequestConfig) => {
|
||||
const qs = credentialId ? `?credential_id=${encodeURIComponent(credentialId)}` : '';
|
||||
return request<{ removed_methods: string[]; removed_count: number; user: { id: string; email: string } }>(
|
||||
`/admin/users/${userId}/mfa/${methodType}${qs}`,
|
||||
{ method: 'DELETE' },
|
||||
true,
|
||||
requestConfig,
|
||||
);
|
||||
},
|
||||
|
||||
// Get linked OAuth/OIDC accounts for a user (admin view)
|
||||
getUserLinkedAccounts: (userId: string, requestConfig?: RequestConfig) =>
|
||||
request<{
|
||||
user: { id: string; email: string; full_name: string | null };
|
||||
linked_accounts: AdminLinkedAccount[];
|
||||
total_auth_methods: number;
|
||||
}>(
|
||||
`/admin/users/${userId}/linked-accounts`,
|
||||
{},
|
||||
true,
|
||||
requestConfig,
|
||||
),
|
||||
|
||||
// Unlink an OAuth/OIDC provider from a user's account (admin action)
|
||||
// provider: provider name ('google', 'github', 'microsoft', 'oidc') or method UUID
|
||||
adminUnlinkUserProvider: (userId: string, provider: string, requestConfig?: RequestConfig) =>
|
||||
request<{ provider: string; user: { id: string; email: string } }>(
|
||||
`/admin/users/${userId}/linked-accounts/${encodeURIComponent(provider)}`,
|
||||
{ method: 'DELETE' },
|
||||
true,
|
||||
requestConfig,
|
||||
),
|
||||
|
||||
// Set or reset a user's password (admin action — no current password needed)
|
||||
// Creates the password auth method if the user doesn't have one (e.g. OAuth-only users)
|
||||
adminSetUserPassword: (userId: string, password: string, requestConfig?: RequestConfig) =>
|
||||
request<{ user: { id: string; email: string } }>(
|
||||
`/admin/users/${userId}/password`,
|
||||
{ method: 'POST', body: JSON.stringify({ password }) },
|
||||
true,
|
||||
requestConfig,
|
||||
),
|
||||
|
||||
// Get the cert policy for a department
|
||||
getDeptCertPolicy: (orgId: string, deptId: string, requestConfig?: RequestConfig) =>
|
||||
request<{ cert_policy: DeptCertPolicy }>(`/organizations/${orgId}/departments/${deptId}/cert-policy`, {}, true, requestConfig),
|
||||
@@ -622,10 +713,10 @@ export const api = {
|
||||
request<TotpStatusResponse>('/auth/totp/status'),
|
||||
|
||||
// Disable TOTP - wrong password should not log user out
|
||||
disable: (password: string) =>
|
||||
disable: (password?: string | null) =>
|
||||
request<{ message: string }>('/auth/totp/disable', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify({ password: password || null }),
|
||||
}, true, { clearTokenOn401: false }),
|
||||
|
||||
// Regenerate backup codes - wrong password should not log user out
|
||||
@@ -1046,7 +1137,7 @@ export const api = {
|
||||
false,
|
||||
),
|
||||
|
||||
// Accept invite (unauthenticated) — password/name only needed for new accounts
|
||||
// Accept invite — sends Bearer token if present (OAuth users skip password)
|
||||
accept: (token: string, full_name?: string, password?: string) =>
|
||||
request<LoginResponse>(
|
||||
`/invites/${token}/accept`,
|
||||
@@ -1054,7 +1145,7 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ full_name, password, password_confirm: password }),
|
||||
},
|
||||
false,
|
||||
true,
|
||||
),
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,18 +42,20 @@ const PROVIDER_LOGOS: Record<string, string> = {
|
||||
microsoft: "https://www.microsoft.com/favicon.ico",
|
||||
};
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:5000/api/v1') as string;
|
||||
|
||||
const PROVIDER_HELP: Record<string, { docsUrl: string; callbackNote: string }> = {
|
||||
google: {
|
||||
docsUrl: "https://console.cloud.google.com/apis/credentials",
|
||||
callbackNote: "Authorized redirect URI: http://localhost:5000/api/v1/auth/external/google/callback",
|
||||
callbackNote: `Authorized redirect URI: ${API_BASE}/auth/external/google/callback`,
|
||||
},
|
||||
github: {
|
||||
docsUrl: "https://github.com/settings/applications/new",
|
||||
callbackNote: "Authorization callback URL: http://localhost:5000/api/v1/auth/external/github/callback",
|
||||
callbackNote: `Authorization callback URL: ${API_BASE}/auth/external/github/callback`,
|
||||
},
|
||||
microsoft: {
|
||||
docsUrl: "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps",
|
||||
callbackNote: "Redirect URI: http://localhost:5000/api/v1/auth/external/microsoft/callback",
|
||||
callbackNote: `Redirect URI: ${API_BASE}/auth/external/microsoft/callback`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -42,12 +42,6 @@ export default function ForgotPasswordPage() {
|
||||
you'll receive a password reset link shortly.
|
||||
</p>
|
||||
|
||||
<BannerAlert
|
||||
type="info"
|
||||
message="For security reasons, we don't confirm whether an account exists. Check your spam folder if you don't see the email."
|
||||
className="mb-6 text-left"
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Link to="/login">
|
||||
<Button variant="outline" className="w-full">
|
||||
|
||||
@@ -261,8 +261,7 @@ export default function CAsPage() {
|
||||
<div>
|
||||
<h1 className="page-title">Certificate Authorities</h1>
|
||||
<p className="page-description">
|
||||
Manage your organization's SSH CAs — sign user and host certificates to eliminate
|
||||
static <code>authorized_keys</code> files.
|
||||
Manage your organization's SSH CAs with <code>Gatehouse</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
XCircle,
|
||||
Crown,
|
||||
Trash2,
|
||||
ShieldOff,
|
||||
Link2,
|
||||
Unlink,
|
||||
Smartphone,
|
||||
KeyRound,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -60,7 +66,7 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api, OrganizationMember, ApiError, OrgInvite, SSHKey, User as ApiUser } from "@/lib/api";
|
||||
import { api, OrganizationMember, ApiError, OrgInvite, SSHKey, User as ApiUser, AdminMfaMethod, AdminLinkedAccount } from "@/lib/api";
|
||||
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
@@ -85,6 +91,10 @@ function formatDate(d: string | null | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
function capitalize(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function isSuspended(status: string | undefined) {
|
||||
return status === "suspended" || status === "compliance_suspended";
|
||||
}
|
||||
@@ -133,6 +143,24 @@ export default function MembersPage() {
|
||||
const [userSshKeys, setUserSshKeys] = useState<SSHKey[]>([]);
|
||||
const [isDrawerLoading, setIsDrawerLoading] = useState(false);
|
||||
|
||||
// ── MFA management (drawer) ──────────────────────────────────────────────────
|
||||
const [userMfaMethods, setUserMfaMethods] = useState<AdminMfaMethod[]>([]);
|
||||
const [removingMfaId, setRemovingMfaId] = useState<string | null>(null);
|
||||
const [showRemoveAllMfa, setShowRemoveAllMfa] = useState(false);
|
||||
const [isRemovingAllMfa, setIsRemovingAllMfa] = useState(false);
|
||||
|
||||
// ── Linked OAuth accounts (drawer) ──────────────────────────────────────────
|
||||
const [userLinkedAccounts, setUserLinkedAccounts] = useState<AdminLinkedAccount[]>([]);
|
||||
const [totalAuthMethods, setTotalAuthMethods] = useState(0);
|
||||
const [unlinkingProvider, setUnlinkingProvider] = useState<string | null>(null);
|
||||
|
||||
// ── Admin set / change password (drawer) ────────────────────────────────────
|
||||
const [adminPwNew, setAdminPwNew] = useState("");
|
||||
const [adminPwConfirm, setAdminPwConfirm] = useState("");
|
||||
const [adminPwError, setAdminPwError] = useState<string | null>(null);
|
||||
const [isSettingPw, setIsSettingPw] = useState(false);
|
||||
const [adminPwSuccess, setAdminPwSuccess] = useState(false);
|
||||
|
||||
// ── Suspend / Unsuspend ──────────────────────────────────────────────────────
|
||||
const [isSuspending, setIsSuspending] = useState(false);
|
||||
const [showSuspendConfirm, setShowSuspendConfirm] = useState(false);
|
||||
@@ -156,13 +184,94 @@ export default function MembersPage() {
|
||||
const [removeMember, setRemoveMember] = useState<OrganizationMember | null>(null);
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
// ── Remove MFA (drawer) ─────────────────────────────────────────────────────────
|
||||
const handleRemoveMfaMethod = async (method: AdminMfaMethod) => {
|
||||
if (!selectedMember) return;
|
||||
setRemovingMfaId(method.id);
|
||||
try {
|
||||
const methodType = method.type as 'totp' | 'webauthn';
|
||||
const credId = method.type === 'webauthn' ? method.id : undefined;
|
||||
await api.admin.removeUserMfa(selectedMember.user_id, methodType, credId);
|
||||
const refreshed = await api.admin.getUserMfa(selectedMember.user_id);
|
||||
setUserMfaMethods(refreshed.mfa_methods);
|
||||
toast({ title: 'MFA method removed', description: `${method.name} has been removed for ${selectedMember.user?.email}.` });
|
||||
} catch (err) {
|
||||
toast({ variant: 'destructive', title: 'Failed to remove MFA method', description: err instanceof ApiError ? err.message : 'Something went wrong.' });
|
||||
} finally {
|
||||
setRemovingMfaId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAllMfa = async () => {
|
||||
if (!selectedMember) return;
|
||||
setIsRemovingAllMfa(true);
|
||||
try {
|
||||
await api.admin.removeUserMfa(selectedMember.user_id, 'all');
|
||||
setUserMfaMethods([]);
|
||||
setShowRemoveAllMfa(false);
|
||||
toast({ title: 'All MFA methods removed', description: `All MFA methods for ${selectedMember.user?.email} have been cleared.` });
|
||||
} catch (err) {
|
||||
toast({ variant: 'destructive', title: 'Failed to remove MFA methods', description: err instanceof ApiError ? err.message : 'Something went wrong.' });
|
||||
} finally {
|
||||
setIsRemovingAllMfa(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Unlink OAuth provider (drawer) ────────────────────────────────────────────
|
||||
const handleUnlinkProvider = async (account: AdminLinkedAccount) => {
|
||||
if (!selectedMember) return;
|
||||
setUnlinkingProvider(account.id);
|
||||
try {
|
||||
await api.admin.adminUnlinkUserProvider(selectedMember.user_id, account.provider_type);
|
||||
setUserLinkedAccounts((prev) => prev.filter((a) => a.id !== account.id));
|
||||
setTotalAuthMethods((prev) => prev - 1);
|
||||
toast({ title: 'Provider unlinked', description: `${capitalize(account.provider_type)} has been unlinked from ${selectedMember.user?.email}.` });
|
||||
} catch (err) {
|
||||
toast({ variant: 'destructive', title: 'Failed to unlink provider', description: err instanceof ApiError ? err.message : 'Something went wrong.' });
|
||||
} finally {
|
||||
setUnlinkingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Admin set / change password ──────────────────────────────────────────────
|
||||
const handleAdminSetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedMember) return;
|
||||
setAdminPwError(null);
|
||||
setAdminPwSuccess(false);
|
||||
if (adminPwNew.length < 8) {
|
||||
setAdminPwError("Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (adminPwNew !== adminPwConfirm) {
|
||||
setAdminPwError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
setIsSettingPw(true);
|
||||
try {
|
||||
await api.admin.adminSetUserPassword(selectedMember.user_id, adminPwNew);
|
||||
setAdminPwNew("");
|
||||
setAdminPwConfirm("");
|
||||
setAdminPwSuccess(true);
|
||||
// Refresh detailUser so has_password reflects the new state
|
||||
const refreshed = await api.admin.getUser(selectedMember.user_id);
|
||||
setDetailUser(refreshed.user);
|
||||
toast({ title: detailUser?.has_password ? "Password updated" : "Password set", description: `Password ${detailUser?.has_password ? "changed" : "created"} for ${selectedMember.user?.email}.` });
|
||||
} catch (err) {
|
||||
setAdminPwError(err instanceof ApiError ? err.message : "Failed to update password.");
|
||||
} finally {
|
||||
setIsSettingPw(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ── Invite ───────────────────────────────────────────────────────────────────
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState("member");
|
||||
const [isInviting, setIsInviting] = useState(false);
|
||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||
|
||||
// Invite link dialog (when SMTP not configured)
|
||||
// Invite link dialog
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [inviteLinkEmail, setInviteLinkEmail] = useState("");
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
@@ -226,10 +335,24 @@ export default function MembersPage() {
|
||||
setDetailUser(null);
|
||||
setUserSshKeys([]);
|
||||
setIsDrawerLoading(true);
|
||||
setUserMfaMethods([]);
|
||||
setUserLinkedAccounts([]);
|
||||
setTotalAuthMethods(0);
|
||||
try {
|
||||
const data = await api.admin.getUser(member.user_id);
|
||||
setDetailUser(data.user);
|
||||
setUserSshKeys(data.ssh_keys);
|
||||
const [userData, mfaData, linkedData] = await Promise.allSettled([
|
||||
api.admin.getUser(member.user_id),
|
||||
api.admin.getUserMfa(member.user_id),
|
||||
api.admin.getUserLinkedAccounts(member.user_id),
|
||||
]);
|
||||
if (userData.status === 'fulfilled') {
|
||||
setDetailUser(userData.value.user);
|
||||
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 member info
|
||||
} finally {
|
||||
@@ -241,6 +364,13 @@ export default function MembersPage() {
|
||||
setSelectedMember(null);
|
||||
setDetailUser(null);
|
||||
setUserSshKeys([]);
|
||||
setUserMfaMethods([]);
|
||||
setUserLinkedAccounts([]);
|
||||
setTotalAuthMethods(0);
|
||||
setAdminPwNew("");
|
||||
setAdminPwConfirm("");
|
||||
setAdminPwError(null);
|
||||
setAdminPwSuccess(false);
|
||||
};
|
||||
|
||||
// ── Role change (drawer inline select) ──────────────────────────────────────
|
||||
@@ -948,6 +1078,190 @@ export default function MembersPage() {
|
||||
</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">
|
||||
@@ -1053,6 +1367,31 @@ export default function MembersPage() {
|
||||
</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">
|
||||
|
||||
+454
-109
@@ -1,8 +1,18 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Plus, Key, MoreHorizontal, Copy, Trash2, Loader2, AlertCircle, CheckCircle } from "lucide-react";
|
||||
import {
|
||||
Plus, Key, MoreHorizontal, Copy, Trash2, Loader2,
|
||||
AlertCircle, CheckCircle, Network, Terminal, Check,
|
||||
ChevronDown, Globe, RefreshCw, Info,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -16,53 +26,112 @@ import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { api, OIDCClient, OIDCClientWithSecret } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useOrg } from "@/contexts/OrgContext";
|
||||
|
||||
// Derive issuer base URL from the API base
|
||||
const ISSUER_URL = (import.meta.env.VITE_API_BASE_URL ?? "http://localhost:5000/api/v1")
|
||||
.replace(/\/api\/v1\/?$/, "");
|
||||
|
||||
function buildProxyConfig(clientId: string, clientSecret: string, proxyHost: string) {
|
||||
return `provider = "oidc"
|
||||
oidc_issuer_url = "${ISSUER_URL}"
|
||||
client_id = "${clientId}"
|
||||
client_secret = "${clientSecret}"
|
||||
redirect_url = "http://${proxyHost}/oauth2/callback"
|
||||
scope = "openid profile email"
|
||||
cookie_secret = "$(openssl rand -base64 32 | head -c 32)"
|
||||
cookie_secure = false
|
||||
upstream = "http://127.0.0.1:8080/"
|
||||
set_authorization_header = true
|
||||
set_x_auth_request_header = true`;
|
||||
}
|
||||
|
||||
function useCopyButton() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
return { copied, copy };
|
||||
}
|
||||
|
||||
type DialogMode = "generic" | "proxy" | null;
|
||||
|
||||
interface NewSecretState {
|
||||
clientId: string;
|
||||
secret: string;
|
||||
proxyHost?: string;
|
||||
isProxy: boolean;
|
||||
}
|
||||
|
||||
export default function OIDCClientsPage() {
|
||||
const { toast } = useToast();
|
||||
const { selectedOrgId: orgId } = useOrg();
|
||||
const { copy: copySecret, copied: secretCopied } = useCopyButton();
|
||||
const { copy: copyConfig, copied: configCopied } = useCopyButton();
|
||||
|
||||
const [clients, setClients] = useState<OIDCClient[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newSecret, setNewSecret] = useState<{ clientId: string; secret: string } | null>(null);
|
||||
const [newSecret, setNewSecret] = useState<NewSecretState | null>(null);
|
||||
|
||||
// Generic form
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
const urisRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const loadData = (id: string) => {
|
||||
api.organizations.getClients(id)
|
||||
.then((data) => setClients(data.clients))
|
||||
.catch(() => toast({ title: "Error", description: "Failed to load OIDC clients.", variant: "destructive" }))
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
// Proxy form
|
||||
const proxyNameRef = useRef<HTMLInputElement>(null);
|
||||
const proxyHostRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orgId) { setIsLoading(false); return; }
|
||||
setIsLoading(true);
|
||||
loadData(orgId);
|
||||
api.organizations.getClients(orgId)
|
||||
.then((data) => setClients(data.clients))
|
||||
.catch(() => toast({ title: "Error", description: "Failed to load OIDC clients.", variant: "destructive" }))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [orgId]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!orgId || !nameRef.current || !urisRef.current) return;
|
||||
const name = nameRef.current.value.trim();
|
||||
const uris = urisRef.current.value.trim().split(/[\n,]+/).map((u) => u.trim()).filter(Boolean);
|
||||
if (!name || !uris.length) return;
|
||||
if (!orgId) return;
|
||||
|
||||
let name: string;
|
||||
let uris: string[];
|
||||
let proxyHost: string | undefined;
|
||||
|
||||
if (dialogMode === "generic") {
|
||||
name = nameRef.current?.value.trim() ?? "";
|
||||
uris = (urisRef.current?.value ?? "").split(/[\n,]+/).map((u) => u.trim()).filter(Boolean);
|
||||
if (!name || !uris.length) return;
|
||||
} else {
|
||||
name = proxyNameRef.current?.value.trim() ?? "";
|
||||
proxyHost = proxyHostRef.current?.value.trim() ?? "";
|
||||
if (!name || !proxyHost) return;
|
||||
uris = [`http://${proxyHost}/oauth2/callback`];
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const result = await api.organizations.createClient(orgId, name, uris);
|
||||
const created = result.client as OIDCClientWithSecret;
|
||||
setClients((prev) => [...prev, created]);
|
||||
setNewSecret({ clientId: created.client_id, secret: created.client_secret });
|
||||
setIsCreateOpen(false);
|
||||
setNewSecret({
|
||||
clientId: created.client_id,
|
||||
secret: created.client_secret,
|
||||
proxyHost,
|
||||
isProxy: dialogMode === "proxy",
|
||||
});
|
||||
setDialogMode(null);
|
||||
} catch {
|
||||
toast({ title: "Error", description: "Failed to create client.", variant: "destructive" });
|
||||
} finally {
|
||||
@@ -75,127 +144,139 @@ export default function OIDCClientsPage() {
|
||||
try {
|
||||
await api.organizations.deleteClient(orgId, clientId);
|
||||
setClients((prev) => prev.filter((c) => c.id !== clientId));
|
||||
toast({ title: "Client deleted", description: "OIDC client deactivated successfully." });
|
||||
toast({ title: "Client deleted" });
|
||||
} catch {
|
||||
toast({ title: "Error", description: "Failed to delete client.", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() =>
|
||||
toast({ title: "Copied", description: "Copied to clipboard." })
|
||||
);
|
||||
};
|
||||
const proxyConfig = newSecret?.isProxy && newSecret.proxyHost
|
||||
? buildProxyConfig(newSecret.clientId, newSecret.secret, newSecret.proxyHost)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
{/* Header */}
|
||||
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="page-title">OIDC Clients</h1>
|
||||
<p className="page-description">
|
||||
Manage applications that authenticate via Gatehouse
|
||||
</p>
|
||||
<p className="page-description">Applications that authenticate via Gatehouse</p>
|
||||
</div>
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add client
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create OIDC Client</DialogTitle>
|
||||
<DialogDescription>
|
||||
Register a new application to authenticate via Gatehouse
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientName">Client name</Label>
|
||||
<Input id="clientName" placeholder="My Application" ref={nameRef} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="redirectUris">Redirect URIs</Label>
|
||||
<Textarea
|
||||
id="redirectUris"
|
||||
placeholder="https://myapp.example.com/callback"
|
||||
className="min-h-[80px]"
|
||||
ref={urisRef}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
One URI per line. These are the allowed callback URLs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isCreating}>
|
||||
{isCreating ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Creating...</> : "Create client"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button onClick={() => setDialogMode("generic")}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add client
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Show new client secret once */}
|
||||
{/* One-time secret banner */}
|
||||
{newSecret && (
|
||||
<Card className="mb-4 border-success/50 bg-success/5">
|
||||
<CardContent className="p-4 flex items-start gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-success mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground">Client created — save your secret now</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">This secret will not be shown again.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono break-all">{newSecret.secret}</code>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6 flex-shrink-0" onClick={() => copyToClipboard(newSecret.secret)}>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
<Card className="mb-6 border-green-500/40 bg-green-500/5">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<div>
|
||||
<p className="font-medium">Client created — save your secret now</p>
|
||||
<p className="text-sm text-muted-foreground">This will not be shown again.</p>
|
||||
</div>
|
||||
|
||||
{/* Secret row */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Client secret</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs bg-muted px-3 py-2 rounded font-mono break-all">
|
||||
{newSecret.secret}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={() => copySecret(newSecret.secret)}>
|
||||
{secretCopied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* oauth2-proxy config snippet */}
|
||||
{proxyConfig && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
|
||||
<Terminal className="w-3 h-3" />
|
||||
oauth2-proxy config
|
||||
</p>
|
||||
<div className="relative">
|
||||
<pre className="text-xs bg-muted px-3 py-2 rounded font-mono overflow-x-auto whitespace-pre">
|
||||
{proxyConfig}
|
||||
</pre>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() => copyConfig(proxyConfig)}
|
||||
>
|
||||
{configCopied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="w-7 h-7 flex-shrink-0" onClick={() => setNewSecret(null)}>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6" onClick={() => setNewSecret(null)}>×</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Client list */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : clients.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<AlertCircle className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground">No OIDC clients configured yet.</p>
|
||||
<Button className="mt-4" onClick={() => setIsCreateOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add your first client
|
||||
</Button>
|
||||
<CardContent className="py-16 flex flex-col items-center gap-4 text-center">
|
||||
<Network className="w-10 h-10 text-muted-foreground/40" />
|
||||
<div>
|
||||
<p className="font-medium text-muted-foreground">No OIDC clients yet</p>
|
||||
<p className="text-sm text-muted-foreground/70">Register an app to let it authenticate via Gatehouse</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap justify-center">
|
||||
<Button variant="outline" onClick={() => setDialogMode("generic")}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Generic app
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setDialogMode("proxy")}>
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
oauth2-proxy
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{clients.map((client) => (
|
||||
<Card key={client.id}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Key className="w-6 h-6 text-primary" />
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<Key className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{client.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold truncate">{client.name}</p>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<code className="text-xs bg-muted px-2 py-0.5 rounded font-mono truncate max-w-[260px]">
|
||||
{client.client_id}
|
||||
</code>
|
||||
<Button variant="ghost" size="icon" className="w-6 h-6" onClick={() => copyToClipboard(client.client_id)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
onClick={() => navigator.clipboard.writeText(client.client_id).then(() =>
|
||||
toast({ title: "Copied client ID" })
|
||||
)}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{(client.scopes ?? []).map((scope) => (
|
||||
<Badge key={scope} variant="secondary" className="text-xs">
|
||||
{scope}
|
||||
@@ -206,7 +287,7 @@ export default function OIDCClientsPage() {
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" className="flex-shrink-0">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -217,24 +298,288 @@ export default function OIDCClientsPage() {
|
||||
onClick={() => handleDelete(client.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete client
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div className="mt-3 pt-3 border-t flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Created {new Date(client.created_at).toLocaleDateString()}</span>
|
||||
<span>
|
||||
Created {new Date(client.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{(client.redirect_uris ?? []).length} redirect URI{(client.redirect_uris ?? []).length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create dialog */}
|
||||
<Dialog open={dialogMode !== null} onOpenChange={(open) => { if (!open) setDialogMode(null); }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add OIDC Client</DialogTitle>
|
||||
<DialogDescription>Register an application to authenticate via Gatehouse</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={dialogMode ?? "generic"}
|
||||
onValueChange={(v) => setDialogMode(v as DialogMode)}
|
||||
className="mt-2"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="generic" className="flex-1">Generic app</TabsTrigger>
|
||||
<TabsTrigger value="proxy" className="flex-1 flex items-center gap-1.5">
|
||||
<Terminal className="w-3 h-3" />
|
||||
oauth2-proxy
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Generic tab */}
|
||||
<TabsContent value="generic" className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="genericName">Client name</Label>
|
||||
<Input id="genericName" placeholder="My Application" ref={nameRef} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="redirectUris">Redirect URIs</Label>
|
||||
<Textarea
|
||||
id="redirectUris"
|
||||
placeholder={"https://myapp.example.com/callback\nhttps://myapp.example.com/auth/callback"}
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
ref={urisRef}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">One URI per line</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* oauth2-proxy tab */}
|
||||
<TabsContent value="proxy" className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proxyName">Client name</Label>
|
||||
<Input id="proxyName" placeholder="My Protected App" ref={proxyNameRef} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proxyHost">Proxy host</Label>
|
||||
<Input id="proxyHost" placeholder="app.example.com" ref={proxyHostRef} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The hostname where oauth2-proxy runs. Redirect URI will be set to{" "}
|
||||
<code className="bg-muted px-1 rounded">http://{"<host>"}/oauth2/callback</code> automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 border px-3 py-2 text-xs text-muted-foreground">
|
||||
After creating, you'll get a ready-to-paste config snippet for oauth2-proxy.
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setDialogMode(null)} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isCreating}>
|
||||
{isCreating ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Creating…</>
|
||||
) : (
|
||||
"Create client"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Reference ─────────────────────────────────────────── */}
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center gap-2 mb-3 text-sm font-medium text-muted-foreground">
|
||||
<Info className="w-4 h-4" />
|
||||
Integration reference
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" className="space-y-2">
|
||||
|
||||
{/* Endpoints */}
|
||||
<AccordionItem value="endpoints" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="text-sm font-medium hover:no-underline py-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
OIDC endpoints
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-2 text-xs font-mono">
|
||||
{[
|
||||
["Discovery", "GET", "/.well-known/openid-configuration"],
|
||||
["Authorization", "GET", "/oidc/authorize"],
|
||||
["Token", "POST", "/oidc/token"],
|
||||
["UserInfo", "GET", "/oidc/userinfo"],
|
||||
["JWKS", "GET", "/oidc/jwks"],
|
||||
["Revocation", "POST", "/oidc/revoke"],
|
||||
["Introspection", "POST", "/oidc/introspect"],
|
||||
].map(([label, method, path]) => (
|
||||
<div key={path} className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`w-12 justify-center text-[10px] shrink-0 ${method === "POST" ? "border-orange-500/50 text-orange-500" : "border-blue-500/50 text-blue-500"}`}
|
||||
>
|
||||
{method}
|
||||
</Badge>
|
||||
<code className="text-muted-foreground">{ISSUER_URL}{path}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
Issuer: <code className="bg-muted px-1 rounded">{ISSUER_URL}</code>
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Scopes & flows */}
|
||||
<AccordionItem value="scopes" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="text-sm font-medium hover:no-underline py-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<Key className="w-4 h-4 text-muted-foreground" />
|
||||
Scopes & flows
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">Available scopes</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
["openid", "Required. Issues an ID token."],
|
||||
["profile", "Includes name, given_name, family_name."],
|
||||
["email", "Includes email and email_verified."],
|
||||
].map(([scope, desc]) => (
|
||||
<div key={scope} className="flex items-start gap-2">
|
||||
<Badge variant="secondary" className="font-mono text-xs shrink-0">{scope}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">Supported flows</p>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0" />
|
||||
<span><strong>Authorization Code + PKCE</strong> — recommended for all clients</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="w-3.5 h-3.5 text-blue-500 shrink-0" />
|
||||
<span><strong>Refresh Token</strong> — token rotation supported</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-yellow-500 shrink-0" />
|
||||
<span><strong>Authorization Code (no PKCE)</strong> — deprecated, PKCE required for new clients</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">ID token claims</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{["sub", "name", "email", "email_verified", "given_name", "family_name"].map((c) => (
|
||||
<code key={c} className="text-xs bg-muted px-1.5 py-0.5 rounded">{c}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* oauth2-proxy quick-reference */}
|
||||
<AccordionItem value="proxy-ref" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="text-sm font-medium hover:no-underline py-3">
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-muted-foreground" />
|
||||
oauth2-proxy setup
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4 space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use the <strong>oauth2-proxy</strong> tab when creating a client to get a pre-filled config. Or build it manually:
|
||||
</p>
|
||||
|
||||
{/* Step 1 */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">1 — Create a client (use the dialog above)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set the redirect URI to <code className="bg-muted px-1 rounded">http://<your-proxy-host>/oauth2/callback</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">2 — Minimal config</p>
|
||||
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto whitespace-pre">{`provider = "oidc"
|
||||
oidc_issuer_url = "${ISSUER_URL}"
|
||||
client_id = "<your-client-id>"
|
||||
client_secret = "<your-client-secret>"
|
||||
redirect_url = "http://<proxy-host>/oauth2/callback"
|
||||
scope = "openid profile email"
|
||||
cookie_secret = "$(openssl rand -base64 32 | head -c 32)"
|
||||
upstream = "http://127.0.0.1:8080/"
|
||||
set_authorization_header = true
|
||||
set_x_auth_request_header = true`}</pre>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">3 — Run it</p>
|
||||
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto">{`oauth2-proxy --config ./oauth2-proxy.cfg`}</pre>
|
||||
</div>
|
||||
|
||||
{/* Useful headers */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium">Headers forwarded to your upstream</p>
|
||||
<div className="space-y-1 text-xs font-mono">
|
||||
{[
|
||||
["X-Auth-Request-User", "User's subject (sub claim)"],
|
||||
["X-Auth-Request-Email", "User's email address"],
|
||||
["Authorization", "Bearer <access_token> (if set_authorization_header = true)"],
|
||||
].map(([header, desc]) => (
|
||||
<div key={header} className="flex items-start gap-3">
|
||||
<code className="text-muted-foreground shrink-0">{header}</code>
|
||||
<span className="text-muted-foreground/70 font-sans">{desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Docker Compose snippet */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Docker Compose example</p>
|
||||
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto whitespace-pre">{`services:
|
||||
oauth2-proxy:
|
||||
image: oauth2-proxy/oauth2-proxy:latest
|
||||
ports: ["4180:4180"]
|
||||
environment:
|
||||
OAUTH2_PROXY_PROVIDER: oidc
|
||||
OAUTH2_PROXY_OIDC_ISSUER_URL: "${ISSUER_URL}"
|
||||
OAUTH2_PROXY_CLIENT_ID: \${OIDC_CLIENT_ID}
|
||||
OAUTH2_PROXY_CLIENT_SECRET: \${OIDC_CLIENT_SECRET}
|
||||
OAUTH2_PROXY_COOKIE_SECRET: \${COOKIE_SECRET}
|
||||
OAUTH2_PROXY_UPSTREAM: http://app:8080/
|
||||
OAUTH2_PROXY_REDIRECT_URL: http://localhost:4180/oauth2/callback`}</pre>
|
||||
</div>
|
||||
|
||||
{/* Kubernetes snippet */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium">Kubernetes Ingress annotations</p>
|
||||
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto whitespace-pre">{`nginx.ingress.kubernetes.io/auth-url: https://\$host/oauth2/auth
|
||||
nginx.ingress.kubernetes.io/auth-signin: https://\$host/oauth2/sign_in
|
||||
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||
auth_request_set $user \$upstream_http_x_auth_request_user;
|
||||
auth_request_set $email \$upstream_http_x_auth_request_email;
|
||||
proxy_set_header X-User \$user;
|
||||
proxy_set_header X-Email \$email;`}</pre>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
</Accordion>
|
||||
</div>
|
||||
{/* ── /Reference ──────────────────────────────────────────── */}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Lock, Fingerprint, Smartphone, Shield, Plus, CheckCircle, Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import { Lock, Fingerprint, Smartphone, Shield, Plus, CheckCircle, Loader2, Pencil, Trash2, Link2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -9,7 +9,7 @@ import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard";
|
||||
import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard";
|
||||
import { TotpRemoveDialog } from "@/components/security/TotpRemoveDialog";
|
||||
import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter";
|
||||
import { api, ApiError, TotpStatusResponse, PasskeyCredential } from "@/lib/api";
|
||||
import { api, ApiError, TotpStatusResponse, PasskeyCredential, User } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { ComplianceBanner } from "@/components/auth/ComplianceBanner";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
@@ -30,6 +30,9 @@ export default function SecurityPage() {
|
||||
const [showTotpEnrollment, setShowTotpEnrollment] = useState(false);
|
||||
const [showTotpRemove, setShowTotpRemove] = useState(false);
|
||||
|
||||
// Profile (for has_password / linked_providers)
|
||||
const [profile, setProfile] = useState<User | null>(null);
|
||||
|
||||
// Password form state
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
@@ -53,19 +56,49 @@ export default function SecurityPage() {
|
||||
const { toast } = useToast();
|
||||
const { mfaCompliance } = useAuth();
|
||||
|
||||
// Policy requirements (could come from org settings in future)
|
||||
// Whether this user has a password (false for pure OAuth signups)
|
||||
const hasPassword = profile?.has_password ?? true; // default true until loaded
|
||||
const linkedProviders = profile?.linked_providers ?? [];
|
||||
|
||||
// Derive policy requirements from actual org compliance data
|
||||
const effectiveModes = mfaCompliance?.orgs?.map(o => o.effective_mode) ?? [];
|
||||
const policyRequirements = {
|
||||
totpRequired: true,
|
||||
passkeysRequired: false,
|
||||
totpRequired: effectiveModes.some(m =>
|
||||
m === 'require_totp' || m === 'require_totp_or_webauthn'
|
||||
),
|
||||
passkeysRequired: effectiveModes.some(m =>
|
||||
m === 'require_webauthn' || m === 'require_totp_or_webauthn'
|
||||
),
|
||||
minPasswordLength: 12,
|
||||
};
|
||||
// Build a human-readable policy description from the strictest mode active
|
||||
const activePolicyModes = effectiveModes.filter(m => m && m.startsWith('require_'));
|
||||
const policyDescription = (() => {
|
||||
if (activePolicyModes.includes('require_totp_or_webauthn'))
|
||||
return 'Your organization requires TOTP or a passkey for all members.';
|
||||
if (activePolicyModes.includes('require_totp'))
|
||||
return 'Your organization requires TOTP to be enabled for all members.';
|
||||
if (activePolicyModes.includes('require_webauthn'))
|
||||
return 'Your organization requires a passkey for all members.';
|
||||
return null;
|
||||
})();
|
||||
|
||||
// Fetch TOTP status on mount
|
||||
useEffect(() => {
|
||||
fetchProfile();
|
||||
fetchTotpStatus();
|
||||
fetchPasskeys();
|
||||
}, []);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const res = await api.users.me();
|
||||
setProfile(res.user);
|
||||
} catch {
|
||||
// Non-fatal — UI falls back to showing password section
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTotpStatus = async () => {
|
||||
setIsTotpStatusLoading(true);
|
||||
try {
|
||||
@@ -234,20 +267,20 @@ export default function SecurityPage() {
|
||||
<ComplianceBanner compliance={mfaCompliance} />
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Policy Status */}
|
||||
<Card className="border-accent/30 bg-accent/5">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-accent mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Organization Policy</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your organization requires TOTP to be enabled for all members.
|
||||
</p>
|
||||
{/* Policy Status — only shown when the org actually enforces MFA */}
|
||||
{policyDescription && (
|
||||
<Card className="border-accent/30 bg-accent/5">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-5 h-5 text-accent mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Organization Policy</p>
|
||||
<p className="text-sm text-muted-foreground">{policyDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
<Card>
|
||||
@@ -260,16 +293,40 @@ export default function SecurityPage() {
|
||||
</CardTitle>
|
||||
<CardDescription>Manage your account password</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPasswordForm(!showPasswordForm)}
|
||||
>
|
||||
Change password
|
||||
</Button>
|
||||
{hasPassword ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowPasswordForm(!showPasswordForm)}
|
||||
>
|
||||
Change password
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
Not set
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{showPasswordForm && (
|
||||
{!hasPassword && linkedProviders.length > 0 && (
|
||||
<CardContent className="border-t pt-4">
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<Link2 className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<p>
|
||||
Your account uses{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{linkedProviders
|
||||
.map((p) =>
|
||||
({ google: "Google", github: "GitHub", microsoft: "Microsoft", oidc: "SSO" }[p] ?? p)
|
||||
)
|
||||
.join(", ")}
|
||||
</span>{" "}
|
||||
for sign-in. No password is set. Contact your admin if you need one added.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
{hasPassword && showPasswordForm && (
|
||||
<CardContent className="space-y-4 border-t pt-4">
|
||||
{passwordError && (
|
||||
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
@@ -505,6 +562,7 @@ export default function SecurityPage() {
|
||||
setShowTotpRemove(false);
|
||||
}}
|
||||
isRequired={policyRequirements.totpRequired}
|
||||
hasPassword={hasPassword}
|
||||
/>
|
||||
|
||||
{/* Delete Passkey Confirmation */}
|
||||
|
||||
Reference in New Issue
Block a user