Files
LambdaMail/services/webmail/src/app/(auth)/user/login/page.tsx
T
Lucas RayanandClaude Sonnet 5.5 e667843b59 feat(webmail): the new interface - logo, dark and light themes, command menu
A mark (a lambda on an ultramarine tile with the flap of an envelope), the
favicon, home-screen icons and an install manifest. Every colour is a variable
defined once per theme, and the Tailwind scales the screens already use read
them, so dark (the default) and light are a change of values and not of
classes. Geist is served by the app itself. Ctrl+K opens a command menu for
folders, actions, accounts and appearance, and typing something that matches no
command searches the mail; c writes a message and / goes to the search field.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:33:15 -03:00

127 lines
5.1 KiB
TypeScript

"use client";
import React, { useState } from "react";
import { Mail, Lock, AlertCircle } from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Button } from "../../../../components/ui/Button";
import { Logo } from "../../../../components/Logo";
import { ThemeToggle } from "../../../../components/ThemeToggle";
export default function UserLoginPage() {
const t = useTranslations();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
/**
* One step, because the mailbox is one step.
*
* Reading your own mail is behind your own password; the second factor guards
* the console, and is collected on the way in there. This screen used to have
* a code step as well, so an account that had enrolled a factor could not open
* its inbox without its phone.
*/
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const res = await fetch("/api/v1/auth/user/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || t("auth.invalidCredentials"));
// The destination the middleware attached when it sent an unauthenticated
// visitor here, so a deep link survives the sign-in.
// A path on this origin only: "//evil.test" also begins with a slash, and
// following it would turn the sign-in into an open redirect.
const next = new URLSearchParams(window.location.search).get("next");
const safe = next && next.startsWith("/") && !next.startsWith("//");
window.location.href = safe ? next : "/user/mail/inbox";
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t("auth.genericError"));
} finally {
setLoading(false);
}
};
const field =
"w-full min-h-[38px] rounded-xl bg-dark-card py-2.5 pl-[38px] pr-3 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent";
return (
/* No ambient glow behind the card any more: two blurred coloured circles
under a sign-in form are decoration that costs a full-viewport paint and
says nothing. Nocturne's depth is the surface and its edge. */
<div className="relative flex min-h-screen items-center justify-center bg-dark-bg px-4">
<ThemeToggle className="absolute right-4 top-4" />
<div className="w-full max-w-[400px] rounded-2xl bg-dark-panel p-7 shadow-edge">
<div className="mb-6 flex items-center gap-3">
<Logo size={40} className="flex-none" />
<div className="min-w-0">
<h1 className="text-lg font-medium leading-tight text-slate-100">{t("auth.userLoginTitle")}</h1>
<p className="mt-0.5 text-[12.5px] text-slate-400">
{t("common.appName")} &middot; {t("common.userPortal")}
</p>
</div>
</div>
{error && (
<div className="mb-5 flex items-start gap-2.5 rounded-xl bg-rose-900/60 p-3.5 text-xs leading-relaxed text-rose-200 shadow-edge">
<AlertCircle className="mt-px h-4 w-4 flex-none" />
<span>{error}</span>
</div>
)}
<form onSubmit={handleLogin} className="flex flex-col gap-4">
<div>
<label htmlFor="login-email" className="mb-1.5 block text-xs text-slate-400">
{t("auth.emailLabel")}
</label>
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
id="login-email"
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@domain.com"
required
className={field}
/>
</div>
</div>
<div>
<label htmlFor="login-password" className="mb-1.5 block text-xs text-slate-400">
{t("auth.passwordLabel")}
</label>
<div className="relative">
<Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
id="login-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="************"
required
className={field}
/>
</div>
</div>
<Button type="submit" variant="primary" size="lg" className="mt-1 w-full" disabled={loading}>
{loading ? t("common.loading") : t("auth.signInButton")}
</Button>
</form>
</div>
</div>
);
}