diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 1acbfb6e..7e5215f6 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -7,19 +7,23 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; 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 } 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 { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; +const APP_VERSION = "1.1.2"; + export default function LoginPage() { const router = useRouter(); const t = useTranslations("login"); const params = useParams(); 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({ username: "", @@ -46,6 +50,10 @@ export default function LoginPage() { const totpInputRef = useRef(null); const prevError = useRef(null); + useEffect(() => { + initializeTheme(); + }, [initializeTheme]); + useEffect(() => { if (serverUrl) { 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 ( -
+
+ {/* Theme toggle - top right */} +
+ +
+
{/* Logo */}
@@ -342,189 +376,221 @@ export default function LoginPage() {
)} - {/* Login Form */} -
-
-
- - - {/* Custom autocomplete dropdown */} - {showSuggestions && filteredSuggestions.length > 0 && ( -
- {filteredSuggestions.map((username, index) => ( -
selectSuggestion(username)} - > - {username} - -
- ))} + {/* Dev Mode: One-click login */} + {devMode ? ( +
+
- -
- 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" - /> - -
- - {!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 && ( - - )} -
- -
+ ) : ( + /* Login Form */ + - {isLoading ? ( -
- - {t("signing_in")} -
- ) : ( - t("sign_in") - )} - +
+
+ - {oauthMetadata && ( - <> -
-
- -
-
- {t("or")} -
-
- - +
+ ))} +
)} - {t("sign_in_sso")} - - - )} +
- {oauthEnabled && oauthDiscoveryDone && !oauthMetadata && ( -
- -

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

-
- )} - +
+ 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" + /> + +
+ + {!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")} +

+
+ )} + + )} + + + {/* Version number - bottom center */} +
+ v{APP_VERSION}
); diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 20faa4c1..82387707 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -22,5 +22,6 @@ export async function GET() { oauthClientId: process.env.OAUTH_CLIENT_ID || '', oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '', rememberMeEnabled: !!process.env.SESSION_SECRET, + devMode: process.env.DEV_MOCK_JMAP === 'true', }); } diff --git a/hooks/use-config.ts b/hooks/use-config.ts index 892b137e..3f7b1ccf 100644 --- a/hooks/use-config.ts +++ b/hooks/use-config.ts @@ -9,6 +9,7 @@ interface ConfigData { oauthClientId: string; oauthIssuerUrl: string; rememberMeEnabled: boolean; + devMode: boolean; } interface AppConfig extends ConfigData { @@ -65,6 +66,7 @@ export function useConfig(): AppConfig { oauthClientId: configCache?.oauthClientId || '', oauthIssuerUrl: configCache?.oauthIssuerUrl || '', rememberMeEnabled: configCache?.rememberMeEnabled || false, + devMode: configCache?.devMode || false, isLoading: !configCache, error: null, }); @@ -79,6 +81,7 @@ export function useConfig(): AppConfig { oauthClientId: configCache.oauthClientId, oauthIssuerUrl: configCache.oauthIssuerUrl, rememberMeEnabled: configCache.rememberMeEnabled, + devMode: configCache.devMode, isLoading: false, error: null, }); @@ -94,6 +97,7 @@ export function useConfig(): AppConfig { oauthClientId: data.oauthClientId, oauthIssuerUrl: data.oauthIssuerUrl, rememberMeEnabled: data.rememberMeEnabled, + devMode: data.devMode, isLoading: false, error: null, });