diff --git a/frontend/nginx.conf b/frontend/nginx.conf index be15acb..eb55a53 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; } diff --git a/frontend/src/api/backup.ts b/frontend/src/api/backup.ts index faf48f4..db6f85a 100644 --- a/frontend/src/api/backup.ts +++ b/frontend/src/api/backup.ts @@ -1,8 +1,15 @@ import { api } from "@/api/client"; +import { getAuthHeader } from "@/lib/auth"; import { t } from "@/lib/i18n"; export async function exportBackup(): Promise { - 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()); } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c44991e..16dc256 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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(path: string, init?: RequestInit): Promise { + 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; diff --git a/frontend/src/components/auth/LoginGate.tsx b/frontend/src/components/auth/LoginGate.tsx new file mode 100644 index 0000000..b8cbe91 --- /dev/null +++ b/frontend/src/components/auth/LoginGate.tsx @@ -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(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 ( +
+ +
+ ); + } + + if (probe === "required" && !authHeader) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/components/auth/LoginScreen.tsx b/frontend/src/components/auth/LoginScreen.tsx new file mode 100644 index 0000000..1cecdf6 --- /dev/null +++ b/frontend/src/components/auth/LoginScreen.tsx @@ -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("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 ( +
+ + +
+ + Aurum + {t("auth.subtitle")} +
+ +
+
+ + setUsername(event.target.value)} + required + /> +
+
+ + setPassword(event.target.value)} + required + /> +
+ + {status === "invalid" &&

{t("auth.errorInvalidCredentials")}

} + {status === "unreachable" &&

{t("auth.errorUnreachable")}

} + + +
+
+
+
+ ); +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..7809f6a --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -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 { + 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); +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index a8c2e10..66d85b9 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -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 = { "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", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e4ef2c9..99436b8 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( - + + +