Feat(Fix): User & Org Setup Initial (Invite + Create on own) & Chore: UI

This commit is contained in:
2026-03-01 20:11:22 +05:45
parent 4c01fd0107
commit f1a8e313fc
10 changed files with 485 additions and 68 deletions
+18 -2
View File
@@ -42,6 +42,7 @@ import MyMembershipsPage from "@/pages/org/MyMembershipsPage";
import SystemAuditPage from "@/pages/admin/SystemAuditPage"; import SystemAuditPage from "@/pages/admin/SystemAuditPage";
import AdminUsersPage from "@/pages/admin/AdminUsersPage"; import AdminUsersPage from "@/pages/admin/AdminUsersPage";
import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage"; import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage";
import OrgSetupPage from "@/pages/auth/OrgSetupPage";
import NotFound from "@/pages/NotFound"; import NotFound from "@/pages/NotFound";
import ApiDevTools from "@/components/dev/ApiDevTools"; 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). */ /** Redirects already-authenticated users away from guest-only pages (e.g. /login). */
function GuestRoute({ children }: { children: React.ReactNode }) { 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 — // Allow authenticated users through to /login when it's a CLI auth request —
// LoginPage will immediately forward the existing token to the CLI callback. // LoginPage will immediately forward the existing token to the CLI callback.
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const isCli = params.has('cli_token') || params.has('cli_redirect'); const isCli = params.has('cli_token') || params.has('cli_redirect');
if (isLoading) return null; // wait for auth state to resolve if (isLoading) return null; // wait for auth state to resolve
if (isAuthenticated && !isCli) return <Navigate to="/profile" replace />; if (isAuthenticated && !isCli) {
// If the user hasn't set up an org yet, send them there first
return <Navigate to={isOrgMember ? "/profile" : "/org-setup"} replace />;
}
return <>{children}</>; return <>{children}</>;
} }
@@ -107,6 +111,15 @@ function RequireOrgMember({ children }: { children: React.ReactNode }) {
return <>{children}</>; 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 <Navigate to="/login" replace />;
return <>{children}</>;
}
function AppRoutes() { function AppRoutes() {
return ( return (
<AuthProvider> <AuthProvider>
@@ -126,6 +139,9 @@ function AppRoutes() {
<Route path="/error" element={<OIDCErrorPage />} /> <Route path="/error" element={<OIDCErrorPage />} />
<Route path="/oauth/callback" element={<OAuthCallbackPage />} /> <Route path="/oauth/callback" element={<OAuthCallbackPage />} />
<Route path="/activate" element={<ActivatePage />} /> <Route path="/activate" element={<ActivatePage />} />
{/* Org-setup uses the same full-screen centred layout as auth pages,
but requires a valid session token (RequireAuth guard below). */}
<Route path="/org-setup" element={<RequireAuth><OrgSetupPage /></RequireAuth>} />
</Route> </Route>
{/* Protected routes - handles auth and MFA enforcement */} {/* Protected routes - handles auth and MFA enforcement */}
+6 -1
View File
@@ -6,7 +6,7 @@ import { useOrganizations } from '@/hooks/useOrganizations';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
export default function ProtectedLayout() { export default function ProtectedLayout() {
const { isAuthenticated, isLoading, requiresMfaEnrollment } = useAuth(); const { isAuthenticated, isLoading, requiresMfaEnrollment, isOrgMember } = useAuth();
const { isLoading: isOrgsLoading } = useOrganizations(); const { isLoading: isOrgsLoading } = useOrganizations();
if (isLoading || isOrgsLoading) { if (isLoading || isOrgsLoading) {
@@ -24,6 +24,11 @@ export default function ProtectedLayout() {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
// User is logged in but hasn't joined/created an org yet — send to org-setup
if (!isOrgMember) {
return <Navigate to="/org-setup" replace />;
}
if (requiresMfaEnrollment) { if (requiresMfaEnrollment) {
return <MfaEnforcementLayout />; return <MfaEnforcementLayout />;
} }
+23 -3
View File
@@ -1,11 +1,14 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; 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 { interface LoginResult {
requiresTotp: boolean; requiresTotp: boolean;
requiresWebAuthn: boolean; requiresWebAuthn: boolean;
requiresMfaEnrollment?: boolean; requiresMfaEnrollment?: boolean;
requiresOrgSetup?: boolean;
pendingInvites?: PendingInvite[];
isFirstUser?: boolean;
} }
interface AuthContextType { interface AuthContextType {
@@ -22,6 +25,8 @@ interface AuthContextType {
logout: () => Promise<void>; logout: () => Promise<void>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
refreshCompliance: () => Promise<void>; refreshCompliance: () => Promise<void>;
/** Re-check org membership & admin status. Exposed so post-setup pages can update the context. */
checkOrgAdmin: () => Promise<void>;
} }
const AuthContext = createContext<AuthContextType | null>(null); const AuthContext = createContext<AuthContextType | null>(null);
@@ -215,10 +220,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setRequiresMfaEnrollment(false); setRequiresMfaEnrollment(false);
await checkOrgAdmin(); await checkOrgAdmin();
if (!skipNavigate) { 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]); }, [navigate, checkOrgAdmin]);
const verifyWebAuthn = useCallback(async () => { const verifyWebAuthn = useCallback(async () => {
@@ -280,6 +299,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
logout, logout,
refreshUser, refreshUser,
refreshCompliance, refreshCompliance,
checkOrgAdmin,
}} }}
> >
{children} {children}
+36 -2
View File
@@ -84,6 +84,12 @@ export interface LoginResponse {
requires_webauthn?: boolean; requires_webauthn?: boolean;
requires_mfa_enrollment?: boolean; requires_mfa_enrollment?: boolean;
mfa_compliance?: MfaComplianceSummary; 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 { export interface TotpEnrollResponse {
@@ -784,6 +790,13 @@ export const api = {
}, },
organizations: { 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 // Get organization by ID
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),
@@ -980,6 +993,19 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(data), body: JSON.stringify(data),
}, true, requestConfig), }, 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: { invites: {
@@ -1329,13 +1355,21 @@ export interface OrgCA {
public_key: string; public_key: string;
fingerprint: string; fingerprint: string;
is_active: boolean; 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; default_cert_validity_hours: number;
max_cert_validity_hours: number; max_cert_validity_hours: number;
total_certs: number; total_certs: number;
active_certs: number; active_certs: number;
revoked_certs: number; revoked_certs: number;
created_at: string; created_at: string | null;
updated_at: string; 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 // Reusable 403 error handler for API calls
+4 -3
View File
@@ -4,17 +4,18 @@ import { useAuth } from "@/contexts/AuthContext";
const Index = () => { const Index = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isOrgMember, isLoading } = useAuth();
useEffect(() => { useEffect(() => {
if (isLoading) return; // Wait for auth check to complete if (isLoading) return; // Wait for auth check to complete
if (isAuthenticated) { 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 { } else {
navigate("/login"); navigate("/login");
} }
}, [isLoading, isAuthenticated, navigate]); }, [isLoading, isAuthenticated, isOrgMember, navigate]);
return null; return null;
}; };
+13 -5
View File
@@ -57,6 +57,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 isSuspended(status: string | undefined) {
return status === "suspended" || status === "compliance_suspended";
}
function RoleBadge({ role }: { role: string }) { function RoleBadge({ role }: { role: string }) {
const r = (role || "").toLowerCase(); const r = (role || "").toLowerCase();
if (r === "owner") { if (r === "owner") {
@@ -330,7 +334,7 @@ export default function AdminUsersPage() {
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<RoleBadge role={user.org_role || "member"} /> <RoleBadge role={user.org_role || "member"} />
{user.status === "suspended" && ( {isSuspended(user.status) && (
<Badge variant="outline" className="text-xs text-red-600 border-red-300 bg-red-50"> <Badge variant="outline" className="text-xs text-red-600 border-red-300 bg-red-50">
<Ban className="w-3 h-3 mr-1" />Suspended <Ban className="w-3 h-3 mr-1" />Suspended
</Badge> </Badge>
@@ -394,8 +398,8 @@ export default function AdminUsersPage() {
<div className="grid grid-cols-2 gap-2 text-sm"> <div className="grid grid-cols-2 gap-2 text-sm">
<span className="text-muted-foreground">Status</span> <span className="text-muted-foreground">Status</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
{selectedUser.status === "suspended" ? ( {isSuspended(selectedUser.status) ? (
<><Ban className="w-4 h-4 text-red-500" /><span className="text-red-600 font-medium">Suspended</span></> <><Ban className="w-4 h-4 text-red-500" /><span className="text-red-600 font-medium">Suspended{selectedUser.status === "compliance_suspended" ? " (compliance)" : ""}</span></>
) : ( ) : (
<><CheckCircle className="w-4 h-4 text-green-500" /><span className="text-green-600">Active</span></> <><CheckCircle className="w-4 h-4 text-green-500" /><span className="text-green-600">Active</span></>
)} )}
@@ -422,9 +426,13 @@ export default function AdminUsersPage() {
<Ban className="w-4 h-4" /> <Ban className="w-4 h-4" />
Account Access Account Access
</h3> </h3>
{selectedUser.status === "suspended" ? ( {isSuspended(selectedUser.status) ? (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-muted-foreground">This account is suspended. The user cannot log in or request certificates.</p> <p className="text-sm text-muted-foreground">
{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."}
</p>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
+23 -3
View File
@@ -75,10 +75,30 @@ export default function OAuthCallbackPage() {
return; return;
} }
// Organisation creation required // Organisation creation required — store the token and send to /org-setup
if (requiresOrgCreation) { if (requiresOrgCreation) {
setStatus('error'); const orgSetupToken = searchParams.get("token");
setError("No organization found for your account. Please ask an administrator to add you to an organization."); const orgSetupExpiresIn = searchParams.get("expires_in");
const pendingInvitesRaw = searchParams.get("pending_invites");
if (orgSetupToken) {
const expiresAt = orgSetupExpiresIn
? new Date(Date.now() + parseInt(orgSetupExpiresIn, 10) * 1000).toISOString()
: null;
tokenManager.setToken(orgSetupToken, expiresAt);
}
let pendingInvites: Array<{ token: string; organization: { id: string; name: string }; role: string; expires_at: string }> = [];
try {
if (pendingInvitesRaw) pendingInvites = JSON.parse(pendingInvitesRaw);
} catch {
// ignore parse errors
}
navigate('/org-setup', {
replace: true,
state: { pendingInvites, isFirstUser: false },
});
return; return;
} }
+337
View File
@@ -0,0 +1,337 @@
/**
* OrgSetupPage shown after registration or first login when the user has no org.
*
* Layout:
* - If the user has pending invitations show each invite card with a "Join" button.
* Only one org can be joined (once joined, redirect immediately).
* - Always show a "Create a new organisation" expandable section below.
*/
import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { Building2, Plus, ArrowRight, Loader2, Mail, ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { BannerAlert } from "@/components/auth/BannerAlert";
import { api, ApiError, PendingInvite, tokenManager } from "@/lib/api";
import { useAuth } from "@/contexts/AuthContext";
function toSlug(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
}
interface LocationState {
pendingInvites?: PendingInvite[];
isFirstUser?: boolean;
}
export default function OrgSetupPage() {
const navigate = useNavigate();
const location = useLocation();
const { refreshUser, checkOrgAdmin, isOrgMember, isLoading } = useAuth();
// If the user already belongs to an org (e.g. they bookmarked /org-setup),
// redirect them straight to their profile so they don't get stuck.
useEffect(() => {
if (!isLoading && isOrgMember) {
navigate("/profile", { replace: true });
}
}, [isLoading, isOrgMember, navigate]);
// Seed from navigation state on first render (avoids flicker), then always
// fetch from the API so refreshing the page still shows the real invites.
const locationState = (location.state ?? {}) as LocationState;
const [pendingInvites, setPendingInvites] = useState<PendingInvite[]>(
locationState.pendingInvites ?? []
);
const [invitesLoading, setInvitesLoading] = useState(true);
useEffect(() => {
let cancelled = false;
api.users.getMyInvites()
.then((res) => {
if (!cancelled) {
setPendingInvites(res.invites);
setInvitesLoading(false);
}
})
.catch(() => {
if (!cancelled) setInvitesLoading(false);
});
return () => { cancelled = true; };
}, []);
const hasInvites = pendingInvites.length > 0;
// Invite acceptance
const [joiningToken, setJoiningToken] = useState<string | null>(null);
const [joinError, setJoinError] = useState<string | null>(null);
// Create org form — open by default; collapses once we know there are invites
const [createOpen, setCreateOpen] = useState(false);
// Once invite fetch resolves: if no invites, open the create form automatically
useEffect(() => {
if (!invitesLoading) {
setCreateOpen(pendingInvites.length === 0);
}
}, [invitesLoading, pendingInvites.length]);
const [orgName, setOrgName] = useState("");
const [orgSlug, setOrgSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const handleNameChange = (value: string) => {
setOrgName(value);
if (!slugTouched) setOrgSlug(toSlug(value));
};
const handleSlugChange = (value: string) => {
setSlugTouched(true);
setOrgSlug(value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
};
const done = async () => {
await refreshUser();
await checkOrgAdmin();
navigate("/profile", { replace: true });
};
// ── Accept an invite ───────────────────────────────────────────────────────
const handleJoinOrg = async (invite: PendingInvite) => {
setJoinError(null);
setJoiningToken(invite.token);
try {
const result = await api.invites.accept(invite.token);
if (result.token) tokenManager.setToken(result.token, result.expires_at ?? null);
await done();
} catch (err) {
setJoinError(err instanceof ApiError ? err.message : "Failed to join organisation. Please try again.");
setJoiningToken(null);
}
};
// ── Create a new org ───────────────────────────────────────────────────────
const handleCreateOrg = async (e: React.FormEvent) => {
e.preventDefault();
setCreateError(null);
if (!orgName.trim()) { setCreateError("Organisation name is required."); return; }
if (!orgSlug.trim()) { setCreateError("Slug is required."); return; }
setIsCreating(true);
try {
await api.organizations.create(orgName.trim(), orgSlug.trim());
await done();
} catch (err) {
setCreateError(err instanceof ApiError ? err.message : "Failed to create organisation. Please try again.");
} finally {
setIsCreating(false);
}
};
return (
<div className="auth-card" data-testid="org-setup-page">
{/* Header */}
<div className="text-center mb-8">
<div className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center mx-auto mb-4">
<Building2 className="w-6 h-6 text-primary" />
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
{hasInvites ? "You have an invitation!" : "Set up your organisation"}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{hasInvites
? "Join an existing organisation or create your own."
: "Create your organisation to get started. You'll be set as the Owner."}
</p>
</div>
{/* Loading skeleton while fetching invites */}
{invitesLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{/* ── Pending invitations ────────────────────────────────────────────── */}
{hasInvites && (
<div className="mb-6 space-y-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">
<Mail className="w-3.5 h-3.5" />
Invitation{pendingInvites.length > 1 ? "s" : ""} for your email
</div>
{joinError && <BannerAlert type="error" message={joinError} className="mb-2" />}
{pendingInvites.map((invite) => (
<div
key={invite.token}
className="flex items-center justify-between rounded-xl border border-border bg-muted/40 px-4 py-3 gap-4"
data-testid="invite-card"
>
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground truncate">
{invite.organization.name}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
You were invited as{" "}
<span className="font-medium capitalize">{invite.role}</span>
</p>
</div>
<Button
size="sm"
className="shrink-0"
disabled={joiningToken !== null}
onClick={() => handleJoinOrg(invite)}
data-testid="join-org-btn"
>
{joiningToken === invite.token ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Join"
)}
</Button>
</div>
))}
</div>
)}
{/* ── Divider ───────────────────────────────────────────────────────── */}
{hasInvites && (
<div className="relative my-5">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">or</span>
</div>
</div>
)}
{/* ── Create organisation (collapsible when invites are present) ─────── */}
{hasInvites ? (
<div className="rounded-xl border border-border overflow-hidden">
{/* Toggle header */}
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-foreground hover:bg-muted/50 transition-colors"
onClick={() => setCreateOpen((o) => !o)}
data-testid="create-org-toggle"
>
<span className="flex items-center gap-2">
<Plus className="w-4 h-4 text-primary" />
Create a new organisation
</span>
{createOpen
? <ChevronUp className="w-4 h-4 text-muted-foreground" />
: <ChevronDown className="w-4 h-4 text-muted-foreground" />
}
</button>
{/* Collapsible form */}
{createOpen && (
<div className="border-t border-border px-4 py-4">
<CreateOrgForm
orgName={orgName}
orgSlug={orgSlug}
isCreating={isCreating}
createError={createError}
onNameChange={handleNameChange}
onSlugChange={handleSlugChange}
onSubmit={handleCreateOrg}
/>
</div>
)}
</div>
) : (
/* No invites — show the form directly */
<CreateOrgForm
orgName={orgName}
orgSlug={orgSlug}
isCreating={isCreating}
createError={createError}
onNameChange={handleNameChange}
onSlugChange={handleSlugChange}
onSubmit={handleCreateOrg}
/>
)}
</>
)}
</div>
);
}
// ── 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 (
<form onSubmit={onSubmit} className="space-y-4" data-testid="org-setup-create">
{createError && <BannerAlert type="error" message={createError} />}
<div className="space-y-1.5">
<Label htmlFor="orgName">Organisation name</Label>
<Input
id="orgName"
type="text"
placeholder="Acme Corp"
value={orgName}
onChange={(e) => onNameChange(e.target.value)}
required
autoFocus
data-testid="org-name-input"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="orgSlug">
Slug{" "}
<span className="text-xs text-muted-foreground font-normal">
used in URLs, lowercase &amp; hyphens only
</span>
</Label>
<Input
id="orgSlug"
type="text"
placeholder="acme-corp"
value={orgSlug}
onChange={(e) => onSlugChange(e.target.value)}
required
pattern="[a-z0-9][a-z0-9\-]*"
title="Lowercase letters, numbers, and hyphens only"
data-testid="org-slug-input"
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isCreating || !orgName.trim() || !orgSlug.trim()}
data-testid="create-org-btn"
>
{isCreating
? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Creating</>
: <><ArrowRight className="w-4 h-4 mr-2" />Create organisation</>
}
</Button>
</form>
);
}
+18 -44
View File
@@ -1,16 +1,17 @@
import { useState } from "react"; 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 { Mail, Lock, User, ArrowRight, ArrowLeft } 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";
import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter"; import { PasswordStrengthMeter, isPasswordValid } from "@/components/auth/PasswordStrengthMeter";
import { BannerAlert } from "@/components/auth/BannerAlert"; 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() { export default function RegisterPage() {
const navigate = useNavigate();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -43,9 +44,20 @@ export default function RegisterPage() {
setIsLoading(true); setIsLoading(true);
try { try {
await api.auth.register(email, password, name.trim() || undefined); const response = await api.auth.register(email, password, name.trim() || undefined);
// Show "check your email" — verification email was sent
setState("success"); // 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) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
if (err.code === 409) { if (err.code === 409) {
@@ -88,44 +100,6 @@ export default function RegisterPage() {
); );
} }
// Success state - email sent
if (state === "success") {
return (
<div className="auth-card text-center">
<div className="w-16 h-16 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-6">
<Mail className="w-8 h-8 text-success" />
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
Check your email
</h1>
<p className="text-muted-foreground mt-2 mb-6">
We've sent a verification link to <span className="font-medium text-foreground">{email}</span>.
Click the link to verify your account and get started.
</p>
<div className="space-y-3">
<Link to="/login">
<Button className="w-full">
Continue to sign in
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</Link>
</div>
<p className="text-sm text-muted-foreground mt-6">
Didn't receive the email?{" "}
<button
onClick={() => setState("form")}
className="text-accent hover:underline font-medium"
>
Try again
</button>
</p>
</div>
);
}
// Registration form // Registration form
return ( return (
<div className="auth-card"> <div className="auth-card">
+7 -5
View File
@@ -186,7 +186,7 @@ export default function MembersPage() {
if (!orgId || !changeRoleMember) return; if (!orgId || !changeRoleMember) return;
setIsChangingRole(true); setIsChangingRole(true);
try { 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) => setMembers((prev) =>
prev.map((m) => (m.id === changeRoleMember.id ? { ...m, role: updated.member.role } : m)) prev.map((m) => (m.id === changeRoleMember.id ? { ...m, role: updated.member.role } : m))
); );
@@ -331,7 +331,7 @@ export default function MembersPage() {
<TabsContent value="invites"> <TabsContent value="invites">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card> <Card>
<CardContent> <CardContent className="p-6">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold">Pending invitations</h3> <h3 className="text-sm font-semibold">Pending invitations</h3>
<span className="text-sm text-muted-foreground">{isInvitesLoading ? 'Loading...' : `${invites.length}`}</span> <span className="text-sm text-muted-foreground">{isInvitesLoading ? 'Loading...' : `${invites.length}`}</span>
@@ -374,9 +374,11 @@ export default function MembersPage() {
<Card> <Card>
<CardContent className="p-6 space-y-4"> <CardContent className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center justify-between">
<Mail className="w-4 h-4 text-muted-foreground" /> <div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">Send an invitation</h3> <Mail className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-semibold">Send an invitation</h3>
</div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">