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

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",