feat(auth): add 'remember me' functionality and token refresh logic

This commit is contained in:
Daniel Volz
2025-12-27 21:59:21 +01:00
parent 65f007732a
commit cfb8494be3
8 changed files with 131 additions and 14 deletions
+84 -6
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, createContext, useContext, ReactNode } from "react";
import { useState, useEffect, createContext, useContext, ReactNode, useCallback } from "react";
import { useTranslation } from "react-i18next";
// =============================================================================
@@ -22,11 +22,12 @@ interface AuthContextType {
authState: AuthState | null;
loading: boolean;
authError: string | null;
login: (username: string, password: string) => Promise<void>;
login: (username: string, password: string, rememberMe?: boolean) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise<void>;
authFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
// =============================================================================
@@ -56,6 +57,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
fetchAuthState();
}, []);
// Proactively refresh token every 10 minutes to prevent expiration
useEffect(() => {
if (!user || !authState?.authEnabled) return;
const refreshInterval = setInterval(async () => {
const success = await tryRefreshToken();
if (!success) {
// Refresh failed - check if user is still valid
await refreshUser();
}
}, 10 * 60 * 1000); // 10 minutes (before 15 min access token expires)
return () => clearInterval(refreshInterval);
}, [user, authState?.authEnabled]);
async function fetchAuthState() {
try {
setAuthError(null);
@@ -84,6 +100,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (res.ok) {
const userData = await res.json();
setUser(userData);
} else if (res.status === 401) {
// Access token expired - try to refresh it
const refreshed = await tryRefreshToken();
if (refreshed) {
// Retry /auth/me with new token
const retryRes = await fetch("/api/auth/me", { credentials: "include" });
if (retryRes.ok) {
const userData = await retryRes.json();
setUser(userData);
return;
}
}
setUser(null);
} else {
setUser(null);
}
@@ -92,12 +121,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}
async function login(username: string, password: string) {
// Try to refresh the access token using the refresh token
async function tryRefreshToken(): Promise<boolean> {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
return res.ok;
} catch {
return false;
}
}
async function login(username: string, password: string, rememberMe: boolean = false) {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password }),
body: JSON.stringify({ username, password, rememberMe }),
});
if (!res.ok) {
@@ -153,8 +195,32 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await refreshUser();
}
// Fetch wrapper that automatically refreshes token on 401
const authFetch = useCallback(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const options: RequestInit = {
...init,
credentials: "include",
};
let res = await fetch(input, options);
// If 401 and not already a refresh/login request, try to refresh token
if (res.status === 401 && !String(input).includes("/auth/")) {
const refreshed = await tryRefreshToken();
if (refreshed) {
// Retry the original request with new token
res = await fetch(input, options);
} else {
// Refresh failed - user needs to login again
setUser(null);
}
}
return res;
}, []);
return (
<AuthContext.Provider value={{ user, authState, loading, authError, login, register, logout, refreshUser, updateProfile }}>
<AuthContext.Provider value={{ user, authState, loading, authError, login, register, logout, refreshUser, updateProfile, authFetch }}>
{children}
</AuthContext.Provider>
);
@@ -168,6 +234,7 @@ export function LoginForm({ onSuccess, onSwitchToRegister }: { onSuccess?: () =>
const { login, authState } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
@@ -177,7 +244,7 @@ export function LoginForm({ onSuccess, onSwitchToRegister }: { onSuccess?: () =>
setLoading(true);
try {
await login(username, password);
await login(username, password, rememberMe);
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
@@ -220,6 +287,17 @@ export function LoginForm({ onSuccess, onSwitchToRegister }: { onSuccess?: () =>
/>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
/>
<span>{t("auth.rememberMe", "Remember me")}</span>
</label>
</div>
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}>
{loading ? t("common.loading", "Loading...") : t("auth.login", "Login")}
</button>
+2 -1
View File
@@ -254,7 +254,8 @@
"resetEmailSent": "Falls ein Konto mit dieser E-Mail existiert, haben wir einen Link zum Zurücksetzen gesendet.",
"passwordReset": "Passwort zurückgesetzt",
"passwordResetSuccess": "Ihr Passwort wurde zurückgesetzt. Weiterleitung zur Anmeldung...",
"profileUpdated": "Profil erfolgreich aktualisiert"
"profileUpdated": "Profil erfolgreich aktualisiert",
"rememberMe": "Angemeldet bleiben"
},
"common": {
"loading": "Wird geladen...",
+2 -1
View File
@@ -256,7 +256,8 @@
"resetEmailSent": "If an account with this email exists, we've sent a password reset link.",
"passwordReset": "Password Reset",
"passwordResetSuccess": "Your password has been reset. Redirecting to login...",
"profileUpdated": "Profile updated successfully"
"profileUpdated": "Profile updated successfully",
"rememberMe": "Remember me"
},
"common": {
"loading": "Loading...",
+20
View File
@@ -2460,6 +2460,26 @@ h3 .reminder-icon.info-tooltip {
font-weight: 600;
}
.auth-form .checkbox-group {
margin-bottom: 0.5rem;
}
.auth-form .checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
color: var(--text-secondary);
}
.auth-form .checkbox-label input[type="checkbox"] {
width: 1rem;
height: 1rem;
accent-color: var(--accent);
cursor: pointer;
}
.auth-links {
display: flex;
flex-direction: column;