/** * 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 { useQueryClient } from "@tanstack/react-query"; 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 queryClient = useQueryClient(); 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( 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(null); const [joinError, setJoinError] = useState(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(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 (orgName?: string) => { await refreshUser(); await checkOrgAdmin(); queryClient.invalidateQueries({ queryKey: ['organizations'] }); if (orgName) { localStorage.setItem('justJoinedOrg', orgName); } 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(invite.organization.name); } 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(orgName.trim()); } catch (err) { setCreateError(err instanceof ApiError ? err.message : "Failed to create organisation. Please try again."); } finally { setIsCreating(false); } }; return (
{/* Header */}

{hasInvites ? "You have an invitation!" : "Set up your organisation"}

{hasInvites ? "Join an existing organisation or create your own." : "Create your organisation to get started. You'll be set as the Owner."}

{/* Loading skeleton while fetching invites */} {invitesLoading ? (
) : ( <> {/* ── Pending invitations ────────────────────────────────────────────── */} {hasInvites && (
Invitation{pendingInvites.length > 1 ? "s" : ""} for your email
{joinError && } {pendingInvites.map((invite) => (

{invite.organization.name}

You were invited as{" "} {invite.role}

))}
)} {/* ── 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" />
); }