diff --git a/README.md b/README.md index 4c6dcb4c..06cc5dd7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index e172c8d9..6bf56ec2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 5939ae76..1acbfb6e 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -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() {
- {error === 'invalid_credentials' && showTotpField + {error === 'invalid_credentials' && showTotpField && totpCode ? t('error.totp_invalid') : t(`error.${error}`) || t("error.generic")}
@@ -426,64 +422,55 @@ export default function LoginPage() {- {t("totp_hint")} -
- )} -