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 { t } = useTranslation();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [remember, setRemember] = useState(false);
|
||||||
const [status, setStatus] = useState<Status>("idle");
|
const [status, setStatus] = useState<Status>("idle");
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent) => {
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
@@ -24,7 +25,7 @@ export function LoginScreen() {
|
|||||||
setStatus("submitting");
|
setStatus("submitting");
|
||||||
const result = await checkCredentials(buildBasicAuthHeader(username, password));
|
const result = await checkCredentials(buildBasicAuthHeader(username, password));
|
||||||
if (result === "ok") {
|
if (result === "ok") {
|
||||||
setCredentials(username, password);
|
setCredentials(username, password, remember);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus(result === "unauthorized" ? "invalid" : "unreachable");
|
setStatus(result === "unauthorized" ? "invalid" : "unreachable");
|
||||||
@@ -66,6 +67,16 @@ export function LoginScreen() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 === "invalid" && <p className="text-sm text-danger">{t("auth.errorInvalidCredentials")}</p>}
|
||||||
{status === "unreachable" && <p className="text-sm text-danger">{t("auth.errorUnreachable")}</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
|
* this only avoids ever triggering that native prompt, by never letting an
|
||||||
* unauthenticated request happen without us attaching the header ourselves.
|
* unauthenticated request happen without us attaching the header ourselves.
|
||||||
*
|
*
|
||||||
* Kept in sessionStorage, not localStorage: closing the tab signs the user
|
* Two storage tiers, chosen at login time by the "remember me" checkbox:
|
||||||
* back out, which matters more for a finance app than staying logged in
|
* - sessionStorage (default): gone as soon as the tab closes.
|
||||||
* across days.
|
* - 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 {
|
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 {
|
} catch {
|
||||||
return null;
|
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();
|
let currentHeader: string | null = readStored();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
@@ -49,13 +85,20 @@ export function buildBasicAuthHeader(username: string, password: string): string
|
|||||||
return `Basic ${encodeUtf8Base64(`${username}:${password}`)}`;
|
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);
|
currentHeader = buildBasicAuthHeader(username, password);
|
||||||
try {
|
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 {
|
} catch {
|
||||||
// sessionStorage unavailable (private browsing, storage disabled) — the
|
// storage unavailable (private browsing, storage disabled) — the header
|
||||||
// header still works for the rest of this tab's life via the in-memory
|
// still works for the rest of this tab's life via the in-memory
|
||||||
// variable above, it just won't survive a refresh.
|
// variable above, it just won't survive a refresh.
|
||||||
}
|
}
|
||||||
notify();
|
notify();
|
||||||
@@ -68,7 +111,8 @@ export function clearCredentials(): void {
|
|||||||
if (currentHeader === null) return;
|
if (currentHeader === null) return;
|
||||||
currentHeader = null;
|
currentHeader = null;
|
||||||
try {
|
try {
|
||||||
sessionStorage.removeItem(STORAGE_KEY);
|
sessionStorage.removeItem(SESSION_KEY);
|
||||||
|
localStorage.removeItem(REMEMBER_KEY);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — nothing to clean up if storage was never usable
|
// ignore — nothing to clean up if storage was never usable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const ru = {
|
|||||||
"auth.subtitle": "Личные финансы",
|
"auth.subtitle": "Личные финансы",
|
||||||
"auth.usernameLabel": "Логин",
|
"auth.usernameLabel": "Логин",
|
||||||
"auth.passwordLabel": "Пароль",
|
"auth.passwordLabel": "Пароль",
|
||||||
|
"auth.rememberMe": "Запомнить меня на 30 дней",
|
||||||
"auth.submitButton": "Войти",
|
"auth.submitButton": "Войти",
|
||||||
"auth.submitting": "Вход…",
|
"auth.submitting": "Вход…",
|
||||||
"auth.errorInvalidCredentials": "Неверный логин или пароль",
|
"auth.errorInvalidCredentials": "Неверный логин или пароль",
|
||||||
@@ -453,6 +454,7 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"auth.subtitle": "Personal finance",
|
"auth.subtitle": "Personal finance",
|
||||||
"auth.usernameLabel": "Username",
|
"auth.usernameLabel": "Username",
|
||||||
"auth.passwordLabel": "Password",
|
"auth.passwordLabel": "Password",
|
||||||
|
"auth.rememberMe": "Remember me for 30 days",
|
||||||
"auth.submitButton": "Sign in",
|
"auth.submitButton": "Sign in",
|
||||||
"auth.submitting": "Signing in…",
|
"auth.submitting": "Signing in…",
|
||||||
"auth.errorInvalidCredentials": "Incorrect username or password",
|
"auth.errorInvalidCredentials": "Incorrect username or password",
|
||||||
|
|||||||
Reference in New Issue
Block a user