feat: add "Remember me" session persistence and simplify 2FA UX

This commit is contained in:
Matthieu MALVACHE
2026-02-26 00:47:53 +01:00
committed by Matthieu MALVACHE
parent 7b6b8fc132
commit 1b0e3e41c6
17 changed files with 261 additions and 93 deletions
+14 -3
View File
@@ -43,7 +43,7 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
- Toast notifications with undo action support
- Inline form validation with shake animation feedback
- Empty state patterns with contextual actions
- Login UX polish (error shake, password visibility toggle, session expired banner)
- Login UX polish (error shake, discreet 2FA toggle, password visibility toggle, session expired banner)
- Safe area inset support for notched devices
- Screen reader live region announcements
@@ -115,11 +115,12 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
- Trusted senders list for automatic image loading
- HTML sanitization with DOMPurify
- SPF/DKIM/DMARC status indicators
- No password storage (session-based auth)
- No password storage by default (session-based auth)
- TOTP two-factor authentication support
- "Remember me" session persistence (AES-256-GCM encrypted httpOnly cookie, opt-in)
- OAuth2/OIDC with PKCE for SSO login (opt-in, RP-initiated logout, Basic Auth remains default)
- External IdP support (Keycloak, Authentik) via configurable issuer URL
- Session persistence via httpOnly refresh token cookies
- Session persistence via httpOnly cookies (refresh tokens for OAuth, encrypted credentials for Basic Auth)
- CORS misconfiguration detection with actionable error messages
- Shared folder support with proper permissions
- Newsletter unsubscribe support (RFC 2369)
@@ -195,6 +196,16 @@ OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authe
OAuth endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. If your JMAP server delegates auth to an external IdP, set `OAUTH_ISSUER_URL` to the IdP's base URL (e.g., `https://keycloak.example.com/realms/mail`).
#### Remember Me (optional)
To enable "Remember me" for Basic Auth login:
```env
SESSION_SECRET=your-secret-key # Generate with: openssl rand -base64 32
```
When set, a "Remember me" checkbox appears on the login form. Credentials are encrypted with AES-256-GCM and stored in an httpOnly cookie (30-day expiry).
### Development
```bash
+2 -1
View File
@@ -20,6 +20,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] TOTP two-factor authentication (Stalwart-compatible)
- [x] OAuth2/OIDC with PKCE (opt-in SSO, session persistence, RP-initiated logout)
- [x] External IdP support via explicit issuer URL (Keycloak, Authentik, etc.)
- [x] "Remember me" session persistence for Basic Auth (AES-256-GCM encrypted httpOnly cookie)
### JMAP Server Connection
- [x] Session establishment and keep-alive
@@ -78,7 +79,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Confirmation dialog component with promise-based useConfirmDialog hook
- [x] Toast notifications with undo action support and typed durations
- [x] Inline form validation with shake animation (email composer, contact form)
- [x] Login UX polish (error shake, TOTP slide animation, password visibility toggle, session expired banner)
- [x] Login UX polish (error shake, discreet 2FA toggle, password visibility toggle, session expired banner)
- [x] Empty state patterns for contacts (distinct "no data" vs "no search results" with contextual actions)
- [x] WCAG AA reduced-motion media query (global animation/transition reset)
- [x] Safe area inset utilities for notched devices
+41 -54
View File
@@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store";
import { useConfig } from "@/hooks/use-config";
import { cn } from "@/lib/utils";
import { Mail, AlertCircle, Loader2, X, ShieldCheck, Info, Eye, EyeOff, LogIn } from "lucide-react";
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
@@ -19,14 +19,15 @@ export default function LoginPage() {
const t = useTranslations("login");
const params = useParams();
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthClientId, oauthIssuerUrl, isLoading: configLoading, error: configError } = useConfig();
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthClientId, oauthIssuerUrl, rememberMeEnabled, isLoading: configLoading, error: configError } = useConfig();
const [formData, setFormData] = useState({
username: "",
password: "",
});
const [showTotpField, setShowTotpField] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [showTotpField, setShowTotpField] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [sessionExpired, setSessionExpired] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [shakeError, setShakeError] = useState(false);
@@ -127,12 +128,6 @@ export default function LoginPage() {
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [serverUrl]);
useEffect(() => {
if (showTotpField && totpInputRef.current) {
totpInputRef.current.focus();
}
}, [showTotpField]);
useEffect(() => {
if (!oauthEnabled || !serverUrl) return;
discoverOAuth(oauthIssuerUrl || serverUrl)
@@ -290,7 +285,8 @@ export default function LoginPage() {
serverUrl,
formData.username,
formData.password,
showTotpField && totpCode ? totpCode : undefined
totpCode || undefined,
rememberMe
);
if (success) {
@@ -339,7 +335,7 @@ export default function LoginPage() {
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-600 dark:text-red-400">
{error === 'invalid_credentials' && showTotpField
{error === 'invalid_credentials' && showTotpField && totpCode
? t('error.totp_invalid')
: t(`error.${error}`) || t("error.generic")}
</p>
@@ -426,64 +422,55 @@ export default function LoginPage() {
</button>
</div>
{/* 2FA Checkbox */}
<div>
{!showTotpField ? (
<button
type="button"
onClick={() => {
setShowTotpField(true);
setTimeout(() => totpInputRef.current?.focus(), 50);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors text-left"
>
{t("totp_toggle")}
</button>
) : (
<Input
ref={totpInputRef}
id="totp"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-10 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono tracking-widest"
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
aria-label={t("totp_label")}
/>
)}
{rememberMeEnabled && (
<label className="flex items-center gap-2.5 cursor-pointer group select-none">
<span className="relative flex items-center justify-center">
<input
type="checkbox"
checked={showTotpField}
onChange={(e) => {
setShowTotpField(e.target.checked);
if (!e.target.checked) setTotpCode("");
}}
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="peer sr-only"
/>
<span className="flex items-center justify-center w-4.5 h-4.5 rounded border border-border bg-secondary/50 peer-checked:bg-primary peer-checked:border-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background transition-colors">
{showTotpField && (
{rememberMe && (
<svg className="w-3 h-3 text-primary-foreground" viewBox="0 0 12 12" fill="none">
<path d="M2 6L5 9L10 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
</span>
<span className="flex items-center gap-1.5 text-sm text-muted-foreground group-hover:text-foreground transition-colors">
<ShieldCheck className="w-4 h-4" />
{t("totp_checkbox")}
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
{t("remember_me")}
</span>
</label>
{!showTotpField && (
<p className="text-xs text-muted-foreground/80 mt-1.5 ml-7">
{t("totp_hint")}
</p>
)}
</div>
{/* TOTP Input with slide animation */}
<div
className="grid transition-all duration-200 ease-out"
style={{
gridTemplateRows: showTotpField ? '1fr' : '0fr',
opacity: showTotpField ? 1 : 0,
}}
>
<div className="overflow-hidden">
<Input
ref={totpInputRef}
id="totp"
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors text-center font-mono text-lg tracking-widest"
placeholder={t("totp_placeholder")}
autoComplete="one-time-code"
tabIndex={showTotpField ? 0 : -1}
aria-hidden={!showTotpField}
/>
</div>
</div>
)}
</fieldset>
<Button
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_COOKIE_MAX_AGE,
};
export async function POST(request: NextRequest) {
try {
const { serverUrl, username, password } = await request.json();
if (!serverUrl || !username || !password) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const token = encryptSession(serverUrl, username, password);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, COOKIE_OPTIONS);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function GET() {
try {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) {
return NextResponse.json({ error: 'No session' }, { status: 401 });
}
const credentials = decryptSession(token);
if (!credentials) {
cookieStore.delete(SESSION_COOKIE);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
return NextResponse.json(credentials, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
});
} catch (error) {
logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE() {
try {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+1
View File
@@ -21,5 +21,6 @@ export async function GET() {
oauthEnabled: process.env.OAUTH_ENABLED === 'true',
oauthClientId: process.env.OAUTH_CLIENT_ID || '',
oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '',
rememberMeEnabled: !!process.env.SESSION_SECRET,
});
}
+4
View File
@@ -8,6 +8,7 @@ interface ConfigData {
oauthEnabled: boolean;
oauthClientId: string;
oauthIssuerUrl: string;
rememberMeEnabled: boolean;
}
interface AppConfig extends ConfigData {
@@ -63,6 +64,7 @@ export function useConfig(): AppConfig {
oauthEnabled: configCache?.oauthEnabled || false,
oauthClientId: configCache?.oauthClientId || '',
oauthIssuerUrl: configCache?.oauthIssuerUrl || '',
rememberMeEnabled: configCache?.rememberMeEnabled || false,
isLoading: !configCache,
error: null,
});
@@ -76,6 +78,7 @@ export function useConfig(): AppConfig {
oauthEnabled: configCache.oauthEnabled,
oauthClientId: configCache.oauthClientId,
oauthIssuerUrl: configCache.oauthIssuerUrl,
rememberMeEnabled: configCache.rememberMeEnabled,
isLoading: false,
error: null,
});
@@ -90,6 +93,7 @@ export function useConfig(): AppConfig {
oauthEnabled: data.oauthEnabled,
oauthClientId: data.oauthClientId,
oauthIssuerUrl: data.oauthIssuerUrl,
rememberMeEnabled: data.rememberMeEnabled,
isLoading: false,
error: null,
});
+50
View File
@@ -0,0 +1,50 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { logger } from '@/lib/logger';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
function getKey(): Buffer {
const secret = process.env.SESSION_SECRET;
if (!secret) throw new Error('SESSION_SECRET not configured');
return createHash('sha256').update(secret).digest();
}
export function encryptSession(serverUrl: string, username: string, password: string): string {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const payload = JSON.stringify({ v: 1, serverUrl, username, password });
const encrypted = Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString('base64');
}
export function decryptSession(token: string): { serverUrl: string; username: string; password: string } | null {
try {
const key = getKey();
const data = Buffer.from(token, 'base64');
if (data.length < IV_LENGTH + TAG_LENGTH) return null;
const iv = data.subarray(0, IV_LENGTH);
const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
const parsed = JSON.parse(decrypted.toString('utf8'));
if (parsed.v !== 1 || !parsed.serverUrl || !parsed.username || !parsed.password) return null;
return { serverUrl: parsed.serverUrl, username: parsed.username, password: parsed.password };
} catch (error) {
logger.warn('Session decryption failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return null;
}
}
+2
View File
@@ -0,0 +1,2 @@
export const SESSION_COOKIE = 'jmap_session';
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
+2 -4
View File
@@ -23,14 +23,12 @@
"server_not_configured": "Der E-Mail-Server wurde nicht konfiguriert. Bitte kontaktieren Sie Ihren Administrator."
},
"remove_from_history": "Aus Verlauf entfernen",
"totp_toggle": "Ich habe Zwei-Faktor-Authentifizierung",
"totp_label": "Authentifizierungscode",
"totp_placeholder": "000000",
"totp_hide": "Zwei-Faktor-Authentifizierung ausblenden",
"totp_toggle": "Ich habe einen 2FA-Code",
"remember_me": "Angemeldet bleiben",
"show_password": "Passwort anzeigen",
"hide_password": "Passwort verbergen",
"totp_hint": "Aktivieren Sie dies, wenn Ihr Konto einen Zwei-Faktor-Code erfordert",
"totp_checkbox": "Zwei-Faktor-Authentifizierungscode verwenden",
"session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"dismiss": "Schließen",
"or": "oder",
+2 -4
View File
@@ -19,18 +19,16 @@
},
"show_password": "Show password",
"hide_password": "Hide password",
"totp_hint": "Enable this if your account requires a two-factor authentication code",
"totp_toggle": "I have a 2FA code",
"remember_me": "Remember me",
"config_error": {
"title": "Configuration Error",
"fetch_failed": "Unable to load application configuration. Please try again later.",
"server_not_configured": "The mail server has not been configured. Please contact your administrator."
},
"remove_from_history": "Remove from history",
"totp_toggle": "I have two-factor authentication",
"totp_label": "Authentication code",
"totp_placeholder": "000000",
"totp_hide": "Hide two-factor authentication",
"totp_checkbox": "Use two-factor authentication code",
"session_expired": "Your session has expired. Please sign in again.",
"dismiss": "Dismiss",
"or": "or",
+2 -4
View File
@@ -23,14 +23,12 @@
"server_not_configured": "El servidor de correo no ha sido configurado. Por favor, contacte a su administrador."
},
"remove_from_history": "Eliminar del historial",
"totp_toggle": "Tengo autenticación de dos factores",
"totp_label": "Código de autenticación",
"totp_placeholder": "000000",
"totp_hide": "Ocultar autenticación de dos factores",
"totp_toggle": "Tengo un código 2FA",
"remember_me": "Recordarme",
"show_password": "Mostrar contraseña",
"hide_password": "Ocultar contraseña",
"totp_hint": "Activa si tu cuenta requiere un código de autenticación en dos pasos",
"totp_checkbox": "Usar código de autenticación en dos pasos",
"session_expired": "Tu sesión ha expirado. Inicia sesión de nuevo.",
"dismiss": "Cerrar",
"or": "o",
+2 -4
View File
@@ -23,16 +23,14 @@
"server_not_configured": "Le serveur de messagerie n'a pas été configuré. Veuillez contacter votre administrateur."
},
"remove_from_history": "Supprimer de l'historique",
"totp_toggle": "J'ai l'authentification à deux facteurs",
"totp_label": "Code d'authentification",
"totp_placeholder": "000000",
"totp_hide": "Masquer l'authentification à deux facteurs",
"totp_checkbox": "Utiliser un code d'authentification à deux facteurs",
"totp_toggle": "J'ai un code 2FA",
"remember_me": "Se souvenir de moi",
"session_expired": "Votre session a expiré. Veuillez vous reconnecter.",
"dismiss": "Fermer",
"show_password": "Afficher le mot de passe",
"hide_password": "Masquer le mot de passe",
"totp_hint": "Activez si votre compte nécessite un code d'authentification à deux facteurs",
"or": "ou",
"sign_in_sso": "Se connecter avec SSO",
"oauth_completing": "Connexion en cours...",
+2 -4
View File
@@ -23,14 +23,12 @@
"server_not_configured": "Il server di posta non è stato configurato. Contattare l'amministratore."
},
"remove_from_history": "Rimuovi dalla cronologia",
"totp_toggle": "Ho l'autenticazione a due fattori",
"totp_label": "Codice di autenticazione",
"totp_placeholder": "000000",
"totp_hide": "Nascondi autenticazione a due fattori",
"totp_toggle": "Ho un codice 2FA",
"remember_me": "Ricordami",
"show_password": "Mostra password",
"hide_password": "Nascondi password",
"totp_hint": "Attiva se il tuo account richiede un codice di autenticazione a due fattori",
"totp_checkbox": "Usa codice di autenticazione a due fattori",
"session_expired": "La sessione è scaduta. Accedi di nuovo.",
"dismiss": "Chiudi",
"or": "o",
+2 -4
View File
@@ -23,16 +23,14 @@
"server_not_configured": "メールサーバーが設定されていません。管理者にお問い合わせください。"
},
"remove_from_history": "履歴から削除",
"totp_toggle": "二要素認証を使用",
"totp_label": "認証コード",
"totp_placeholder": "000000",
"totp_hide": "二要素認証を非表示",
"totp_checkbox": "二要素認証コードを使用する",
"totp_toggle": "2FAコードを入力",
"remember_me": "ログイン状態を保持",
"session_expired": "セッションが期限切れになりました。再度サインインしてください。",
"dismiss": "閉じる",
"show_password": "パスワードを表示",
"hide_password": "パスワードを隠す",
"totp_hint": "アカウントに二要素認証コードが必要な場合に有効にしてください",
"or": "または",
"sign_in_sso": "SSOでサインイン",
"oauth_completing": "サインイン処理中...",
+2 -4
View File
@@ -23,14 +23,12 @@
"server_not_configured": "De mailserver is niet geconfigureerd. Neem contact op met je beheerder."
},
"remove_from_history": "Verwijder uit geschiedenis",
"totp_toggle": "Ik heb tweefactorauthenticatie",
"totp_label": "Authenticatiecode",
"totp_placeholder": "000000",
"totp_hide": "Tweefactorauthenticatie verbergen",
"totp_toggle": "Ik heb een 2FA-code",
"remember_me": "Onthoud mij",
"show_password": "Wachtwoord tonen",
"hide_password": "Wachtwoord verbergen",
"totp_hint": "Schakel in als uw account een tweefactorauthenticatiecode vereist",
"totp_checkbox": "Tweefactorauthenticatiecode gebruiken",
"session_expired": "Uw sessie is verlopen. Meld u opnieuw aan.",
"dismiss": "Sluiten",
"or": "of",
+2 -4
View File
@@ -23,14 +23,12 @@
"server_not_configured": "O servidor de e-mail não foi configurado. Por favor, contate seu administrador."
},
"remove_from_history": "Remover do histórico",
"totp_toggle": "Tenho autenticação de dois fatores",
"totp_label": "Código de autenticação",
"totp_placeholder": "000000",
"totp_hide": "Ocultar autenticação de dois fatores",
"totp_toggle": "Tenho um código 2FA",
"remember_me": "Lembrar-me",
"show_password": "Mostrar senha",
"hide_password": "Ocultar senha",
"totp_hint": "Ative se sua conta requer um código de autenticação de dois fatores",
"totp_checkbox": "Usar código de autenticação de dois fatores",
"session_expired": "Sua sessão expirou. Faça login novamente.",
"dismiss": "Fechar",
"or": "ou",
+65 -3
View File
@@ -20,10 +20,11 @@ interface AuthState {
identities: Identity[];
primaryIdentity: Identity | null;
authMode: 'basic' | 'oauth';
rememberMe: boolean;
accessToken: string | null;
tokenExpiresAt: number | null;
login: (serverUrl: string, username: string, password: string, totp?: string) => Promise<boolean>;
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
refreshAccessToken: () => Promise<string | null>;
logout: () => void;
@@ -126,10 +127,11 @@ export const useAuthStore = create<AuthState>()(
identities: [],
primaryIdentity: null,
authMode: 'basic',
rememberMe: false,
accessToken: null,
tokenExpiresAt: null,
login: async (serverUrl, username, password, totp) => {
login: async (serverUrl, username, password, totp, rememberMe) => {
const effectivePassword = totp ? `${password}$${totp}` : password;
set({ isLoading: true, error: null });
@@ -154,6 +156,23 @@ export const useAuthStore = create<AuthState>()(
error: null,
});
if (rememberMe) {
try {
const res = await fetch('/api/auth/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl, username, password: effectivePassword }),
});
if (res.ok) {
set({ rememberMe: true });
} else {
debug.error('Failed to store session: server returned', res.status);
}
} catch (err) {
debug.error('Failed to store session:', err);
}
}
return true;
} catch (error) {
debug.error('Login error:', error);
@@ -272,6 +291,7 @@ export const useAuthStore = create<AuthState>()(
identities: [],
primaryIdentity: null,
authMode: 'basic',
rememberMe: false,
accessToken: null,
tokenExpiresAt: null,
error: null,
@@ -296,6 +316,10 @@ export const useAuthStore = create<AuthState>()(
useCalendarStore.getState().clearState();
useFilterStore.getState().clearState();
fetch('/api/auth/session', { method: 'DELETE' }).catch((err) => {
debug.error('Failed to clear session cookie:', err);
});
if (wasOAuth) {
fetch('/api/auth/token', { method: 'DELETE' })
.then((res) => {
@@ -349,6 +373,40 @@ export const useAuthStore = create<AuthState>()(
}
}
if (state.authMode === 'basic') {
set({ isLoading: true });
try {
const res = await fetch('/api/auth/session');
if (res.ok) {
const data = await res.json();
if (!data.serverUrl || !data.username || !data.password) {
debug.error('Session restore returned incomplete data');
throw new Error('Incomplete session data');
}
const { serverUrl, username, password } = data;
const client = new JMAPClient(serverUrl, username, password);
await client.connect();
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
initializeFeatureStores(client);
set({
isAuthenticated: true,
isLoading: false,
serverUrl,
username,
client,
identities,
primaryIdentity,
authMode: 'basic',
});
return;
}
} catch (error) {
debug.error('Basic session restore failed:', error);
}
}
markSessionExpired();
set({
@@ -358,6 +416,7 @@ export const useAuthStore = create<AuthState>()(
serverUrl: null,
username: null,
authMode: 'basic',
rememberMe: false,
accessToken: null,
tokenExpiresAt: null,
});
@@ -374,7 +433,10 @@ export const useAuthStore = create<AuthState>()(
serverUrl: state.serverUrl,
username: state.username,
authMode: state.authMode,
isAuthenticated: state.authMode === 'oauth' ? state.isAuthenticated : undefined,
isAuthenticated: (state.authMode === 'oauth' || state.rememberMe)
? state.isAuthenticated
: undefined,
rememberMe: state.rememberMe,
}),
}
)