Feat(Fix): User & Org Setup Initial (Invite + Create on own) & Chore: UI
This commit is contained in:
+18
-2
@@ -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,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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (response.requires_org_setup) {
|
||||||
|
navigate('/org-setup', {
|
||||||
|
state: {
|
||||||
|
pendingInvites: response.pending_invites ?? [],
|
||||||
|
isFirstUser: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
navigate('/profile');
|
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
@@ -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
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,10 +374,12 @@ 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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Mail className="w-4 h-4 text-muted-foreground" />
|
<Mail className="w-4 h-4 text-muted-foreground" />
|
||||||
<h3 className="text-sm font-semibold">Send an invitation</h3>
|
<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">
|
||||||
<Label>Email address</Label>
|
<Label>Email address</Label>
|
||||||
|
|||||||
Reference in New Issue
Block a user