diff --git a/index.html b/index.html index 4793c90..838b15b 100644 --- a/index.html +++ b/index.html @@ -6,15 +6,24 @@ Gatehouse — Identity & Access + + + + + - + + - - + + + + + diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..89531d7 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/gatehouse-logo.svg b/public/gatehouse-logo.svg new file mode 100644 index 0000000..1b24a07 --- /dev/null +++ b/public/gatehouse-logo.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx index 0ba10a0..714830f 100644 --- a/src/components/navigation/AppSidebar.tsx +++ b/src/components/navigation/AppSidebar.tsx @@ -13,6 +13,7 @@ import { ScrollText, Terminal, ShieldCheck, + Key, } from "lucide-react"; import { GatehouseLogo } from "@/components/branding/GatehouseLogo"; import { NavLink } from "@/components/NavLink"; @@ -57,8 +58,9 @@ const orgAdminNavItems = [ const adminNavItems = [ { title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck }, - { title: "Org Audit Log", url: "/org/audit", icon: FileText }, - { title: "System Logs", url: "/admin/audit", icon: ScrollText }, + { title: "OIDC Clients", url: "/org/clients", icon: Key }, + { title: "Org Audit Log", url: "/org/audit", icon: FileText }, + { title: "System Logs", url: "/admin/audit", icon: ScrollText }, ]; export function AppSidebar() { diff --git a/src/components/security/TotpRemoveDialog.tsx b/src/components/security/TotpRemoveDialog.tsx index 69098d2..14ce0aa 100644 --- a/src/components/security/TotpRemoveDialog.tsx +++ b/src/components/security/TotpRemoveDialog.tsx @@ -18,6 +18,7 @@ interface TotpRemoveDialogProps { onOpenChange: (open: boolean) => void; onSuccess: () => void; isRequired?: boolean; + hasPassword?: boolean; } export function TotpRemoveDialog({ @@ -25,6 +26,7 @@ export function TotpRemoveDialog({ onOpenChange, onSuccess, isRequired = false, + hasPassword = true, }: TotpRemoveDialogProps) { const [isLoading, setIsLoading] = useState(false); const [password, setPassword] = useState(""); @@ -45,7 +47,7 @@ export function TotpRemoveDialog({ }; const handleRemove = async () => { - if (!password) { + if (hasPassword && !password) { setError("Password is required to disable TOTP"); return; } @@ -54,7 +56,7 @@ export function TotpRemoveDialog({ setError(null); try { - await api.totp.disable(password); + await api.totp.disable(hasPassword ? password : null); toast({ title: "Two-factor authentication disabled", @@ -80,7 +82,7 @@ export function TotpRemoveDialog({ }; const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && password) { + if (e.key === "Enter" && (!hasPassword || password)) { handleRemove(); } }; @@ -109,25 +111,30 @@ export function TotpRemoveDialog({
-
- - { - setPassword(e.target.value); - setError(null); - }} - onKeyDown={handleKeyDown} - disabled={isLoading} - autoFocus - /> - {error && ( -

{error}

- )} -
+ {hasPassword && ( +
+ + { + setPassword(e.target.value); + setError(null); + }} + onKeyDown={handleKeyDown} + disabled={isLoading} + autoFocus + /> + {error && ( +

{error}

+ )} +
+ )} + {!hasPassword && error && ( +

{error}

+ )}
+
+ )} + {isSuspended(selectedUser.status) ? (

@@ -532,6 +717,168 @@ export default function AdminUsersPage() {

)} + {/* ── MFA Methods section ────────────────────────────────────────── */} + {selectedUser.id !== currentUser?.id && ( +
+
+

+ + MFA Methods +

+ {userMfaMethods.length > 1 && ( + + )} +
+ + {isMfaLoading ? ( +
+ +
+ ) : userMfaMethods.length === 0 ? ( +

+ No MFA methods configured. +

+ ) : ( +
+ {userMfaMethods.map((method) => ( +
+
+ {method.type === "totp" ? ( + + ) : ( + + )} +
+

{method.name}

+ {method.last_used_at && ( +

+ Last used: {formatDate(method.last_used_at)} +

+ )} +
+
+ +
+ ))} +
+ )} +

+ 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. +

+
+ )} + + {/* ── Linked Accounts section ────────────────────────────────── */} + {selectedUser.id !== currentUser?.id && ( +
+

+ + Linked OAuth Accounts +

+ + {userLinkedAccounts.length === 0 ? ( +

+ No OAuth providers linked. +

+ ) : ( +
+ {userLinkedAccounts.map((account) => { + const isOnlyMethod = totalAuthMethods <= 1; + return ( +
+
+ +
+

{account.provider_type}

+ {account.email && ( +

{account.email}

+ )} + {account.linked_at && ( +

+ Linked: {formatDate(account.linked_at)} +

+ )} +
+
+ +
+ ); + })} +
+ )} +

+ Unlink an OAuth provider to prevent sign-in via that provider. + Cannot unlink if it is the user's only sign-in method. +

+
+ )} + + {/* ── Admin Password Reset section ──────────────────────────── */} + {selectedUser.id !== currentUser?.id && ( +
+

+ + Password +

+

+ Set a new password for this user. Use this when a user is locked out or needs a password added to their account. +

+ +
+ )} + {/* SSH Keys section */}
@@ -680,6 +1027,37 @@ export default function AdminUsersPage() { + {/* ── Remove All MFA confirmation ───────────────────────────────────────── */} + + + + + + Remove all MFA methods? + + + All MFA methods for{" "} + {selectedUser?.full_name || selectedUser?.email} 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. + + + + + + + + + {/* ── Hard delete confirmation ──────────────────────────────────────────── */} + {/* ── Admin password reset dialog ───────────────────────────────────── */} + { + setShowPasswordReset(open); + if (!open) { setNewPassword(""); setNewPasswordConfirm(""); setPasswordResetError(null); } + }} + > + + + + + Set password for {selectedUser?.email} + + + The user will be able to log in with this password immediately. This does not affect their existing OAuth logins. + + +
+ {passwordResetError && ( +
{passwordResetError}
+ )} +
+ + { setNewPassword(e.target.value); setPasswordResetError(null); }} + disabled={isResettingPassword} + autoFocus + /> +
+
+ + { setNewPasswordConfirm(e.target.value); setPasswordResetError(null); }} + disabled={isResettingPassword} + onKeyDown={(e) => { if (e.key === "Enter" && newPassword && newPasswordConfirm) handlePasswordReset(); }} + /> +
+
+ + + + +
+
); } diff --git a/src/pages/admin/OAuthProvidersPage.tsx b/src/pages/admin/OAuthProvidersPage.tsx index 20be9a9..bec814f 100644 --- a/src/pages/admin/OAuthProvidersPage.tsx +++ b/src/pages/admin/OAuthProvidersPage.tsx @@ -42,18 +42,20 @@ const PROVIDER_LOGOS: Record = { 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 = { google: { docsUrl: "https://console.cloud.google.com/apis/credentials", - callbackNote: "Authorized redirect URI: http://localhost:5000/api/v1/auth/external/google/callback", + callbackNote: `Authorized redirect URI: ${API_BASE}/auth/external/google/callback`, }, github: { docsUrl: "https://github.com/settings/applications/new", - callbackNote: "Authorization callback URL: http://localhost:5000/api/v1/auth/external/github/callback", + callbackNote: `Authorization callback URL: ${API_BASE}/auth/external/github/callback`, }, microsoft: { docsUrl: "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps", - callbackNote: "Redirect URI: http://localhost:5000/api/v1/auth/external/microsoft/callback", + callbackNote: `Redirect URI: ${API_BASE}/auth/external/microsoft/callback`, }, }; diff --git a/src/pages/auth/ForgotPasswordPage.tsx b/src/pages/auth/ForgotPasswordPage.tsx index 2d8bd89..85f4aef 100644 --- a/src/pages/auth/ForgotPasswordPage.tsx +++ b/src/pages/auth/ForgotPasswordPage.tsx @@ -42,12 +42,6 @@ export default function ForgotPasswordPage() { you'll receive a password reset link shortly.

- -
- ); -} - -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 ( -
- - -
-
- - {isSystem ? : isUser ? : } - {ca.name} - {isSystem ? ( - - - System - - ) : ca.is_active ? ( - Active - ) : ( - Inactive - )} - - {ca.description && ( - {ca.description} - )} -
- {ca.key_type} -
-
- - {/* Stats — hidden for system CAs (we have no cert records for them) */} - {!isSystem && ( -
-
-

{ca.active_certs}

-

Active certs

-
-
-

{ca.total_certs}

-

Total issued

-
-
-

{ca.default_cert_validity_hours}h

-

Default validity

-
-
-

{ca.next_serial_number ?? '—'}

-

Next serial

-
-
- )} - - {/* Fingerprint */} -
-

Fingerprint

- {ca.fingerprint} -
- - {/* Public key */} -
-
-

Public key

- -
-