mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: add "remember me for 30 days" to the login screen
Default stays sessionStorage (signed out on tab close). Checking the box instead stores the credential in localStorage with an explicit 30-day expiry checked on every page load, so staying logged in has a hard ceiling rather than lasting forever. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
This commit is contained in:
@@ -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<Status>("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() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(event) => setRemember(event.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-text-primary"
|
||||
/>
|
||||
{t("auth.rememberMe")}
|
||||
</label>
|
||||
|
||||
{status === "invalid" && <p className="text-sm text-danger">{t("auth.errorInvalidCredentials")}</p>}
|
||||
{status === "unreachable" && <p className="text-sm text-danger">{t("auth.errorUnreachable")}</p>}
|
||||
|
||||
|
||||
@@ -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<RememberedEntry>;
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<keyof typeof ru, string> = {
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user