The previous pass kept the dark base and the red icons, which did not read as the acampada. Now: - Light theme: white page, black ink and borders, black buttons with a hard neon offset shadow, neon for focus and the active nav item; h1/h2 in Anton with the stepped neon shadow of the site headings. - Nav, empty-state, spinner and pattern assets in black; platform icons keep their dark disc with a neon stroke. - New mark: three dome tents wired from their doors into a switch with an antenna, drawn in the inked style of the acampada logo (curved poles, ball ends, hatching) over a neon offset copy. LogoMark renders it inline; visible product name is T-HUB. - docs/brand: t-hub.svg and a lockup with the Anton wordmark converted to outlines so it renders without the font. - Public submission page moved to the light palette. Checked with headless Chromium screenshots (offline sample data) on desktop and mobile.
356 lines
14 KiB
TypeScript
356 lines
14 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/t-hub.svg"
|
|
alt="T-HUB: three tents wired to a switch with an antenna"
|
|
width={400}
|
|
height={400}
|
|
className="mx-auto mb-3 h-20 w-20"
|
|
/>
|
|
<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>
|
|
);
|
|
}
|