feat: add devMode configuration to runtime settings and update LoginPage for theme management

This commit is contained in:
Linus Rath
2026-03-11 16:01:31 +01:00
parent 40e7fa3776
commit d09022378b
3 changed files with 251 additions and 180 deletions
+246 -180
View File
@@ -7,19 +7,23 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn } from "lucide-react"; import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
const APP_VERSION = "1.1.2";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations("login"); const t = useTranslations("login");
const params = useParams(); const params = useParams();
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthClientId, oauthIssuerUrl, rememberMeEnabled, isLoading: configLoading, error: configError } = useConfig(); const { theme, setTheme, initializeTheme } = useThemeStore();
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, isLoading: configLoading, error: configError } = useConfig();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: "", username: "",
@@ -46,6 +50,10 @@ export default function LoginPage() {
const totpInputRef = useRef<HTMLInputElement>(null); const totpInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null); const prevError = useRef<string | null>(null);
useEffect(() => {
initializeTheme();
}, [initializeTheme]);
useEffect(() => { useEffect(() => {
if (serverUrl) { if (serverUrl) {
document.title = appName; document.title = appName;
@@ -295,8 +303,34 @@ export default function LoginPage() {
} }
}; };
const handleDevLogin = async () => {
const success = await login(serverUrl, "dev@localhost", "dev");
if (success) {
router.push('/');
}
};
const themeIcon = theme === 'light' ? Sun : theme === 'dark' ? Moon : Monitor;
const ThemeIcon = themeIcon;
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20"> <div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-background via-background to-muted/20 relative">
{/* Theme toggle - top right */}
<div className="absolute top-4 right-4">
<button
type="button"
onClick={() => {
const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light';
setTheme(next);
}}
className="p-2.5 rounded-lg bg-secondary/60 hover:bg-secondary border border-border/50 text-muted-foreground hover:text-foreground transition-colors"
aria-label={`Theme: ${theme}`}
title={`Theme: ${theme}`}
>
<ThemeIcon className="w-4 h-4" />
</button>
</div>
<div className="w-full max-w-sm mx-auto px-4"> <div className="w-full max-w-sm mx-auto px-4">
{/* Logo */} {/* Logo */}
<div className="text-center mb-12"> <div className="text-center mb-12">
@@ -342,189 +376,221 @@ export default function LoginPage() {
</div> </div>
)} )}
{/* Login Form */} {/* Dev Mode: One-click login */}
<form {devMode ? (
onSubmit={handleSubmit} <div className="space-y-4">
className={cn("space-y-4", shakeError && "animate-shake")} <Button
> type="button"
<fieldset disabled={isLoading} className="space-y-4"> className="w-full h-12 font-medium text-base bg-primary hover:bg-primary/90 transition-all duration-200 shadow-lg shadow-primary/20"
<div className="relative"> onClick={handleDevLogin}
<Input disabled={isLoading}
ref={inputRef} >
id="username" {isLoading ? (
type="text" <div className="flex items-center gap-2">
value={formData.username} <Loader2 className="w-4 h-4 animate-spin" />
onChange={handleUsernameChange} {t("signing_in")}
onFocus={handleUsernameFocus} </div>
onKeyDown={handleKeyDown} ) : (
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors" <div className="flex items-center gap-2">
placeholder={t("username_placeholder")} <LogIn className="w-4 h-4" />
required {t("sign_in")}
autoComplete="off"
data-form-type="other"
data-lpignore="true"
autoFocus
/>
{/* Custom autocomplete dropdown */}
{showSuggestions && filteredSuggestions.length > 0 && (
<div
ref={suggestionsRef}
className="absolute top-full mt-1 w-full bg-secondary border border-border rounded-md shadow-lg z-50 overflow-hidden"
>
{filteredSuggestions.map((username, index) => (
<div
key={username}
className={cn(
"px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors",
index === selectedSuggestionIndex && "bg-muted"
)}
onClick={() => selectSuggestion(username)}
>
<span className="text-sm text-foreground">{username}</span>
<button
type="button"
onClick={(e) => removeUsername(username, e)}
className="p-1 hover:bg-background rounded transition-colors"
title={t("remove_from_history")}
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
))}
</div> </div>
)} )}
</div> </Button>
<p className="text-center text-xs text-muted-foreground">
<div className="relative"> Dev mode logging in as dev@localhost
<Input </p>
id="password" </div>
type={showPassword ? "text" : "password"} ) : (
value={formData.password} /* Login Form */
onChange={(e) => setFormData({ ...formData, password: e.target.value })} <form
className="h-12 px-4 pr-11 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors" onSubmit={handleSubmit}
placeholder={t("password_placeholder")} className={cn("space-y-4", shakeError && "animate-shake")}
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground hover:text-foreground transition-colors"
aria-label={showPassword ? t("hide_password") : t("show_password")}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-4.5 h-4.5" />
) : (
<Eye className="w-4.5 h-4.5" />
)}
</button>
</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={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">
{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="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
{t("remember_me")}
</span>
</label>
)}
</fieldset>
<Button
type="submit"
className="w-full h-12 font-medium text-base bg-primary hover:bg-primary/90 transition-all duration-200 shadow-lg shadow-primary/20"
disabled={isLoading}
> >
{isLoading ? ( <fieldset disabled={isLoading} className="space-y-4">
<div className="flex items-center gap-2"> <div className="relative">
<Loader2 className="w-4 h-4 animate-spin" /> <Input
{t("signing_in")} ref={inputRef}
</div> id="username"
) : ( type="text"
t("sign_in") value={formData.username}
)} onChange={handleUsernameChange}
</Button> onFocus={handleUsernameFocus}
onKeyDown={handleKeyDown}
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
placeholder={t("username_placeholder")}
required
autoComplete="off"
data-form-type="other"
data-lpignore="true"
autoFocus
/>
{oauthMetadata && ( {/* Custom autocomplete dropdown */}
<> {showSuggestions && filteredSuggestions.length > 0 && (
<div className="relative my-6"> <div
<div className="absolute inset-0 flex items-center"> ref={suggestionsRef}
<span className="w-full border-t border-border" /> className="absolute top-full mt-1 w-full bg-secondary border border-border rounded-md shadow-lg z-50 overflow-hidden"
</div> >
<div className="relative flex justify-center text-xs uppercase"> {filteredSuggestions.map((username, index) => (
<span className="bg-background px-2 text-muted-foreground">{t("or")}</span> <div
</div> key={username}
</div> className={cn(
"px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors",
<Button index === selectedSuggestionIndex && "bg-muted"
type="button" )}
variant="outline" onClick={() => selectSuggestion(username)}
className="w-full h-12 font-medium text-base" >
onClick={handleOAuthLogin} <span className="text-sm text-foreground">{username}</span>
disabled={oauthLoading || isLoading} <button
> type="button"
{oauthLoading ? ( onClick={(e) => removeUsername(username, e)}
<Loader2 className="w-4 h-4 animate-spin mr-2" /> className="p-1 hover:bg-background rounded transition-colors"
) : ( title={t("remove_from_history")}
<LogIn className="w-4 h-4 mr-2" /> >
<X className="w-3 h-3 text-muted-foreground" />
</button>
</div>
))}
</div>
)} )}
{t("sign_in_sso")} </div>
</Button>
</>
)}
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && ( <div className="relative">
<div className="mt-4 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg flex items-start gap-2"> <Input
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" /> id="password"
<p className="text-sm text-amber-700 dark:text-amber-400"> type={showPassword ? "text" : "password"}
{t("error.oauth_discovery_failed")} value={formData.password}
</p> onChange={(e) => setFormData({ ...formData, password: e.target.value })}
</div> 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")}
</form> required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded text-muted-foreground hover:text-foreground transition-colors"
aria-label={showPassword ? t("hide_password") : t("show_password")}
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-4.5 h-4.5" />
) : (
<Eye className="w-4.5 h-4.5" />
)}
</button>
</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={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">
{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="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
{t("remember_me")}
</span>
</label>
)}
</fieldset>
<Button
type="submit"
className="w-full h-12 font-medium text-base bg-primary hover:bg-primary/90 transition-all duration-200 shadow-lg shadow-primary/20"
disabled={isLoading}
>
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t("signing_in")}
</div>
) : (
t("sign_in")
)}
</Button>
{oauthMetadata && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">{t("or")}</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full h-12 font-medium text-base"
onClick={handleOAuthLogin}
disabled={oauthLoading || isLoading}
>
{oauthLoading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<LogIn className="w-4 h-4 mr-2" />
)}
{t("sign_in_sso")}
</Button>
</>
)}
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
<div className="mt-4 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-400">
{t("error.oauth_discovery_failed")}
</p>
</div>
)}
</form>
)}
</div>
{/* Version number - bottom center */}
<div className="absolute bottom-4 text-xs text-muted-foreground/50">
v{APP_VERSION}
</div> </div>
</div> </div>
); );
+1
View File
@@ -22,5 +22,6 @@ export async function GET() {
oauthClientId: process.env.OAUTH_CLIENT_ID || '', oauthClientId: process.env.OAUTH_CLIENT_ID || '',
oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '', oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '',
rememberMeEnabled: !!process.env.SESSION_SECRET, rememberMeEnabled: !!process.env.SESSION_SECRET,
devMode: process.env.DEV_MOCK_JMAP === 'true',
}); });
} }
+4
View File
@@ -9,6 +9,7 @@ interface ConfigData {
oauthClientId: string; oauthClientId: string;
oauthIssuerUrl: string; oauthIssuerUrl: string;
rememberMeEnabled: boolean; rememberMeEnabled: boolean;
devMode: boolean;
} }
interface AppConfig extends ConfigData { interface AppConfig extends ConfigData {
@@ -65,6 +66,7 @@ export function useConfig(): AppConfig {
oauthClientId: configCache?.oauthClientId || '', oauthClientId: configCache?.oauthClientId || '',
oauthIssuerUrl: configCache?.oauthIssuerUrl || '', oauthIssuerUrl: configCache?.oauthIssuerUrl || '',
rememberMeEnabled: configCache?.rememberMeEnabled || false, rememberMeEnabled: configCache?.rememberMeEnabled || false,
devMode: configCache?.devMode || false,
isLoading: !configCache, isLoading: !configCache,
error: null, error: null,
}); });
@@ -79,6 +81,7 @@ export function useConfig(): AppConfig {
oauthClientId: configCache.oauthClientId, oauthClientId: configCache.oauthClientId,
oauthIssuerUrl: configCache.oauthIssuerUrl, oauthIssuerUrl: configCache.oauthIssuerUrl,
rememberMeEnabled: configCache.rememberMeEnabled, rememberMeEnabled: configCache.rememberMeEnabled,
devMode: configCache.devMode,
isLoading: false, isLoading: false,
error: null, error: null,
}); });
@@ -94,6 +97,7 @@ export function useConfig(): AppConfig {
oauthClientId: data.oauthClientId, oauthClientId: data.oauthClientId,
oauthIssuerUrl: data.oauthIssuerUrl, oauthIssuerUrl: data.oauthIssuerUrl,
rememberMeEnabled: data.rememberMeEnabled, rememberMeEnabled: data.rememberMeEnabled,
devMode: data.devMode,
isLoading: false, isLoading: false,
error: null, error: null,
}); });