diff --git a/src/App.tsx b/src/App.tsx index 4c7d7d3..d2e33ff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,7 @@ import MyMembershipsPage from "@/pages/org/MyMembershipsPage"; import SystemAuditPage from "@/pages/admin/SystemAuditPage"; import AdminUsersPage from "@/pages/admin/AdminUsersPage"; import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage"; +import OrgSetupPage from "@/pages/auth/OrgSetupPage"; import NotFound from "@/pages/NotFound"; import ApiDevTools from "@/components/dev/ApiDevTools"; @@ -79,13 +80,16 @@ import { Navigate } from "react-router-dom"; /** Redirects already-authenticated users away from guest-only pages (e.g. /login). */ function GuestRoute({ children }: { children: React.ReactNode }) { - const { isAuthenticated, isLoading } = useAuth(); + const { isAuthenticated, isOrgMember, isLoading } = useAuth(); // Allow authenticated users through to /login when it's a CLI auth request — // LoginPage will immediately forward the existing token to the CLI callback. const params = new URLSearchParams(window.location.search); const isCli = params.has('cli_token') || params.has('cli_redirect'); if (isLoading) return null; // wait for auth state to resolve - if (isAuthenticated && !isCli) return ; + if (isAuthenticated && !isCli) { + // If the user hasn't set up an org yet, send them there first + return ; + } return <>{children}; } @@ -107,6 +111,15 @@ function RequireOrgMember({ children }: { children: React.ReactNode }) { return <>{children}; } +/** + * Used for /org-setup which lives inside PublicLayout */ +function RequireAuth({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuth(); + if (isLoading) return null; + if (!isAuthenticated) return ; + return <>{children}; +} + function AppRoutes() { return ( @@ -126,6 +139,9 @@ function AppRoutes() { } /> } /> } /> + {/* Org-setup uses the same full-screen centred layout as auth pages, + but requires a valid session token (RequireAuth guard below). */} + } /> {/* Protected routes - handles auth and MFA enforcement */} diff --git a/src/components/layouts/ProtectedLayout.tsx b/src/components/layouts/ProtectedLayout.tsx index 6424ae4..99c0f1e 100644 --- a/src/components/layouts/ProtectedLayout.tsx +++ b/src/components/layouts/ProtectedLayout.tsx @@ -6,7 +6,7 @@ import { useOrganizations } from '@/hooks/useOrganizations'; import { Loader2 } from 'lucide-react'; export default function ProtectedLayout() { - const { isAuthenticated, isLoading, requiresMfaEnrollment } = useAuth(); + const { isAuthenticated, isLoading, requiresMfaEnrollment, isOrgMember } = useAuth(); const { isLoading: isOrgsLoading } = useOrganizations(); if (isLoading || isOrgsLoading) { @@ -24,6 +24,11 @@ export default function ProtectedLayout() { return ; } + // User is logged in but hasn't joined/created an org yet — send to org-setup + if (!isOrgMember) { + return ; + } + if (requiresMfaEnrollment) { return ; } diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index a9e0144..4eb6068 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,11 +1,14 @@ import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; -import { api, User, ApiError, tokenManager, MfaComplianceSummary } from '@/lib/api'; +import { api, User, ApiError, tokenManager, MfaComplianceSummary, PendingInvite } from '@/lib/api'; interface LoginResult { requiresTotp: boolean; requiresWebAuthn: boolean; requiresMfaEnrollment?: boolean; + requiresOrgSetup?: boolean; + pendingInvites?: PendingInvite[]; + isFirstUser?: boolean; } interface AuthContextType { @@ -22,6 +25,8 @@ interface AuthContextType { logout: () => Promise; refreshUser: () => Promise; refreshCompliance: () => Promise; + /** Re-check org membership & admin status. Exposed so post-setup pages can update the context. */ + checkOrgAdmin: () => Promise; } const AuthContext = createContext(null); @@ -215,10 +220,24 @@ export function AuthProvider({ children }: { children: ReactNode }) { setRequiresMfaEnrollment(false); await checkOrgAdmin(); if (!skipNavigate) { - navigate('/profile'); + if (response.requires_org_setup) { + navigate('/org-setup', { + state: { + pendingInvites: response.pending_invites ?? [], + isFirstUser: false, + }, + }); + } else { + navigate('/profile'); + } } } - return { requiresTotp: false, requiresWebAuthn: false }; + return { + requiresTotp: false, + requiresWebAuthn: false, + requiresOrgSetup: response.requires_org_setup, + pendingInvites: response.pending_invites, + }; }, [navigate, checkOrgAdmin]); const verifyWebAuthn = useCallback(async () => { @@ -280,6 +299,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { logout, refreshUser, refreshCompliance, + checkOrgAdmin, }} > {children} diff --git a/src/lib/api.ts b/src/lib/api.ts index d13eb7a..ee725e0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -84,6 +84,12 @@ export interface LoginResponse { requires_webauthn?: boolean; requires_mfa_enrollment?: boolean; mfa_compliance?: MfaComplianceSummary; + /** Set on login when the user belongs to no organisations. */ + requires_org_setup?: boolean; + /** Pending invitations for the user's email (present when requires_org_setup is true). */ + pending_invites?: PendingInvite[]; + /** True when the registering user is the very first user on this instance. */ + is_first_user?: boolean; } export interface TotpEnrollResponse { @@ -784,6 +790,13 @@ export const api = { }, organizations: { + // Create a new organization (caller becomes owner) + create: (name: string, slug: string, description?: string) => + request<{ organization: Organization }>('/organizations', { + method: 'POST', + body: JSON.stringify({ name, slug, description }), + }, true), + // Get organization by ID getById: (orgId: string, requestConfig?: RequestConfig) => request<{ organization: Organization; member_count: number }>(`/organizations/${orgId}`, {}, true, requestConfig), @@ -980,6 +993,19 @@ export const api = { method: 'PATCH', body: JSON.stringify(data), }, true, requestConfig), + + // Rotate (replace) a CA's key pair — returns updated CA + old_fingerprint + rotateCA: (orgId: string, caId: string, data?: { key_type?: 'ed25519' | 'rsa' | 'ecdsa'; reason?: string }, requestConfig?: RequestConfig) => + request<{ ca: OrgCA; old_fingerprint: string }>(`/organizations/${orgId}/cas/${caId}/rotate`, { + method: 'POST', + body: JSON.stringify(data ?? {}), + }, true, requestConfig), + + // Soft-delete a CA + deleteCA: (orgId: string, caId: string, requestConfig?: RequestConfig) => + request<{ ca_id: string }>(`/organizations/${orgId}/cas/${caId}`, { + method: 'DELETE', + }, true, requestConfig), }, invites: { @@ -1329,13 +1355,21 @@ export interface OrgCA { public_key: string; fingerprint: string; is_active: boolean; + /** True when this entry represents the server-wide config-file CA. + * System CAs are read-only — they cannot be edited, deleted, or replaced + * from the UI. */ + is_system?: boolean; default_cert_validity_hours: number; max_cert_validity_hours: number; total_certs: number; active_certs: number; revoked_certs: number; - created_at: string; - updated_at: string; + created_at: string | null; + updated_at: string | null; + /** Set when the key was last rotated. */ + rotated_at: string | null; + /** Reason provided when the key was last rotated. */ + rotation_reason: string | null; } // Reusable 403 error handler for API calls diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 66bdbd4..8741efc 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -4,17 +4,18 @@ import { useAuth } from "@/contexts/AuthContext"; const Index = () => { const navigate = useNavigate(); - const { isAuthenticated, isLoading } = useAuth(); + const { isAuthenticated, isOrgMember, isLoading } = useAuth(); useEffect(() => { if (isLoading) return; // Wait for auth check to complete if (isAuthenticated) { - navigate("/profile"); + // If the user has no org yet, send them to the org-setup page first + navigate(isOrgMember ? "/profile" : "/org-setup", { replace: true }); } else { navigate("/login"); } - }, [isLoading, isAuthenticated, navigate]); + }, [isLoading, isAuthenticated, isOrgMember, navigate]); return null; }; diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx index 629bf36..3589da5 100644 --- a/src/pages/admin/AdminUsersPage.tsx +++ b/src/pages/admin/AdminUsersPage.tsx @@ -57,6 +57,10 @@ function formatDate(d: string | null) { return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } +function isSuspended(status: string | undefined) { + return status === "suspended" || status === "compliance_suspended"; +} + function RoleBadge({ role }: { role: string }) { const r = (role || "").toLowerCase(); if (r === "owner") { @@ -330,7 +334,7 @@ export default function AdminUsersPage() {
- {user.status === "suspended" && ( + {isSuspended(user.status) && ( Suspended @@ -394,8 +398,8 @@ export default function AdminUsersPage() {
Status - {selectedUser.status === "suspended" ? ( - <>Suspended + {isSuspended(selectedUser.status) ? ( + <>Suspended{selectedUser.status === "compliance_suspended" ? " (compliance)" : ""} ) : ( <>Active )} @@ -422,9 +426,13 @@ export default function AdminUsersPage() { Account Access - {selectedUser.status === "suspended" ? ( + {isSuspended(selectedUser.status) ? (
-

This account is suspended. The user cannot log in or request certificates.

+

+ {selectedUser.status === "compliance_suspended" + ? "This account is suspended due to MFA compliance. The user cannot log in or request certificates." + : "This account is suspended. The user cannot log in or request certificates."} +

+
+ ))} +
+ )} + + {/* ── Divider ───────────────────────────────────────────────────────── */} + {hasInvites && ( +
+
+ +
+
+ or +
+
+ )} + + {/* ── Create organisation (collapsible when invites are present) ─────── */} + {hasInvites ? ( +
+ {/* Toggle header */} + + + {/* Collapsible form */} + {createOpen && ( +
+ +
+ )} +
+ ) : ( + /* No invites — show the form directly */ + + )} + + )} +
+ ); +} + +// ── Reusable create-org form ───────────────────────────────────────────────── +interface CreateOrgFormProps { + orgName: string; + orgSlug: string; + isCreating: boolean; + createError: string | null; + onNameChange: (v: string) => void; + onSlugChange: (v: string) => void; + onSubmit: (e: React.FormEvent) => void; +} + +function CreateOrgForm({ + orgName, orgSlug, isCreating, createError, + onNameChange, onSlugChange, onSubmit, +}: CreateOrgFormProps) { + return ( +
+ {createError && } + +
+ + onNameChange(e.target.value)} + required + autoFocus + data-testid="org-name-input" + /> +
+ +
+ + onSlugChange(e.target.value)} + required + pattern="[a-z0-9][a-z0-9\-]*" + title="Lowercase letters, numbers, and hyphens only" + data-testid="org-slug-input" + /> +
+ + + + ); +} diff --git a/src/pages/auth/RegisterPage.tsx b/src/pages/auth/RegisterPage.tsx index 26b5358..cf84110 100644 --- a/src/pages/auth/RegisterPage.tsx +++ b/src/pages/auth/RegisterPage.tsx @@ -1,16 +1,17 @@ import { useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { Mail, Lock, User, ArrowRight, ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter"; import { BannerAlert } from "@/components/auth/BannerAlert"; -import { api, ApiError } from "@/lib/api"; +import { api, ApiError, tokenManager } from "@/lib/api"; -type RegistrationState = "form" | "success" | "disabled"; +type RegistrationState = "form" | "disabled"; export default function RegisterPage() { + const navigate = useNavigate(); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -43,9 +44,20 @@ export default function RegisterPage() { setIsLoading(true); try { - await api.auth.register(email, password, name.trim() || undefined); - // Show "check your email" — verification email was sent - setState("success"); + const response = await api.auth.register(email, password, name.trim() || undefined); + + // Store the session token so ProtectedLayout lets the user through + if (response.token) { + tokenManager.setToken(response.token, response.expires_at ?? null); + } + + // Navigate to org-setup so the user can name their org or accept an invite + navigate("/org-setup", { + state: { + pendingInvites: response.pending_invites ?? [], + isFirstUser: response.is_first_user ?? false, + }, + }); } catch (err) { if (err instanceof ApiError) { if (err.code === 409) { @@ -88,44 +100,6 @@ export default function RegisterPage() { ); } - // Success state - email sent - if (state === "success") { - return ( -
-
- -
- -

- Check your email -

-

- We've sent a verification link to {email}. - Click the link to verify your account and get started. -

- -
- - - -
- -

- Didn't receive the email?{" "} - -

-
- ); - } - // Registration form return (
diff --git a/src/pages/org/MembersPage.tsx b/src/pages/org/MembersPage.tsx index 0326575..eb716df 100644 --- a/src/pages/org/MembersPage.tsx +++ b/src/pages/org/MembersPage.tsx @@ -186,7 +186,7 @@ export default function MembersPage() { if (!orgId || !changeRoleMember) return; setIsChangingRole(true); try { - const updated = await api.organizations.updateMemberRole(orgId, changeRoleMember.user_id, newRole.toUpperCase()); + const updated = await api.organizations.updateMemberRole(orgId, changeRoleMember.user_id, newRole.toLowerCase()); setMembers((prev) => prev.map((m) => (m.id === changeRoleMember.id ? { ...m, role: updated.member.role } : m)) ); @@ -331,7 +331,7 @@ export default function MembersPage() {
- +

Pending invitations

{isInvitesLoading ? 'Loading...' : `${invites.length}`} @@ -374,9 +374,11 @@ export default function MembersPage() { -
- -

Send an invitation

+
+
+ +

Send an invitation

+