The console demanded a second factor and refused to open without one, but
enrolment lived on the other surface - so an operator whose only account
was the administrator had no way in. The password was already verified at
that point, so the refusal now carries a short-lived grant that permits
enrolling a factor and nothing else, and the admin login screen walks
through it: secret, confirmation, recovery codes, sign in. Two tests cover
it, including that the grant opens no other endpoint.
The rewrite also undid work that was already in place:
- The postcss override was dropped and the advisory came back, along
with a critical one in vitest. postcss is a direct dependency now, so
the fix is the version plus an override scoped to Next's own copy;
vitest moves to 3.2.7. The lockfile needed regenerating for either to
take effect. Zero advisories now.
- 100 language-lint violations: the admin console had been rewritten
with its strings in Portuguese, hardcoded. That fails CI, and it also
puts those strings beyond the reach of the translation bundles - an
English or Spanish user would have read Portuguese. They are English
in the source again.
- The mail screen put a fixed 320px list beside the reader with no
breakpoint, so on a phone the viewport overflowed and the message was
a few pixels wide. One pane at a time below md, with a way back.
- The icon-only navigation toggles had nothing for a screen reader to
announce.
The layouts' own mobile navigation and every previously fixed behaviour
survived the rewrite - checked rather than assumed.
Also fixes a marker collision I introduced: the E2E helper locates the
repository by a migrations directory, and services/migrations satisfied
that one directory too early. It now requires the SQL to actually be
there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
8.1 KiB
TypeScript
209 lines
8.1 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import {
|
|
Inbox,
|
|
Send,
|
|
FileText,
|
|
Archive,
|
|
AlertTriangle,
|
|
Trash2,
|
|
Settings,
|
|
PenSquare,
|
|
LogOut,
|
|
Mail,
|
|
ShieldCheck,
|
|
Menu,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useTranslations } from "../../../i18n/provider";
|
|
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
|
|
|
interface FolderCount {
|
|
special_use: string;
|
|
name: string;
|
|
unread_count: number;
|
|
}
|
|
|
|
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
|
const t = useTranslations();
|
|
const pathname = usePathname();
|
|
const [folders, setFolders] = useState<FolderCount[]>([]);
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
void fetch("/api/v1/mail/folders")
|
|
.then((res) => (res.ok ? res.json() : []))
|
|
.then(setFolders)
|
|
.catch(() => undefined);
|
|
}, []);
|
|
|
|
const unreadFor = (role: string) =>
|
|
folders.find((f) => f.special_use === role || f.name.toLowerCase() === role)?.unread_count ?? 0;
|
|
|
|
const navItems = [
|
|
{ href: "/user/mail/inbox", label: t("mail.inbox"), icon: Inbox, role: "inbox" },
|
|
{ href: "/user/mail/sent", label: t("mail.sent"), icon: Send, role: "sent" },
|
|
{ href: "/user/mail/drafts", label: t("mail.drafts"), icon: FileText, role: "drafts" },
|
|
{ href: "/user/mail/archive", label: "Archive", icon: Archive, role: "archive" },
|
|
{ href: "/user/mail/junk", label: "Spam", icon: AlertTriangle, role: "junk" },
|
|
{ href: "/user/mail/trash", label: "Trash", icon: Trash2, role: "trash" },
|
|
];
|
|
|
|
const handleLogout = () => {
|
|
void fetch("/api/v1/auth/logout", { method: "POST" }).finally(() => {
|
|
window.location.href = "/user/login";
|
|
});
|
|
};
|
|
|
|
const SidebarContent = (
|
|
<div className="flex flex-col justify-between h-full p-4">
|
|
<div>
|
|
{/* Webmail Surface Branding */}
|
|
<div className="flex items-center gap-3 px-2 mb-6">
|
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-indigo-600 to-cyan-500 border border-indigo-400/30 flex items-center justify-center text-white font-bold shadow-lg shadow-indigo-500/20">
|
|
<Mail className="w-5 h-5 text-white" />
|
|
</div>
|
|
<div>
|
|
<div className="font-bold text-base text-white tracking-tight">LambdaMail</div>
|
|
<div className="text-[10px] text-indigo-400 uppercase tracking-widest font-mono font-semibold">
|
|
{t("common.userPortal")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Compose Floating Button */}
|
|
<Link
|
|
href="/user/compose"
|
|
className="group relative flex items-center justify-center gap-2 w-full py-3 px-4 mb-6 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-sm transition-all duration-200 shadow-lg shadow-indigo-600/30 active:scale-[0.98]"
|
|
>
|
|
<PenSquare className="w-4 h-4 transition-transform group-hover:rotate-12" />
|
|
<span>{t("mail.compose")}</span>
|
|
</Link>
|
|
|
|
{/* Folder Navigation List */}
|
|
<nav className="space-y-1 text-sm font-medium">
|
|
{navItems.map((item) => {
|
|
const Icon = item.icon;
|
|
const isActive = pathname === item.href || (item.role === "inbox" && pathname === "/user/mail");
|
|
const unread = unreadFor(item.role);
|
|
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
className={`flex items-center justify-between px-3.5 py-2.5 rounded-xl transition-all duration-150 ${
|
|
isActive
|
|
? "bg-indigo-600/20 text-indigo-300 border border-indigo-500/30 shadow-sm"
|
|
: "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40 border border-transparent"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Icon className={`w-4 h-4 ${isActive ? "text-indigo-400" : "text-slate-400"}`} />
|
|
<span>{item.label}</span>
|
|
</div>
|
|
{unread > 0 && (
|
|
<span
|
|
className={`text-xs px-2 py-0.5 rounded-full font-semibold ${
|
|
isActive ? "bg-indigo-500/40 text-indigo-200" : "bg-slate-800 text-slate-300"
|
|
}`}
|
|
>
|
|
{unread}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Footer Controls & User Settings */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between gap-2 p-2 rounded-xl bg-slate-900/60 border border-slate-800/80">
|
|
<LanguageSwitcher />
|
|
<button
|
|
type="button"
|
|
onClick={handleLogout}
|
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-slate-700/80 text-xs font-medium text-slate-300 hover:text-rose-400 hover:border-rose-500/40 hover:bg-rose-500/10 transition-all"
|
|
>
|
|
<LogOut className="w-3.5 h-3.5" />
|
|
<span>{t("settings.signOut")}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<Link
|
|
href="/user/settings"
|
|
className={`flex items-center justify-between p-2.5 rounded-xl border transition-all ${
|
|
pathname === "/user/settings"
|
|
? "bg-indigo-600/15 border-indigo-500/30 text-slate-100"
|
|
: "bg-slate-900/80 border-slate-800 hover:border-slate-700 text-slate-300"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-indigo-500 to-cyan-500 text-white flex items-center justify-center font-bold text-xs flex-shrink-0 shadow-sm">
|
|
U
|
|
</div>
|
|
<div className="truncate">
|
|
<div className="text-xs font-semibold text-slate-200 truncate">user@lambdamail.local</div>
|
|
<div className="text-[10px] text-slate-400 flex items-center gap-1">
|
|
<ShieldCheck className="w-3 h-3 text-emerald-400 inline" /> 2FA Ativo
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Settings className="w-4 h-4 text-slate-400 hover:text-slate-200 transition-colors" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
|
|
{/* Mobile Top Header */}
|
|
<div className="md:hidden flex items-center justify-between p-4 border-b border-slate-800 bg-slate-900/90 z-20 w-full fixed top-0 left-0">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-white font-bold">
|
|
<Mail className="w-4 h-4" />
|
|
</div>
|
|
<span className="font-bold text-slate-100">LambdaMail</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setMobileOpen(!mobileOpen)}
|
|
aria-label={t("ui.toggleMenu")}
|
|
aria-expanded={mobileOpen}
|
|
className="p-2 rounded-lg bg-slate-800 text-slate-300 hover:text-white"
|
|
>
|
|
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Desktop Sidebar */}
|
|
<aside className="hidden md:flex w-64 border-r border-slate-800/80 bg-slate-900/60 backdrop-blur-xl flex-col flex-shrink-0">
|
|
{SidebarContent}
|
|
</aside>
|
|
|
|
{/* Mobile Overlay Menu */}
|
|
<AnimatePresence>
|
|
{mobileOpen && (
|
|
<motion.aside
|
|
initial={{ x: "-100%" }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: "-100%" }}
|
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
|
className="fixed inset-0 z-30 bg-slate-950 w-72 border-r border-slate-800 pt-16 md:hidden"
|
|
>
|
|
{SidebarContent}
|
|
</motion.aside>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Main Content Area */}
|
|
<main className="flex-1 flex flex-col overflow-hidden pt-16 md:pt-0 bg-dark-bg relative">
|
|
{children}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|