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:
1 parent
34b12881b9
commit
73bbb1a0d7
6 files changed
+453
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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}`}>
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user