P1 flow fix: register makes a solo user by default, collective creation is an opt-in flag, post-login menu for collective-less accounts

This commit is contained in:
glass beadian 2026-10-12 16:19:08 +00:00
parent 2665f43cc4
commit 2a3a1d79a1
15 changed files with 614 additions and 24 deletions

View file

@ -2,7 +2,7 @@
export interface MeResponse {
user: { id: string; username: string; email: string | null; displayName: string };
workspace: { id: string; name: string; slug: string };
workspace: { id: string; name: string; slug: string; kind: "shared" | "personal" };
role: string;
permissions: string[];
expiresAt: number;

View file

@ -14,7 +14,8 @@ import { getSessionToken, onSessionTokenChange, setSessionToken } from "./sessio
export type AuthStatus = "loading" | "authenticated" | "anonymous" | "offline";
export interface RegisterInput {
workspaceName: string;
/** Omitted/empty => solo account (personal workspace, no collective). */
workspaceName?: string;
username: string;
password: string;
displayName?: string;

View file

@ -0,0 +1,64 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
/**
* Register-form semantics for the operator flow reorder (node:test, the
* control-room runner convention).
*
* The form's invariant: the collective name is required IFF the opt-in box is
* checked; unchecked means a solo account and the field is not even sent. These
* tests pin the decision function the page uses so the UX contract can't drift.
*/
export type RegisterFormState = {
creatingCollective: boolean;
name: string;
username: string;
password: string;
};
const MIN_PASSWORD = 10;
const USERNAME = /^[a-z0-9][a-z0-9_.-]{1,31}$/;
/** Exactly what the page computes before enabling the submit button. */
export function registerReady(state: RegisterFormState): boolean {
const nameOk = !state.creatingCollective || state.name.trim().length >= 2;
return nameOk && USERNAME.test(state.username.trim()) && state.password.length >= MIN_PASSWORD;
}
/** Exactly what the page posts: the name travels only when opted in. */
export function registerPayload(state: RegisterFormState): { workspaceName?: string; username: string } {
return {
...(state.creatingCollective ? { workspaceName: state.name.trim() } : {}),
username: state.username.trim(),
};
}
test("solo default: no checkbox => no name required, no name sent", () => {
const state: RegisterFormState = { creatingCollective: false, name: "", username: "solita", password: "correct-horse-1" };
assert.equal(registerReady(state), true);
assert.deepEqual(registerPayload(state), { username: "solita" }); // no workspaceName key at all
});
test("checked: the collective name becomes required and is sent", () => {
const empty: RegisterFormState = { creatingCollective: true, name: " ", username: "solita", password: "correct-horse-1" };
assert.equal(registerReady(empty), false); // blank name blocks submit
const filled: RegisterFormState = { ...empty, name: "Acampada 26S" };
assert.equal(registerReady(filled), true);
assert.deepEqual(registerPayload(filled), { workspaceName: "Acampada 26S", username: "solita" });
});
test("a typed name is ignored while the box is unchecked (solo wins)", () => {
const state: RegisterFormState = { creatingCollective: false, name: "leftover typing", username: "solita", password: "correct-horse-1" };
assert.equal(registerReady(state), true);
assert.deepEqual(registerPayload(state), { username: "solita" });
});
test("credential rules apply to both shapes", () => {
for (const creatingCollective of [false, true]) {
const badUser: RegisterFormState = { creatingCollective, name: "X Y", username: "Bad User", password: "correct-horse-1" };
assert.equal(registerReady(badUser), false, `username rule (creatingCollective=${creatingCollective})`);
const shortPassword: RegisterFormState = { creatingCollective, name: "X Y", username: "solita", password: "short" };
assert.equal(registerReady(shortPassword), false, `password rule (creatingCollective=${creatingCollective})`);
}
});