"use client"; import { useEffect, useState, type FormEvent } from "react"; import Link from "next/link"; import { Badge, Button, Card, Input } from "../../../components/ui/primitives"; import { LoadingPanel } from "../../../components/brand/loading"; import { apiFetch, ApiError, ApiUnreachableError, errorMessage } from "../../../lib/api-client"; import type { InviteInfo } from "../../../lib/api-types"; import type { ManualLanguage } from "@tactical-media-broadcast-hub/core"; import { useAuth } from "../../../lib/auth-store"; const MIN_PASSWORD = 10; const USERNAME_PATTERN = /^[a-z0-9][a-z0-9_.-]{1,31}$/; const USERNAME_TITLE = "2-32 characters: lowercase letters, digits, dot, underscore or hyphen"; const API_UNREACHABLE = "API unreachable — check that the API server is running."; type Phase = "loading" | "preview" | "dead" | "unreachable"; /** Why the link no longer works — drives the copy on the dead-token card. */ type DeadReason = "unknown" | "expired" | "exhausted"; /** 404 (unknown/revoked) vs 410 (expired or exhausted, told apart by the message). Anything else reads as unknown. */ function deadReasonFrom(err: unknown): DeadReason { if (err instanceof ApiError && err.status === 410) { const message = err.message.toLowerCase(); if (message.includes("expired")) return "expired"; if (message.includes("used up") || message.includes("no uses left")) return "exhausted"; } return "unknown"; } const isAlreadyMember = (err: unknown): boolean => err instanceof ApiError && err.status === 409; /** * One-line collective labelling for the claim card ("Collectives: X · Y"). * Null when unknown or empty — the card must read fine without it. */ function collectiveLabel(collectives: Array<{ id: string; name: string }> | null, lang: ManualLanguage): string | null { if (collectives === null || collectives.length === 0) return null; const names = collectives.map((c) => c.name).join(" · "); return lang === "es" ? `Colectivos: ${names}` : `Collectives: ${names}`; } function usesLeftLine(info: InviteInfo): string { const left = info.maxUses === null ? "unlimited uses" : `${Math.max(info.maxUses - info.usedCount, 0)} use${info.maxUses - info.usedCount === 1 ? "" : "s"} left`; return `${left} · expires ${new Date(info.expiresAt).toLocaleDateString()}`; } /** Sign in with an existing account, then claim: login installs the account session, claimInvite swaps it for the workspace one. */ function JoinSignInForm({ token, onSwitch, onAlreadyMember, }: { token: string; onSwitch: () => void; onAlreadyMember: () => void; }): React.ReactElement { const { login, claimInvite } = useAuth(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); async function submit(e: FormEvent): Promise { e.preventDefault(); setBusy(true); setError(null); try { await login(username.trim(), password); await claimInvite(token); window.location.assign("/streams"); } catch (err) { // Login worked but the workspace already holds this user: that is a success state. if (isAlreadyMember(err)) { setBusy(false); onAlreadyMember(); return; } setError(errorMessage(err)); setBusy(false); } } return (
void submit(e)}>

Sign in

Use the username and password you registered with.

setUsername(e.target.value)} placeholder="username" autoComplete="username" autoFocus /> setPassword(e.target.value)} placeholder="Password" autoComplete="current-password" />
{error !== null &&

{error}

}
); } /** * Create-or-authenticate inline, then claim. Registering normally spins up a * new workspace, so the claim endpoint takes the credentials itself and hands * back a session issued in the invited workspace. */ function RegisterToJoinForm({ token, onSwitch, onAlreadyMember, }: { token: string; onSwitch: () => void; onAlreadyMember: () => void; }): React.ReactElement { const { claimInviteWithCredentials } = useAuth(); const [displayName, setDisplayName] = useState(""); const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); async function submit(e: FormEvent): Promise { e.preventDefault(); setBusy(true); setError(null); try { await claimInviteWithCredentials(token, { username: username.trim(), password, ...(displayName.trim() === "" ? {} : { displayName: displayName.trim() }), ...(email.trim() === "" ? {} : { email: email.trim() }), }); window.location.assign("/streams"); } catch (err) { if (isAlreadyMember(err)) { setBusy(false); onAlreadyMember(); return; } setError(errorMessage(err)); setBusy(false); } } const ready = USERNAME_PATTERN.test(username.trim()) && password.length >= MIN_PASSWORD; return (
void submit(e)}>

Create your account

Registering accepts the invite in one step — no workspace of your own is created.

setDisplayName(e.target.value)} placeholder="Your name (optional)" autoComplete="name" /> setUsername(e.target.value.toLowerCase())} placeholder="Username — this is how you sign in" autoComplete="username" pattern="[a-z0-9][a-z0-9_.-]{1,31}" title={USERNAME_TITLE} /> setPassword(e.target.value)} placeholder={`Password (min ${MIN_PASSWORD} characters)`} autoComplete="new-password" /> setEmail(e.target.value)} placeholder="Email (optional — password recovery)" autoComplete="email" />
{error !== null &&

{error}

}
); } export default function JoinWorkspacePage({ params }: { params: { token: string } }): React.ReactElement { const { status, session, isLoading, claimInvite } = useAuth(); const [phase, setPhase] = useState("loading"); const [info, setInfo] = useState(null); const [deadReason, setDeadReason] = useState("unknown"); const [mode, setMode] = useState<"register" | "signin">("register"); const [claiming, setClaiming] = useState(false); const [claimError, setClaimError] = useState(null); const [alreadyMember, setAlreadyMember] = useState(false); const [attempt, setAttempt] = useState(0); // Collective names for the claim card (names-only public read). Purely // cosmetic labelling: a failure renders the card without it, never blocks. const [collectives, setCollectives] = useState | null>(null); // The preview is public: fetch it immediately rather than waiting for the session to settle. useEffect(() => { let cancelled = false; setPhase("loading"); apiFetch(`/invites/${encodeURIComponent(params.token)}/info`, { redirectOn401: false }) .then((preview) => { if (cancelled) return; setInfo(preview); setPhase("preview"); // Best-effort collective labelling: the token is already proven valid. apiFetch<{ collectives: Array<{ id: string; name: string }> }>(`/collectives-public/for-invite/${encodeURIComponent(params.token)}`, { redirectOn401: false, }) .then((result) => { if (!cancelled) setCollectives(result.collectives); }) .catch(() => undefined); }) .catch((err: unknown) => { if (cancelled) return; if (err instanceof ApiUnreachableError) setPhase("unreachable"); else { setDeadReason(deadReasonFrom(err)); setPhase("dead"); } }); return () => { cancelled = true; }; }, [params.token, attempt]); const retry = (): void => setAttempt((n) => n + 1); async function claim(): Promise { setClaiming(true); setClaimError(null); try { await claimInvite(params.token); // Full navigation so the (main) shell mounts with the freshly installed session. window.location.assign("/streams"); } catch (err) { if (isAlreadyMember(err)) { setAlreadyMember(true); setClaiming(false); return; } setClaimError(errorMessage(err)); setClaiming(false); } } if (phase === "loading" || isLoading) return ; if (phase === "unreachable") { return (
!

Can't reach the server

{API_UNREACHABLE} Your invite link is still fine — try again.

); } if (phase === "dead") { return (
✕

This invite link isn't valid

{deadReason === "expired" ? "It expired." : deadReason === "exhausted" ? "It has already been used up." : "It was revoked or never existed."}{" "} Ask a workspace owner for a fresh invite.

Start or sign in to your own workspace
); } if (info === null) return ; const memberCta = alreadyMember ? (
You're already in this workspace. Go to Streams
) : (
Signed in as @{session?.user.username}
); // Signed in already: one click claims the invite with the current session. if (status === "authenticated" && session !== null) { return (

Join {info.workspaceName}

You've been invited to join {info.workspaceName} as{" "} {info.role}.

{usesLeftLine(info)}

{collectiveLabel(collectives, "en") !== null && (

{collectiveLabel(collectives, "en")}

)} {claimError !== null &&

{claimError}

} {memberCta}
); } // Anonymous (or the API dropped between mount and now): hero + register/sign-in forms. return (
Tactical Media Broadcast Hub — arrows from a circle, warm red on black

Join {info.workspaceName}

You've been invited as {info.role} · {usesLeftLine(info)}

{collectiveLabel(collectives, "en") !== null && (

{collectiveLabel(collectives, "en")}

)}
{mode === "signin" ? ( setMode("register")} onAlreadyMember={() => setAlreadyMember(true)} /> ) : ( setMode("signin")} onAlreadyMember={() => setAlreadyMember(true)} /> )} {alreadyMember && (

You're already in this workspace.

Go to Streams
)}
); }