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,
+18
View File
@@ -136,3 +136,21 @@ export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATI
export const SESSION_DURATION_MS = parsedSessionMs ?? parseDurationMs(DEFAULT_SESSION_DURATION)!;
/** Session length in seconds — passed to `jwt.sign({ expiresIn })` (number = seconds). */
export const SESSION_DURATION_SECONDS = Math.floor(SESSION_DURATION_MS / 1000);
// SESSION_DURATION_REMEMBER is the session length used when the user ticks
// "Remember me" on the login form: a longer-lived JWT `exp` claim plus a
// persistent `trek_session` cookie `maxAge`. An unticked login keeps
// SESSION_DURATION and a browser-session cookie (no `maxAge`). Same ms-style
// format and fallback behavior as SESSION_DURATION.
const DEFAULT_SESSION_DURATION_REMEMBER = '30d';
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
const parsedRememberMs = parseDurationMs(rawRememberDuration);
if (parsedRememberMs == null) {
console.warn(`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`);
}
/** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */
export const SESSION_DURATION_REMEMBER = parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
/** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
export const SESSION_DURATION_REMEMBER_SECONDS = Math.floor(SESSION_DURATION_REMEMBER_MS / 1000);
@@ -87,7 +87,7 @@ export class AuthPublicController {
if (result.mfa_required) {
return { mfa_required: true, mfa_token: result.mfa_token };
}
this.auth.setAuthCookie(res, result.token!, req);
this.auth.setAuthCookie(res, result.token!, req, result.remember);
return { token: result.token, user: result.user };
}
@@ -146,7 +146,7 @@ export class AuthPublicController {
throw new HttpException({ error: result.error }, result.status!);
}
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
this.auth.setAuthCookie(res, result.token!, req);
this.auth.setAuthCookie(res, result.token!, req, result.remember);
return { token: result.token, user: result.user };
}
+1 -1
View File
@@ -14,7 +14,7 @@ import type { User } from '../../types';
@Injectable()
export class AuthService {
// Cookie
setAuthCookie(res: Response, token: string, req: Request) { setAuthCookie(res, token, req); }
setAuthCookie(res: Response, token: string, req: Request, remember?: boolean) { setAuthCookie(res, token, req, remember); }
clearAuthCookie(res: Response, req: Request) { clearAuthCookie(res, req); }
// Reset-email delivery (canonical app URL, never request headers)
+18 -7
View File
@@ -7,7 +7,7 @@ import { authenticator } from 'otplib';
import QRCode from 'qrcode';
import { randomBytes, createHash } from 'crypto';
import { db } from '../db/database';
import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config';
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../config';
import { validatePassword } from './passwordPolicy';
import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto';
import { getAllPermissions } from './permissions';
@@ -181,14 +181,17 @@ export function isOidcOnlyMode(): boolean {
return !resolveAuthToggles().password_login;
}
export function generateToken(user: { id: number | bigint; password_version?: number }) {
export function generateToken(user: { id: number | bigint; password_version?: number }, rememberMe = false) {
const pv = typeof user.password_version === 'number'
? user.password_version
: ((db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0);
// "Remember me" extends the JWT lifetime to match the persistent cookie maxAge;
// the cookie service decides session-vs-persistent off the same flag.
const expiresIn = rememberMe ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS;
return jwt.sign(
{ id: user.id, pv },
JWT_SECRET,
{ expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' }
{ expiresIn, algorithm: 'HS256' }
);
}
@@ -443,6 +446,7 @@ export function registerUser(body: {
export function loginUser(body: {
email?: string;
password?: string;
remember_me?: boolean;
}): {
error?: string;
status?: number;
@@ -450,6 +454,7 @@ export function loginUser(body: {
user?: Record<string, unknown>;
mfa_required?: boolean;
mfa_token?: string;
remember?: boolean;
auditUserId?: number | null;
auditAction?: string;
auditDetails?: Record<string, unknown>;
@@ -458,7 +463,8 @@ export function loginUser(body: {
return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 };
}
const { email, password } = body;
const { email, password, remember_me } = body;
const remember = remember_me === true;
if (!email || !password) {
return { error: 'Email and password are required', status: 400 };
}
@@ -500,12 +506,13 @@ export function loginUser(body: {
}
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
const token = generateToken(user);
const token = generateToken(user, remember);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
return {
token,
user: { ...userSafe, avatar_url: avatarUrl(user) },
remember,
auditUserId: Number(user.id),
auditAction: 'user.login',
auditDetails: { email },
@@ -1066,14 +1073,17 @@ export function disableMfa(
export function verifyMfaLogin(body: {
mfa_token?: string;
code?: string;
remember_me?: boolean;
}): {
error?: string;
status?: number;
token?: string;
user?: Record<string, unknown>;
remember?: boolean;
auditUserId?: number;
} {
const { mfa_token, code } = body;
const { mfa_token, code, remember_me } = body;
const remember = remember_me === true;
if (!mfa_token || !code) {
return { error: 'Verification token and code are required', status: 400 };
}
@@ -1104,11 +1114,12 @@ export function verifyMfaLogin(body: {
);
}
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
const sessionToken = generateToken(user);
const sessionToken = generateToken(user, remember);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
return {
token: sessionToken,
user: { ...userSafe, avatar_url: avatarUrl(user) },
remember,
auditUserId: Number(user.id),
};
} catch {
+25 -8
View File
@@ -1,8 +1,17 @@
import { Request, Response } from 'express';
import { SESSION_DURATION_MS } from '../config';
import { SESSION_DURATION_MS, SESSION_DURATION_REMEMBER_MS } from '../config';
const COOKIE_NAME = 'trek_session';
/**
* Controls the cookie lifetime for a login:
* - `undefined` → persistent `maxAge: SESSION_DURATION_MS` (the historical
* default, used by register/demo and anything that doesn't opt in).
* - `true` → persistent `maxAge: SESSION_DURATION_REMEMBER_MS` ("Remember me").
* - `false` → no `maxAge` — a browser-session cookie cleared on browser close.
*/
export type RememberOption = boolean | undefined;
/**
* Decide whether the session cookie should carry the `Secure` flag.
*
@@ -18,27 +27,35 @@ const COOKIE_NAME = 'trek_session';
* on the outermost hop, the cookie is `Secure`. `COOKIE_SECURE=false`
* remains the explicit escape hatch for plain-HTTP LAN testing.
*/
export function cookieOptions(clear = false, req?: Request) {
export function cookieOptions(clear = false, req?: Request, remember?: RememberOption) {
if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') {
return buildOptions(clear, false);
return buildOptions(clear, false, remember);
}
const envSecure = process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === 'true';
const requestSecure = req?.secure === true;
return buildOptions(clear, envSecure || requestSecure);
return buildOptions(clear, envSecure || requestSecure, remember);
}
function buildOptions(clear: boolean, secure: boolean) {
function resolveMaxAge(remember: RememberOption): { maxAge: number } | Record<string, never> {
// false → session cookie (omit maxAge); true → the longer "remember me"
// window; undefined → the historical default. Each maxAge matches the JWT exp.
if (remember === false) return {};
if (remember === true) return { maxAge: SESSION_DURATION_REMEMBER_MS };
return { maxAge: SESSION_DURATION_MS };
}
function buildOptions(clear: boolean, secure: boolean, remember?: RememberOption) {
return {
httpOnly: true,
secure,
sameSite: 'lax' as const,
path: '/',
...(clear ? {} : { maxAge: SESSION_DURATION_MS }), // matches the JWT expiry (SESSION_DURATION)
...(clear ? {} : resolveMaxAge(remember)),
};
}
export function setAuthCookie(res: Response, token: string, req?: Request): void {
res.cookie(COOKIE_NAME, token, cookieOptions(false, req));
export function setAuthCookie(res: Response, token: string, req?: Request, remember?: RememberOption): void {
res.cookie(COOKIE_NAME, token, cookieOptions(false, req, remember));
}
export function clearAuthCookie(res: Response, req?: Request): void {
+22
View File
@@ -98,6 +98,28 @@ describe('Auth e2e (real auth guard + real cookie service + temp SQLite)', () =>
expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true);
}, 10000);
it('POST /login with remember_me sets a persistent cookie (Max-Age present)', async () => {
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 }, remember: true });
const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw', remember_me: true });
expect(res.status).toBe(200);
const setCookie = res.headers['set-cookie'] as unknown as string[];
const cookie = setCookie.find((c) => c.startsWith('trek_session='))!;
expect(cookie).toMatch(/Max-Age=\d+/i);
// 30d default — well above the 24h (86400s) non-remember window.
const maxAge = Number(/Max-Age=(\d+)/i.exec(cookie)?.[1]);
expect(maxAge).toBeGreaterThan(86_400);
}, 10000);
it('POST /login without remember_me sets a session cookie (no Max-Age)', async () => {
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 }, remember: false });
const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw' });
expect(res.status).toBe(200);
const setCookie = res.headers['set-cookie'] as unknown as string[];
const cookie = setCookie.find((c) => c.startsWith('trek_session='))!;
expect(cookie).not.toMatch(/Max-Age/i);
expect(cookie).not.toMatch(/Expires/i);
}, 10000);
it('POST /logout clears the session cookie', async () => {
const res = await request(server).post('/api/auth/logout');
expect(res.status).toBe(200);
@@ -82,9 +82,10 @@ describe('AuthPublicController', () => {
const setAuthCookie = vi.fn();
const mfa = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial<AuthService>), rl());
expect(await mfa.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
const ok = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial<AuthService>), rl());
const ok = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user, remember: true }), setAuthCookie } as Partial<AuthService>), rl());
expect(await ok.login({}, req, res)).toEqual({ token: 'tk', user });
expect(setAuthCookie).toHaveBeenCalled();
// The "remember me" flag from the service rides through to the cookie service.
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req, true);
const bad = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial<AuthService>), rl());
expect(await thrownAsync(() => bad.login({}, req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } });
}, 10000);
+13
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cookieOptions } from '../../../src/services/cookie';
import { SESSION_DURATION_MS, SESSION_DURATION_REMEMBER_MS } from '../../../src/config';
describe('cookieOptions', () => {
afterEach(() => {
@@ -53,4 +54,16 @@ describe('cookieOptions', () => {
const opts = cookieOptions(true);
expect(opts).not.toHaveProperty('maxAge');
});
it('keeps the default SESSION_DURATION maxAge when remember is undefined', () => {
expect(cookieOptions(false, undefined)).toHaveProperty('maxAge', SESSION_DURATION_MS);
});
it('uses the longer SESSION_DURATION_REMEMBER maxAge when remember is true', () => {
expect(cookieOptions(false, undefined, true)).toHaveProperty('maxAge', SESSION_DURATION_REMEMBER_MS);
});
it('omits maxAge (session cookie) when remember is false', () => {
expect(cookieOptions(false, undefined, false)).not.toHaveProperty('maxAge');
});
});
+7
View File
@@ -19,6 +19,10 @@ export type RegisterRequest = z.infer<typeof registerRequestSchema>;
export const loginRequestSchema = z.object({
email: z.string(),
password: z.string(),
// "Remember me" — when true the server issues a longer-lived
// (SESSION_DURATION_REMEMBER) JWT + persistent cookie; when false/absent the
// session lasts SESSION_DURATION and the cookie is a browser-session cookie.
remember_me: z.boolean().optional(),
});
export type LoginRequest = z.infer<typeof loginRequestSchema>;
@@ -45,6 +49,9 @@ export type ChangePasswordRequest = z.infer<typeof changePasswordRequestSchema>;
export const mfaVerifyLoginRequestSchema = z.object({
mfa_token: z.string(),
code: z.string(),
// Carries the login-form "Remember me" choice through the second (MFA) leg,
// since the session token is only minted once the MFA code is verified.
remember_me: z.boolean().optional(),
});
export type MfaVerifyLoginRequest = z.infer<typeof mfaVerifyLoginRequestSchema>;
+1
View File
@@ -59,6 +59,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'اسم المستخدم مطلوب',
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
'login.forgotPassword': 'نسيت كلمة المرور؟',
'login.rememberMe': 'تذكرني',
'login.forgotPasswordTitle': 'إعادة تعيين كلمة المرور',
'login.forgotPasswordBody':
'أدخل عنوان البريد الإلكتروني المسجَّل. إذا كان الحساب موجودًا، سنرسل رابط إعادة التعيين.',
+1
View File
@@ -62,6 +62,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Nome de usuário é obrigatório',
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
'login.forgotPassword': 'Esqueceu a senha?',
'login.rememberMe': 'Lembrar de mim',
'login.forgotPasswordTitle': 'Redefinir sua senha',
'login.forgotPasswordBody':
'Digite o e-mail cadastrado. Se houver uma conta, enviaremos um link de redefinição.',
+1
View File
@@ -64,6 +64,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Uživatelské jméno je povinné',
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
'login.forgotPassword': 'Zapomenuté heslo?',
'login.rememberMe': 'Zapamatovat si mě',
'login.forgotPasswordTitle': 'Obnovení hesla',
'login.forgotPasswordBody':
'Zadej e-mail použitý při registraci. Pokud účet existuje, pošleme odkaz pro obnovení.',
+1
View File
@@ -65,6 +65,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Benutzername ist erforderlich',
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
'login.forgotPassword': 'Passwort vergessen?',
'login.rememberMe': 'Angemeldet bleiben',
'login.forgotPasswordTitle': 'Passwort zurücksetzen',
'login.forgotPasswordBody':
'Gib die E-Mail-Adresse deines Kontos ein. Falls ein Konto existiert, schicken wir dir einen Reset-Link.',
+1
View File
@@ -63,6 +63,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Username is required',
'login.passwordMinLength': 'Password must be at least 8 characters',
'login.forgotPassword': 'Forgot password?',
'login.rememberMe': 'Remember me',
'login.forgotPasswordTitle': 'Reset your password',
'login.forgotPasswordBody':
"Enter the email address you signed up with. If an account exists, we'll send a reset link.",
+1
View File
@@ -57,6 +57,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'El nombre de usuario es obligatorio',
'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
'login.forgotPassword': '¿Olvidaste tu contraseña?',
'login.rememberMe': 'Recuérdame',
'login.forgotPasswordTitle': 'Restablecer tu contraseña',
'login.forgotPasswordBody':
'Introduce la dirección de correo con la que te registraste. Si existe una cuenta, enviaremos un enlace.',
+1
View File
@@ -60,6 +60,7 @@ const login: TranslationStrings = {
'login.passwordMinLength':
'Le mot de passe doit comporter au moins 8 caractères',
'login.forgotPassword': 'Mot de passe oublié ?',
'login.rememberMe': 'Se souvenir de moi',
'login.forgotPasswordTitle': 'Réinitialiser votre mot de passe',
'login.forgotPasswordBody':
"Entrez l'adresse e-mail associée à votre compte. Si un compte existe, nous enverrons un lien de réinitialisation.",
+1
View File
@@ -70,6 +70,7 @@ const login: TranslationStrings = {
'login.passwordMinLength':
'Ο κωδικός πρέπει να έχει τουλάχιστον 8 χαρακτήρες',
'login.forgotPassword': 'Ξεχάσατε τον κωδικό;',
'login.rememberMe': 'Να με θυμάσαι',
'login.forgotPasswordTitle': 'Επαναφορά του κωδικού σας',
'login.forgotPasswordBody':
'Εισάγετε το email με το οποίο εγγραφήκατε. Αν υπάρχει λογαριασμός, θα στείλουμε έναν σύνδεσμο επαναφοράς.',
+1
View File
@@ -69,6 +69,7 @@ const login: TranslationStrings = {
'login.passwordMinLength':
'A jelszónak legalább 8 karakter hosszúnak kell lennie',
'login.forgotPassword': 'Elfelejtetted a jelszavad?',
'login.rememberMe': 'Emlékezz rám',
'login.forgotPasswordTitle': 'Jelszó visszaállítása',
'login.forgotPasswordBody':
'Írd be a regisztrációnál használt e-mail-címet. Ha létezik fiók, küldünk egy visszaállítási linket.',
+1
View File
@@ -65,6 +65,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Nama pengguna wajib diisi',
'login.passwordMinLength': 'Kata sandi minimal 8 karakter',
'login.forgotPassword': 'Lupa kata sandi?',
'login.rememberMe': 'Ingat saya',
'login.forgotPasswordTitle': 'Setel ulang kata sandi',
'login.forgotPasswordBody':
'Masukkan alamat email akunmu. Jika akun ada, kami akan mengirim tautan reset.',
+1
View File
@@ -64,6 +64,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Il nome utente è obbligatorio',
'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri',
'login.forgotPassword': 'Password dimenticata?',
'login.rememberMe': 'Ricordami',
'login.forgotPasswordTitle': 'Reimposta la password',
'login.forgotPasswordBody':
'Inserisci lindirizzo email del tuo account. Se esiste un account, invieremo un link per reimpostarla.',
+1
View File
@@ -63,6 +63,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'ユーザー名を入力してください',
'login.passwordMinLength': 'パスワードは8文字以上である必要があります',
'login.forgotPassword': 'パスワードを忘れた場合',
'login.rememberMe': 'ログイン状態を保持する',
'login.forgotPasswordTitle': 'パスワードをリセット',
'login.forgotPasswordBody':
'登録時のメールアドレスを入力してください。アカウントが存在する場合、リセット用リンクを送信します。',
+1
View File
@@ -62,6 +62,7 @@ const login: TranslationStrings = {
'login.usernameRequired': '사용자 이름을 입력하세요',
'login.passwordMinLength': '비밀번호는 최소 8자 이상이어야 합니다',
'login.forgotPassword': '비밀번호를 잊으셨나요?',
'login.rememberMe': '로그인 상태 유지',
'login.forgotPasswordTitle': '비밀번호 재설정',
'login.forgotPasswordBody':
'가입 시 사용한 이메일 주소를 입력하세요. 계정이 존재하면 재설정 링크를 보내드립니다.',
+1
View File
@@ -56,6 +56,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Gebruikersnaam is vereist',
'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten',
'login.forgotPassword': 'Wachtwoord vergeten?',
'login.rememberMe': 'Ingelogd blijven',
'login.forgotPasswordTitle': 'Wachtwoord resetten',
'login.forgotPasswordBody':
'Voer het e-mailadres van je account in. Als er een account bestaat, sturen we een resetlink.',
+1
View File
@@ -65,6 +65,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Nazwa użytkownika jest wymagana',
'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków',
'login.forgotPassword': 'Nie pamiętasz hasła?',
'login.rememberMe': 'Zapamiętaj mnie',
'login.forgotPasswordTitle': 'Zresetuj hasło',
'login.forgotPasswordBody':
'Wpisz adres e-mail użyty przy rejestracji. Jeśli konto istnieje, wyślemy link do resetu.',
+1
View File
@@ -56,6 +56,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Имя пользователя обязательно',
'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов',
'login.forgotPassword': 'Забыли пароль?',
'login.rememberMe': 'Запомнить меня',
'login.forgotPasswordTitle': 'Сброс пароля',
'login.forgotPasswordBody':
'Введите e-mail, с которым вы регистрировались. Если аккаунт найдём — отправим ссылку для сброса.',
+1
View File
@@ -67,6 +67,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Kullanıcı adı gerekli',
'login.passwordMinLength': 'Şifre en az 8 karakter olmalıdır',
'login.forgotPassword': 'Parolanızı mı unuttunuz?',
'login.rememberMe': 'Beni hatırla',
'login.forgotPasswordTitle': 'Şifrenizi sıfırlayın',
'login.forgotPasswordBody':
"Enter the email address you signed up with. If an account exists, we'll send a reset link.",
+1
View File
@@ -57,6 +57,7 @@ const login: TranslationStrings = {
'login.usernameRequired': 'Ім’я користувача обов’язкове',
'login.passwordMinLength': 'Пароль має містити щонайменше 8 символів',
'login.forgotPassword': 'Забули пароль?',
'login.rememberMe': "Запам'ятати мене",
'login.forgotPasswordTitle': 'Скидання пароля',
'login.forgotPasswordBody':
'Введіть електронну пошту, з якою ви реєструвалися. Якщо акаунт існує — буде надіслано посилання для скидання.',
+1
View File
@@ -51,6 +51,7 @@ const login: TranslationStrings = {
'login.usernameRequired': '使用者名稱為必填',
'login.passwordMinLength': '密碼至少需要8個字元',
'login.forgotPassword': '忘記密碼?',
'login.rememberMe': '記住我',
'login.forgotPasswordTitle': '重設密碼',
'login.forgotPasswordBody':
'請輸入您註冊時使用的電子郵件。若帳號存在,我們將傳送重設連結。',
+1
View File
@@ -51,6 +51,7 @@ const login: TranslationStrings = {
'login.usernameRequired': '用户名为必填项',
'login.passwordMinLength': '密码至少需要8个字符',
'login.forgotPassword': '忘记密码?',
'login.rememberMe': '记住我',
'login.forgotPasswordTitle': '重置密码',
'login.forgotPasswordBody':
'输入您注册时使用的邮箱地址。若账户存在,我们将发送重置链接。',
+2 -1
View File
@@ -22,7 +22,8 @@ Complete reference for all environment variables TREK reads.
| `TZ` | Timezone for logs, reminders, and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions; `debug` = verbose details | `info` |
| `DEFAULT_LANGUAGE` | Default language on the login page — see supported codes below | `en` |
| `SESSION_DURATION` | How long a login session stays valid before re-login is required. Applies to both the `trek_session` JWT `exp` claim and the cookie `maxAge`, so they never drift apart. Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. Does not affect the short-lived MFA challenge token or MCP OAuth tokens (those keep their own TTL). | `24h` |
| `SESSION_DURATION` | How long a login session stays valid before re-login is required. Used when **"Remember me" is unchecked** on the login form (the default): applies to the `trek_session` JWT `exp` claim, and the cookie is issued as a **browser-session cookie** (no `maxAge`, cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. Does not affect the short-lived MFA challenge token or MCP OAuth tokens (those keep their own TTL). | `24h` |
| `SESSION_DURATION_REMEMBER` | Session length used when the user **ticks "Remember me"** on login: a longer-lived JWT `exp` claim plus a **persistent** `trek_session` cookie whose `maxAge` matches, so the session survives browser restarts. Same `ms`-style format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email notification links | same-origin |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless. | `false` |
| `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links. | — |