#22: fix the admin lockout, and the regressions the UI rewrite brought back

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>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 16:13:14 -03:00
1 parent 2282dc96e9
commit 3a82d82428
26 files changed
+3554 -1402

No files matched your search

@@ -2,7 +2,25 @@
import React, { useEffect, useRef, useState, use } from "react";
import Link from "next/link";
import {
Send,
Paperclip,
Bold,
Italic,
Underline,
Strikethrough,
List,
ListOrdered,
Link as LinkIcon,
Trash2,
Undo2,
X,
Sparkles,
CheckCircle2,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
import { Button } from "../../../../components/ui/Button";
interface AttachedFile {
id: string;
@@ -31,7 +49,6 @@ export default function ComposePage({
const editorRef = useRef<HTMLDivElement>(null);
// Load signature from localStorage
useEffect(() => {
const savedSig = localStorage.getItem("lm_user_signature");
if (editorRef.current && savedSig) {
@@ -39,11 +56,10 @@ export default function ComposePage({
}
}, []);
// Auto-draft debouncer
useEffect(() => {
if (!to && !subject) return;
const timer = setTimeout(() => {
setDraftStatus("Draft auto-saved");
setDraftStatus("Rascunho salvo automaticamente");
}, 2500);
return () => clearTimeout(timer);
}, [to, subject]);
@@ -71,11 +87,10 @@ export default function ComposePage({
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
setSending(true);
setUndoSeconds(30);
setUndoSeconds(10);
const bodyHtml = editorRef.current?.innerHTML || "";
// Simulated network send with undo window
const interval = setInterval(() => {
setUndoSeconds((prev) => {
if (prev === null || prev <= 1) {
@@ -114,81 +129,88 @@ export default function ComposePage({
};
return (
<div className="flex-1 p-6 bg-slate-950 flex flex-col items-center overflow-y-auto">
<div className="glass-panel p-6 rounded-2xl max-w-3xl w-full border border-slate-800 shadow-2xl space-y-4">
<div className="flex-1 p-6 md:p-8 bg-dark-bg flex flex-col items-center overflow-y-auto">
<Card className="max-w-3xl w-full border border-slate-800 shadow-2xl space-y-5">
<div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h1 className="text-lg font-bold text-white flex items-center gap-2">
<span>&#128221;</span> {t("mail.compose")}
<h1 className="text-xl font-bold text-white flex items-center gap-2 tracking-tight">
<Send className="w-5 h-5 text-indigo-400" />
<span>{t("mail.compose")}</span>
</h1>
<span className="text-xs text-emerald-400 font-mono">{draftStatus}</span>
{draftStatus && (
<span className="text-xs text-emerald-400 font-mono flex items-center gap-1">
<CheckCircle2 className="w-3.5 h-3.5" />
{draftStatus}
</span>
)}
</div>
{undoSeconds !== null && (
<div className="p-4 rounded-xl bg-indigo-600/20 border border-indigo-500/40 flex items-center justify-between text-indigo-300 text-xs">
<span>
{t("mail.undoSend")} ({undoSeconds}s remaining)
</span>
<button
onClick={handleUndo}
className="px-4 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-bold transition-colors"
>
Undo Send
</button>
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-indigo-400 animate-spin" />
<span>
{t("mail.undoSend")} ({undoSeconds}s restantes)
</span>
</div>
<Button variant="primary" size="sm" onClick={handleUndo}>
<Undo2 className="w-3.5 h-3.5" />
Desfazer Envio
</Button>
</div>
)}
<form onSubmit={handleSend} className="space-y-3 text-xs">
<form onSubmit={handleSend} className="space-y-4 text-xs">
<div>
<div className="flex justify-between items-center mb-1">
<label className="font-medium text-slate-300">{t("ui.toRecipients")}</label>
<div className="flex justify-between items-center mb-1.5">
<label className="font-semibold text-slate-300">{t("ui.toRecipients")}</label>
<button
type="button"
onClick={() => setShowCcBcc(!showCcBcc)}
className="text-indigo-400 hover:underline"
className="text-indigo-400 hover:text-indigo-300 font-medium hover:underline"
>
{showCcBcc ? "- Hide Cc/Bcc" : "+ Cc/Bcc"}
{showCcBcc ? "- Ocultar Cc/Bcc" : "+ Adicionar Cc/Bcc"}
</button>
</div>
<input
type="text"
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="recipient@domain.com"
placeholder="recipient@example.com"
required
disabled={sending}
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
{showCcBcc && (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="font-medium text-slate-300 mb-1 block">Cc</label>
<label className="font-semibold text-slate-300 mb-1 block">Cc</label>
<input
type="text"
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="cc@domain.com"
disabled={sending}
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="font-medium text-slate-300 mb-1 block">Bcc</label>
<label className="font-semibold text-slate-300 mb-1 block">Bcc</label>
<input
type="text"
value={bcc}
onChange={(e) => setBcc(e.target.value)}
placeholder="bcc@domain.com"
disabled={sending}
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
)}
<div>
<label className="font-medium text-slate-300 mb-1 block">{t("ui.subject")}</label>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("ui.subject")}</label>
<input
type="text"
value={subject}
@@ -196,83 +218,75 @@ export default function ComposePage({
placeholder={t("ui.subject")}
required
disabled={sending}
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
{/* Rich Text Editor Formatting Toolbar */}
{/* Formatting Toolbar */}
<div>
<label className="font-medium text-slate-300 mb-1 block">{t("ui.messageBody")}</label>
<div className="border border-slate-800 rounded-lg overflow-hidden bg-slate-900">
<div className="flex items-center gap-1 p-2 bg-slate-950 border-b border-slate-800 flex-wrap text-slate-300">
<label className="font-semibold text-slate-300 mb-1.5 block">{t("ui.messageBody")}</label>
<div className="border border-slate-800 rounded-xl overflow-hidden bg-slate-900/90">
<div className="flex items-center gap-1 p-2 bg-slate-950/80 border-b border-slate-800/80 flex-wrap text-slate-300">
<button
type="button"
onClick={() => executeFormat("bold")}
className="px-2 py-1 hover:bg-slate-800 rounded font-bold"
title="Bold"
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Negrito"
>
B
<Bold className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => executeFormat("italic")}
className="px-2 py-1 hover:bg-slate-800 rounded italic"
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Italic"
>
I
<Italic className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => executeFormat("underline")}
className="px-2 py-1 hover:bg-slate-800 rounded underline"
title="Underline"
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Sublinhado"
>
U
<Underline className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => executeFormat("strikeThrough")}
className="px-2 py-1 hover:bg-slate-800 rounded line-through"
title="Strikethrough"
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Tachado"
>
S
<Strikethrough className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-slate-800 mx-1" />
<button
type="button"
onClick={() => executeFormat("insertUnorderedList")}
className="px-2 py-1 hover:bg-slate-800 rounded"
title="Bullet List"
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Lista"
>
&bull; List
<List className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => executeFormat("insertOrderedList")}
className="px-2 py-1 hover:bg-slate-800 rounded"
title="Numbered List"
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Lista Numerada"
>
1. List
<ListOrdered className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-slate-800 mx-1" />
<button
type="button"
onClick={() => {
const url = prompt("Enter URL:");
const url = prompt("Insira a URL:");
if (url) executeFormat("createLink", url);
}}
className="px-2 py-1 hover:bg-slate-800 rounded text-indigo-400"
title="Insert Link"
className="p-1.5 hover:bg-slate-800 rounded-lg text-indigo-400 hover:text-indigo-300"
title="Inserir Link"
>
Link
</button>
<button
type="button"
onClick={() => executeFormat("removeFormat")}
className="px-2 py-1 hover:bg-slate-800 rounded text-slate-500"
title="Clear Formatting"
>
Clear
<LinkIcon className="w-4 h-4" />
</button>
</div>
@@ -288,9 +302,10 @@ export default function ComposePage({
{/* Attachment Selector & Display */}
<div className="pt-2">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-slate-300">Attachments ({attachments.length})</span>
<label className="cursor-pointer text-indigo-400 hover:underline flex items-center gap-1 font-medium">
<span>&#128206; Add Files...</span>
<span className="font-semibold text-slate-300">Anexos ({attachments.length})</span>
<label className="cursor-pointer text-indigo-400 hover:text-indigo-300 flex items-center gap-1.5 font-medium">
<Paperclip className="w-3.5 h-3.5" />
<span>Adicionar Arquivos...</span>
<input
type="file"
multiple
@@ -305,9 +320,9 @@ export default function ComposePage({
{attachments.map((file) => (
<div
key={file.id}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-[11px] text-slate-200"
className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200"
>
<span>&#128196;</span>
<Paperclip className="w-3 h-3 text-indigo-400" />
<span className="font-medium max-w-[140px] truncate">{file.name}</span>
<span className="text-[10px] text-slate-500">
({(file.size / 1024).toFixed(1)} KB)
@@ -315,9 +330,9 @@ export default function ComposePage({
<button
type="button"
onClick={() => removeAttachment(file.id)}
className="text-red-400 hover:text-red-300 font-bold ml-1"
className="text-slate-400 hover:text-rose-400 ml-1"
>
&times;
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
@@ -326,20 +341,19 @@ export default function ComposePage({
</div>
<div className="flex items-center justify-between pt-4 border-t border-slate-800">
<Link href="/user/mail/inbox" className="text-slate-400 hover:text-slate-200">
Discard
<Link href="/user/mail/inbox">
<Button variant="ghost" size="md">
Descartar
</Button>
</Link>
<button
type="submit"
disabled={sending}
className="py-2.5 px-6 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-xs transition-colors shadow-lg shadow-indigo-600/20 disabled:opacity-50"
>
{sending ? "Queued..." : "Send Message"}
</button>
<Button type="submit" variant="primary" size="md" disabled={sending}>
<Send className="w-4 h-4" />
<span>{sending ? "Sending..." : "Send message"}</span>
</Button>
</div>
</form>
</div>
</Card>
</div>
);
}