feat: add "Remember me" session persistence and simplify 2FA UX
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const SESSION_COOKIE = 'jmap_session';
|
||||
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "サインイン処理中...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user