feat(frontend): first-account setup screen and Konta card in Ustawienia

With config.setup the login form becomes "Utwórz pierwsze konto" (a 409
falls back to login). Ustawienia gets accounts: list, add, change own
password (local only), delete with confirmation, not the last one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-24 23:12:11 +02:00
1 parent 34b12881b9
commit 73bbb1a0d7
6 files changed
+453 -5

No files matched your search

+3
View File
@@ -44,6 +44,9 @@ serwerem" with a retry. The screen has the login and password form (always the w
ends the session: the app unmounts, so every poller stops, and the login screen says "Sesja wygasła"; the hash is kept, so
after login the same screen opens. "Wyloguj" is in Stan and the side panel.
When `api/auth/config` has `setup`, the form is "Utwórz pierwsze konto" (`api/auth/setup`; a 409 falls back to the login form).
The "Konta" card at the end of Ustawienia (`Accounts.tsx`, only with a login of our own) adds and deletes accounts and changes the own password.
## Install on a phone (PWA)
The build has a manifest (`public/`), icons and a service worker (`src/sw.js`, written to `dist/sw.js` with the list of built
+184
View File
@@ -0,0 +1,184 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { answer } from "./fixtures";
type Handler = (body: any) => Promise<Response>;
const bodyOf = (init?: RequestInit) => (init?.body ? JSON.parse(String(init.body)) : undefined);
const empty = () => Promise.resolve(new Response(null, { status: 204 }));
// A logged-in user and a list of accounts; POSTs go to the handlers by URL.
async function open(method: string, users: string[], posts: Record<string, Handler> = {}) {
const fetch = vi.fn((url: string, init?: RequestInit) => {
if (init?.method === "POST") return (posts[url] ?? (() => answer({}, 404)))(bodyOf(init));
if (url === "api/auth/me") return answer({ user: "mariusz", method });
if (url === "api/auth/users") return answer(users.map((username) => ({ username })));
return answer({}, 404);
});
vi.stubGlobal("fetch", fetch);
await mount();
return fetch;
}
async function mount() {
const { AuthGate } = await import("./Auth");
const { Accounts } = await import("./Accounts");
render(<AuthGate><Accounts /></AuthGate>);
}
const type = (label: string, value: string) => fireEvent.change(screen.getByLabelText(label), { target: { value } });
const click = (name: string) => fireEvent.click(screen.getByRole("button", { name }));
const fillNew = (name: string, password: string, repeat = password) => {
click("Dodaj konto");
type("Login", name);
type("Hasło", password);
type("Powtórz hasło", repeat);
click("Zapisz");
};
const changePassword = () => {
click("Zmień moje hasło");
type("Obecne hasło", "stare-haslo-1");
type("Nowe hasło", "nowe-haslo-123");
type("Powtórz hasło", "nowe-haslo-123");
click("Zapisz");
};
beforeEach(() => {
vi.stubGlobal("matchMedia", () => ({ matches: false, addEventListener() {}, removeEventListener() {} }));
});
describe("Accounts", () => {
it("lists the accounts and marks the own one", async () => {
await open("local", ["mariusz", "ola"]);
expect(await screen.findByText("ola")).toBeInTheDocument();
expect(screen.getByText("(Ty)")).toBeInTheDocument();
});
it("is not shown on a server without login", async () => {
const fetch = await open("none", []);
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith("api/auth/me", expect.anything()));
expect(screen.queryByText("Konta")).toBeNull();
expect(fetch.mock.calls.map(([url]) => url)).not.toContain("api/auth/users");
});
it("adds an account and lists the accounts again", async () => {
const post = vi.fn(() => answer({ username: "ola" }));
const fetch = await open("local", ["mariusz"], { "api/auth/users": post });
await screen.findByText("mariusz");
fillNew("ola", "dlugie-haslo-1");
expect(await screen.findByText("Dodano konto")).toBeInTheDocument();
expect(post).toHaveBeenCalledWith({ username: "ola", password: "dlugie-haslo-1" });
expect(fetch.mock.calls.filter(([url, init]) => url === "api/auth/users" && init?.method !== "POST")).toHaveLength(2);
});
it.each([
["exists", () => answer({ error: "exists" }, 409), "Takie konto już istnieje"],
["weak", () => answer({ error: "weak_password" }, 400), "Hasło jest za słabe (co najmniej 10 znaków)"],
])("says so when adding fails: %s", async (_name, reply, text) => {
await open("local", ["mariusz"], { "api/auth/users": reply });
await screen.findByText("mariusz");
fillNew("ola", "dlugie-haslo-1");
expect(await screen.findByRole("alert")).toHaveTextContent(text);
});
it("checks the repeated password before sending", async () => {
const post = vi.fn(() => answer({}));
await open("local", ["mariusz"], { "api/auth/users": post });
await screen.findByText("mariusz");
fillNew("ola", "dlugie-haslo-1", "inne-haslo-123");
expect(await screen.findByRole("alert")).toHaveTextContent("Hasła nie są takie same");
expect(post).not.toHaveBeenCalled();
});
it("changes the own password", async () => {
const post = vi.fn(empty);
await open("local", ["mariusz"], { "api/auth/password": post });
await screen.findByText("mariusz");
changePassword();
expect(await screen.findByText("Hasło zmienione")).toBeInTheDocument();
expect(post).toHaveBeenCalledWith({ current: "stare-haslo-1", new: "nowe-haslo-123" });
});
it.each([
["401", () => answer({ error: "invalid" }, 401), "Nieprawidłowe obecne hasło"],
["400", () => answer({ error: "weak_password" }, 400), "Hasło jest za słabe (co najmniej 10 znaków)"],
["429", () => answer({ error: "throttled", retryAfterSeconds: 120 }, 429), "Za dużo prób – spróbuj za 2 min"],
])("says so when the password change fails: %s", async (_name, reply, text) => {
await open("local", ["mariusz"], { "api/auth/password": reply });
await screen.findByText("mariusz");
changePassword();
expect(await screen.findByRole("alert")).toHaveTextContent(text);
});
it("offers no password change to a Keycloak user", async () => {
await open("oidc", ["mariusz"]);
await screen.findByText("mariusz");
expect(screen.getByRole("button", { name: "Dodaj konto" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Zmień moje hasło" })).toBeNull();
});
it("deletes only after the confirmation", async () => {
const post = vi.fn(empty);
await open("local", ["mariusz", "ola"], { "api/auth/users/ola/delete": post });
await screen.findByText("ola");
click("Usuń ola");
expect(screen.getByText("Usunąć konto ola?")).toBeInTheDocument();
expect(post).not.toHaveBeenCalled();
click("Anuluj");
expect(screen.queryByText("Usunąć konto ola?")).toBeNull();
click("Usuń ola");
click("Tak, usuń");
expect(await screen.findByText("Usunięto konto ola")).toBeInTheDocument();
expect(post).toHaveBeenCalledTimes(1);
});
it("does not delete the last account", async () => {
await open("local", ["mariusz"]);
await screen.findByText("mariusz");
expect(screen.getByRole("button", { name: "Usuń mariusz" })).toBeDisabled();
});
it("says so when the server refuses to delete the last account", async () => {
await open("local", ["mariusz", "ola"], { "api/auth/users/ola/delete": () => answer({ error: "last_user" }, 409) });
await screen.findByText("ola");
click("Usuń ola");
click("Tak, usuń");
expect(await screen.findByText("Nie można usunąć ostatniego konta")).toBeInTheDocument();
});
it("returns to the login screen when the own account is deleted", async () => {
let deleted = false;
vi.stubGlobal("fetch", vi.fn((url: string, init?: RequestInit) => {
if (init?.method === "POST") {
deleted = true;
return empty();
}
if (url === "api/auth/me") return answer({ user: "mariusz", method: "local" });
if (url === "api/auth/users") return deleted ? answer({ error: "unauthenticated" }, 401) : answer([{ username: "mariusz" }, { username: "ola" }]);
return answer({ local: true, oidc: false });
}));
await mount();
await screen.findByText("ola");
click("Usuń mariusz");
click("Tak, usuń");
expect(await screen.findByText("Sesja wygasła – zaloguj się ponownie")).toBeInTheDocument();
});
});
+130
View File
@@ -0,0 +1,130 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { getJson, postAuth } from "./api";
import { accountFailure, BUTTON, FIELD, newPasswordProblem, useSession } from "./Auth";
import { Card } from "./ui";
type Form = "add" | "password" | null;
function Field({ label, value, onChange, auto, type = "password" }: {
label: string; value: string; onChange: (value: string) => void; auto: string; type?: string;
}) {
return (
<label className="block">
<span className="mb-1 block font-semibold">{label}</span>
<input value={value} onChange={(e) => onChange(e.target.value)} type={type} autoComplete={auto} required
autoCapitalize="none" spellCheck={false} className={FIELD} />
</label>
);
}
// Sends the fields once the new password passes the checks; says what went wrong, or closes the form.
function AccountForm({ title, fields, send, onDone }: {
title: string; fields: "add" | "password";
send: (values: Record<string, string>) => Promise<{ status: number; body: any }>; onDone: () => void;
}) {
const [values, setValues] = useState<Record<string, string>>({});
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const set = (key: string) => (value: string) => setValues((now) => ({ ...now, [key]: value }));
const secret = (fields === "add" ? values.password : values.new) ?? "";
const submit = async (event: FormEvent) => {
event.preventDefault();
const problem = newPasswordProblem(secret, values.repeat ?? "");
if (problem) return setError(problem);
setSending(true);
setError("");
const { status, body } = await send(values);
setSending(false);
if (status === 200 || status === 204) return onDone();
setError(status === 401 ? "Nieprawidłowe obecne hasło" : accountFailure(status, body));
};
return (
<form onSubmit={submit} className="mt-3 space-y-3 border-t border-edge pt-3">
<h3 className="font-semibold">{title}</h3>
{fields === "add" ? (
<>
<Field label="Login" value={values.username ?? ""} onChange={set("username")} auto="off" type="text" />
<Field label="Hasło" value={values.password ?? ""} onChange={set("password")} auto="new-password" />
</>
) : (
<>
<Field label="Obecne hasło" value={values.current ?? ""} onChange={set("current")} auto="current-password" />
<Field label="Nowe hasło" value={values.new ?? ""} onChange={set("new")} auto="new-password" />
</>
)}
<Field label="Powtórz hasło" value={values.repeat ?? ""} onChange={set("repeat")} auto="new-password" />
{error && <p role="alert" className="font-semibold text-alarm">{error}</p>}
<button type="submit" disabled={sending} className={`${BUTTON} bg-brand text-white dark:text-side`}>Zapisz</button>
</form>
);
}
const SMALL = "min-h-11 rounded-xl border-2 border-brand px-3 font-bold text-brand disabled:opacity-40";
/** The accounts that may log in: add, change the own password, delete. Nothing without a login of our own. */
export function Accounts() {
const session = useSession();
const [users, setUsers] = useState<string[]>();
const [form, setForm] = useState<Form>(null);
const [confirming, setConfirming] = useState<string | null>(null);
const [message, setMessage] = useState("");
const managed = session != null && session.method !== "none";
const load = useCallback(() => {
getJson<{ username: string }[]>("api/auth/users").then(
(list) => setUsers(list.map((u) => u.username)), () => setMessage("Nie udało się pobrać kont"));
}, []);
useEffect(() => {
if (managed) load();
}, [managed, load]);
if (!managed) return null;
const done = (text: string) => () => {
setForm(null);
setMessage(text);
load();
};
// Deleting yourself ends the session: the list request that follows gets 401 and the login screen shows.
const remove = async (name: string) => {
setConfirming(null);
const { status, body } = await postAuth(`api/auth/users/${encodeURIComponent(name)}/delete`);
if (status === 204) return done(`Usunięto konto ${name}`)();
setMessage(body?.error === "last_user" ? "Nie można usunąć ostatniego konta" : accountFailure(status, body));
};
const toggle = (next: Form) => setForm(form === next ? null : next);
return (
<Card title="Konta" className="mt-4">
{users?.map((name) => (
<div key={name} className="flex items-center justify-between gap-3 border-t border-edge py-2 first:border-t-0">
<span>{name}{name === session.user && <span className="text-soft"> (Ty)</span>}</span>
{confirming === name ? (
<span className="flex flex-wrap items-center justify-end gap-2">
<span className="text-sm font-semibold">Usunąć konto {name}?</span>
<button onClick={() => remove(name)} className={`${SMALL} !border-alarm bg-alarm !text-white`}>Tak, usuń</button>
<button onClick={() => setConfirming(null)} className={SMALL}>Anuluj</button>
</span>
) : (
<button onClick={() => setConfirming(name)} disabled={users.length < 2} aria-label={`Usuń ${name}`} className={SMALL}>Usuń</button>
)}
</div>
))}
{message && <p role="status" className="mt-2 text-sm font-semibold text-soft">{message}</p>}
<div className="mt-3 flex flex-wrap gap-2">
<button onClick={() => toggle("add")} aria-pressed={form === "add"} className={SMALL}>Dodaj konto</button>
{session.method === "local" && (
<button onClick={() => toggle("password")} aria-pressed={form === "password"} className={SMALL}>Zmień moje hasło</button>
)}
</div>
{form === "add" && (
<AccountForm title="Nowe konto" fields="add" onDone={done("Dodano konto")}
send={({ username, password }) => postAuth("api/auth/users", { username, password })} />
)}
{form === "password" && (
<AccountForm title="Zmiana hasła" fields="password" onDone={done("Hasło zmienione")}
send={({ current, new: next }) => postAuth("api/auth/password", { current, new: next })} />
)}
</Card>
);
}
+65
View File
@@ -236,3 +236,68 @@ describe("session", () => {
expect(screen.queryByText("Sesja wygasła – zaloguj się ponownie")).toBeNull();
});
});
describe("first account", () => {
const SETUP = { local: true, oidc: true, setup: true };
const fill = (password: string, repeat: string) => {
fireEvent.change(screen.getByLabelText("Login"), { target: { value: "mariusz" } });
fireEvent.change(screen.getByLabelText("Hasło"), { target: { value: password } });
fireEvent.change(screen.getByLabelText("Powtórz hasło"), { target: { value: repeat } });
fireEvent.click(screen.getByRole("button", { name: "Utwórz konto" }));
};
it("offers the creation instead of the login form, with Keycloak below, and opens the app", async () => {
const post = vi.fn(() => answer(ME));
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": post });
await open();
expect(await screen.findByText("Utwórz pierwsze konto")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Zaloguj" })).toBeNull();
expect(screen.getByRole("button", { name: "Zaloguj przez Keycloak" })).toBeInTheDocument();
fill("dlugie-haslo-1", "dlugie-haslo-1");
expect(await screen.findByRole("navigation", { name: "Ekrany" })).toBeInTheDocument();
expect(post).toHaveBeenCalledWith({ username: "mariusz", password: "dlugie-haslo-1" });
});
it.each([
["too short", "krotkie", "krotkie", "Hasło musi mieć co najmniej 10 znaków"],
["different", "dlugie-haslo-1", "dlugie-haslo-2", "Hasła nie są takie same"],
])("does not send a password that is %s", async (_name, password, repeat, text) => {
const post = vi.fn(() => answer(ME));
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": post });
await open();
await screen.findByText("Utwórz pierwsze konto");
fill(password, repeat);
expect(await screen.findByRole("alert")).toHaveTextContent(text);
expect(post).not.toHaveBeenCalled();
});
it("shows the login form when somebody created the account first", async () => {
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": () => answer({ error: "setup_done" }, 409) });
await open();
await screen.findByText("Utwórz pierwsze konto");
fill("dlugie-haslo-1", "dlugie-haslo-1");
expect(await loginScreen()).toBeInTheDocument();
expect(screen.getByText("Konto już istnieje – zaloguj się")).toBeInTheDocument();
});
it.each([
["a weak password", () => answer({ error: "weak_password" }, 400), "Hasło jest za słabe (co najmniej 10 znaków)"],
["a bad login", () => answer({ error: "bad_username" }, 400), "Nieprawidłowy login"],
["too many tries", () => answer({ error: "throttled", retryAfterSeconds: 60 }, 429), "Za dużo prób – spróbuj za 1 min"],
["a lost network", () => Promise.reject(new TypeError("offline")), "Brak połączenia z serwerem"],
])("says so on %s from the server", async (_name, reply, text) => {
server({ "GET api/auth/me": unauthenticated, "GET api/auth/config": SETUP, "POST api/auth/setup": reply });
await open();
await screen.findByText("Utwórz pierwsze konto");
fill("dlugie-haslo-1", "dlugie-haslo-1");
expect(await screen.findByRole("alert")).toHaveTextContent(text);
});
});
+69 -5
View File
@@ -4,7 +4,7 @@ import { Icon } from "./icons";
import { Banner, Card, Loading } from "./ui";
interface Session { user: string; method: "local" | "oidc" | "none" }
interface AuthConfig { local: boolean; oidc: boolean }
interface AuthConfig { local: boolean; oidc: boolean; setup?: boolean }
type State =
| { kind: "loading" }
@@ -24,8 +24,23 @@ const OIDC_ERRORS = new Map([
// Keycloak sends the browser back to "#login?error=…".
const oidcError = () => OIDC_ERRORS.get(new URLSearchParams(location.hash.split("?")[1]).get("error") ?? "");
const FIELD = "min-h-12 w-full rounded-xl border-2 border-off bg-page px-3 text-lg text-ink focus:border-brand focus:outline-none";
const BUTTON = "min-h-12 w-full rounded-xl border-2 border-brand px-3 text-lg font-bold transition active:scale-[0.98] disabled:opacity-40";
export const FIELD = "min-h-12 w-full rounded-xl border-2 border-off bg-page px-3 text-lg text-ink focus:border-brand focus:outline-none";
export const BUTTON = "min-h-12 w-full rounded-xl border-2 border-brand px-3 text-lg font-bold transition active:scale-[0.98] disabled:opacity-40";
export const MIN_PASSWORD = 10;
/** The message for a new password that cannot be sent, or "" when it can. */
export function newPasswordProblem(password: string, repeat: string) {
if (password.length < MIN_PASSWORD) return `Hasło musi mieć co najmniej ${MIN_PASSWORD} znaków`;
return password === repeat ? "" : "Hasła nie są takie same";
}
/** What the server's refusal of a new account or password means. */
export function accountFailure(status: number, body: { error?: string; retryAfterSeconds?: number } | null) {
if (status === 400) return body?.error === "weak_password" ? `Hasło jest za słabe (co najmniej ${MIN_PASSWORD} znaków)` : "Nieprawidłowy login";
if (status === 409) return "Takie konto już istnieje";
return failure(status, body);
}
function failure(status: number, body: { retryAfterSeconds?: number } | null) {
if (status === 0) return "Brak połączenia z serwerem";
@@ -90,17 +105,61 @@ function LoginForm({ onLogin }: { onLogin: (session: Session) => void }) {
);
}
function SetupForm({ onLogin, onExists }: { onLogin: (session: Session) => void; onExists: () => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const submit = async (event: FormEvent) => {
event.preventDefault();
const problem = newPasswordProblem(password, repeat);
if (problem) return setError(problem);
setSending(true);
setError("");
const { status, body } = await postAuth("api/auth/setup", { username, password });
if (status === 200) return onLogin(body);
if (status === 409) return onExists();
setError(accountFailure(status, body));
setSending(false);
};
return (
<form onSubmit={submit} className="space-y-4">
<h2 className="text-xl font-bold">Utwórz pierwsze konto</h2>
<label className="block">
<span className="mb-1 block font-semibold">Login</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required
autoCapitalize="none" spellCheck={false} className={FIELD} />
</label>
<label className="block">
<span className="mb-1 block font-semibold">Hasło</span>
<input value={password} onChange={(e) => setPassword(e.target.value)} type="password" autoComplete="new-password" required className={FIELD} />
</label>
<label className="block">
<span className="mb-1 block font-semibold">Powtórz hasło</span>
<input value={repeat} onChange={(e) => setRepeat(e.target.value)} type="password" autoComplete="new-password" required className={FIELD} />
</label>
{error && <p role="alert" className="font-semibold text-alarm">{error}</p>}
<button type="submit" disabled={sending} className={`${BUTTON} bg-brand text-white dark:text-side`}>Utwórz konto</button>
</form>
);
}
function Login({ expired, onLogin }: { expired: boolean; onLogin: (session: Session) => void }) {
// The form is the way in when Keycloak is down, so it also shows when the config cannot be read.
const [config, setConfig] = useState<AuthConfig>();
useEffect(() => {
getJson<AuthConfig>("api/auth/config").then(setConfig, () => setConfig({ local: true, oidc: false }));
}, []);
const [accountExists, setAccountExists] = useState(false);
const keycloakProblem = oidcError();
return (
<Screen>
{expired && <Banner tone="warn" compact>Sesja wygasła – zaloguj się ponownie</Banner>}
{accountExists && <Banner tone="ok" compact>Konto już istnieje – zaloguj się</Banner>}
{keycloakProblem && <Banner tone="bad" compact>{keycloakProblem}</Banner>}
{!config ? <Loading /> : !config.local && !config.oidc ? (
<>
@@ -109,7 +168,9 @@ function Login({ expired, onLogin }: { expired: boolean; onLogin: (session: Sess
</>
) : (
<>
{config.local && <LoginForm onLogin={onLogin} />}
{config.local && (config.setup && !accountExists
? <SetupForm onLogin={onLogin} onExists={() => setAccountExists(true)} />
: <LoginForm onLogin={onLogin} />)}
{config.local && config.oidc && (
<p className="flex items-center gap-3 text-soft before:h-px before:flex-1 before:bg-edge after:h-px after:flex-1 after:bg-edge">lub</p>
)}
@@ -165,9 +226,12 @@ export function AuthGate({ children }: { children: ReactNode }) {
}
}
/** The logged-in user, for the screens that manage accounts; null outside a login. */
export const useSession = () => useContext(SessionContext);
/** "Zalogowano: …" with the logout button; nothing outside a login (the tests of single screens). */
export function Account({ className = "", card = false }: { className?: string; card?: boolean }) {
const session = useContext(SessionContext);
const session = useSession();
if (!session || session.method === "none") return null; // no login in a development setup
const line = (
<div className={`flex items-center justify-between gap-3 ${className}`}>
+2
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, type ReactNode } from "react";
import { postJson, WriteError } from "./api";
import { Accounts } from "./Accounts";
import { ChangeHistory } from "./ChangeHistory";
import { dateTime, degrees, duration, localInput, withOffset } from "./format";
import { reload, usePoll } from "./poll";
@@ -343,6 +344,7 @@ export function Settings() {
</Card>
</div>
<ChangeHistory />
<Accounts />
</>
);
}