From ab72fc06ffc0f889d804c2e2c53da840789b0ecd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 12 Mar 2026 02:20:56 +0100 Subject: [PATCH] feat: add Stalwart account security management - Add Stalwart API client library (lib/stalwart/client.ts) - Add server-side proxy routes for auth, crypto, password, principal, probe - Add account security Zustand store with full state management - Add Security settings tab with password change, display name, TOTP 2FA, app passwords, and encryption-at-rest controls - Add stalwartFeaturesEnabled config flag (opt-out via STALWART_FEATURES=false) - Add i18n translations for all 8 locales (en, de, es, fr, it, ja, nl, pt) - Add tests for Stalwart client (24 tests) and security store (29 tests) --- .gitignore | 1 + app/[locale]/login/page.tsx | 644 ++++++++++-------- app/[locale]/page.tsx | 18 +- app/[locale]/settings/page.tsx | 7 +- app/api/account/stalwart/auth/route.ts | 100 +++ app/api/account/stalwart/crypto/route.ts | 94 +++ app/api/account/stalwart/password/route.ts | 104 +++ app/api/account/stalwart/principal/route.ts | 103 +++ app/api/account/stalwart/probe/route.ts | 65 ++ app/api/config/route.ts | 1 + components/layout/resize-handle.tsx | 13 +- .../settings/account-security-settings.tsx | 517 ++++++++++++++ hooks/use-config.ts | 4 + lib/__tests__/stalwart-client.test.ts | 246 +++++++ lib/stalwart/client.ts | 185 +++++ locales/de/common.json | 67 +- locales/en/common.json | 67 +- locales/es/common.json | 67 +- locales/fr/common.json | 67 +- locales/it/common.json | 67 +- locales/ja/common.json | 67 +- locales/nl/common.json | 67 +- locales/pt/common.json | 67 +- .../__tests__/account-security-store.test.ts | 424 ++++++++++++ stores/account-security-store.ts | 353 ++++++++++ stores/theme-store.ts | 38 +- 26 files changed, 3143 insertions(+), 310 deletions(-) create mode 100644 app/api/account/stalwart/auth/route.ts create mode 100644 app/api/account/stalwart/crypto/route.ts create mode 100644 app/api/account/stalwart/password/route.ts create mode 100644 app/api/account/stalwart/principal/route.ts create mode 100644 app/api/account/stalwart/probe/route.ts create mode 100644 components/settings/account-security-settings.tsx create mode 100644 lib/__tests__/stalwart-client.test.ts create mode 100644 lib/stalwart/client.ts create mode 100644 stores/__tests__/account-security-store.test.ts create mode 100644 stores/account-security-store.ts diff --git a/.gitignore b/.gitignore index fd6cd363..f70b6fde 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ # misc .DS_Store *.pem +/specifications/ # debug npm-debug.log* diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 158a71f7..c28900f7 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter } from "@/i18n/navigation"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -10,13 +10,19 @@ import { useAuthStore } from "@/stores/auth-store"; import { useThemeStore } from "@/stores/theme-store"; import { useConfig } from "@/hooks/use-config"; import { cn } from "@/lib/utils"; -import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor } from "lucide-react"; +import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } 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"; const APP_VERSION = "1.1.2"; +const THEME_OPTIONS = [ + { value: "light" as const, icon: Sun, label: "Light" }, + { value: "dark" as const, icon: Moon, label: "Dark" }, + { value: "system" as const, icon: Monitor, label: "System" }, +]; + export default function LoginPage() { const router = useRouter(); const t = useTranslations("login"); @@ -35,6 +41,7 @@ export default function LoginPage() { const [sessionExpired, setSessionExpired] = useState(false); const [showPassword, setShowPassword] = useState(false); const [shakeError, setShakeError] = useState(false); + const [showThemeMenu, setShowThemeMenu] = useState(false); const [savedUsernames, setSavedUsernames] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); @@ -49,6 +56,7 @@ export default function LoginPage() { const justSelectedSuggestion = useRef(false); const totpInputRef = useRef(null); const prevError = useRef(null); + const themeMenuRef = useRef(null); useEffect(() => { initializeTheme(); @@ -138,6 +146,9 @@ export default function LoginPage() { inputRef.current && !inputRef.current.contains(event.target as Node)) { setShowSuggestions(false); } + if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) { + setShowThemeMenu(false); + } }; document.addEventListener("mousedown", handleClickOutside); @@ -157,9 +168,14 @@ export default function LoginPage() { }); }, [oauthEnabled, serverUrl, oauthIssuerUrl]); + const handleThemeSelect = useCallback((newTheme: "light" | "dark" | "system") => { + setTheme(newTheme); + setShowThemeMenu(false); + }, [setTheme]); + if (configLoading) { return ( -
+
{t("loading")} @@ -170,15 +186,17 @@ export default function LoginPage() { if (configError) { return ( -
-
-
- +
+
+
+
+ +
+

{t("config_error.title")}

+

+ {t("config_error.fetch_failed")} +

-

{t("config_error.title")}

-

- {t("config_error.fetch_failed")} -

); @@ -186,15 +204,17 @@ export default function LoginPage() { if (!serverUrl) { return ( -
-
-
- +
+
+
+
+ +
+

{t("config_error.title")}

+

+ {t("config_error.server_not_configured")} +

-

{t("config_error.title")}

-

- {t("config_error.server_not_configured")} -

); @@ -334,287 +354,355 @@ export default function LoginPage() { } }; - const themeIcon = theme === 'light' ? Sun : theme === 'dark' ? Moon : Monitor; - const ThemeIcon = themeIcon; + const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2]; + const CurrentThemeIcon = currentThemeOption.icon; return ( -
- {/* Theme toggle - top right */} -
+
+ {/* Theme toggle - top right, dropdown style */} +
+ + {showThemeMenu && ( +
+ {THEME_OPTIONS.map((option) => { + const Icon = option.icon; + const isActive = theme === option.value; + return ( + + ); + })} +
+ )}
-
- {/* Logo */} -
-
- -
-

- {appName} -

-
- - {/* Session Expired Banner */} - {sessionExpired && ( -
- -

- {t("session_expired")} -

- -
- )} - - {/* Error Message */} - {error && ( -
- -

- {error === 'invalid_credentials' && showTotpField && totpCode - ? t('error.totp_invalid') - : t(`error.${error}`) || t("error.generic")} +

+ {/* Card container */} +
+ {/* Header section with logo */} +
+
+ +
+

+ {appName} +

+

+ {t("title") !== appName ? t("title") : "Sign in to your account"}

- )} - {/* Dev Mode: One-click login */} - {devMode ? ( -
- -

- Dev mode — logging in as dev@localhost -

-
- ) : ( - /* Login Form */ -
-
-
- - - {/* Custom autocomplete dropdown */} - {showSuggestions && filteredSuggestions.length > 0 && ( -
- {filteredSuggestions.map((username, index) => ( -
selectSuggestion(username)} - > - {username} - -
- ))} -
- )} -
- -
- setFormData({ ...formData, password: e.target.value })} - className="h-12 px-4 pr-11 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors" - placeholder={t("password_placeholder")} - required - autoComplete="current-password" - /> + {/* Form section */} +
+ {/* Session Expired Banner */} + {sessionExpired && ( +
+ +

+ {t("session_expired")} +

- - {!showTotpField ? ( - - ) : ( - 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 && ( - - )} -
- - - - {oauthMetadata && ( - <> -
-
- -
-
- {t("or")} -
-
- - - )} - {oauthEnabled && oauthDiscoveryDone && !oauthMetadata && ( -
- -

- {t("error.oauth_discovery_failed")} + {/* Error Message */} + {error && ( +

+ +

+ {error === 'invalid_credentials' && showTotpField && totpCode + ? t('error.totp_invalid') + : t(`error.${error}`) || t("error.generic")}

)} - - )} -
- {/* Version number - bottom center */} -
- v{APP_VERSION} + {/* Dev Mode: One-click login */} + {devMode ? ( +
+ +

+ Dev mode — logging in as dev@localhost +

+
+ ) : ( + /* Login Form */ +
+
+ {/* Username field */} +
+ +
+ + + {/* Custom autocomplete dropdown */} + {showSuggestions && filteredSuggestions.length > 0 && ( +
+ {filteredSuggestions.map((username, index) => ( +
selectSuggestion(username)} + > + {username} + +
+ ))} +
+ )} +
+
+ + {/* Password field */} +
+ +
+ setFormData({ ...formData, password: e.target.value })} + className="h-11 px-3.5 pr-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200" + placeholder={t("password_placeholder")} + required + autoComplete="current-password" + /> + +
+
+ + {/* 2FA toggle / field */} + {!showTotpField ? ( + + ) : ( +
+ + setTotpCode(e.target.value.replace(/\D/g, ''))} + className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest" + placeholder={t("totp_placeholder")} + autoComplete="one-time-code" + aria-label={t("totp_label")} + /> +
+ )} + + {/* Remember me */} + {rememberMeEnabled && ( + + )} +
+ + + + {oauthMetadata && ( + <> +
+
+ +
+
+ {t("or")} +
+
+ + + + )} + + {oauthEnabled && oauthDiscoveryDone && !oauthMetadata && ( +
+ +

+ {t("error.oauth_discovery_failed")} +

+
+ )} +
+ )} +
+
+ + {/* Version number - below card */} +

+ v{APP_VERSION} +

); diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 969b490f..535db389 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -52,6 +52,9 @@ export default function Home() { const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const [showShortcutsModal, setShowShortcutsModal] = useState(false); const [showAdvancedFields, setShowAdvancedFields] = useState(false); + // Column resize state (disable transitions during drag) + const [isResizing, setIsResizing] = useState(false); + const dragStartWidth = useRef(0); // Mobile conversation view state const [conversationThread, setConversationThread] = useState(null); const [conversationEmails, setConversationEmails] = useState([]); @@ -834,7 +837,8 @@ export default function Home() { {/* Sidebar - overlay on mobile/tablet, fixed on desktop */}