Merge pull request #3 from jamesii-b/gatehouse/secuird-CA-merge-v2.01
Gatehouse/secuird ca merge v2.01
This commit is contained in:
+12
-3
@@ -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>
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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,8 +58,9 @@ const orgAdminNavItems = [
|
|||||||
|
|
||||||
const adminNavItems = [
|
const adminNavItems = [
|
||||||
{ title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck },
|
{ title: "Certificate Auth.", url: "/org/cas", icon: ShieldCheck },
|
||||||
{ title: "Org Audit Log", url: "/org/audit", icon: FileText },
|
{ title: "OIDC Clients", url: "/org/clients", icon: Key },
|
||||||
{ title: "System Logs", url: "/admin/audit", icon: ScrollText },
|
{ title: "Org Audit Log", url: "/org/audit", icon: FileText },
|
||||||
|
{ title: "System Logs", url: "/admin/audit", icon: ScrollText },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
|||||||
@@ -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,25 +111,30 @@ export function TotpRemoveDialog({
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4">
|
||||||
<div className="space-y-2">
|
{hasPassword && (
|
||||||
<Label htmlFor="password-confirm">Enter your password to confirm</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label htmlFor="password-confirm">Enter your password to confirm</Label>
|
||||||
id="password-confirm"
|
<Input
|
||||||
type="password"
|
id="password-confirm"
|
||||||
placeholder="Your current password"
|
type="password"
|
||||||
value={password}
|
placeholder="Your current password"
|
||||||
onChange={(e) => {
|
value={password}
|
||||||
setPassword(e.target.value);
|
onChange={(e) => {
|
||||||
setError(null);
|
setPassword(e.target.value);
|
||||||
}}
|
setError(null);
|
||||||
onKeyDown={handleKeyDown}
|
}}
|
||||||
disabled={isLoading}
|
onKeyDown={handleKeyDown}
|
||||||
autoFocus
|
disabled={isLoading}
|
||||||
/>
|
autoFocus
|
||||||
{error && (
|
/>
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
{error && (
|
||||||
)}
|
<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
@@ -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),
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
+180
-595
@@ -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,22 +227,43 @@ 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>
|
<div className="flex items-start gap-3">
|
||||||
<h1 className="page-title">Certificate Authorities</h1>
|
<Shield className="w-6 h-6 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
<p className="page-description">
|
<div>
|
||||||
Manage your organization's SSH certificate authorities and access controls
|
<h1 className="page-title">Certificate Authorities</h1>
|
||||||
</p>
|
<p className="page-description">
|
||||||
|
Manage your organization's SSH CAs with <code>Gatehouse</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -511,275 +272,99 @@ export default function CAsPage() {
|
|||||||
<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 ? (
|
||||||
|
<Badge variant="secondary" className="text-xs ml-1">System</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-green-500/10 text-green-700 border-0 text-xs ml-1">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs ml-1 text-muted-foreground">
|
||||||
|
Not set
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
<TabsTrigger value="host" className="flex items-center gap-2 px-4 py-2">
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
<span>Host CA</span>
|
||||||
|
{hostCA ? (
|
||||||
|
hostCA.is_system ? (
|
||||||
|
<Badge variant="secondary" className="text-xs ml-1">System</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-green-500/10 text-green-700 border-0 text-xs ml-1">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs ml-1 text-muted-foreground">
|
||||||
|
Not set
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="user" className="mt-0">
|
||||||
|
<CASection caType="user" ca={userCA} {...sectionProps} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="host" className="mt-0">
|
||||||
|
<CASection caType="host" ca={hostCA} {...sectionProps} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Edit CA Dialog ── */}
|
{/* ── Dialogs ─────────────────────────────────────────────────────────── */}
|
||||||
<Dialog open={isEditDialogOpen} onOpenChange={(open) => { setIsEditDialogOpen(open); if (!open) setEditError(null); }}>
|
<CreateCADialog
|
||||||
<DialogContent className="sm:max-w-md">
|
open={isCreateOpen}
|
||||||
<DialogHeader>
|
onOpenChange={(o) => { setIsCreateOpen(o); if (!o) setCreateError(null); }}
|
||||||
<DialogTitle>Edit CA Configuration</DialogTitle>
|
caType={createCaType}
|
||||||
<DialogDescription>
|
form={createForm}
|
||||||
Update certificate validity settings for <strong>{editingCA?.name}</strong>
|
onFormChange={setCreateForm}
|
||||||
</DialogDescription>
|
error={createError}
|
||||||
</DialogHeader>
|
isLoading={isCreating}
|
||||||
<div className="space-y-4 py-4">
|
onSubmit={handleCreateCA}
|
||||||
{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 ── */}
|
<EditCADialog
|
||||||
<Dialog open={isCreateOpen} onOpenChange={(open) => { setIsCreateOpen(open); if (!open) setCreateError(null); }}>
|
open={isEditOpen}
|
||||||
<DialogContent className="sm:max-w-md">
|
onOpenChange={(o) => { setIsEditOpen(o); if (!o) setEditError(null); }}
|
||||||
<DialogHeader>
|
ca={editingCA}
|
||||||
<DialogTitle className="flex items-center gap-2">
|
form={editForm}
|
||||||
{createCaType === "user" ? <User className="w-5 h-5" /> : <Server className="w-5 h-5" />}
|
onFormChange={setEditForm}
|
||||||
Generate {createCaType === "user" ? "User" : "Host"} Signing Key
|
error={editError}
|
||||||
</DialogTitle>
|
isLoading={isEditSaving}
|
||||||
<DialogDescription>
|
onSubmit={handleSaveCA}
|
||||||
{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">
|
<RotateCADialog
|
||||||
{createError && (
|
open={isRotateOpen}
|
||||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm flex items-start gap-2">
|
onOpenChange={(o) => { setIsRotateOpen(o); if (!o) setRotateError(null); }}
|
||||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
ca={rotatingCA}
|
||||||
<span>{createError}</span>
|
keyType={rotateKeyType}
|
||||||
</div>
|
onKeyTypeChange={setRotateKeyType}
|
||||||
)}
|
reason={rotateReason}
|
||||||
|
onReasonChange={setRotateReason}
|
||||||
|
error={rotateError}
|
||||||
|
isLoading={isRotating}
|
||||||
|
onSubmit={handleConfirmRotate}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<DeleteCADialog
|
||||||
<Label htmlFor="ca-name">Name <span className="text-destructive">*</span></Label>
|
open={isDeleteOpen}
|
||||||
<Input
|
onOpenChange={setIsDeleteOpen}
|
||||||
id="ca-name"
|
ca={deletingCA}
|
||||||
placeholder={createCaType === "user" ? "User CA" : "Host CA"}
|
isLoading={isDeleting}
|
||||||
value={createForm.name}
|
onConfirm={handleConfirmDelete}
|
||||||
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</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</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</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* ── Delete CA Confirmation ── */}
|
|
||||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Certificate Authority?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will permanently deactivate <strong>{deletingCA?.name}</strong>.
|
|
||||||
No new certificates can be signed with this CA after deletion.
|
|
||||||
Existing certificates remain valid until they expire.
|
|
||||||
{deletingCA?.active_certs ? (
|
|
||||||
<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>
|
|
||||||
) : null}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={handleConfirmDelete}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
|
||||||
Delete CA
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
+454
-109
@@ -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;
|
||||||
if (!name || !uris.length) return;
|
let uris: string[];
|
||||||
|
let proxyHost: string | undefined;
|
||||||
|
|
||||||
|
if (dialogMode === "generic") {
|
||||||
|
name = nameRef.current?.value.trim() ?? "";
|
||||||
|
uris = (urisRef.current?.value ?? "").split(/[\n,]+/).map((u) => u.trim()).filter(Boolean);
|
||||||
|
if (!name || !uris.length) return;
|
||||||
|
} else {
|
||||||
|
name = proxyNameRef.current?.value.trim() ?? "";
|
||||||
|
proxyHost = proxyHostRef.current?.value.trim() ?? "";
|
||||||
|
if (!name || !proxyHost) return;
|
||||||
|
uris = [`http://${proxyHost}/oauth2/callback`];
|
||||||
|
}
|
||||||
|
|
||||||
setIsCreating(true);
|
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>
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
<Button>
|
Add client
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
</Button>
|
||||||
Add client
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create OIDC Client</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Register a new application to authenticate via Gatehouse
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="clientName">Client name</Label>
|
|
||||||
<Input id="clientName" placeholder="My Application" ref={nameRef} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="redirectUris">Redirect URIs</Label>
|
|
||||||
<Textarea
|
|
||||||
id="redirectUris"
|
|
||||||
placeholder="https://myapp.example.com/callback"
|
|
||||||
className="min-h-[80px]"
|
|
||||||
ref={urisRef}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
One URI per line. These are the allowed callback URLs.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={isCreating}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreate} disabled={isCreating}>
|
|
||||||
{isCreating ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Creating...</> : "Create client"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</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>
|
||||||
<div className="flex items-center gap-2">
|
<p className="font-medium">Client created — save your secret now</p>
|
||||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono break-all">{newSecret.secret}</code>
|
<p className="text-sm text-muted-foreground">This will not be shown again.</p>
|
||||||
<Button variant="ghost" size="icon" className="w-6 h-6 flex-shrink-0" onClick={() => copyToClipboard(newSecret.secret)}>
|
</div>
|
||||||
<Copy className="w-3 h-3" />
|
|
||||||
</Button>
|
{/* Secret row */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Client secret</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-xs bg-muted px-3 py-2 rounded font-mono break-all">
|
||||||
|
{newSecret.secret}
|
||||||
|
</code>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => copySecret(newSecret.secret)}>
|
||||||
|
{secretCopied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* oauth2-proxy config snippet */}
|
||||||
|
{proxyConfig && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
|
||||||
|
<Terminal className="w-3 h-3" />
|
||||||
|
oauth2-proxy config
|
||||||
|
</p>
|
||||||
|
<div className="relative">
|
||||||
|
<pre className="text-xs bg-muted px-3 py-2 rounded font-mono overflow-x-auto whitespace-pre">
|
||||||
|
{proxyConfig}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onClick={() => copyConfig(proxyConfig)}
|
||||||
|
>
|
||||||
|
{configCopied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" className="w-7 h-7 flex-shrink-0" onClick={() => setNewSecret(null)}>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" className="w-6 h-6" onClick={() => setNewSecret(null)}>×</Button>
|
|
||||||
</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>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<p className="text-sm text-muted-foreground/70">Register an app to let it authenticate via Gatehouse</p>
|
||||||
Add your first client
|
</div>
|
||||||
</Button>
|
<div className="flex gap-2 flex-wrap justify-center">
|
||||||
|
<Button variant="outline" onClick={() => setDialogMode("generic")}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Generic app
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setDialogMode("proxy")}>
|
||||||
|
<Terminal className="w-4 h-4 mr-2" />
|
||||||
|
oauth2-proxy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</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 & flows
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pb-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">Available scopes</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
["openid", "Required. Issues an ID token."],
|
||||||
|
["profile", "Includes name, given_name, family_name."],
|
||||||
|
["email", "Includes email and email_verified."],
|
||||||
|
].map(([scope, desc]) => (
|
||||||
|
<div key={scope} className="flex items-start gap-2">
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs shrink-0">{scope}</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">{desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">Supported flows</p>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500 shrink-0" />
|
||||||
|
<span><strong>Authorization Code + PKCE</strong> — recommended for all clients</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 text-blue-500 shrink-0" />
|
||||||
|
<span><strong>Refresh Token</strong> — token rotation supported</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 text-yellow-500 shrink-0" />
|
||||||
|
<span><strong>Authorization Code (no PKCE)</strong> — deprecated, PKCE required for new clients</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">ID token claims</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{["sub", "name", "email", "email_verified", "given_name", "family_name"].map((c) => (
|
||||||
|
<code key={c} className="text-xs bg-muted px-1.5 py-0.5 rounded">{c}</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* oauth2-proxy quick-reference */}
|
||||||
|
<AccordionItem value="proxy-ref" className="border rounded-lg px-4">
|
||||||
|
<AccordionTrigger className="text-sm font-medium hover:no-underline py-3">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Terminal className="w-4 h-4 text-muted-foreground" />
|
||||||
|
oauth2-proxy setup
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pb-4 space-y-4">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use the <strong>oauth2-proxy</strong> tab when creating a client to get a pre-filled config. Or build it manually:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Step 1 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium">1 — Create a client (use the dialog above)</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Set the redirect URI to <code className="bg-muted px-1 rounded">http://<your-proxy-host>/oauth2/callback</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium">2 — Minimal config</p>
|
||||||
|
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto whitespace-pre">{`provider = "oidc"
|
||||||
|
oidc_issuer_url = "${ISSUER_URL}"
|
||||||
|
client_id = "<your-client-id>"
|
||||||
|
client_secret = "<your-client-secret>"
|
||||||
|
redirect_url = "http://<proxy-host>/oauth2/callback"
|
||||||
|
scope = "openid profile email"
|
||||||
|
cookie_secret = "$(openssl rand -base64 32 | head -c 32)"
|
||||||
|
upstream = "http://127.0.0.1:8080/"
|
||||||
|
set_authorization_header = true
|
||||||
|
set_x_auth_request_header = true`}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 3 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium">3 — Run it</p>
|
||||||
|
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto">{`oauth2-proxy --config ./oauth2-proxy.cfg`}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Useful headers */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium">Headers forwarded to your upstream</p>
|
||||||
|
<div className="space-y-1 text-xs font-mono">
|
||||||
|
{[
|
||||||
|
["X-Auth-Request-User", "User's subject (sub claim)"],
|
||||||
|
["X-Auth-Request-Email", "User's email address"],
|
||||||
|
["Authorization", "Bearer <access_token> (if set_authorization_header = true)"],
|
||||||
|
].map(([header, desc]) => (
|
||||||
|
<div key={header} className="flex items-start gap-3">
|
||||||
|
<code className="text-muted-foreground shrink-0">{header}</code>
|
||||||
|
<span className="text-muted-foreground/70 font-sans">{desc}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Docker Compose snippet */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium">Docker Compose example</p>
|
||||||
|
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto whitespace-pre">{`services:
|
||||||
|
oauth2-proxy:
|
||||||
|
image: oauth2-proxy/oauth2-proxy:latest
|
||||||
|
ports: ["4180:4180"]
|
||||||
|
environment:
|
||||||
|
OAUTH2_PROXY_PROVIDER: oidc
|
||||||
|
OAUTH2_PROXY_OIDC_ISSUER_URL: "${ISSUER_URL}"
|
||||||
|
OAUTH2_PROXY_CLIENT_ID: \${OIDC_CLIENT_ID}
|
||||||
|
OAUTH2_PROXY_CLIENT_SECRET: \${OIDC_CLIENT_SECRET}
|
||||||
|
OAUTH2_PROXY_COOKIE_SECRET: \${COOKIE_SECRET}
|
||||||
|
OAUTH2_PROXY_UPSTREAM: http://app:8080/
|
||||||
|
OAUTH2_PROXY_REDIRECT_URL: http://localhost:4180/oauth2/callback`}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kubernetes snippet */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium">Kubernetes Ingress annotations</p>
|
||||||
|
<pre className="text-xs bg-muted rounded p-3 font-mono overflow-x-auto whitespace-pre">{`nginx.ingress.kubernetes.io/auth-url: https://\$host/oauth2/auth
|
||||||
|
nginx.ingress.kubernetes.io/auth-signin: https://\$host/oauth2/sign_in
|
||||||
|
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||||
|
auth_request_set $user \$upstream_http_x_auth_request_user;
|
||||||
|
auth_request_set $email \$upstream_http_x_auth_request_email;
|
||||||
|
proxy_set_header X-User \$user;
|
||||||
|
proxy_set_header X-Email \$email;`}</pre>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
{/* ── /Reference ──────────────────────────────────────────── */}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
toast({
|
title: "Deletion failed",
|
||||||
title: "Cannot delete organization",
|
description: err instanceof ApiError ? err.message : "An unexpected error occurred.",
|
||||||
description: "This organization still has other members. Transfer ownership or remove all members first.",
|
variant: "destructive",
|
||||||
variant: "destructive",
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: "Deletion failed",
|
|
||||||
description: err instanceof ApiError ? err.message : "An unexpected error occurred.",
|
|
||||||
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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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@<principal></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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
<Card className="border-accent/30 bg-accent/5">
|
{policyDescription && (
|
||||||
<CardContent className="p-4">
|
<Card className="border-accent/30 bg-accent/5">
|
||||||
<div className="flex items-start gap-3">
|
<CardContent className="p-4">
|
||||||
<Shield className="w-5 h-5 text-accent mt-0.5" />
|
<div className="flex items-start gap-3">
|
||||||
<div>
|
<Shield className="w-5 h-5 text-accent mt-0.5" />
|
||||||
<p className="text-sm font-medium text-foreground">Organization Policy</p>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm font-medium text-foreground">Organization Policy</p>
|
||||||
Your organization requires TOTP to be enabled for all members.
|
<p className="text-sm text-muted-foreground">{policyDescription}</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
)}
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -260,16 +293,40 @@ export default function SecurityPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Manage your account password</CardDescription>
|
<CardDescription>Manage your account password</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{hasPassword ? (
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => setShowPasswordForm(!showPasswordForm)}
|
size="sm"
|
||||||
>
|
onClick={() => setShowPasswordForm(!showPasswordForm)}
|
||||||
Change password
|
>
|
||||||
</Button>
|
Change password
|
||||||
|
</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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user