diff --git a/frontend/src/components/auth/LoginScreen.tsx b/frontend/src/components/auth/LoginScreen.tsx index 1cecdf6..f503358 100644 --- a/frontend/src/components/auth/LoginScreen.tsx +++ b/frontend/src/components/auth/LoginScreen.tsx @@ -17,6 +17,7 @@ export function LoginScreen() { const { t } = useTranslation(); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); + const [remember, setRemember] = useState(false); const [status, setStatus] = useState("idle"); const handleSubmit = async (event: FormEvent) => { @@ -24,7 +25,7 @@ export function LoginScreen() { setStatus("submitting"); const result = await checkCredentials(buildBasicAuthHeader(username, password)); if (result === "ok") { - setCredentials(username, password); + setCredentials(username, password, remember); return; } setStatus(result === "unauthorized" ? "invalid" : "unreachable"); @@ -66,6 +67,16 @@ export function LoginScreen() { /> + + {status === "invalid" &&

{t("auth.errorInvalidCredentials")}

} {status === "unreachable" &&

{t("auth.errorUnreachable")}

} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 7809f6a..f7ca478 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -9,20 +9,56 @@ import { useSyncExternalStore } from "react"; * 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. + * Two storage tiers, chosen at login time by the "remember me" checkbox: + * - sessionStorage (default): gone as soon as the tab closes. + * - localStorage, with an explicit expiry stamped into the stored value: + * survives closing the tab/browser, but only for REMEMBER_DAYS — an + * unbounded "stay logged in forever" is too much for a finance app. + * The expiry is only checked when this module loads (i.e. on page load/ + * reload) — a tab left open across the expiry moment without reloading + * keeps working until its next reload or a 401 forces a fresh check. */ -const STORAGE_KEY = "aurum:basicAuth"; +const SESSION_KEY = "aurum:basicAuth"; +const REMEMBER_KEY = "aurum:basicAuth:remember"; +const REMEMBER_DAYS = 30; +const REMEMBER_MS = REMEMBER_DAYS * 24 * 60 * 60 * 1000; -function readStored(): string | null { +interface RememberedEntry { + header: string; + expiresAt: number; // epoch ms +} + +function readRemembered(): string | null { try { - return sessionStorage.getItem(STORAGE_KEY); + const raw = localStorage.getItem(REMEMBER_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.header !== "string" || typeof parsed.expiresAt !== "number") { + localStorage.removeItem(REMEMBER_KEY); + return null; + } + if (Date.now() >= parsed.expiresAt) { + localStorage.removeItem(REMEMBER_KEY); + return null; + } + return parsed.header; } catch { return null; } } +function readSession(): string | null { + try { + return sessionStorage.getItem(SESSION_KEY); + } catch { + return null; + } +} + +function readStored(): string | null { + return readRemembered() ?? readSession(); +} + let currentHeader: string | null = readStored(); const listeners = new Set<() => void>(); @@ -49,13 +85,20 @@ export function buildBasicAuthHeader(username: string, password: string): string return `Basic ${encodeUtf8Base64(`${username}:${password}`)}`; } -export function setCredentials(username: string, password: string): void { +export function setCredentials(username: string, password: string, remember: boolean): void { currentHeader = buildBasicAuthHeader(username, password); try { - sessionStorage.setItem(STORAGE_KEY, currentHeader); + if (remember) { + const entry: RememberedEntry = { header: currentHeader, expiresAt: Date.now() + REMEMBER_MS }; + localStorage.setItem(REMEMBER_KEY, JSON.stringify(entry)); + sessionStorage.removeItem(SESSION_KEY); + } else { + sessionStorage.setItem(SESSION_KEY, currentHeader); + localStorage.removeItem(REMEMBER_KEY); + } } catch { - // sessionStorage unavailable (private browsing, storage disabled) — the - // header still works for the rest of this tab's life via the in-memory + // storage 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(); @@ -68,7 +111,8 @@ export function clearCredentials(): void { if (currentHeader === null) return; currentHeader = null; try { - sessionStorage.removeItem(STORAGE_KEY); + sessionStorage.removeItem(SESSION_KEY); + localStorage.removeItem(REMEMBER_KEY); } catch { // ignore — nothing to clean up if storage was never usable } diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 66d85b9..6b5ff91 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -46,6 +46,7 @@ const ru = { "auth.subtitle": "Личные финансы", "auth.usernameLabel": "Логин", "auth.passwordLabel": "Пароль", + "auth.rememberMe": "Запомнить меня на 30 дней", "auth.submitButton": "Войти", "auth.submitting": "Вход…", "auth.errorInvalidCredentials": "Неверный логин или пароль", @@ -453,6 +454,7 @@ const en: Record = { "auth.subtitle": "Personal finance", "auth.usernameLabel": "Username", "auth.passwordLabel": "Password", + "auth.rememberMe": "Remember me for 30 days", "auth.submitButton": "Sign in", "auth.submitting": "Signing in…", "auth.errorInvalidCredentials": "Incorrect username or password",