- 030: groups.parent_id tree + kind discriminator + group_rooms mapping - /collectives module: create/tree/join APIs, MatrixProvisioner (open room public+federated, closed room E2EE-at-creation, onboarding room), NullProvisioner fail-soft when ARCHIVE_PROVISIONER_* unset - web root becomes the public participation manual (ES-first + EN toggle, three paths: join by invite, start collective, solo account) - participation manual content in core
356 lines
15 KiB
TypeScript
356 lines
15 KiB
TypeScript
"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<string | null>(null);
|
|
|
|
async function submit(e: FormEvent): Promise<void> {
|
|
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 (
|
|
<form onSubmit={(e) => void submit(e)}>
|
|
<h2 className="mb-1 text-base font-semibold">Sign in</h2>
|
|
<p className="mb-4 text-xs text-text-faint">Use the username and password you registered with.</p>
|
|
<div className="space-y-2">
|
|
<Input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" autoComplete="username" autoFocus />
|
|
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" autoComplete="current-password" />
|
|
</div>
|
|
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
|
|
<div className="mt-6 flex items-center justify-between">
|
|
<Button type="button" variant="ghost" onClick={onSwitch}>New here? Create an account</Button>
|
|
<Button type="submit" variant="primary" disabled={busy || username.trim() === "" || password === ""}>
|
|
{busy ? "Joining…" : "Sign in & join"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<string | null>(null);
|
|
|
|
async function submit(e: FormEvent): Promise<void> {
|
|
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 (
|
|
<form onSubmit={(e) => void submit(e)}>
|
|
<h2 className="mb-1 text-base font-semibold">Create your account</h2>
|
|
<p className="mb-4 text-xs text-text-faint">
|
|
Registering accepts the invite in one step — no workspace of your own is created.
|
|
</p>
|
|
<div className="space-y-2">
|
|
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Your name (optional)" autoComplete="name" />
|
|
<Input
|
|
type="text"
|
|
value={username}
|
|
onChange={(e) => 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}
|
|
/>
|
|
<Input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder={`Password (min ${MIN_PASSWORD} characters)`}
|
|
autoComplete="new-password"
|
|
/>
|
|
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email (optional — password recovery)" autoComplete="email" />
|
|
</div>
|
|
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
|
|
<div className="mt-6 flex items-center justify-between">
|
|
<Button type="button" variant="ghost" onClick={onSwitch}>I already have an account</Button>
|
|
<Button type="submit" variant="primary" disabled={busy || !ready}>
|
|
{busy ? "Joining…" : "Join workspace"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export default function JoinWorkspacePage({ params }: { params: { token: string } }): React.ReactElement {
|
|
const { status, session, isLoading, claimInvite } = useAuth();
|
|
const [phase, setPhase] = useState<Phase>("loading");
|
|
const [info, setInfo] = useState<InviteInfo | null>(null);
|
|
const [deadReason, setDeadReason] = useState<DeadReason>("unknown");
|
|
const [mode, setMode] = useState<"register" | "signin">("register");
|
|
const [claiming, setClaiming] = useState(false);
|
|
const [claimError, setClaimError] = useState<string | null>(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<Array<{ id: string; name: string }> | null>(null);
|
|
|
|
// The preview is public: fetch it immediately rather than waiting for the session to settle.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setPhase("loading");
|
|
apiFetch<InviteInfo>(`/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<void> {
|
|
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 <LoadingPanel label="Checking invite…" />;
|
|
|
|
if (phase === "unreachable") {
|
|
return (
|
|
<Card className="w-[520px] max-w-full p-6 text-center">
|
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-warn/15 text-lg text-warn">!</div>
|
|
<h1 className="text-base font-semibold">Can't reach the server</h1>
|
|
<p className="mt-1 text-xs text-text-faint">
|
|
{API_UNREACHABLE} Your invite link is still fine — try again.
|
|
</p>
|
|
<Button variant="outline" className="mt-6" onClick={retry}>Retry</Button>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (phase === "dead") {
|
|
return (
|
|
<Card className="w-[520px] max-w-full p-6 text-center">
|
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-danger/15 text-lg text-danger">✕</div>
|
|
<h1 className="text-base font-semibold">This invite link isn't valid</h1>
|
|
<p className="mt-1 text-xs text-text-faint">
|
|
{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.
|
|
</p>
|
|
<Link href="/onboarding" className="mt-6 inline-block rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">
|
|
Start or sign in to your own workspace
|
|
</Link>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (info === null) return <LoadingPanel label="Checking invite…" />;
|
|
|
|
const memberCta = alreadyMember ? (
|
|
<div className="mt-6 flex items-center justify-between">
|
|
<span className="text-xs text-text-faint">You're already in this workspace.</span>
|
|
<Link href="/streams" className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">Go to Streams</Link>
|
|
</div>
|
|
) : (
|
|
<div className="mt-6 flex items-center justify-between">
|
|
<span className="text-2xs text-text-faint">Signed in as @{session?.user.username}</span>
|
|
<Button variant="primary" disabled={claiming} onClick={() => void claim()}>
|
|
{claiming ? "Joining…" : "Join workspace"}
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
// Signed in already: one click claims the invite with the current session.
|
|
if (status === "authenticated" && session !== null) {
|
|
return (
|
|
<Card className="w-[520px] max-w-full p-6">
|
|
<h1 className="text-xl font-semibold tracking-tight">Join {info.workspaceName}</h1>
|
|
<p className="mt-1 text-xs text-text-faint">
|
|
You've been invited to join <span className="text-text">{info.workspaceName}</span> as{" "}
|
|
<Badge tone="accent">{info.role}</Badge>.
|
|
</p>
|
|
<p className="mt-2 text-2xs text-text-faint">{usesLeftLine(info)}</p>
|
|
{collectiveLabel(collectives, "en") !== null && (
|
|
<p className="mt-1 text-2xs text-text-faint">{collectiveLabel(collectives, "en")}</p>
|
|
)}
|
|
{claimError !== null && <p className="mt-2 text-xs text-danger">{claimError}</p>}
|
|
{memberCta}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// Anonymous (or the API dropped between mount and now): hero + register/sign-in forms.
|
|
return (
|
|
<Card className="w-[520px] max-w-full p-6">
|
|
<div className="mb-5 text-center">
|
|
<img
|
|
src="/brand/hero.png"
|
|
alt="Tactical Media Broadcast Hub — arrows from a circle, warm red on black"
|
|
width={1024}
|
|
height={1024}
|
|
className="mx-auto mb-3 h-20 w-20 rounded-lg object-cover"
|
|
/>
|
|
<h1 className="text-xl font-semibold tracking-tight">Join {info.workspaceName}</h1>
|
|
<p className="mt-1 text-2xs text-text-faint">
|
|
You've been invited as <Badge tone="accent">{info.role}</Badge> · {usesLeftLine(info)}
|
|
</p>
|
|
{collectiveLabel(collectives, "en") !== null && (
|
|
<p className="mt-0.5 text-2xs text-text-faint">{collectiveLabel(collectives, "en")}</p>
|
|
)}
|
|
</div>
|
|
{mode === "signin" ? (
|
|
<JoinSignInForm token={params.token} onSwitch={() => setMode("register")} onAlreadyMember={() => setAlreadyMember(true)} />
|
|
) : (
|
|
<RegisterToJoinForm token={params.token} onSwitch={() => setMode("signin")} onAlreadyMember={() => setAlreadyMember(true)} />
|
|
)}
|
|
{alreadyMember && (
|
|
<div className="mt-5 border-t border-border-subtle pt-4 text-center">
|
|
<p className="text-xs text-text-faint">You're already in this workspace.</p>
|
|
<Link href="/streams" className="mt-2 inline-block rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover">
|
|
Go to Streams
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|