Merge pull request #3 from jamesii-b/gatehouse/secuird-CA-merge-v2.01

Gatehouse/secuird ca merge v2.01
This commit is contained in:
2026-03-05 16:55:36 +10:30
committed by GitHub
23 changed files with 3109 additions and 823 deletions
+12 -3
View File
@@ -7,14 +7,23 @@
<meta name="description" content="Gatehouse is a self-hosted identity and access platform providing secure authentication, organization-level security policy, and OIDC-based Single Sign-On." /> <meta name="description" content="Gatehouse is a self-hosted identity and access platform providing secure authentication, organization-level security policy, and OIDC-based Single Sign-On." />
<meta name="author" content="Gatehouse" /> <meta name="author" content="Gatehouse" />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/gatehouse-logo.svg" />
<!-- Open Graph -->
<meta property="og:title" content="Gatehouse — Identity & Access" /> <meta property="og:title" content="Gatehouse — Identity & Access" />
<meta property="og:description" content="Secure authentication and access management for your organization." /> <meta property="og:description" content="Secure authentication and access management for your organization." />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" /> <meta property="og:image" content="/gatehouse-logo.svg" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@Lovable" /> <meta name="twitter:site" content="@Gatehouse" />
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" /> <meta name="twitter:image" content="/gatehouse-logo.svg" />
<!-- Theme color -->
<meta name="theme-color" content="#36b9a6" />
</head> </head>
<body> <body>
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<!-- Background - Primary color (Teal #36b9a6) -->
<rect width="24" height="24" rx="3" fill="#36b9a6"/>
<!-- Left pillar -->
<path d="M4 4h3v16H4V4z" fill="#ffffff"/>
<!-- Right pillar -->
<path d="M17 4h3v16h-3V4z" fill="#ffffff"/>
<!-- Archway -->
<path d="M7 4h10v3H7V4z" fill="#ffffff" opacity="0.7"/>
<!-- Keyhole -->
<circle cx="12" cy="14" r="2" fill="#ffffff" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 496 B

+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" width="128" height="128">
<!-- Background circle (optional, can be removed) -->
<rect width="24" height="24" rx="4" fill="#36b9a6"/>
<!-- Abstract gate - two pillars with archway -->
<!-- Left pillar -->
<path
d="M4 4h3v16H4V4z"
fill="#ffffff"
/>
<!-- Right pillar -->
<path
d="M17 4h3v16h-3V4z"
fill="#ffffff"
/>
<!-- Archway/top bar -->
<path
d="M7 4h10v3H7V4z"
fill="#ffffff"
opacity="0.7"
/>
<!-- Keyhole/entry indicator -->
<circle
cx="12"
cy="14"
r="2"
fill="#ffffff"
opacity="0.5"
/>
</svg>

After

Width:  |  Height:  |  Size: 661 B

+2
View File
@@ -13,6 +13,7 @@ import {
ScrollText, ScrollText,
Terminal, Terminal,
ShieldCheck, ShieldCheck,
Key,
} from "lucide-react"; } from "lucide-react";
import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; import { GatehouseLogo } from "@/components/branding/GatehouseLogo";
import { NavLink } from "@/components/NavLink"; import { NavLink } from "@/components/NavLink";
@@ -57,6 +58,7 @@ const orgAdminNavItems = [
const adminNavItems = [ const adminNavItems = [
{ title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck }, { title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck },
{ title: "OIDC Clients", url: "/org/clients", icon: Key },
{ title: "Org Audit Log", url: "/org/audit", icon: FileText }, { title: "Org Audit Log", url: "/org/audit", icon: FileText },
{ title: "System Logs", url: "/admin/audit", icon: ScrollText }, { title: "System Logs", url: "/admin/audit", icon: ScrollText },
]; ];
+11 -4
View File
@@ -18,6 +18,7 @@ interface TotpRemoveDialogProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSuccess: () => void; onSuccess: () => void;
isRequired?: boolean; isRequired?: boolean;
hasPassword?: boolean;
} }
export function TotpRemoveDialog({ export function TotpRemoveDialog({
@@ -25,6 +26,7 @@ export function TotpRemoveDialog({
onOpenChange, onOpenChange,
onSuccess, onSuccess,
isRequired = false, isRequired = false,
hasPassword = true,
}: TotpRemoveDialogProps) { }: TotpRemoveDialogProps) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -45,7 +47,7 @@ export function TotpRemoveDialog({
}; };
const handleRemove = async () => { const handleRemove = async () => {
if (!password) { if (hasPassword && !password) {
setError("Password is required to disable TOTP"); setError("Password is required to disable TOTP");
return; return;
} }
@@ -54,7 +56,7 @@ export function TotpRemoveDialog({
setError(null); setError(null);
try { try {
await api.totp.disable(password); await api.totp.disable(hasPassword ? password : null);
toast({ toast({
title: "Two-factor authentication disabled", title: "Two-factor authentication disabled",
@@ -80,7 +82,7 @@ export function TotpRemoveDialog({
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && password) { if (e.key === "Enter" && (!hasPassword || password)) {
handleRemove(); handleRemove();
} }
}; };
@@ -109,6 +111,7 @@ export function TotpRemoveDialog({
</AlertDialogHeader> </AlertDialogHeader>
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
{hasPassword && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password-confirm">Enter your password to confirm</Label> <Label htmlFor="password-confirm">Enter your password to confirm</Label>
<Input <Input
@@ -128,6 +131,10 @@ export function TotpRemoveDialog({
<p className="text-sm text-destructive">{error}</p> <p className="text-sm text-destructive">{error}</p>
)} )}
</div> </div>
)}
{!hasPassword && error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
@@ -140,7 +147,7 @@ export function TotpRemoveDialog({
<Button <Button
variant="destructive" variant="destructive"
onClick={handleRemove} onClick={handleRemove}
disabled={isLoading || !password} disabled={isLoading || (hasPassword && !password)}
> >
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />} {isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Remove TOTP Remove TOTP
+120 -7
View File
@@ -29,6 +29,10 @@ export interface User {
org_role?: string; org_role?: string;
org_id?: string; org_id?: string;
activated?: boolean; activated?: boolean;
// Auth method capabilities — present on /users/me response
has_password?: boolean;
totp_enabled?: boolean;
linked_providers?: string[];
} }
export interface Organization { export interface Organization {
@@ -141,6 +145,34 @@ export interface WebAuthnLoginCompleteResponse {
expires_at: string; 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 // External Auth Types
export type ExternalProviderId = 'google' | 'github' | 'microsoft'; export type ExternalProviderId = 'google' | 'github' | 'microsoft';
@@ -552,6 +584,10 @@ export const api = {
unsuspendUser: (userId: string, requestConfig?: RequestConfig) => unsuspendUser: (userId: string, requestConfig?: RequestConfig) =>
request<{ user: User }>(`/admin/users/${userId}/unsuspend`, { method: 'POST' }, true, 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 // Permanently delete a user — revokes certs, cascades DB delete, unrecoverable
hardDeleteUser: (userId: string, requestConfig?: RequestConfig) => hardDeleteUser: (userId: string, requestConfig?: RequestConfig) =>
request<{ deleted_user_id: string; deleted_user_email: string; ssh_keys_deleted: number; certs_revoked: number }>( request<{ deleted_user_id: string; deleted_user_email: string; ssh_keys_deleted: number; certs_revoked: number }>(
@@ -561,6 +597,61 @@ export const api = {
requestConfig, 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 // Get the cert policy for a department
getDeptCertPolicy: (orgId: string, deptId: string, requestConfig?: RequestConfig) => getDeptCertPolicy: (orgId: string, deptId: string, requestConfig?: RequestConfig) =>
request<{ cert_policy: DeptCertPolicy }>(`/organizations/${orgId}/departments/${deptId}/cert-policy`, {}, true, 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'), request<TotpStatusResponse>('/auth/totp/status'),
// Disable TOTP - wrong password should not log user out // Disable TOTP - wrong password should not log user out
disable: (password: string) => disable: (password?: string | null) =>
request<{ message: string }>('/auth/totp/disable', { request<{ message: string }>('/auth/totp/disable', {
method: 'DELETE', method: 'DELETE',
body: JSON.stringify({ password }), body: JSON.stringify({ password: password || null }),
}, true, { clearTokenOn401: false }), }, true, { clearTokenOn401: false }),
// Regenerate backup codes - wrong password should not log user out // Regenerate backup codes - wrong password should not log user out
@@ -814,9 +905,12 @@ export const api = {
getById: (orgId: string, requestConfig?: RequestConfig) => getById: (orgId: string, requestConfig?: RequestConfig) =>
request<{ organization: Organization; member_count: number }>(`/organizations/${orgId}`, {}, true, requestConfig), request<{ organization: Organization; member_count: number }>(`/organizations/${orgId}`, {}, true, requestConfig),
// Delete an organization (owner only; must have no other members) // Delete an organization (owner only; pass confirm=true when other members exist)
deleteOrganization: (orgId: string, requestConfig?: RequestConfig) => deleteOrganization: (orgId: string, confirm?: boolean, requestConfig?: RequestConfig) =>
request<{ message: string }>(`/organizations/${orgId}`, { method: 'DELETE' }, true, requestConfig), request<{ message: string }>(`/organizations/${orgId}`, {
method: 'DELETE',
...(confirm ? { body: JSON.stringify({ confirm: true }) } : {}),
}, true, requestConfig),
// Get organization members // Get organization members
getMembers: (orgId: string, requestConfig?: RequestConfig) => getMembers: (orgId: string, requestConfig?: RequestConfig) =>
@@ -1043,7 +1137,7 @@ export const api = {
false, 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) => accept: (token: string, full_name?: string, password?: string) =>
request<LoginResponse>( request<LoginResponse>(
`/invites/${token}/accept`, `/invites/${token}/accept`,
@@ -1051,7 +1145,7 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ full_name, password, password_confirm: password }), body: JSON.stringify({ full_name, password, password_confirm: password }),
}, },
false, true,
), ),
}, },
@@ -1098,6 +1192,25 @@ export const api = {
body: JSON.stringify({ key_id, principals, cert_type, expiry_hours }), body: JSON.stringify({ key_id, principals, cert_type, expiry_hours }),
}, true, requestConfig), }, true, requestConfig),
// Issue a host certificate by submitting a raw server host public key
// (admin-only; does not require a pre-registered SSHKey record)
signHostCert: (
hostPublicKey: string,
principals: string[],
validityHours: number,
caId: string,
requestConfig?: RequestConfig,
) =>
request<SSHSignResponse>('/ssh/sign/host', {
method: 'POST',
body: JSON.stringify({
host_public_key: hostPublicKey,
principals,
validity_hours: validityHours,
ca_id: caId,
}),
}, true, requestConfig),
// Get the merged department certificate policy for the current user (used in sign dialog) // Get the merged department certificate policy for the current user (used in sign dialog)
getMyDeptCertPolicy: (requestConfig?: RequestConfig) => getMyDeptCertPolicy: (requestConfig?: RequestConfig) =>
request<{ policy: DeptCertPolicy }>('/ssh/dept-cert-policy', {}, true, requestConfig), request<{ policy: DeptCertPolicy }>('/ssh/dept-cert-policy', {}, true, requestConfig),
+447 -4
View File
@@ -14,6 +14,12 @@ import {
UserCheck, UserCheck,
AlertTriangle, AlertTriangle,
Trash2, Trash2,
ShieldOff,
Smartphone,
KeyRound,
Link2,
Unlink,
Lock,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -50,7 +56,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; 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"; import { useAuth } from "@/contexts/AuthContext";
function formatDate(d: string | null) { 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" }); 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) { function isSuspended(status: string | undefined) {
return status === "suspended" || status === "compliance_suspended"; return status === "suspended" || status === "compliance_suspended";
} }
@@ -124,11 +134,33 @@ export default function AdminUsersPage() {
const [isSuspending, setIsSuspending] = useState(false); const [isSuspending, setIsSuspending] = useState(false);
const [showSuspendConfirm, setShowSuspendConfirm] = useState(false); const [showSuspendConfirm, setShowSuspendConfirm] = useState(false);
// Force-verify email
const [isVerifyingEmail, setIsVerifyingEmail] = useState(false);
// Hard delete // Hard delete
const [showHardDelete, setShowHardDelete] = useState(false); const [showHardDelete, setShowHardDelete] = useState(false);
const [hardDeleteConfirmEmail, setHardDeleteConfirmEmail] = useState(""); const [hardDeleteConfirmEmail, setHardDeleteConfirmEmail] = useState("");
const [isHardDeleting, setIsHardDeleting] = useState(false); 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 ───────────────────────────────────────────────────────────── // ── Fetch users ─────────────────────────────────────────────────────────────
const fetchUsers = useCallback(async (q: string, pg: number) => { const fetchUsers = useCallback(async (q: string, pg: number) => {
setIsLoading(true); setIsLoading(true);
@@ -167,12 +199,24 @@ export default function AdminUsersPage() {
const openUserDrawer = async (user: ApiUser) => { const openUserDrawer = async (user: ApiUser) => {
setSelectedUser(user); setSelectedUser(user);
setUserSshKeys([]); setUserSshKeys([]);
setUserMfaMethods([]);
setUserLinkedAccounts([]);
setTotalAuthMethods(0);
setIsDrawerLoading(true); setIsDrawerLoading(true);
try { try {
const data = await api.admin.getUser(user.id); const [userData, mfaData, linkedData] = await Promise.allSettled([
setUserSshKeys(data.ssh_keys); 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 { } catch {
// Non-fatal — drawer still shows basic user info // Non-fatal
} finally { } finally {
setIsDrawerLoading(false); 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 ───────────────────────────────────────────────────────── // ── Hard delete user ─────────────────────────────────────────────────────────
const handleHardDelete = async () => { const handleHardDelete = async () => {
if (!selectedUser) return; 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 // Filter by role client-side
const filteredUsers = users.filter((u) => { const filteredUsers = users.filter((u) => {
if (roleFilter === "all") return true; if (roleFilter === "all") return true;
@@ -466,6 +629,28 @@ export default function AdminUsersPage() {
<Ban className="w-4 h-4" /> <Ban className="w-4 h-4" />
Account Access Account Access
</h3> </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 &amp; activate account
</Button>
</div>
)}
{isSuspended(selectedUser.status) ? ( {isSuspended(selectedUser.status) ? (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -532,6 +717,168 @@ export default function AdminUsersPage() {
</div> </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 */} {/* SSH Keys section */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -680,6 +1027,37 @@ export default function AdminUsersPage() {
</DialogContent> </DialogContent>
</Dialog> </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 ──────────────────────────────────────────── */} {/* ── Hard delete confirmation ──────────────────────────────────────────── */}
<Dialog <Dialog
open={showHardDelete} open={showHardDelete}
@@ -728,6 +1106,71 @@ export default function AdminUsersPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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> </div>
); );
} }
+5 -3
View File
@@ -42,18 +42,20 @@ const PROVIDER_LOGOS: Record<string, string> = {
microsoft: "https://www.microsoft.com/favicon.ico", 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 }> = { const PROVIDER_HELP: Record<string, { docsUrl: string; callbackNote: string }> = {
google: { google: {
docsUrl: "https://console.cloud.google.com/apis/credentials", 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: { github: {
docsUrl: "https://github.com/settings/applications/new", 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: { microsoft: {
docsUrl: "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps", 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`,
}, },
}; };
-6
View File
@@ -42,12 +42,6 @@ export default function ForgotPasswordPage() {
you'll receive a password reset link shortly. you'll receive a password reset link shortly.
</p> </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"> <div className="space-y-3">
<Link to="/login"> <Link to="/login">
<Button variant="outline" className="w-full"> <Button variant="outline" className="w-full">
+177 -592
View File
@@ -1,298 +1,23 @@
// ─── THIS FILE IS THE LEAN ORCHESTRATOR ──────────────────────────────────────
// Heavy sub-components live in ./ca/ — edit them there for isolated changes.
// ─────────────────────────────────────────────────────────────────────────────
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import { Loader2, Server, Shield, User } from "lucide-react";
Shield, import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
ShieldAlert,
Copy,
CheckCircle,
Loader2,
Terminal,
Plus,
User,
Server,
Settings,
AlertCircle,
ServerCog,
RefreshCw,
ShieldOff,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
import { api, OrgCA, ApiError } from "@/lib/api"; import { api, OrgCA, ApiError } from "@/lib/api";
import { CASection } from "./ca/CASection";
function CopyButton({ text }: { text: string }) { import {
const [copied, setCopied] = useState(false); CreateCADialog,
const { toast } = useToast(); CreateCAForm,
const handleCopy = async () => { EditCADialog,
try { EditCAForm,
await navigator.clipboard.writeText(text); RotateCADialog,
setCopied(true); DeleteCADialog,
toast({ title: "Copied to clipboard" }); } from "./ca/CADialogs";
setTimeout(() => setCopied(false), 2000);
} catch {
toast({ variant: "destructive", title: "Copy failed" });
}
};
return (
<Button variant="ghost" size="icon" className="h-8 w-8 flex-shrink-0" onClick={handleCopy}>
{copied ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
);
}
function formatDate(d: string | null) {
if (!d) return "—";
return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
// ─── CA Detail Card ───────────────────────────────────────────────────────────
interface CADetailCardProps {
ca: OrgCA;
onEdit: (ca: OrgCA) => void;
onRotate: (ca: OrgCA) => void;
onDelete: (ca: OrgCA) => void;
}
function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardProps) {
const isUser = ca.ca_type === "user";
const isSystem = !!ca.is_system;
const sshConfig = isUser
? `# /etc/ssh/sshd_config:\nTrustedUserCAKeys /etc/ssh/trusted_user_ca_keys\n\n# Add public key:\necho '${ca.public_key.trim()}' \\\n >> /etc/ssh/trusted_user_ca_keys`
: `# /etc/ssh/sshd_config:\nHostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub\n\n# Add to known_hosts (clients):\n@cert-authority * ${ca.public_key.trim()}`;
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-base flex items-center gap-2">
{isSystem ? <ServerCog className="w-4 h-4" /> : isUser ? <User className="w-4 h-4" /> : <Server className="w-4 h-4" />}
{ca.name}
{isSystem ? (
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<ServerCog className="w-3 h-3" />
System
</Badge>
) : ca.is_active ? (
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">Active</Badge>
) : (
<Badge variant="secondary" className="text-xs">Inactive</Badge>
)}
</CardTitle>
{ca.description && (
<CardDescription className="mt-1">{ca.description}</CardDescription>
)}
</div>
<Badge variant="outline" className="text-xs font-mono">{ca.key_type}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats — hidden for system CAs (we have no cert records for them) */}
{!isSystem && (
<div className="grid grid-cols-4 gap-3 text-center">
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.active_certs}</p>
<p className="text-xs text-muted-foreground">Active certs</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.total_certs}</p>
<p className="text-xs text-muted-foreground">Total issued</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.default_cert_validity_hours}h</p>
<p className="text-xs text-muted-foreground">Default validity</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.next_serial_number ?? '—'}</p>
<p className="text-xs text-muted-foreground">Next serial</p>
</div>
</div>
)}
{/* Fingerprint */}
<div>
<p className="text-xs text-muted-foreground mb-1">Fingerprint</p>
<code className="text-xs font-mono bg-muted px-2 py-1 rounded break-all">{ca.fingerprint}</code>
</div>
{/* Public key */}
<div>
<div className="flex items-center justify-between mb-1">
<p className="text-xs text-muted-foreground">Public key</p>
<CopyButton text={ca.public_key} />
</div>
<Textarea readOnly value={ca.public_key} className="font-mono text-xs min-h-[60px]" />
</div>
{/* Setup instructions */}
<div className="rounded-lg bg-muted p-3">
<p className="text-xs font-semibold flex items-center gap-1 mb-1">
<Terminal className="w-3 h-3" />
{isUser ? "Add to SSH servers (sshd_config)" : "Host certificate setup"}
</p>
<pre className="text-xs font-mono whitespace-pre-wrap break-all">{sshConfig}</pre>
</div>
{ca.created_at && (
<p className="text-xs text-muted-foreground">Created {formatDate(ca.created_at)}</p>
)}
{ca.rotated_at && (
<p className="text-xs text-muted-foreground">
Key rotated {formatDate(ca.rotated_at)}
{ca.rotation_reason && <> {ca.rotation_reason}</>}
</p>
)}
{!isSystem && (
<div className="pt-2 border-t space-y-2">
<Button variant="outline" size="sm" onClick={() => onEdit(ca)} className="w-full">
<Settings className="w-3 h-3 mr-2" />
Edit Configuration
</Button>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" onClick={() => onRotate(ca)} className="w-full">
<RefreshCw className="w-3 h-3 mr-2" />
Rotate Key
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onDelete(ca)}
className="w-full text-destructive hover:text-destructive border-destructive/30 hover:border-destructive/60"
>
<ShieldOff className="w-3 h-3 mr-2" />
Delete CA
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
// ─── CA Section (one per type) ────────────────────────────────────────────────
interface CASectionProps {
caType: "user" | "host";
ca: OrgCA | null;
onCreateClick: (caType: "user" | "host") => void;
onEdit: (ca: OrgCA) => void;
onRotate: (ca: OrgCA) => void;
onDelete: (ca: OrgCA) => void;
}
function CASection({ caType, ca, onCreateClick, onEdit, onRotate, onDelete }: CASectionProps) {
const isUser = caType === "user";
const title = isUser ? "User Signing Key" : "Host Signing Key";
const subtitle = isUser
? "Signs SSH user certificates so users can authenticate to servers."
: "Signs SSH host certificates so clients can verify server identity.";
const Icon = isUser ? User : Server;
const isSystem = !!ca?.is_system;
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">{title}</h2>
{ca ? (
isSystem ? (
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<ServerCog className="w-3 h-3" />
System (read-only)
</Badge>
) : (
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">Configured</Badge>
)
) : (
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Not configured
</Badge>
)}
</div>
{ca ? (
<>
<CADetailCard ca={ca} onEdit={onEdit} onRotate={onRotate} onDelete={onDelete} />
{/* When only a system CA is present, offer to generate a managed replacement */}
{isSystem && (
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30 p-3 text-xs text-amber-800 dark:text-amber-300">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="font-semibold mb-1">Using server-configured CA</p>
<p>
Certificates are being signed by a CA key loaded from the server configuration,
not managed through this UI. Generate a managed key below to take full control
of certificate issuance from Gatehouse.
</p>
</div>
<Button onClick={() => onCreateClick(caType)} size="sm" variant="outline" className="flex-shrink-0">
<Plus className="w-3 h-3 mr-1" />
Generate managed key
</Button>
</div>
)}
</>
) : (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center py-10 text-muted-foreground">
<ShieldAlert className="w-10 h-10 mb-3 opacity-30" />
<p className="text-sm font-medium mb-1">No {title} configured</p>
<p className="text-xs text-center mb-4 max-w-sm">{subtitle}</p>
<Button onClick={() => onCreateClick(caType)} size="sm" variant="outline">
<Plus className="w-4 h-4 mr-2" />
Generate {title}
</Button>
</CardContent>
</Card>
)}
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function CAsPage() { export default function CAsPage() {
const params = useParams<{ orgId?: string }>(); const params = useParams<{ orgId?: string }>();
@@ -303,44 +28,48 @@ export default function CAsPage() {
const [cas, setCAs] = useState<OrgCA[]>([]); const [cas, setCAs] = useState<OrgCA[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
// Create CA dialog // ── Create dialog ──────────────────────────────────────────────────────────
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [createCaType, setCreateCaType] = useState<"user" | "host">("user"); const [createCaType, setCreateCaType] = useState<"user" | "host">("user");
const [createForm, setCreateForm] = useState({ const [createForm, setCreateForm] = useState<CreateCAForm>({
name: "", name: "",
description: "", description: "",
key_type: "ed25519" as "ed25519" | "rsa" | "ecdsa", key_type: "ed25519",
default_cert_validity_hours: 8, default_cert_validity_hours: 8,
max_cert_validity_hours: 720, max_cert_validity_hours: 720,
}); });
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
// Edit CA dialog // ── Edit dialog ────────────────────────────────────────────────────────────
const [editingCA, setEditingCA] = useState<OrgCA | null>(null); const [editingCA, setEditingCA] = useState<OrgCA | null>(null);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isEditOpen, setIsEditOpen] = useState(false);
const [editFormData, setEditFormData] = useState({ const [editForm, setEditForm] = useState<EditCAForm>({
default_cert_validity_hours: 1, default_cert_validity_hours: 1,
max_cert_validity_hours: 24, max_cert_validity_hours: 24,
}); });
const [isEditSaving, setIsEditSaving] = useState(false); const [isEditSaving, setIsEditSaving] = useState(false);
const [editError, setEditError] = useState<string | null>(null); const [editError, setEditError] = useState<string | null>(null);
// Rotate CA dialog // ── Rotate dialog ──────────────────────────────────────────────────────────
const [rotatingCA, setRotatingCA] = useState<OrgCA | null>(null); const [rotatingCA, setRotatingCA] = useState<OrgCA | null>(null);
const [isRotateDialogOpen, setIsRotateDialogOpen] = useState(false); const [isRotateOpen, setIsRotateOpen] = useState(false);
const [rotateKeyType, setRotateKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519"); const [rotateKeyType, setRotateKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
const [rotateReason, setRotateReason] = useState(""); const [rotateReason, setRotateReason] = useState("");
const [isRotating, setIsRotating] = useState(false); const [isRotating, setIsRotating] = useState(false);
const [rotateError, setRotateError] = useState<string | null>(null); const [rotateError, setRotateError] = useState<string | null>(null);
// Delete CA dialog // ── Delete dialog ──────────────────────────────────────────────────────────
const [deletingCA, setDeletingCA] = useState<OrgCA | null>(null); const [deletingCA, setDeletingCA] = useState<OrgCA | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// ── Load CAs ───────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!orgId) { setIsLoading(false); return; } if (!orgId) {
setIsLoading(false);
return;
}
(async () => { (async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@@ -348,7 +77,11 @@ export default function CAsPage() {
setCAs(data.cas); setCAs(data.cas);
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.code === 403) { if (err instanceof ApiError && err.code === 403) {
toast({ variant: "destructive", title: "Access denied", description: "Admin or owner role required." }); toast({
variant: "destructive",
title: "Access denied",
description: "Admin or owner role required.",
});
} else { } else {
toast({ variant: "destructive", title: "Failed to load CAs" }); toast({ variant: "destructive", title: "Failed to load CAs" });
} }
@@ -361,6 +94,7 @@ export default function CAsPage() {
const userCA = cas.find((c) => c.ca_type === "user") ?? null; const userCA = cas.find((c) => c.ca_type === "user") ?? null;
const hostCA = cas.find((c) => c.ca_type === "host") ?? null; const hostCA = cas.find((c) => c.ca_type === "host") ?? null;
// ── Handlers: Create ───────────────────────────────────────────────────────
const handleOpenCreate = (caType: "user" | "host") => { const handleOpenCreate = (caType: "user" | "host") => {
setCreateCaType(caType); setCreateCaType(caType);
setCreateForm({ setCreateForm({
@@ -376,12 +110,17 @@ export default function CAsPage() {
const handleCreateCA = async () => { const handleCreateCA = async () => {
if (!orgId) return; if (!orgId) return;
if (!createForm.name.trim()) { setCreateError("Name is required"); return; } if (!createForm.name.trim()) {
setCreateError("Name is required");
return;
}
if (createForm.default_cert_validity_hours <= 0 || createForm.max_cert_validity_hours <= 0) { if (createForm.default_cert_validity_hours <= 0 || createForm.max_cert_validity_hours <= 0) {
setCreateError("Validity hours must be greater than 0"); return; setCreateError("Validity hours must be greater than 0");
return;
} }
if (createForm.default_cert_validity_hours > createForm.max_cert_validity_hours) { if (createForm.default_cert_validity_hours > createForm.max_cert_validity_hours) {
setCreateError("Default validity must be ≤ maximum validity"); return; setCreateError("Default validity must be ≤ maximum validity");
return;
} }
setIsCreating(true); setIsCreating(true);
setCreateError(null); setCreateError(null);
@@ -401,39 +140,40 @@ export default function CAsPage() {
description: result.ca.name, description: result.ca.name,
}); });
} catch (err) { } catch (err) {
if (err instanceof ApiError) { setCreateError(
setCreateError(err.message); err instanceof ApiError ? err.message : "Failed to create CA — please try again",
} else { );
setCreateError("Failed to create CA — please try again");
}
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
}; };
// ── Handlers: Edit ─────────────────────────────────────────────────────────
const handleEditCA = (ca: OrgCA) => { const handleEditCA = (ca: OrgCA) => {
setEditingCA(ca); setEditingCA(ca);
setEditFormData({ setEditForm({
default_cert_validity_hours: ca.default_cert_validity_hours, default_cert_validity_hours: ca.default_cert_validity_hours,
max_cert_validity_hours: ca.max_cert_validity_hours, max_cert_validity_hours: ca.max_cert_validity_hours,
}); });
setEditError(null); setEditError(null);
setIsEditDialogOpen(true); setIsEditOpen(true);
}; };
const handleSaveCA = async () => { const handleSaveCA = async () => {
if (!orgId || !editingCA) return; if (!orgId || !editingCA) return;
if (editFormData.default_cert_validity_hours <= 0 || editFormData.max_cert_validity_hours <= 0) { if (editForm.default_cert_validity_hours <= 0 || editForm.max_cert_validity_hours <= 0) {
setEditError("Validity hours must be greater than 0"); return; setEditError("Validity hours must be greater than 0");
return;
} }
if (editFormData.default_cert_validity_hours > editFormData.max_cert_validity_hours) { if (editForm.default_cert_validity_hours > editForm.max_cert_validity_hours) {
setEditError("Default validity must be less than or equal to maximum validity"); return; setEditError("Default validity must be less than or equal to maximum validity");
return;
} }
setIsEditSaving(true); setIsEditSaving(true);
try { try {
const updated = await api.organizations.updateCA(orgId, editingCA.id, editFormData); const updated = await api.organizations.updateCA(orgId, editingCA.id, editForm);
setCAs(cas.map((ca) => (ca.id === editingCA.id ? updated.ca : ca))); setCAs(cas.map((ca) => (ca.id === editingCA.id ? updated.ca : ca)));
setIsEditDialogOpen(false); setIsEditOpen(false);
setEditingCA(null); setEditingCA(null);
toast({ title: "CA configuration updated" }); toast({ title: "CA configuration updated" });
} catch (err) { } catch (err) {
@@ -443,13 +183,13 @@ export default function CAsPage() {
} }
}; };
// ── Rotate handlers ── // ── Handlers: Rotate ───────────────────────────────────────────────────────
const handleRotateCA = (ca: OrgCA) => { const handleRotateCA = (ca: OrgCA) => {
setRotatingCA(ca); setRotatingCA(ca);
setRotateKeyType((ca.key_type as "ed25519" | "rsa" | "ecdsa") || "ed25519"); setRotateKeyType((ca.key_type as "ed25519" | "rsa" | "ecdsa") || "ed25519");
setRotateReason(""); setRotateReason("");
setRotateError(null); setRotateError(null);
setIsRotateDialogOpen(true); setIsRotateOpen(true);
}; };
const handleConfirmRotate = async () => { const handleConfirmRotate = async () => {
@@ -462,7 +202,7 @@ export default function CAsPage() {
reason: rotateReason.trim() || undefined, reason: rotateReason.trim() || undefined,
}); });
setCAs(cas.map((ca) => (ca.id === rotatingCA.id ? result.ca : ca))); setCAs(cas.map((ca) => (ca.id === rotatingCA.id ? result.ca : ca)));
setIsRotateDialogOpen(false); setIsRotateOpen(false);
setRotatingCA(null); setRotatingCA(null);
toast({ toast({
title: "CA key rotated successfully", title: "CA key rotated successfully",
@@ -475,10 +215,10 @@ export default function CAsPage() {
} }
}; };
// ── Delete handlers ── // ── Handlers: Delete ───────────────────────────────────────────────────────
const handleDeleteCA = (ca: OrgCA) => { const handleDeleteCA = (ca: OrgCA) => {
setDeletingCA(ca); setDeletingCA(ca);
setIsDeleteDialogOpen(true); setIsDeleteOpen(true);
}; };
const handleConfirmDelete = async () => { const handleConfirmDelete = async () => {
@@ -487,299 +227,144 @@ export default function CAsPage() {
try { try {
await api.organizations.deleteCA(orgId, deletingCA.id); await api.organizations.deleteCA(orgId, deletingCA.id);
setCAs(cas.filter((ca) => ca.id !== deletingCA.id)); setCAs(cas.filter((ca) => ca.id !== deletingCA.id));
setIsDeleteDialogOpen(false); setIsDeleteOpen(false);
setDeletingCA(null); setDeletingCA(null);
toast({ title: "CA deleted", description: "Existing certificates remain valid until they expire." }); toast({
title: "CA deleted",
description: "Existing certificates remain valid until they expire.",
});
} catch (err) { } catch (err) {
toast({ variant: "destructive", title: "Failed to delete CA", description: err instanceof ApiError ? err.message : "" }); toast({
variant: "destructive",
title: "Failed to delete CA",
description: err instanceof ApiError ? err.message : "",
});
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
} }
}; return ( };
// Shared section event props
const sectionProps = {
onCreateClick: handleOpenCreate,
onEdit: handleEditCA,
onRotate: handleRotateCA,
onDelete: handleDeleteCA,
};
return (
<div className="page-container"> <div className="page-container">
{/* Page header */}
<div className="page-header"> <div className="page-header">
<div className="flex items-start gap-3">
<Shield className="w-6 h-6 text-muted-foreground mt-0.5 flex-shrink-0" />
<div> <div>
<h1 className="page-title">Certificate Authorities</h1> <h1 className="page-title">Certificate Authorities</h1>
<p className="page-description"> <p className="page-description">
Manage your organization's SSH certificate authorities and access controls Manage your organization's SSH CAs with <code>Gatehouse</code>
</p> </p>
</div> </div>
</div> </div>
</div>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div> </div>
) : ( ) : (
<div className="space-y-8"> <Tabs defaultValue="user" className="space-y-4">
<CASection caType="user" ca={userCA} onCreateClick={handleOpenCreate} onEdit={handleEditCA} onRotate={handleRotateCA} onDelete={handleDeleteCA} /> <TabsList className="h-auto gap-1">
<div className="border-t" /> <TabsTrigger value="user" className="flex items-center gap-2 px-4 py-2">
<CASection caType="host" ca={hostCA} onCreateClick={handleOpenCreate} onEdit={handleEditCA} onRotate={handleRotateCA} onDelete={handleDeleteCA} /> <User className="w-4 h-4" />
</div> <span>User CA</span>
)} {userCA ? (
userCA.is_system ? (
{/* ── Edit CA Dialog ── */} <Badge variant="secondary" className="text-xs ml-1">System</Badge>
<Dialog open={isEditDialogOpen} onOpenChange={(open) => { setIsEditDialogOpen(open); if (!open) setEditError(null); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit CA Configuration</DialogTitle>
<DialogDescription>
Update certificate validity settings for <strong>{editingCA?.name}</strong>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{editError && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
{editError}
</div>
)}
<div className="space-y-2">
<Label htmlFor="default-validity">Default Certificate Validity (hours)</Label>
<Input
id="default-validity"
type="number"
min="1"
value={editFormData.default_cert_validity_hours}
onChange={(e) => setEditFormData({ ...editFormData, default_cert_validity_hours: parseInt(e.target.value) || 1 })}
disabled={isEditSaving}
/>
<p className="text-xs text-muted-foreground">Default validity period when issuing new certificates</p>
</div>
<div className="space-y-2">
<Label htmlFor="max-validity">Maximum Certificate Validity (hours)</Label>
<Input
id="max-validity"
type="number"
min="1"
value={editFormData.max_cert_validity_hours}
onChange={(e) => setEditFormData({ ...editFormData, max_cert_validity_hours: parseInt(e.target.value) || 1 })}
disabled={isEditSaving}
/>
<p className="text-xs text-muted-foreground">Maximum allowed validity period for any certificate from this CA</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)} disabled={isEditSaving}>Cancel</Button>
<Button onClick={handleSaveCA} disabled={isEditSaving}>
{isEditSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Create CA Dialog ── */}
<Dialog open={isCreateOpen} onOpenChange={(open) => { setIsCreateOpen(open); if (!open) setCreateError(null); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{createCaType === "user" ? <User className="w-5 h-5" /> : <Server className="w-5 h-5" />}
Generate {createCaType === "user" ? "User" : "Host"} Signing Key
</DialogTitle>
<DialogDescription>
{createCaType === "user"
? "Creates a key pair for signing SSH user certificates. The private key is stored securely and never exposed."
: "Creates a key pair for signing SSH host certificates, allowing clients to verify server identity."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{createError && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{createError}</span>
</div>
)}
<div className="space-y-2">
<Label htmlFor="ca-name">Name <span className="text-destructive">*</span></Label>
<Input
id="ca-name"
placeholder={createCaType === "user" ? "User CA" : "Host CA"}
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
disabled={isCreating}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ca-description">Description</Label>
<Input
id="ca-description"
placeholder="Optional description"
value={createForm.description}
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
disabled={isCreating}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ca-key-type">Key Algorithm</Label>
<Select
value={createForm.key_type}
onValueChange={(v) => setCreateForm({ ...createForm, key_type: v as "ed25519" | "rsa" | "ecdsa" })}
disabled={isCreating}
>
<SelectTrigger id="ca-key-type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ed25519">Ed25519 (recommended)</SelectItem>
<SelectItem value="ecdsa">ECDSA (P-521)</SelectItem>
<SelectItem value="rsa">RSA-4096</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="ca-default-validity">Default validity (hours)</Label>
<Input
id="ca-default-validity"
type="number"
min="1"
value={createForm.default_cert_validity_hours}
onChange={(e) => setCreateForm({ ...createForm, default_cert_validity_hours: parseInt(e.target.value) || 1 })}
disabled={isCreating}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ca-max-validity">Max validity (hours)</Label>
<Input
id="ca-max-validity"
type="number"
min="1"
value={createForm.max_cert_validity_hours}
onChange={(e) => setCreateForm({ ...createForm, max_cert_validity_hours: parseInt(e.target.value) || 1 })}
disabled={isCreating}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={isCreating}>Cancel</Button>
<Button onClick={handleCreateCA} disabled={isCreating}>
{isCreating ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Generating key…</>
) : ( ) : (
<><Shield className="w-4 h-4 mr-2" />Generate Key</> <Badge className="bg-green-500/10 text-green-700 border-0 text-xs ml-1">
)} Active
</Button> </Badge>
</DialogFooter> )
</DialogContent>
</Dialog>
{/* ── Rotate CA Dialog ── */}
<Dialog open={isRotateDialogOpen} onOpenChange={(open) => { setIsRotateDialogOpen(open); if (!open) setRotateError(null); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RefreshCw className="w-5 h-5" />
Rotate CA Key
</DialogTitle>
<DialogDescription>
Generate a new key pair for <strong>{rotatingCA?.name}</strong>.
Previously-issued certificates remain valid until they expire, but all new
certificates will be signed with the new key. You must update
{rotatingCA?.ca_type === "user"
? " TrustedUserCAKeys on your SSH servers"
: " @cert-authority in client known_hosts files"} after rotation.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{rotateError && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{rotateError}</span>
</div>
)}
{rotatingCA && (
<div className="rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 p-3 text-xs text-amber-800 dark:text-amber-300">
<p className="font-semibold mb-1">⚠ Important</p>
<p>
Current fingerprint: <code className="font-mono">{rotatingCA.fingerprint}</code>
</p>
<p className="mt-1">
After rotation, you <strong>must</strong> replace this fingerprint on every server /
client that trusts this CA. Until updated, new certificates won't be accepted.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="rotate-key-type">New Key Algorithm</Label>
<Select
value={rotateKeyType}
onValueChange={(v) => setRotateKeyType(v as "ed25519" | "rsa" | "ecdsa")}
disabled={isRotating}
>
<SelectTrigger id="rotate-key-type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ed25519">Ed25519 (recommended)</SelectItem>
<SelectItem value="ecdsa">ECDSA (P-521)</SelectItem>
<SelectItem value="rsa">RSA-4096</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rotate-reason">Reason (optional)</Label>
<Input
id="rotate-reason"
placeholder="e.g. Suspected key compromise, Scheduled rotation"
value={rotateReason}
onChange={(e) => setRotateReason(e.target.value)}
disabled={isRotating}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRotateDialogOpen(false)} disabled={isRotating}>
Cancel
</Button>
<Button onClick={handleConfirmRotate} disabled={isRotating} variant="destructive">
{isRotating ? (
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Rotating</>
) : ( ) : (
<><RefreshCw className="w-4 h-4 mr-2" />Rotate Key</> <Badge variant="outline" className="text-xs ml-1 text-muted-foreground">
Not set
</Badge>
)} )}
</Button> </TabsTrigger>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Delete CA Confirmation ── */} <TabsTrigger value="host" className="flex items-center gap-2 px-4 py-2">
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}> <Server className="w-4 h-4" />
<AlertDialogContent> <span>Host CA</span>
<AlertDialogHeader> {hostCA ? (
<AlertDialogTitle>Delete Certificate Authority?</AlertDialogTitle> hostCA.is_system ? (
<AlertDialogDescription> <Badge variant="secondary" className="text-xs ml-1">System</Badge>
This will permanently deactivate <strong>{deletingCA?.name}</strong>. ) : (
No new certificates can be signed with this CA after deletion. <Badge className="bg-green-500/10 text-green-700 border-0 text-xs ml-1">
Existing certificates remain valid until they expire. Active
{deletingCA?.active_certs ? ( </Badge>
<span className="block mt-2 font-semibold text-amber-600 dark:text-amber-400"> )
This CA has {deletingCA.active_certs} active certificate{deletingCA.active_certs !== 1 ? "s" : ""}. ) : (
</span> <Badge variant="outline" className="text-xs ml-1 text-muted-foreground">
) : null} Not set
</AlertDialogDescription> </Badge>
</AlertDialogHeader> )}
<AlertDialogFooter> </TabsTrigger>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel> </TabsList>
<AlertDialogAction
onClick={handleConfirmDelete} <TabsContent value="user" className="mt-0">
disabled={isDeleting} <CASection caType="user" ca={userCA} {...sectionProps} />
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" </TabsContent>
>
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />} <TabsContent value="host" className="mt-0">
Delete CA <CASection caType="host" ca={hostCA} {...sectionProps} />
</AlertDialogAction> </TabsContent>
</AlertDialogFooter> </Tabs>
</AlertDialogContent> )}
</AlertDialog>
{/* ── Dialogs ─────────────────────────────────────────────────────────── */}
<CreateCADialog
open={isCreateOpen}
onOpenChange={(o) => { setIsCreateOpen(o); if (!o) setCreateError(null); }}
caType={createCaType}
form={createForm}
onFormChange={setCreateForm}
error={createError}
isLoading={isCreating}
onSubmit={handleCreateCA}
/>
<EditCADialog
open={isEditOpen}
onOpenChange={(o) => { setIsEditOpen(o); if (!o) setEditError(null); }}
ca={editingCA}
form={editForm}
onFormChange={setEditForm}
error={editError}
isLoading={isEditSaving}
onSubmit={handleSaveCA}
/>
<RotateCADialog
open={isRotateOpen}
onOpenChange={(o) => { setIsRotateOpen(o); if (!o) setRotateError(null); }}
ca={rotatingCA}
keyType={rotateKeyType}
onKeyTypeChange={setRotateKeyType}
reason={rotateReason}
onReasonChange={setRotateReason}
error={rotateError}
isLoading={isRotating}
onSubmit={handleConfirmRotate}
/>
<DeleteCADialog
open={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
ca={deletingCA}
isLoading={isDeleting}
onConfirm={handleConfirmDelete}
/>
</div> </div>
); );
} }
-3
View File
@@ -658,9 +658,6 @@ export default function DepartmentsPage() {
}) })
)} )}
</div> </div>
<div className="mt-2 text-xs text-muted-foreground">
Created {new Date(dept.created_at).toLocaleDateString()}
</div>
{/* Members toggle */} {/* Members toggle */}
<button <button
+344 -5
View File
@@ -20,6 +20,12 @@ import {
XCircle, XCircle,
Crown, Crown,
Trash2, Trash2,
ShieldOff,
Link2,
Unlink,
Smartphone,
KeyRound,
Lock,
} from "lucide-react"; } from "lucide-react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -60,7 +66,7 @@ import {
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast"; 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 { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
import { useAuth } from "@/contexts/AuthContext"; 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) { function isSuspended(status: string | undefined) {
return status === "suspended" || status === "compliance_suspended"; return status === "suspended" || status === "compliance_suspended";
} }
@@ -133,6 +143,24 @@ export default function MembersPage() {
const [userSshKeys, setUserSshKeys] = useState<SSHKey[]>([]); const [userSshKeys, setUserSshKeys] = useState<SSHKey[]>([]);
const [isDrawerLoading, setIsDrawerLoading] = useState(false); 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 ────────────────────────────────────────────────────── // ── Suspend / Unsuspend ──────────────────────────────────────────────────────
const [isSuspending, setIsSuspending] = useState(false); const [isSuspending, setIsSuspending] = useState(false);
const [showSuspendConfirm, setShowSuspendConfirm] = useState(false); const [showSuspendConfirm, setShowSuspendConfirm] = useState(false);
@@ -156,13 +184,94 @@ export default function MembersPage() {
const [removeMember, setRemoveMember] = useState<OrganizationMember | null>(null); const [removeMember, setRemoveMember] = useState<OrganizationMember | null>(null);
const [isRemoving, setIsRemoving] = useState(false); 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 ─────────────────────────────────────────────────────────────────── // ── Invite ───────────────────────────────────────────────────────────────────
const [inviteEmail, setInviteEmail] = useState(""); const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState("member"); const [inviteRole, setInviteRole] = useState("member");
const [isInviting, setIsInviting] = useState(false); const [isInviting, setIsInviting] = useState(false);
const [inviteError, setInviteError] = useState<string | null>(null); 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 [inviteLink, setInviteLink] = useState<string | null>(null);
const [inviteLinkEmail, setInviteLinkEmail] = useState(""); const [inviteLinkEmail, setInviteLinkEmail] = useState("");
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
@@ -226,10 +335,24 @@ export default function MembersPage() {
setDetailUser(null); setDetailUser(null);
setUserSshKeys([]); setUserSshKeys([]);
setIsDrawerLoading(true); setIsDrawerLoading(true);
setUserMfaMethods([]);
setUserLinkedAccounts([]);
setTotalAuthMethods(0);
try { try {
const data = await api.admin.getUser(member.user_id); const [userData, mfaData, linkedData] = await Promise.allSettled([
setDetailUser(data.user); api.admin.getUser(member.user_id),
setUserSshKeys(data.ssh_keys); 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 { } catch {
// Non-fatal — drawer still shows member info // Non-fatal — drawer still shows member info
} finally { } finally {
@@ -241,6 +364,13 @@ export default function MembersPage() {
setSelectedMember(null); setSelectedMember(null);
setDetailUser(null); setDetailUser(null);
setUserSshKeys([]); setUserSshKeys([]);
setUserMfaMethods([]);
setUserLinkedAccounts([]);
setTotalAuthMethods(0);
setAdminPwNew("");
setAdminPwConfirm("");
setAdminPwError(null);
setAdminPwSuccess(false);
}; };
// ── Role change (drawer inline select) ────────────────────────────────────── // ── Role change (drawer inline select) ──────────────────────────────────────
@@ -948,6 +1078,190 @@ export default function MembersPage() {
</div> </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 */} {/* SSH Keys */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -1053,6 +1367,31 @@ export default function MembersPage() {
</DialogContent> </DialogContent>
</Dialog> </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 ───────────────────────────────────────── */} {/* ── Suspend confirmation dialog ───────────────────────────────────────── */}
<Dialog open={showSuspendConfirm} onOpenChange={setShowSuspendConfirm}> <Dialog open={showSuspendConfirm} onOpenChange={setShowSuspendConfirm}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
+446 -101
View File
@@ -1,8 +1,18 @@
import { useState, useEffect, useRef } from "react"; 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 { 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 { Badge } from "@/components/ui/badge";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -16,53 +26,112 @@ import {
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { api, OIDCClient, OIDCClientWithSecret } from "@/lib/api"; import { api, OIDCClient, OIDCClientWithSecret } from "@/lib/api";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useOrg } from "@/contexts/OrgContext"; 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() { export default function OIDCClientsPage() {
const { toast } = useToast(); const { toast } = useToast();
const { selectedOrgId: orgId } = useOrg(); const { selectedOrgId: orgId } = useOrg();
const { copy: copySecret, copied: secretCopied } = useCopyButton();
const { copy: copyConfig, copied: configCopied } = useCopyButton();
const [clients, setClients] = useState<OIDCClient[]>([]); const [clients, setClients] = useState<OIDCClient[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isCreateOpen, setIsCreateOpen] = useState(false); const [dialogMode, setDialogMode] = useState<DialogMode>(null);
const [isCreating, setIsCreating] = useState(false); 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 nameRef = useRef<HTMLInputElement>(null);
const urisRef = useRef<HTMLTextAreaElement>(null); const urisRef = useRef<HTMLTextAreaElement>(null);
const loadData = (id: string) => { // Proxy form
api.organizations.getClients(id) const proxyNameRef = useRef<HTMLInputElement>(null);
.then((data) => setClients(data.clients)) const proxyHostRef = useRef<HTMLInputElement>(null);
.catch(() => toast({ title: "Error", description: "Failed to load OIDC clients.", variant: "destructive" }))
.finally(() => setIsLoading(false));
};
useEffect(() => { useEffect(() => {
if (!orgId) { setIsLoading(false); return; } if (!orgId) { setIsLoading(false); return; }
setIsLoading(true); 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]); }, [orgId]);
const handleCreate = async () => { const handleCreate = async () => {
if (!orgId || !nameRef.current || !urisRef.current) return; if (!orgId) return;
const name = nameRef.current.value.trim();
const uris = urisRef.current.value.trim().split(/[\n,]+/).map((u) => u.trim()).filter(Boolean); 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; 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); setIsCreating(true);
try { try {
const result = await api.organizations.createClient(orgId, name, uris); const result = await api.organizations.createClient(orgId, name, uris);
const created = result.client as OIDCClientWithSecret; const created = result.client as OIDCClientWithSecret;
setClients((prev) => [...prev, created]); setClients((prev) => [...prev, created]);
setNewSecret({ clientId: created.client_id, secret: created.client_secret }); setNewSecret({
setIsCreateOpen(false); clientId: created.client_id,
secret: created.client_secret,
proxyHost,
isProxy: dialogMode === "proxy",
});
setDialogMode(null);
} catch { } catch {
toast({ title: "Error", description: "Failed to create client.", variant: "destructive" }); toast({ title: "Error", description: "Failed to create client.", variant: "destructive" });
} finally { } finally {
@@ -75,127 +144,139 @@ export default function OIDCClientsPage() {
try { try {
await api.organizations.deleteClient(orgId, clientId); await api.organizations.deleteClient(orgId, clientId);
setClients((prev) => prev.filter((c) => c.id !== clientId)); setClients((prev) => prev.filter((c) => c.id !== clientId));
toast({ title: "Client deleted", description: "OIDC client deactivated successfully." }); toast({ title: "Client deleted" });
} catch { } catch {
toast({ title: "Error", description: "Failed to delete client.", variant: "destructive" }); toast({ title: "Error", description: "Failed to delete client.", variant: "destructive" });
} }
}; };
const copyToClipboard = (text: string) => { const proxyConfig = newSecret?.isProxy && newSecret.proxyHost
navigator.clipboard.writeText(text).then(() => ? buildProxyConfig(newSecret.clientId, newSecret.secret, newSecret.proxyHost)
toast({ title: "Copied", description: "Copied to clipboard." }) : null;
);
};
return ( return (
<div className="page-container"> <div className="page-container">
{/* Header */}
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="page-title">OIDC Clients</h1> <h1 className="page-title">OIDC Clients</h1>
<p className="page-description"> <p className="page-description">Applications that authenticate via Gatehouse</p>
Manage applications that authenticate via Gatehouse
</p>
</div> </div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}> <Button onClick={() => setDialogMode("generic")}>
<DialogTrigger asChild>
<Button>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Add client Add client
</Button> </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>
</div> </div>
{/* Show new client secret once */} {/* One-time secret banner */}
{newSecret && ( {newSecret && (
<Card className="mb-4 border-success/50 bg-success/5"> <Card className="mb-6 border-green-500/40 bg-green-500/5">
<CardContent className="p-4 flex items-start gap-3"> <CardContent className="p-4">
<CheckCircle className="w-5 h-5 text-success mt-0.5 flex-shrink-0" /> <div className="flex items-start gap-3">
<div className="flex-1 min-w-0"> <CheckCircle className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
<p className="font-medium text-foreground">Client created save your secret now</p> <div className="flex-1 min-w-0 space-y-3">
<p className="text-sm text-muted-foreground mb-2">This secret will not be shown again.</p> <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"> <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> <code className="flex-1 text-xs bg-muted px-3 py-2 rounded font-mono break-all">
<Button variant="ghost" size="icon" className="w-6 h-6 flex-shrink-0" onClick={() => copyToClipboard(newSecret.secret)}> {newSecret.secret}
<Copy className="w-3 h-3" /> </code>
<Button variant="outline" size="sm" onClick={() => copySecret(newSecret.secret)}>
{secretCopied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
</Button> </Button>
</div> </div>
</div> </div>
<Button variant="ghost" size="icon" className="w-6 h-6" onClick={() => setNewSecret(null)}>×</Button>
{/* 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>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Client list */}
{isLoading ? ( {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" /> <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div> </div>
) : clients.length === 0 ? ( ) : clients.length === 0 ? (
<Card> <Card>
<CardContent className="text-center py-12"> <CardContent className="py-16 flex flex-col items-center gap-4 text-center">
<AlertCircle className="w-10 h-10 mx-auto mb-3 text-muted-foreground/50" /> <Network className="w-10 h-10 text-muted-foreground/40" />
<p className="text-muted-foreground">No OIDC clients configured yet.</p> <div>
<Button className="mt-4" onClick={() => setIsCreateOpen(true)}> <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" /> <Plus className="w-4 h-4 mr-2" />
Add your first client Generic app
</Button> </Button>
<Button variant="outline" onClick={() => setDialogMode("proxy")}>
<Terminal className="w-4 h-4 mr-2" />
oauth2-proxy
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-3">
{clients.map((client) => ( {clients.map((client) => (
<Card key={client.id}> <Card key={client.id}>
<CardContent className="p-5"> <CardContent className="p-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-3 min-w-0">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center"> <div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Key className="w-6 h-6 text-primary" /> <Key className="w-4 h-4 text-primary" />
</div> </div>
<div> <div className="min-w-0">
<h3 className="font-semibold text-foreground">{client.name}</h3> <p className="font-semibold truncate">{client.name}</p>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-1.5 mt-1">
<code className="text-xs bg-muted px-2 py-1 rounded font-mono"> <code className="text-xs bg-muted px-2 py-0.5 rounded font-mono truncate max-w-[260px]">
{client.client_id} {client.client_id}
</code> </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" /> <Copy className="w-3 h-3" />
</Button> </Button>
</div> </div>
<div className="flex flex-wrap gap-1 mt-3"> <div className="flex flex-wrap gap-1 mt-2">
{(client.scopes ?? []).map((scope) => ( {(client.scopes ?? []).map((scope) => (
<Badge key={scope} variant="secondary" className="text-xs"> <Badge key={scope} variant="secondary" className="text-xs">
{scope} {scope}
@@ -206,7 +287,7 @@ export default function OIDCClientsPage() {
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon" className="flex-shrink-0">
<MoreHorizontal className="w-4 h-4" /> <MoreHorizontal className="w-4 h-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -217,24 +298,288 @@ export default function OIDCClientsPage() {
onClick={() => handleDelete(client.id)} onClick={() => handleDelete(client.id)}
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Delete client Delete
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </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> <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" : ""} {(client.redirect_uris ?? []).length} redirect URI{(client.redirect_uris ?? []).length !== 1 ? "s" : ""}
</div> </span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </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 &amp; 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://&lt;your-proxy-host&gt;/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> </div>
); );
} }
+16 -17
View File
@@ -46,7 +46,8 @@ export default function OrgOverviewPage() {
if (!selectedOrg) return; if (!selectedOrg) return;
setIsDeleting(true); setIsDeleting(true);
try { try {
await api.organizations.deleteOrganization(selectedOrg.id); // If there are other members, pass confirm=true to acknowledge forced removal.
await api.organizations.deleteOrganization(selectedOrg.id, memberCount > 1);
toast({ title: "Organization deleted", description: `"${selectedOrg.name}" has been deleted.` }); toast({ title: "Organization deleted", description: `"${selectedOrg.name}" has been deleted.` });
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
// Refresh org list; context will auto-select next available org // Refresh org list; context will auto-select next available org
@@ -59,19 +60,11 @@ export default function OrgOverviewPage() {
navigate("/org-setup"); navigate("/org-setup");
} }
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.type === "ORG_HAS_MEMBERS") {
toast({
title: "Cannot delete organization",
description: "This organization still has other members. Transfer ownership or remove all members first.",
variant: "destructive",
});
} else {
toast({ toast({
title: "Deletion failed", title: "Deletion failed",
description: err instanceof ApiError ? err.message : "An unexpected error occurred.", description: err instanceof ApiError ? err.message : "An unexpected error occurred.",
variant: "destructive", variant: "destructive",
}); });
}
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
@@ -220,17 +213,15 @@ export default function OrgOverviewPage() {
<div> <div>
<p className="text-sm font-medium text-destructive">Delete Organization</p> <p className="text-sm font-medium text-destructive">Delete Organization</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Permanently deletes this organization.{" "}
{memberCount > 1 {memberCount > 1
? `You must remove all ${memberCount - 1} other member${memberCount > 2 ? "s" : ""} first.` ? `Permanently deletes this organization and removes all ${memberCount - 1} other member${memberCount > 2 ? "s" : ""}. This action cannot be undone.`
: "This action cannot be undone."} : "Permanently deletes this organization. This action cannot be undone."}
</p> </p>
</div> </div>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => setDeleteDialogOpen(true)} onClick={() => setDeleteDialogOpen(true)}
disabled={memberCount > 1}
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Delete Delete
@@ -253,10 +244,18 @@ export default function OrgOverviewPage() {
data. This action <strong>cannot be undone</strong>. data. This action <strong>cannot be undone</strong>.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"> <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive space-y-1">
<AlertTriangle className="w-4 h-4 inline mr-2" /> <p className="flex items-center gap-1 font-medium">
You are about to delete <strong>{org?.name}</strong>. All settings, <AlertTriangle className="w-4 h-4 inline mr-1" />
policies, OIDC clients, and CA configurations will be lost. You are about to delete <strong>{org?.name}</strong>.
</p>
<p>All settings, policies, OIDC clients, and CA configurations will be lost.</p>
{memberCount > 1 && (
<p className="font-medium">
This will also remove all {memberCount - 1} other member
{memberCount > 2 ? "s" : ""} from the organization.
</p>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}> <Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}>
-3
View File
@@ -271,9 +271,6 @@ export default function PrincipalsPage() {
})} })}
</div> </div>
<div className="mt-2 text-xs text-muted-foreground">
Created {new Date(principal.created_at).toLocaleDateString()}
</div>
</div> </div>
<DropdownMenu> <DropdownMenu>
+236
View File
@@ -0,0 +1,236 @@
import {
MoreHorizontal,
RefreshCw,
Server,
ServerCog,
Settings,
ShieldOff,
Terminal,
User,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Textarea } from "@/components/ui/textarea";
import { OrgCA } from "@/lib/api";
import { formatDate } from "./utils";
import { CopyButton } from "./CopyButton";
interface CADetailCardProps {
ca: OrgCA;
onEdit: (ca: OrgCA) => void;
onRotate: (ca: OrgCA) => void;
onDelete: (ca: OrgCA) => void;
}
export function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardProps) {
const isUser = ca.ca_type === "user";
const isSystem = !!ca.is_system;
// ── User CA: server trusts this public key so it accepts user certs ──────
const userCaServerSnippet = `# On each SSH server — trust Gatehouse-issued user certificates:
echo '${ca.public_key.trim()}' >> /etc/ssh/trusted_user_ca_keys
# /etc/ssh/sshd_config (add once, then reload sshd):
TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
# Create /etc/ssh/auth_principals/<unix-user> containing one principal per line.`;
// ── Host CA: clients trust this public key so they can verify server certs ─
const hostCaClientSnippet = `# On SSH clients — trust host certificates signed by this CA:
# Add to ~/.ssh/known_hosts (or /etc/ssh/ssh_known_hosts for system-wide):
@cert-authority * ${ca.public_key.trim()}
# Server side (separate step)
# 1. Collect the server's HOST public key:
# cat /etc/ssh/ssh_host_ed25519_key.pub
# 2. Submit it to Gatehouse "Issue Host Certificate" to get a signed cert.
# 3. Install the cert on the server:
# /etc/ssh/sshd_config:
# HostKey /etc/ssh/ssh_host_ed25519_key
# HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
# 4. Verify the cert (NOT this CA key):
# ssh-keygen -L -f /etc/ssh/ssh_host_ed25519_key-cert.pub
# Type must be: ssh-ed25519-cert-v01@openssh.com host certificate`;
const sshConfig = isUser ? userCaServerSnippet : hostCaClientSnippet;
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-base flex items-center gap-2 flex-wrap">
{isSystem ? (
<ServerCog className="w-4 h-4 flex-shrink-0" />
) : isUser ? (
<User className="w-4 h-4 flex-shrink-0" />
) : (
<Server className="w-4 h-4 flex-shrink-0" />
)}
<span className="truncate">{ca.name}</span>
{isSystem ? (
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<ServerCog className="w-3 h-3" />
System
</Badge>
) : ca.is_active ? (
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">Active</Badge>
) : (
<Badge variant="secondary" className="text-xs">Inactive</Badge>
)}
</CardTitle>
{ca.description && (
<CardDescription className="mt-1">{ca.description}</CardDescription>
)}
</div>
{/* Right side: key-type badge + actions menu */}
<div className="flex items-center gap-1 flex-shrink-0">
<Badge variant="outline" className="text-xs font-mono">{ca.key_type}</Badge>
{/* ⋯ actions — only for non-system CAs */}
{!isSystem && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="w-4 h-4" />
<span className="sr-only">CA actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => onEdit(ca)}>
<Settings className="w-3.5 h-3.5 mr-2" />
Edit configuration
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onRotate(ca)}>
<RefreshCw className="w-3.5 h-3.5 mr-2" />
Rotate key
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(ca)}
className="text-destructive focus:text-destructive"
>
<ShieldOff className="w-3.5 h-3.5 mr-2" />
Delete CA
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats row — hidden for system CAs */}
{!isSystem && (
<div className="grid grid-cols-4 gap-3 text-center">
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.active_certs}</p>
<p className="text-xs text-muted-foreground">Active certs</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.total_certs}</p>
<p className="text-xs text-muted-foreground">Total issued</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.default_cert_validity_hours}h</p>
<p className="text-xs text-muted-foreground">Default validity</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.next_serial_number ?? "—"}</p>
<p className="text-xs text-muted-foreground">Next serial</p>
</div>
</div>
)}
{/* Fingerprint — with copy button */}
<div>
<p className="text-xs text-muted-foreground mb-1">Fingerprint</p>
<div className="flex items-center gap-1">
<code className="text-xs font-mono bg-muted px-2 py-1 rounded break-all flex-1">
{ca.fingerprint}
</code>
<CopyButton text={ca.fingerprint} />
</div>
</div>
{/* Public key */}
<div>
<div className="flex items-center justify-between mb-1">
<div>
<p className="text-xs font-medium">
{isUser ? "User CA public key" : "Host CA public key"}
</p>
<p className="text-xs text-muted-foreground">
{isUser
? "Distribute to SSH servers → TrustedUserCAKeys"
: "Distribute to SSH clients → known_hosts @cert-authority (NOT HostCertificate)"}
</p>
</div>
<CopyButton text={ca.public_key} />
</div>
<Textarea readOnly value={ca.public_key} className="font-mono text-xs min-h-[60px]" />
</div>
{/* Setup instructions — collapsible */}
<Accordion type="single" collapsible className="border rounded-lg px-1">
<AccordionItem value="setup" className="border-none">
<AccordionTrigger className="py-2 text-xs font-semibold hover:no-underline">
<span className="flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5" />
{isUser
? "Server setup — trust Gatehouse user certificates"
: "Client setup — trust Gatehouse host certificates"}
</span>
</AccordionTrigger>
<AccordionContent className="pb-3">
{!isUser && (
<div className="mb-2 rounded border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/40 px-2 py-1.5 text-xs text-amber-800 dark:text-amber-300">
<strong>Two separate steps:</strong> (1) Put this CA public key in client{" "}
<code className="font-mono">known_hosts</code>. (2) Issue a host certificate
for each server via Gatehouse and install it as{" "}
<code className="font-mono">HostCertificate</code>.
</div>
)}
<pre className="text-xs font-mono whitespace-pre-wrap break-all bg-muted rounded p-3">
{sshConfig}
</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* Metadata */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{ca.created_at && <span>Created {formatDate(ca.created_at)}</span>}
{ca.rotated_at && (
<span>
Key rotated {formatDate(ca.rotated_at)}
{ca.rotation_reason && <> {ca.rotation_reason}</>}
</span>
)}
</div>
</CardContent>
</Card>
);
}
+468
View File
@@ -0,0 +1,468 @@
import { Loader2, AlertCircle, Shield, User, Server, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { OrgCA } from "@/lib/api";
// ─────────────────────────────────────────────────────────────────────────────
// Create CA Dialog
// ─────────────────────────────────────────────────────────────────────────────
export interface CreateCAForm {
name: string;
description: string;
key_type: "ed25519" | "rsa" | "ecdsa";
default_cert_validity_hours: number;
max_cert_validity_hours: number;
}
interface CreateCADialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
caType: "user" | "host";
form: CreateCAForm;
onFormChange: (form: CreateCAForm) => void;
error: string | null;
isLoading: boolean;
onSubmit: () => void;
}
export function CreateCADialog({
open,
onOpenChange,
caType,
form,
onFormChange,
error,
isLoading,
onSubmit,
}: CreateCADialogProps) {
return (
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{caType === "user" ? <User className="w-5 h-5" /> : <Server className="w-5 h-5" />}
Generate {caType === "user" ? "User" : "Host"} Signing Key
</DialogTitle>
<DialogDescription>
{caType === "user"
? "Creates a key pair for signing SSH user certificates. The private key is stored securely and never exposed."
: "Creates a key pair for signing SSH host certificates, allowing clients to verify server identity."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<div className="space-y-2">
<Label htmlFor="ca-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="ca-name"
placeholder={caType === "user" ? "User CA" : "Host CA"}
value={form.name}
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ca-description">Description</Label>
<Input
id="ca-description"
placeholder="Optional description"
value={form.description}
onChange={(e) => onFormChange({ ...form, description: e.target.value })}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ca-key-type">Key Algorithm</Label>
<Select
value={form.key_type}
onValueChange={(v) =>
onFormChange({ ...form, key_type: v as "ed25519" | "rsa" | "ecdsa" })
}
disabled={isLoading}
>
<SelectTrigger id="ca-key-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ed25519">Ed25519 (recommended)</SelectItem>
<SelectItem value="ecdsa">ECDSA (P-521)</SelectItem>
<SelectItem value="rsa">RSA-4096</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="ca-default-validity">Default validity (hours)</Label>
<Input
id="ca-default-validity"
type="number"
min="1"
value={form.default_cert_validity_hours}
onChange={(e) =>
onFormChange({
...form,
default_cert_validity_hours: parseInt(e.target.value) || 1,
})
}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ca-max-validity">Max validity (hours)</Label>
<Input
id="ca-max-validity"
type="number"
min="1"
value={form.max_cert_validity_hours}
onChange={(e) =>
onFormChange({
...form,
max_cert_validity_hours: parseInt(e.target.value) || 1,
})
}
disabled={isLoading}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating key
</>
) : (
<>
<Shield className="w-4 h-4 mr-2" />
Generate Key
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Edit CA Dialog
// ─────────────────────────────────────────────────────────────────────────────
export interface EditCAForm {
default_cert_validity_hours: number;
max_cert_validity_hours: number;
}
interface EditCADialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
ca: OrgCA | null;
form: EditCAForm;
onFormChange: (form: EditCAForm) => void;
error: string | null;
isLoading: boolean;
onSubmit: () => void;
}
export function EditCADialog({
open,
onOpenChange,
ca,
form,
onFormChange,
error,
isLoading,
onSubmit,
}: EditCADialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit CA Configuration</DialogTitle>
<DialogDescription>
Update certificate validity settings for <strong>{ca?.name}</strong>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="default-validity">Default Certificate Validity (hours)</Label>
<Input
id="default-validity"
type="number"
min="1"
value={form.default_cert_validity_hours}
onChange={(e) =>
onFormChange({
...form,
default_cert_validity_hours: parseInt(e.target.value) || 1,
})
}
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
Default validity period when issuing new certificates
</p>
</div>
<div className="space-y-2">
<Label htmlFor="max-validity">Maximum Certificate Validity (hours)</Label>
<Input
id="max-validity"
type="number"
min="1"
value={form.max_cert_validity_hours}
onChange={(e) =>
onFormChange({
...form,
max_cert_validity_hours: parseInt(e.target.value) || 1,
})
}
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
Maximum allowed validity period for any certificate from this CA
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={isLoading}>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Rotate CA Dialog
// ─────────────────────────────────────────────────────────────────────────────
interface RotateCADialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
ca: OrgCA | null;
keyType: "ed25519" | "rsa" | "ecdsa";
onKeyTypeChange: (kt: "ed25519" | "rsa" | "ecdsa") => void;
reason: string;
onReasonChange: (r: string) => void;
error: string | null;
isLoading: boolean;
onSubmit: () => void;
}
export function RotateCADialog({
open,
onOpenChange,
ca,
keyType,
onKeyTypeChange,
reason,
onReasonChange,
error,
isLoading,
onSubmit,
}: RotateCADialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RefreshCw className="w-5 h-5" />
Rotate CA Key
</DialogTitle>
<DialogDescription>
Generate a new key pair for <strong>{ca?.name}</strong>. Previously-issued
certificates remain valid until they expire, but all new certificates will be signed
with the new key. You must update{" "}
{ca?.ca_type === "user"
? "TrustedUserCAKeys on your SSH servers"
: "@cert-authority in client known_hosts files"}{" "}
after rotation.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{ca && (
<div className="rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 p-3 text-xs text-amber-800 dark:text-amber-300">
<p className="font-semibold mb-1"> Important</p>
<p>
Current fingerprint:{" "}
<code className="font-mono">{ca.fingerprint}</code>
</p>
<p className="mt-1">
After rotation, you <strong>must</strong> replace this fingerprint on every
server / client that trusts this CA. Until updated, new certificates won't be
accepted.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="rotate-key-type">New Key Algorithm</Label>
<Select
value={keyType}
onValueChange={(v) => onKeyTypeChange(v as "ed25519" | "rsa" | "ecdsa")}
disabled={isLoading}
>
<SelectTrigger id="rotate-key-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ed25519">Ed25519 (recommended)</SelectItem>
<SelectItem value="ecdsa">ECDSA (P-521)</SelectItem>
<SelectItem value="rsa">RSA-4096</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rotate-reason">Reason (optional)</Label>
<Input
id="rotate-reason"
placeholder="e.g. Suspected key compromise, Scheduled rotation"
value={reason}
onChange={(e) => onReasonChange(e.target.value)}
disabled={isLoading}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={isLoading} variant="destructive">
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Rotating
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Rotate Key
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Delete CA Dialog
// ─────────────────────────────────────────────────────────────────────────────
interface DeleteCADialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
ca: OrgCA | null;
isLoading: boolean;
onConfirm: () => void;
}
export function DeleteCADialog({
open,
onOpenChange,
ca,
isLoading,
onConfirm,
}: DeleteCADialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Certificate Authority?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently deactivate <strong>{ca?.name}</strong>. No new certificates
can be signed with this CA after deletion. Existing certificates remain valid until
they expire.
{ca?.active_certs ? (
<span className="block mt-2 font-semibold text-amber-600 dark:text-amber-400">
This CA has {ca.active_certs} active certificate
{ca.active_certs !== 1 ? "s" : ""}.
</span>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={isLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Delete CA
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+161
View File
@@ -0,0 +1,161 @@
import { AlertCircle, Plus, Server, ServerCog, ShieldAlert, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { OrgCA } from "@/lib/api";
import { CADetailCard } from "./CADetailCard";
import { IssueHostCertPanel } from "./IssueHostCertPanel";
interface CASectionProps {
caType: "user" | "host";
ca: OrgCA | null;
onCreateClick: (caType: "user" | "host") => void;
onEdit: (ca: OrgCA) => void;
onRotate: (ca: OrgCA) => void;
onDelete: (ca: OrgCA) => void;
}
const SECTION_META = {
user: {
title: "User CA",
subtitle:
"Signs SSH user certificates. Servers trust users who present a valid cert by adding this CA's public key to TrustedUserCAKeys.",
emptyDescription:
"No User CA configured. Generate a key pair to start issuing SSH user certificates.",
},
host: {
title: "Host CA",
subtitle:
"Signs SSH host certificates. Clients trust servers whose cert is signed by this CA. The CA public key goes in the client's known_hosts — not HostCertificate (that is issued per-server separately).",
emptyDescription:
"No Host CA configured. Generate a key pair to start issuing SSH host certificates.",
},
} as const;
// ── Tiny numbered step label used in the Host CA flow ────────────────────────
function StepLabel({ n, label }: { n: number; label: string }) {
return (
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold flex-shrink-0">
{n}
</span>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{label}
</span>
</div>
);
}
export function CASection({
caType,
ca,
onCreateClick,
onEdit,
onRotate,
onDelete,
}: CASectionProps) {
const isUser = caType === "user";
const { title, subtitle, emptyDescription } = SECTION_META[caType];
const Icon = isUser ? User : Server;
const isSystem = !!ca?.is_system;
return (
<div className="space-y-4">
{/* Section header */}
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div>
<h2 className="text-sm font-semibold leading-tight">{title}</h2>
{/* Only show the verbose subtitle when there's no CA yet */}
{!ca && (
<p className="text-xs text-muted-foreground max-w-prose">{subtitle}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{ca ? (
isSystem ? (
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<ServerCog className="w-3 h-3" />
System (read-only)
</Badge>
) : (
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">Configured</Badge>
)
) : (
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Not configured
</Badge>
)}
</div>
</div>
{/* Content */}
{ca ? (
<div className="space-y-6">
{isUser ? (
/* ── User CA: single card, no numbered steps needed ─────────── */
<CADetailCard ca={ca} onEdit={onEdit} onRotate={onRotate} onDelete={onDelete} />
) : (
/* ── Host CA: two explicit numbered steps ────────────────────── */
<>
{/* Step 1 — CA key → clients' known_hosts */}
<div className="space-y-2">
<StepLabel n={1} label="Distribute CA key to clients" />
<CADetailCard ca={ca} onEdit={onEdit} onRotate={onRotate} onDelete={onDelete} />
</div>
{/* Step 2 — sign each server's host public key */}
{!isSystem && (
<div className="space-y-2">
<StepLabel n={2} label="Sign each server's host key" />
<IssueHostCertPanel ca={ca} />
</div>
)}
</>
)}
{/* System CA upgrade prompt */}
{isSystem && (
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30 p-3 text-xs text-amber-800 dark:text-amber-300">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="font-semibold mb-1">Using server-configured CA</p>
<p>
Certificates are being signed by a CA key loaded from the server
configuration, not managed through this UI. Generate a managed key below to
take full control of certificate issuance from Gatehouse.
</p>
</div>
<Button
onClick={() => onCreateClick(caType)}
size="sm"
variant="outline"
className="flex-shrink-0"
>
<Plus className="w-3 h-3 mr-1" />
Generate managed key
</Button>
</div>
)}
</div>
) : (
/* Empty state */
<Card className="border-dashed">
<CardContent className="flex flex-col items-center py-10 text-muted-foreground">
<ShieldAlert className="w-10 h-10 mb-3 opacity-30" />
<p className="text-sm font-medium mb-1">No {title} configured</p>
<p className="text-xs text-center mb-4 max-w-sm">{emptyDescription}</p>
<Button onClick={() => onCreateClick(caType)} size="sm" variant="outline">
<Plus className="w-4 h-4 mr-2" />
Generate {title}
</Button>
</CardContent>
</Card>
)}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useState } from "react";
import { Copy, CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
interface CopyButtonProps {
text: string;
}
export function CopyButton({ text }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast({ title: "Copied to clipboard" });
setTimeout(() => setCopied(false), 2000);
} catch {
toast({ variant: "destructive", title: "Copy failed" });
}
};
return (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={handleCopy}
title="Copy to clipboard"
>
{copied ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
);
}
+344
View File
@@ -0,0 +1,344 @@
import { useState } from "react";
import {
AlertCircle,
CheckCircle,
ChevronDown,
ChevronRight,
FileKey,
HelpCircle,
Loader2,
Terminal,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { api, ApiError, OrgCA } from "@/lib/api";
import { classifySshKeyMaterial } from "./utils";
import { CopyButton } from "./CopyButton";
interface IssueHostCertPanelProps {
ca: OrgCA;
}
// Preset validity options: [label, hours]
const VALIDITY_PRESETS: [string, number][] = [
["1d", 24],
["7d", 168],
["30d", 720],
["1y", 8760],
];
export function IssueHostCertPanel({ ca }: IssueHostCertPanelProps) {
const { toast } = useToast();
const [isExpanded, setIsExpanded] = useState(false);
const [showHowItWorks, setShowHowItWorks] = useState(false);
const [hostPubKey, setHostPubKey] = useState("");
const [principals, setPrincipals] = useState("");
const [validityHours, setValidityHours] = useState("720");
const [isIssuing, setIsIssuing] = useState(false);
const [issueError, setIssueError] = useState<string | null>(null);
const [hostCert, setHostCert] = useState<string | null>(null);
const [keyWarning, setKeyWarning] = useState<string | null>(null);
const handlePubKeyChange = (value: string) => {
setHostPubKey(value);
setIssueError(null);
setKeyWarning(null);
if (!value.trim()) return;
const kind = classifySshKeyMaterial(value.trim());
if (kind === "certificate") {
setKeyWarning(
"⚠ This looks like a certificate (ssh-…-cert-v01@openssh.com), not a public key. " +
"Paste the server's host PUBLIC key (from /etc/ssh/ssh_host_ed25519_key.pub), not an existing certificate.",
);
} else if (kind === "private_key") {
setKeyWarning("⚠ This looks like a PRIVATE key. Never paste private keys here. Use the .pub file.");
} else if (kind === "unknown") {
setKeyWarning("⚠ Unrecognised key format. Expected: ssh-ed25519 AAAA… or ecdsa-sha2-nistp256 AAAA…");
}
};
const handleIssue = async () => {
setIssueError(null);
setHostCert(null);
const kind = classifySshKeyMaterial(hostPubKey.trim());
if (kind === "certificate") {
setIssueError(
"You pasted a certificate, not a host public key. " +
"Get the server's host public key: cat /etc/ssh/ssh_host_ed25519_key.pub",
);
return;
}
if (kind === "private_key") {
setIssueError("Private keys must never be pasted here. Use the .pub file.");
return;
}
const principalList = principals
.split(/[\s,]+/)
.map((p) => p.trim())
.filter(Boolean);
if (principalList.length === 0) {
setIssueError("At least one principal (hostname/FQDN) is required.");
return;
}
const hours = parseInt(validityHours, 10);
if (!hours || hours < 1) {
setIssueError("Validity must be a positive number of hours.");
return;
}
setIsIssuing(true);
try {
const result = await api.ssh.signHostCert(hostPubKey.trim(), principalList, hours, ca.id);
setHostCert(result.certificate);
toast({ title: "Host certificate issued", description: `Serial #${result.serial}` });
} catch (err) {
setIssueError(
err instanceof ApiError ? err.message : "Failed to issue host certificate",
);
} finally {
setIsIssuing(false);
}
};
const serverInstallSnippet = hostCert
? `# 1. Copy the certificate to the server:
cat > /etc/ssh/ssh_host_ed25519_key-cert.pub << 'CERT'
${hostCert.trim()}
CERT
# 2. /etc/ssh/sshd_config (ensure these two lines exist):
HostKey /etc/ssh/ssh_host_ed25519_key
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
# 3. Reload sshd:
systemctl reload sshd
# 4. Verify the cert (must show type = host certificate):
ssh-keygen -L -f /etc/ssh/ssh_host_ed25519_key-cert.pub`
: "";
return (
<Card className="border-dashed border-blue-300 dark:border-blue-700">
<CardHeader
className="py-3 cursor-pointer select-none"
onClick={() => setIsExpanded((v) => !v)}
>
<CardTitle className="text-sm flex items-center gap-2">
<FileKey className="w-4 h-4 text-blue-600 dark:text-blue-400" />
Issue Host Certificate
<span className="ml-auto">
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</span>
</CardTitle>
<CardDescription className="text-xs">
Paste a server's host public key to receive a signed certificate to install as{" "}
<code>HostCertificate</code> in <code>sshd_config</code>.
</CardDescription>
</CardHeader>
{isExpanded && (
<CardContent className="space-y-4 pt-0">
{/* ── "How it works" — collapsed by default ──────────────── */}
<div>
<button
type="button"
onClick={() => setShowHowItWorks((v) => !v)}
className="flex items-center gap-1.5 text-xs text-blue-600 dark:text-blue-400 hover:underline focus:outline-none"
>
<HelpCircle className="w-3.5 h-3.5" />
How host certificates work
{showHowItWorks ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
{showHowItWorks && (
<div className="mt-2 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/30 p-3 text-xs text-blue-800 dark:text-blue-300 space-y-1.5">
<p>
<strong>Step 1 (done above):</strong> Distribute the Host CA public key to SSH
clients via <code className="font-mono">@cert-authority</code> in{" "}
<code className="font-mono">known_hosts</code>.
</p>
<p>
<strong>Step 2 (here):</strong> For each server, collect its host public key,
paste it below, and Gatehouse will sign it. Install the resulting certificate
as <code className="font-mono">HostCertificate</code> in{" "}
<code className="font-mono">sshd_config</code>.
</p>
<p className="text-amber-700 dark:text-amber-400">
Do <strong>not</strong> put the CA public key from Step 1 into{" "}
<code className="font-mono">HostCertificate</code> that directive requires a
signed certificate, not a CA public key.
</p>
</div>
)}
</div>
{issueError && (
<div className="rounded-md bg-destructive/10 text-destructive text-sm p-3 flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
{issueError}
</div>
)}
{!hostCert ? (
<>
{/* Host public key */}
<div className="space-y-1.5">
<Label className="text-sm">
Server host public key{" "}
<span className="ml-1 font-normal text-muted-foreground">
(<code>/etc/ssh/ssh_host_ed25519_key.pub</code>)
</span>
</Label>
<p className="text-xs text-muted-foreground">
Run{" "}
<code className="font-mono bg-muted px-1 rounded">
cat /etc/ssh/ssh_host_ed25519_key.pub
</code>{" "}
on the server and paste the result.
</p>
<Textarea
placeholder="ssh-ed25519 AAAA... root@server"
value={hostPubKey}
onChange={(e) => handlePubKeyChange(e.target.value)}
className="font-mono text-xs min-h-[80px]"
disabled={isIssuing}
/>
{keyWarning && (
<p className="text-xs text-amber-700 dark:text-amber-400">{keyWarning}</p>
)}
</div>
{/* Principals */}
<div className="space-y-1.5">
<Label className="text-sm">
Principals{" "}
<span className="ml-1 font-normal text-muted-foreground">
(hostnames/FQDNs, space or comma separated)
</span>
</Label>
<p className="text-xs text-muted-foreground">
Must match what clients type in{" "}
<code className="font-mono">ssh user@&lt;principal&gt;</code>.
</p>
<Input
placeholder="prod.example.com web01.internal"
value={principals}
onChange={(e) => setPrincipals(e.target.value)}
disabled={isIssuing}
/>
</div>
{/* Validity */}
<div className="space-y-1.5">
<Label className="text-sm">Validity (hours)</Label>
<div className="flex items-center gap-2 flex-wrap">
<Input
type="number"
min={1}
value={validityHours}
onChange={(e) => setValidityHours(e.target.value)}
className="w-28"
disabled={isIssuing}
/>
{/* Quick preset buttons */}
<div className="flex items-center gap-1">
{VALIDITY_PRESETS.map(([label, hours]) => (
<Button
key={label}
type="button"
size="sm"
variant={validityHours === String(hours) ? "default" : "outline"}
className="h-8 px-2 text-xs"
onClick={() => setValidityHours(String(hours))}
disabled={isIssuing}
>
{label}
</Button>
))}
</div>
</div>
<p className="text-xs text-muted-foreground">
Host certs are typically longer-lived than user certs.
</p>
</div>
<Button
onClick={handleIssue}
disabled={isIssuing || !hostPubKey.trim() || !principals.trim() || !!keyWarning}
size="sm"
>
{isIssuing && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
<FileKey className="w-4 h-4 mr-2" />
Issue host certificate
</Button>
</>
) : (
/* ── Success view ─────────────────────────────────────── */
<div className="space-y-3">
<p className="text-sm font-medium text-green-700 dark:text-green-400 flex items-center gap-1.5">
<CheckCircle className="w-4 h-4" />
Host certificate issued
</p>
{/* Certificate text */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Certificate</Label>
<CopyButton text={hostCert} />
</div>
<Textarea
readOnly
value={hostCert}
className="font-mono text-xs min-h-[80px]"
/>
</div>
{/* Install snippet */}
<div className="rounded-lg bg-muted p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold flex items-center gap-1">
<Terminal className="w-3 h-3" />
Install on the server
</p>
<CopyButton text={serverInstallSnippet} />
</div>
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
{serverInstallSnippet}
</pre>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setHostCert(null);
setHostPubKey("");
setPrincipals("");
}}
>
Issue another
</Button>
</div>
)}
</CardContent>
)}
</Card>
);
}
+32
View File
@@ -0,0 +1,32 @@
// ─── Shared utilities for the Certificate Authorities page ───────────────────
export function formatDate(d: string | null): string {
if (!d) return "—";
return new Date(d).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export type SshKeyMaterialKind = "certificate" | "public_key" | "private_key" | "unknown";
/**
* Inspect the first token of a raw SSH key/cert string and classify it.
* Used to warn the user before they accidentally paste a certificate where
* a public key is expected, or vice-versa.
*/
export function classifySshKeyMaterial(raw: string): SshKeyMaterialKind {
const line = raw.trim().split(/\s+/)[0] ?? "";
if (/-cert-v01@openssh\.com$/.test(line)) return "certificate";
if (
/^(ssh-ed25519|ssh-rsa|ssh-dss|ecdsa-sha2-nistp\d+|sk-ssh-ed25519@openssh\.com)$/.test(line)
)
return "public_key";
if (
raw.trim().startsWith("-----BEGIN OPENSSH PRIVATE KEY-----") ||
raw.trim().startsWith("-----BEGIN RSA PRIVATE KEY-----")
)
return "private_key";
return "unknown";
}
+81 -12
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Mail, Upload, CheckCircle, AlertCircle, Loader2, Bell, AlertTriangle, Trash2, Building2 } from "lucide-react"; import { Mail, Upload, CheckCircle, AlertCircle, Loader2, Bell, AlertTriangle, Trash2, Building2, TriangleAlert } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -76,6 +76,7 @@ export default function ProfilePage() {
// Delete account dialog state // Delete account dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [confirmEmail, setConfirmEmail] = useState("");
// Sync local name state with user data // Sync local name state with user data
useEffect(() => { useEffect(() => {
@@ -108,14 +109,23 @@ export default function ProfilePage() {
await api.users.deleteMe(); await api.users.deleteMe();
toast({ title: "Account deleted", description: "Your account has been deleted." }); toast({ title: "Account deleted", description: "Your account has been deleted." });
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setConfirmEmail("");
await logout(); await logout();
navigate("/login"); navigate("/login");
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.type === "USER_IS_SOLE_OWNER") { if (err instanceof ApiError && err.type === "USER_IS_SOLE_OWNER") {
const orgs: string[] = (err.details?.organizations as string[]) ?? []; const details = err.details as {
transfer_ownership?: string[];
} | undefined;
const transferOrgs = details?.transfer_ownership ?? [];
toast({ toast({
title: "Cannot delete account", title: "Cannot delete account",
description: `You are the sole owner of: ${orgs.join(", ")}. Transfer ownership or delete those organizations first.`, description:
transferOrgs.length > 0
? `You are the owner of ${transferOrgs.join(", ")} and other members exist. Transfer ownership to another member before deleting your account.`
: "You own organizations with other members. Transfer ownership first.",
variant: "destructive", variant: "destructive",
}); });
} else { } else {
@@ -325,7 +335,8 @@ export default function ProfilePage() {
<p className="text-sm font-medium text-destructive">Delete Account</p> <p className="text-sm font-medium text-destructive">Delete Account</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Permanently deletes your profile and all associated data. If you own Permanently deletes your profile and all associated data. If you own
any organizations you must transfer ownership or delete them first. organizations with other members, transfer ownership first. Sole-member
organizations are deleted automatically.
</p> </p>
</div> </div>
<Button <Button
@@ -342,7 +353,13 @@ export default function ProfilePage() {
</div> </div>
{/* Delete account confirmation dialog */} {/* Delete account confirmation dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Dialog
open={deleteDialogOpen}
onOpenChange={(open) => {
setDeleteDialogOpen(open);
if (!open) setConfirmEmail("");
}}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive"> <DialogTitle className="flex items-center gap-2 text-destructive">
@@ -354,24 +371,76 @@ export default function ProfilePage() {
permanently deleted. This action <strong>cannot be undone</strong>. permanently deleted. This action <strong>cannot be undone</strong>.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40 p-3 text-sm text-amber-800 dark:text-amber-300 space-y-1">
{/* Org ownership warning */}
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40 p-3 text-sm text-amber-800 dark:text-amber-300 space-y-2">
<p className="flex items-center gap-2 font-medium"> <p className="flex items-center gap-2 font-medium">
<Building2 className="w-4 h-4" /> <Building2 className="w-4 h-4" />
Organization ownership check Organization ownership check
</p> </p>
<p> <p>
If you are the sole owner of any organization that has other members, If you own organizations with other members, you must{" "}
you must <strong>transfer ownership</strong> to another member or delete <strong>transfer ownership</strong> to another member first.
those organizations before proceeding. </p>
<p>
Organizations where you are the <strong>sole member</strong> will
be automatically deleted along with your account.
</p> </p>
</div> </div>
{/* What will be deleted */}
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive space-y-1">
<p className="font-medium flex items-center gap-2">
<TriangleAlert className="w-4 h-4" />
The following will be permanently deleted:
</p>
<ul className="list-disc list-inside space-y-0.5 text-destructive/80 pl-1">
<li>Your profile and account data</li>
<li>All SSH keys and active certificates</li>
<li>All linked accounts (Google, GitHub, etc.)</li>
<li>All active sessions</li>
<li>All passkeys and MFA methods</li>
</ul>
</div>
{/* Email confirmation input */}
<div className="space-y-2">
<Label htmlFor="confirm-email" className="text-sm">
Type your email address{" "}
<span className="font-mono font-semibold text-foreground">
{user.email}
</span>{" "}
to confirm:
</Label>
<Input
id="confirm-email"
type="email"
placeholder={user.email}
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
disabled={isDeleting}
autoComplete="off"
/>
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={isDeleting}> <Button
variant="outline"
onClick={() => {
setDeleteDialogOpen(false);
setConfirmEmail("");
}}
disabled={isDeleting}
>
Cancel Cancel
</Button> </Button>
<Button variant="destructive" onClick={handleDeleteAccount} disabled={isDeleting}> <Button
variant="destructive"
onClick={handleDeleteAccount}
disabled={isDeleting || confirmEmail.trim().toLowerCase() !== user.email.toLowerCase()}
>
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />} {isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Yes, delete my account Yes, permanently delete my account
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+68 -10
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -9,7 +9,7 @@ import { AddPasskeyWizard } from "@/components/security/AddPasskeyWizard";
import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard"; import { TotpEnrollmentWizard } from "@/components/security/TotpEnrollmentWizard";
import { TotpRemoveDialog } from "@/components/security/TotpRemoveDialog"; import { TotpRemoveDialog } from "@/components/security/TotpRemoveDialog";
import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter"; 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 { useToast } from "@/hooks/use-toast";
import { ComplianceBanner } from "@/components/auth/ComplianceBanner"; import { ComplianceBanner } from "@/components/auth/ComplianceBanner";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
@@ -30,6 +30,9 @@ export default function SecurityPage() {
const [showTotpEnrollment, setShowTotpEnrollment] = useState(false); const [showTotpEnrollment, setShowTotpEnrollment] = useState(false);
const [showTotpRemove, setShowTotpRemove] = useState(false); const [showTotpRemove, setShowTotpRemove] = useState(false);
// Profile (for has_password / linked_providers)
const [profile, setProfile] = useState<User | null>(null);
// Password form state // Password form state
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
@@ -53,19 +56,49 @@ export default function SecurityPage() {
const { toast } = useToast(); const { toast } = useToast();
const { mfaCompliance } = useAuth(); 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 = { const policyRequirements = {
totpRequired: true, totpRequired: effectiveModes.some(m =>
passkeysRequired: false, m === 'require_totp' || m === 'require_totp_or_webauthn'
),
passkeysRequired: effectiveModes.some(m =>
m === 'require_webauthn' || m === 'require_totp_or_webauthn'
),
minPasswordLength: 12, 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 // Fetch TOTP status on mount
useEffect(() => { useEffect(() => {
fetchProfile();
fetchTotpStatus(); fetchTotpStatus();
fetchPasskeys(); 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 () => { const fetchTotpStatus = async () => {
setIsTotpStatusLoading(true); setIsTotpStatusLoading(true);
try { try {
@@ -234,20 +267,20 @@ export default function SecurityPage() {
<ComplianceBanner compliance={mfaCompliance} /> <ComplianceBanner compliance={mfaCompliance} />
<div className="space-y-6"> <div className="space-y-6">
{/* Policy Status */} {/* Policy Status — only shown when the org actually enforces MFA */}
{policyDescription && (
<Card className="border-accent/30 bg-accent/5"> <Card className="border-accent/30 bg-accent/5">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Shield className="w-5 h-5 text-accent mt-0.5" /> <Shield className="w-5 h-5 text-accent mt-0.5" />
<div> <div>
<p className="text-sm font-medium text-foreground">Organization Policy</p> <p className="text-sm font-medium text-foreground">Organization Policy</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{policyDescription}</p>
Your organization requires TOTP to be enabled for all members.
</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)}
{/* Password */} {/* Password */}
<Card> <Card>
@@ -260,6 +293,7 @@ export default function SecurityPage() {
</CardTitle> </CardTitle>
<CardDescription>Manage your account password</CardDescription> <CardDescription>Manage your account password</CardDescription>
</div> </div>
{hasPassword ? (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -267,9 +301,32 @@ export default function SecurityPage() {
> >
Change password Change password
</Button> </Button>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">
Not set
</Badge>
)}
</div> </div>
</CardHeader> </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"> <CardContent className="space-y-4 border-t pt-4">
{passwordError && ( {passwordError && (
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm"> <div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
@@ -505,6 +562,7 @@ export default function SecurityPage() {
setShowTotpRemove(false); setShowTotpRemove(false);
}} }}
isRequired={policyRequirements.totpRequired} isRequired={policyRequirements.totpRequired}
hasPassword={hasPassword}
/> />
{/* Delete Passkey Confirmation */} {/* Delete Passkey Confirmation */}