mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
50
frontend/src/components/auth/LoginGate.tsx
Normal file
50
frontend/src/components/auth/LoginGate.tsx
Normal 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}</>;
|
||||
}
|
||||
80
frontend/src/components/auth/LoginScreen.tsx
Normal file
80
frontend/src/components/auth/LoginScreen.tsx
Normal 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
115
frontend/src/lib/auth.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user