feat(auth): add "Remember me" checkbox to extend session lifetime (#1189)

Adds a "Remember me" checkbox to the login form (single responsive page,
covers mobile + desktop). Unchecked (default) issues the existing
SESSION_DURATION JWT with a browser-session cookie (no maxAge); checked
issues a longer-lived JWT plus a persistent cookie sized by the new
SESSION_DURATION_REMEMBER env var (default 30d). The choice is threaded
through the MFA verify leg so it survives the step-up.

Register/demo logins keep their current persistent behaviour.
This commit is contained in:
jubnl
2026-06-15 12:21:05 +02:00
committed by GitHub
parent 2d413c99cf
commit bf969ee80d
34 changed files with 184 additions and 32 deletions
+32
View File
@@ -103,6 +103,38 @@ describe('LoginPage', () => {
});
});
describe('FE-PAGE-LOGIN-007: Remember me sends remember_me to the API', () => {
it('renders an unchecked checkbox and forwards remember_me: true when ticked', async () => {
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/auth/login', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ user: { id: 1, username: 'test', email: 'test@example.com', role: 'user' } });
}),
);
const user = userEvent.setup();
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
});
const checkbox = screen.getByRole('checkbox', { name: /remember me/i });
expect(checkbox).not.toBeChecked();
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
await user.click(checkbox);
expect(checkbox).toBeChecked();
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(capturedBody).toEqual(expect.objectContaining({ remember_me: true }));
});
});
});
describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => {
it('shows a Register button to switch to registration mode', async () => {
// Default appConfig has allow_registration: true, has_users: true
+11 -2
View File
@@ -9,7 +9,7 @@ export default function LoginPage(): React.ReactElement {
const {
navigate,
mode, setMode,
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
@@ -572,7 +572,16 @@ export default function LoginPage(): React.ReactElement {
</button>
</div>
{mode === 'login' && (
<div style={{ textAlign: 'right', marginTop: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginTop: 8 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 7, cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500 }}>
<input
type="checkbox"
checked={rememberMe}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRememberMe(e.target.checked)}
style={{ width: 15, height: 15, accentColor: '#111827', cursor: 'pointer', flexShrink: 0 }}
/>
{t('login.rememberMe')}
</label>
<button type="button" onClick={() => navigate('/forgot-password')} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
+4 -3
View File
@@ -37,6 +37,7 @@ export function useLogin() {
const [username, setUsername] = useState<string>('')
const [email, setEmail] = useState<string>('')
const [password, setPassword] = useState<string>('')
const [rememberMe, setRememberMe] = useState<boolean>(false)
const [showPassword, setShowPassword] = useState<boolean>(false)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
@@ -242,7 +243,7 @@ export function useLogin() {
setIsLoading(false)
return
}
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
const mfaResult = await completeMfaLogin(mfaToken, mfaCode, rememberMe)
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
setSavedLoginPassword(password)
setPasswordChangeStep(true)
@@ -258,7 +259,7 @@ export function useLogin() {
if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return }
await register(username, email, password, inviteToken || undefined)
} else {
const result = await login(email, password)
const result = await login(email, password, rememberMe)
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
setMfaToken(result.mfa_token)
setMfaStep(true)
@@ -289,7 +290,7 @@ export function useLogin() {
return {
navigate,
mode, setMode,
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
isLoading, error, setError, appConfig, inviteToken,
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
+6 -6
View File
@@ -39,8 +39,8 @@ interface AuthState {
placesAutocompleteEnabled: boolean
placesDetailsEnabled: boolean
login: (email: string, password: string) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string, rememberMe?: boolean) => Promise<AuthResponse>
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
logout: () => Promise<void>
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
@@ -99,11 +99,11 @@ export const useAuthStore = create<AuthState>()(
placesAutocompleteEnabled: true,
placesDetailsEnabled: true,
login: async (email: string, password: string) => {
login: async (email: string, password: string, rememberMe?: boolean) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
const data = await authApi.login({ email, password, remember_me: rememberMe }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
if (data.mfa_required && data.mfa_token) {
set({ isLoading: false, error: null })
return { mfa_required: true as const, mfa_token: data.mfa_token }
@@ -128,11 +128,11 @@ export const useAuthStore = create<AuthState>()(
}
},
completeMfaLogin: async (mfaToken: string, code: string) => {
completeMfaLogin: async (mfaToken: string, code: string, rememberMe?: boolean) => {
authSequence++
set({ isLoading: true, error: null })
try {
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, ''), remember_me: rememberMe })
set({
user: data.user,
isAuthenticated: true,