mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 11:06:40 +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;
|
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 / {
|
location / {
|
||||||
include /etc/nginx/basic-auth.conf;
|
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { api } from "@/api/client";
|
import { api } from "@/api/client";
|
||||||
|
import { getAuthHeader } from "@/lib/auth";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
|
|
||||||
export async function exportBackup(): Promise<void> {
|
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) {
|
if (!response.ok) {
|
||||||
throw new Error(await response.text());
|
throw new Error(await response.text());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { clearCredentials, getAuthHeader } from "@/lib/auth";
|
||||||
|
|
||||||
const API_BASE = "/api";
|
const API_BASE = "/api";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -9,11 +11,22 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const authHeader = getAuthHeader();
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(authHeader ? { Authorization: authHeader } : {}),
|
||||||
|
},
|
||||||
...init,
|
...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) {
|
if (!response.ok) {
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
let message = body || response.statusText;
|
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.perMonth": "/мес",
|
||||||
"common.allShort": "Всё",
|
"common.allShort": "Всё",
|
||||||
|
|
||||||
|
"auth.subtitle": "Личные финансы",
|
||||||
|
"auth.usernameLabel": "Логин",
|
||||||
|
"auth.passwordLabel": "Пароль",
|
||||||
|
"auth.submitButton": "Войти",
|
||||||
|
"auth.submitting": "Вход…",
|
||||||
|
"auth.errorInvalidCredentials": "Неверный логин или пароль",
|
||||||
|
"auth.errorUnreachable": "Не удалось подключиться к серверу. Проверьте соединение и попробуйте снова.",
|
||||||
|
|
||||||
"dashboard.errorLoading": "Не удалось загрузить данные дашборда. Проверьте, что backend запущен.",
|
"dashboard.errorLoading": "Не удалось загрузить данные дашборда. Проверьте, что backend запущен.",
|
||||||
"dashboard.statRealIncomeLabel": "Реальный доход",
|
"dashboard.statRealIncomeLabel": "Реальный доход",
|
||||||
"dashboard.statRealIncomeCaption": "зарплаты, возвраты",
|
"dashboard.statRealIncomeCaption": "зарплаты, возвраты",
|
||||||
@@ -442,6 +450,14 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"common.perMonth": "/mo",
|
"common.perMonth": "/mo",
|
||||||
"common.allShort": "All",
|
"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.errorLoading": "Failed to load dashboard data. Check that the backend is running.",
|
||||||
"dashboard.statRealIncomeLabel": "Real income",
|
"dashboard.statRealIncomeLabel": "Real income",
|
||||||
"dashboard.statRealIncomeCaption": "paychecks, refunds",
|
"dashboard.statRealIncomeCaption": "paychecks, refunds",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "@/App";
|
import App from "@/App";
|
||||||
|
import { LoginGate } from "@/components/auth/LoginGate";
|
||||||
import "@/lib/theme";
|
import "@/lib/theme";
|
||||||
import "@/index.css";
|
import "@/index.css";
|
||||||
|
|
||||||
@@ -19,7 +20,9 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<LoginGate>
|
||||||
|
<App />
|
||||||
|
</LoginGate>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
|
|||||||
Reference in New Issue
Block a user