"use client"; import { useEffect, useState, type FormEvent } from "react"; import Link from "next/link"; import { Badge, Button, Card, Input } from "../../../components/ui/primitives"; import { OfflineBadge } from "../../../components/ui/offline-badge"; import { ArrowGlyph, LogoMark } from "../../../components/brand/logo"; import { PLATFORMS } from "../../../lib/platforms"; import { useApiResource } from "../../../lib/use-api"; import { api, errorMessage } from "../../../lib/api-client"; import type { MemberMutationResult } from "../../../lib/api-types"; import type { ConnectProvider } from "../../../lib/connections-client"; import { useAuth } from "../../../lib/auth-store"; // Step labels for the collective flow; solo accounts skip this wizard entirely. const STEPS = ["Name your collective", "Invite team", "Connect accounts"]; const MIN_PASSWORD = 10; const INVITE_ROLE_CHOICES = ["Owner", "Editor", "Viewer"] as const; const DEFAULT_INVITE_ROLE = "Editor"; const NO_PROVIDERS: ConnectProvider[] = []; function platformName(id: string): string { return PLATFORMS[id]?.displayName ?? id; } function SignInForm({ onSwitch }: { onSwitch: () => void }): React.ReactElement { const { login } = 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); window.location.assign("/streams"); } catch (err) { 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}

}
); } export default function OnboardingPage(): React.ReactElement { const { status, session, register, adoptToken, logout } = useAuth(); const offline = status === "offline"; // /login deep-links here in sign-in mode; the default entry is create-account. const [mode, setMode] = useState<"register" | "signin">( typeof window !== "undefined" && window.location.search.includes("mode=signin") ? "signin" : "register", ); const [step, setStep] = useState(0); const [name, setName] = useState(""); // Collectives opt-in (operator reorder #2): default OFF = solo account. const [creatingCollective, setCreatingCollective] = useState(false); const [displayName, setDisplayName] = useState(""); const [username, setUsername] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [emails, setEmails] = useState(""); const [inviteRole, setInviteRole] = useState(DEFAULT_INVITE_ROLE); const { data: providers, source: providerSource } = useApiResource("/connections/providers", (p) => p, NO_PROVIDERS); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [sessionExpired, setSessionExpired] = useState(false); // The API client redirects here with ?reason=session on a 401: default to sign-in. useEffect(() => { if (new URLSearchParams(window.location.search).get("reason") === "session") { setSessionExpired(true); setMode("signin"); } }, []); async function createWorkspace(): Promise { if (offline) { if (!creatingCollective) window.location.assign("/streams"); else setStep(1); return; } setBusy(true); setError(null); try { await register({ // Solo default: omitting workspaceName registers a personal workspace. ...(creatingCollective ? { workspaceName: name.trim() } : {}), username: username.trim(), password, ...(displayName.trim() === "" ? {} : { displayName: displayName.trim() }), ...(email.trim() === "" ? {} : { email: email.trim() }), }); // Solo accounts skip the team-invite wizard: there is no collective to // invite anyone into yet. They land straight in the app (which shows the // "no collectives" home menu until they create or join one). if (!creatingCollective) window.location.assign("/streams"); else setStep(1); } catch (err) { setError(errorMessage(err)); } finally { setBusy(false); } } async function sendInvites(): Promise { const list = emails.split(/[\s,;]+/).map((e) => e.trim()).filter((e) => e.includes("@")); if (offline || list.length === 0) { setStep(2); return; } setBusy(true); setError(null); const failures: string[] = []; // Sequential: each invite bumps the authz epoch and returns the token the next call must use. for (const invitee of list) { try { const result = await api.post("/team/members/invite", { email: invitee, role: inviteRole }); adoptToken(result.token); } catch (err) { failures.push(`${invitee}: ${errorMessage(err)}`); } } setBusy(false); if (failures.length > 0) setError(failures.join(" · ")); else setStep(2); } const usernameValid = /^[a-z0-9][a-z0-9_.-]{1,31}$/.test(username.trim()); const registerReady = (!creatingCollective || name.trim().length >= 2) && (offline || (usernameValid && password.length >= MIN_PASSWORD)); // Signed-in visitors who land here (e.g. from the sidebar) get a way out rather than a second registration form. if (status === "authenticated" && session !== null && step === 0) { return (

You're signed in

@{session.user.username} · {session.role} of {session.workspace.name}

Continue to Streams
); } return (
{offline && (
)} {sessionExpired && step === 0 && (

Your session ended (expired, or your role changed). Sign in again.

)} {mode === "signin" && step === 0 && !offline ? ( setMode("register")} /> ) : ( <>
{STEPS.map((s, i) => (
{i < step ? "✓" : i + 1} {s} {i < STEPS.length - 1 && }
))}
{step === 0 && (
{ e.preventDefault(); void createWorkspace(); }} >
Horizontal Collaborative Broadcast System
T-HUB

T-HUB

Your platform. Their feeds. No algorithm in between.

{/* Operator reorder #1/#2: registration makes a USER by default — a solo account with no collective. Creating a collective is an explicit opt-in that reveals the name field and the classic workspace flow (provisioner mints rooms once a collective is actually created from inside the app). */} {creatingCollective ? ( <>

Name your collective

The tenant boundary for accounts, drafts, and audit.

setName(e.target.value)} placeholder="Your collective, your studio…" autoFocus /> ) : (

Create your account

)} {!offline && (

Your owner account

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="2-32 characters: lowercase letters, digits, dot, underscore or hyphen" /> setPassword(e.target.value)} placeholder={`Password (min ${MIN_PASSWORD} characters)`} autoComplete="new-password" /> setEmail(e.target.value)} placeholder="Email (optional)" autoComplete="email" />
)} {error !== null &&

{error}

}
{offline ? : ( )}
)} {step === 1 && (

Invite your team

Comma-separated emails. Invitees join at the chosen role; adjust roles on the Team page.