feat: branded login screen in front of Basic Auth

The browser's native Basic Auth prompt can't be restyled — it's chrome UI,
not part of the page. Replace it with Aurum's own login screen: nginx keeps
auth_basic on /api/* (the actual protection, unchanged), but drops it from
the SPA shell (just HTML/JS/CSS, no data) so the app can render its own
branded form instead. On submit the app validates the credentials against a
real protected endpoint, then attaches the resulting Authorization header to
every request itself — sessionStorage-backed, so closing the tab signs out.
Instances with Basic Auth left off (the default) never see this screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
This commit is contained in:
ZProger
2026-08-30 03:05:01 +03:00
parent 76c5caa667
commit 1de9ac272e
8 changed files with 293 additions and 5 deletions

View File

@@ -26,9 +26,13 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# SPA fallback — client-side routing handles the rest.
# SPA fallback — client-side routing handles the rest. Deliberately NOT
# behind auth_basic: this is just the built HTML/JS/CSS shell, no
# financial data lives in it. The app's own branded login screen
# (frontend/src/components/auth/LoginGate.tsx) is what actually gates
# entry, by requiring a valid Authorization header on every /api/ call
# above — the real protection stays exactly where it was.
location / {
include /etc/nginx/basic-auth.conf;
try_files $uri $uri/ /index.html;
}

View File

@@ -1,8 +1,15 @@
import { api } from "@/api/client";
import { getAuthHeader } from "@/lib/auth";
import { t } from "@/lib/i18n";
export async function exportBackup(): Promise<void> {
const response = await fetch("/api/backup/export");
// Raw fetch (not api/client.ts's request()) — the response is a file
// download, not JSON — so the Authorization header has to be attached
// here by hand too, same as every other request.
const authHeader = getAuthHeader();
const response = await fetch("/api/backup/export", {
headers: authHeader ? { Authorization: authHeader } : {},
});
if (!response.ok) {
throw new Error(await response.text());
}

View File

@@ -1,3 +1,5 @@
import { clearCredentials, getAuthHeader } from "@/lib/auth";
const API_BASE = "/api";
export class ApiError extends Error {
@@ -9,11 +11,22 @@ export class ApiError extends Error {
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const authHeader = getAuthHeader();
const response = await fetch(`${API_BASE}${path}`, {
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
...(authHeader ? { Authorization: authHeader } : {}),
},
...init,
});
if (response.status === 401) {
// Stored credentials are missing/stale (password changed, or Basic Auth
// was just turned on) — drop them so LoginGate falls back to the login
// screen instead of every request failing silently forever.
clearCredentials();
}
if (!response.ok) {
const body = await response.text();
let message = body || response.statusText;

View File

@@ -0,0 +1,50 @@
import { type ReactNode, useEffect, useState } from "react";
import { Logo } from "@/components/layout/Logo";
import { LoginScreen } from "@/components/auth/LoginScreen";
import { checkCredentials, useAuthHeader } from "@/lib/auth";
type Probe = "checking" | "required" | "not-required";
/** Wraps the whole app. Renders the login screen only when this instance
* actually has Basic Auth turned on (AURUM_BASIC_AUTH_USER/PASSWORD in
* .env, see frontend/docker-entrypoint.d/20-basic-auth.sh) — most installs
* don't, and this must never force a login screen on those. When stored
* credentials go stale (password changed, or auth was just turned on) the
* 401 handler in api/client.ts clears them, useAuthHeader() picks that up
* reactively, and this falls back to the login screen on its own. */
export function LoginGate({ children }: { children: ReactNode }) {
const authHeader = useAuthHeader();
const [probe, setProbe] = useState<Probe>(authHeader ? "not-required" : "checking");
useEffect(() => {
if (authHeader) {
setProbe("not-required");
return;
}
let cancelled = false;
checkCredentials(null).then((result) => {
if (cancelled) return;
// A network/backend error here isn't an auth problem — don't block
// the user behind a login screen for an unrelated outage, let the
// app's own per-page error states (e.g. dashboard.errorLoading) explain it.
setProbe(result === "unauthorized" ? "required" : "not-required");
});
return () => {
cancelled = true;
};
}, [authHeader]);
if (probe === "checking") {
return (
<div className="flex min-h-screen items-center justify-center bg-surface-0">
<Logo size={40} className="animate-pulse" />
</div>
);
}
if (probe === "required" && !authHeader) {
return <LoginScreen />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,80 @@
import { type FormEvent, useState } from "react";
import { Logo } from "@/components/layout/Logo";
import { Button } from "@/components/ui/Button";
import { Card, CardContent } from "@/components/ui/Card";
import { Input, Label } from "@/components/ui/Input";
import { buildBasicAuthHeader, checkCredentials, setCredentials } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n";
type Status = "idle" | "submitting" | "invalid" | "unreachable";
/** Shown by LoginGate when this instance requires Basic Auth and no valid
* credentials are stored yet. Doesn't implement auth itself — it just
* collects a username/password, checks them against a real protected
* endpoint, and on success hands them to lib/auth.ts's setCredentials(),
* which is what every subsequent request actually authenticates with. */
export function LoginScreen() {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [status, setStatus] = useState<Status>("idle");
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setStatus("submitting");
const result = await checkCredentials(buildBasicAuthHeader(username, password));
if (result === "ok") {
setCredentials(username, password);
return;
}
setStatus(result === "unauthorized" ? "invalid" : "unreachable");
};
return (
<div className="flex min-h-screen items-center justify-center bg-surface-0 px-4">
<Card className="w-full max-w-sm">
<CardContent className="flex flex-col items-center gap-6 p-6 pt-8 sm:p-8">
<div className="flex flex-col items-center gap-1.5">
<Logo size={40} />
<span className="text-lg font-semibold tracking-tight text-text-primary">Aurum</span>
<span className="text-xs text-text-muted">{t("auth.subtitle")}</span>
</div>
<form onSubmit={handleSubmit} className="flex w-full flex-col gap-4">
<div>
<Label htmlFor="auth-username">{t("auth.usernameLabel")}</Label>
<Input
id="auth-username"
name="username"
autoComplete="username"
autoFocus
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
</div>
<div>
<Label htmlFor="auth-password">{t("auth.passwordLabel")}</Label>
<Input
id="auth-password"
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</div>
{status === "invalid" && <p className="text-sm text-danger">{t("auth.errorInvalidCredentials")}</p>}
{status === "unreachable" && <p className="text-sm text-danger">{t("auth.errorUnreachable")}</p>}
<Button type="submit" className="w-full" disabled={status === "submitting"}>
{status === "submitting" ? t("auth.submitting") : t("auth.submitButton")}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

115
frontend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,115 @@
import { useSyncExternalStore } from "react";
/**
* Client-side mirror of the HTTP Basic Auth credentials Aurum's own login
* screen collects (see components/auth/LoginScreen.tsx), so every fetch can
* attach `Authorization` itself instead of relying on the browser's own
* unstyled Basic Auth prompt. nginx's `auth_basic` (see
* frontend/docker-entrypoint.d/20-basic-auth.sh) is still the actual gate —
* this only avoids ever triggering that native prompt, by never letting an
* unauthenticated request happen without us attaching the header ourselves.
*
* Kept in sessionStorage, not localStorage: closing the tab signs the user
* back out, which matters more for a finance app than staying logged in
* across days.
*/
const STORAGE_KEY = "aurum:basicAuth";
function readStored(): string | null {
try {
return sessionStorage.getItem(STORAGE_KEY);
} catch {
return null;
}
}
let currentHeader: string | null = readStored();
const listeners = new Set<() => void>();
function notify(): void {
listeners.forEach((listener) => listener());
}
export function getAuthHeader(): string | null {
return currentHeader;
}
// btoa() only handles Latin1 — the UI is bilingual RU/EN, so a Cyrillic
// password has to survive this, not just ASCII ones.
function encodeUtf8Base64(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
export function buildBasicAuthHeader(username: string, password: string): string {
return `Basic ${encodeUtf8Base64(`${username}:${password}`)}`;
}
export function setCredentials(username: string, password: string): void {
currentHeader = buildBasicAuthHeader(username, password);
try {
sessionStorage.setItem(STORAGE_KEY, currentHeader);
} catch {
// sessionStorage unavailable (private browsing, storage disabled) — the
// header still works for the rest of this tab's life via the in-memory
// variable above, it just won't survive a refresh.
}
notify();
}
/** Called on any 401 response (see api/client.ts) so a revoked or changed
* password falls back to the login screen instead of every request failing
* silently forever. */
export function clearCredentials(): void {
if (currentHeader === null) return;
currentHeader = null;
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
// ignore — nothing to clean up if storage was never usable
}
notify();
}
export type CredentialCheck = "ok" | "unauthorized" | "unreachable";
// A request with NO Authorization header at all, hitting an endpoint that
// replies 401 + WWW-Authenticate: Basic, is exactly what makes some
// browsers pop their own native Basic Auth dialog even for a plain
// fetch() — the one thing this whole login screen exists to avoid. A
// request that already carries *some* Authorization header, even a wrong
// one, never triggers that. So LoginGate's "is auth even required?" probe
// (called with header=null) uses this fixed placeholder instead of
// omitting the header — it's guaranteed wrong, which is exactly what's
// needed to tell "not configured" (200, header ignored) apart from
// "configured, please log in" (401).
const PROBE_HEADER = `Basic ${btoa("__aurum_probe__:__aurum_probe__")}`;
/** Hits a lightweight, always-protected endpoint with the given header (or
* none) to find out whether Basic Auth is required/satisfied. Used both to
* skip the login screen entirely when this instance has no auth configured
* (see LoginGate.tsx), and to validate a login attempt before saving it
* (see LoginScreen.tsx). */
export async function checkCredentials(header: string | null): Promise<CredentialCheck> {
try {
const response = await fetch("/api/accounts", {
headers: { Authorization: header ?? PROBE_HEADER },
});
return response.status === 401 ? "unauthorized" : "ok";
} catch {
return "unreachable";
}
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useAuthHeader(): string | null {
return useSyncExternalStore(subscribe, () => currentHeader);
}

View File

@@ -43,6 +43,14 @@ const ru = {
"common.perMonth": "/мес",
"common.allShort": "Всё",
"auth.subtitle": "Личные финансы",
"auth.usernameLabel": "Логин",
"auth.passwordLabel": "Пароль",
"auth.submitButton": "Войти",
"auth.submitting": "Вход…",
"auth.errorInvalidCredentials": "Неверный логин или пароль",
"auth.errorUnreachable": "Не удалось подключиться к серверу. Проверьте соединение и попробуйте снова.",
"dashboard.errorLoading": "Не удалось загрузить данные дашборда. Проверьте, что backend запущен.",
"dashboard.statRealIncomeLabel": "Реальный доход",
"dashboard.statRealIncomeCaption": "зарплаты, возвраты",
@@ -442,6 +450,14 @@ const en: Record<keyof typeof ru, string> = {
"common.perMonth": "/mo",
"common.allShort": "All",
"auth.subtitle": "Personal finance",
"auth.usernameLabel": "Username",
"auth.passwordLabel": "Password",
"auth.submitButton": "Sign in",
"auth.submitting": "Signing in…",
"auth.errorInvalidCredentials": "Incorrect username or password",
"auth.errorUnreachable": "Couldn't reach the server. Check your connection and try again.",
"dashboard.errorLoading": "Failed to load dashboard data. Check that the backend is running.",
"dashboard.statRealIncomeLabel": "Real income",
"dashboard.statRealIncomeCaption": "paychecks, refunds",

View File

@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import App from "@/App";
import { LoginGate } from "@/components/auth/LoginGate";
import "@/lib/theme";
import "@/index.css";
@@ -19,7 +20,9 @@ createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<LoginGate>
<App />
</LoginGate>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>