diff --git a/frontend/src/components/auth/LoginGate.tsx b/frontend/src/components/auth/LoginGate.tsx index b8cbe91..deaee86 100644 --- a/frontend/src/components/auth/LoginGate.tsx +++ b/frontend/src/components/auth/LoginGate.tsx @@ -1,9 +1,17 @@ import { type ReactNode, useEffect, useState } from "react"; import { Logo } from "@/components/layout/Logo"; import { LoginScreen } from "@/components/auth/LoginScreen"; +import { NoAuthBanner } from "@/components/auth/NoAuthBanner"; import { checkCredentials, useAuthHeader } from "@/lib/auth"; -type Probe = "checking" | "required" | "not-required"; +// "not-configured" and "authenticated" both render `children` the same way, +// but they must stay distinct: only "not-configured" means this instance has +// no AURUM_BASIC_AUTH_USER/PASSWORD at all (nginx never sent 401 to our +// deliberately-wrong probe header), which is what triggers NoAuthBanner. +// Collapsing them back into one "not-required" state would make an +// authenticated session with valid stored credentials show the "this +// instance has no password" warning too. +type Probe = "checking" | "required" | "not-configured" | "authenticated"; /** Wraps the whole app. Renders the login screen only when this instance * actually has Basic Auth turned on (AURUM_BASIC_AUTH_USER/PASSWORD in @@ -14,11 +22,11 @@ type Probe = "checking" | "required" | "not-required"; * 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"); + const [probe, setProbe] = useState(authHeader ? "authenticated" : "checking"); useEffect(() => { if (authHeader) { - setProbe("not-required"); + setProbe("authenticated"); return; } let cancelled = false; @@ -27,7 +35,7 @@ export function LoginGate({ children }: { children: ReactNode }) { // 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"); + setProbe(result === "unauthorized" ? "required" : "not-configured"); }); return () => { cancelled = true; @@ -46,5 +54,10 @@ export function LoginGate({ children }: { children: ReactNode }) { return ; } - return <>{children}; + return ( + <> + {probe === "not-configured" && } + {children} + + ); } diff --git a/frontend/src/components/auth/NoAuthBanner.tsx b/frontend/src/components/auth/NoAuthBanner.tsx new file mode 100644 index 0000000..227cd75 --- /dev/null +++ b/frontend/src/components/auth/NoAuthBanner.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import { TriangleAlert, X } from "lucide-react"; +import { useTranslation } from "@/lib/i18n"; + +const DISMISS_KEY = "aurum:noAuthBanner:dismissed"; + +/** Shown by LoginGate for the entire session whenever this instance has no + * AURUM_BASIC_AUTH_USER/PASSWORD configured (see frontend/docker-entrypoint.d/ + * 20-basic-auth.sh) — that state is otherwise only logged to the container's + * stderr at startup, which most self-hosters never look at. Dismissal is + * remembered only in sessionStorage, not localStorage, so the warning comes + * back on the next visit instead of being silence-able forever. */ +export function NoAuthBanner() { + const { t } = useTranslation(); + const [dismissed, setDismissed] = useState(() => { + try { + return sessionStorage.getItem(DISMISS_KEY) === "1"; + } catch { + return false; + } + }); + + if (dismissed) return null; + + function handleDismiss() { + setDismissed(true); + try { + sessionStorage.setItem(DISMISS_KEY, "1"); + } catch { + // storage unavailable — banner just reappears on next render, harmless + } + } + + return ( +
+ + {t("auth.noAuthBanner")} + +
+ ); +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index a11a804..6d3a2f8 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -54,6 +54,8 @@ const ru = { "auth.submitting": "Вход…", "auth.errorInvalidCredentials": "Неверный логин или пароль", "auth.errorUnreachable": "Не удалось подключиться к серверу. Проверьте соединение и попробуйте снова.", + "auth.noAuthBanner": "Этот экземпляр Aurum не защищён паролем — все данные доступны любому, кто до него достучится. Задайте AURUM_BASIC_AUTH_USER и AURUM_BASIC_AUTH_PASSWORD в .env, чтобы включить вход.", + "auth.noAuthBannerDismiss": "Понятно", "dashboard.errorLoading": "Не удалось загрузить данные дашборда. Проверьте, что backend запущен.", "dashboard.statRealIncomeLabel": "Реальный доход", @@ -558,6 +560,8 @@ const en: Record = { "auth.submitting": "Signing in…", "auth.errorInvalidCredentials": "Incorrect username or password", "auth.errorUnreachable": "Couldn't reach the server. Check your connection and try again.", + "auth.noAuthBanner": "This Aurum instance has no password protection — all data is accessible to anyone who can reach it. Set AURUM_BASIC_AUTH_USER and AURUM_BASIC_AUTH_PASSWORD in .env to enable login.", + "auth.noAuthBannerDismiss": "Got it", "dashboard.errorLoading": "Failed to load dashboard data. Check that the backend is running.", "dashboard.statRealIncomeLabel": "Real income",