This commit is contained in:
Anaïs Saraiva
2026-10-02 10:57:38 +02:00
parent 95cfbff731
commit 487aaad873
7 changed files with 1324 additions and 360 deletions
+3 -1
View File
@@ -2,7 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b0d10" />
<meta name="color-scheme" content="dark" />
<title>Internal iHorizon Mail System</title>
<meta
name="description"
+42 -14
View File
@@ -1,3 +1,5 @@
import { useEffect, useState } from "react";
type ResetLinkModalProps = {
variant?: "invite" | "reset";
email: string;
@@ -13,50 +15,76 @@ export default function ResetLinkModal({
expiresAt,
onClose
}: ResetLinkModalProps) {
const [copied, setCopied] = useState(false);
useEffect(() => {
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(resetLink);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// The field stays visible even if clipboard access is unavailable.
// Fallback: select the field so the user can copy manually.
setCopied(false);
}
};
const isInvite = variant === "invite";
return (
<div className="modal-backdrop" role="presentation">
<div className="modal-card" role="dialog" aria-modal="true">
<div className="modal-backdrop" role="presentation" onClick={onClose}>
<div
className="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="reset-link-title"
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">
<div>
<h2>{isInvite ? "Mailbox setup link" : "Temporary reset link"}</h2>
<h2 id="reset-link-title">{isInvite ? "Mailbox setup link" : "Temporary reset link"}</h2>
<p>
{isInvite
? <>Share this one-time setup link for <strong>{email}</strong>. The mailbox will only be created after the user chooses a password.</>
: <>No email was sent for <strong>{email}</strong>. Share this one-time link yourself.</>}
</p>
</div>
<button type="button" className="ghost-button" onClick={onClose}>
Close
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
✕
</button>
</div>
<div className="stack">
<label className="field">
<span>{isInvite ? "Setup link" : "One-time link"}</span>
<input type="text" readOnly value={resetLink} />
</label>
<div className="link-field">
<label className="field">
<span>{isInvite ? "Setup link" : "One-time link"}</span>
<div className="link-row">
<input type="text" readOnly value={resetLink} onFocus={(e) => e.target.select()} />
<button type="button" className="ghost-button" onClick={handleCopy}>
{copied ? "Copied ✓" : "Copy"}
</button>
</div>
</label>
<span className="copy-feedback" aria-live="polite">{copied ? "Link copied to clipboard." : ""}</span>
</div>
<p className="modal-copy">
This link expires at {expiresAt} and becomes invalid after first use.
</p>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose}>
<button type="button" className="primary-button" onClick={onClose}>
Done
</button>
<button type="button" className="primary-button" onClick={handleCopy}>
Copy link
</button>
</div>
</div>
</div>
+24 -5
View File
@@ -19,6 +19,16 @@ export default function UserFormModal({
setError(null);
}, []);
useEffect(() => {
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && !busy) {
onClose();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [busy, onClose]);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
@@ -40,17 +50,23 @@ export default function UserFormModal({
};
return (
<div className="modal-backdrop" role="presentation">
<div className="modal-card" role="dialog" aria-modal="true">
<div className="modal-backdrop" role="presentation" onClick={() => !busy && onClose()}>
<div
className="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="create-user-title"
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">
<div>
<h2>Create user</h2>
<h2 id="create-user-title">Create user</h2>
<p>
A 48-hour setup link will be generated. The mailbox will only be created after the user chooses a password through that link.
</p>
</div>
<button type="button" className="ghost-button" onClick={onClose} disabled={busy}>
Close
<button type="button" className="modal-close" onClick={onClose} disabled={busy} aria-label="Close">
✕
</button>
</div>
@@ -62,6 +78,9 @@ export default function UserFormModal({
value={nextEmail}
onChange={(event) => setNextEmail(event.target.value)}
placeholder="[email protected]"
autoComplete="email"
inputMode="email"
enterKeyHint="done"
autoFocus
disabled={busy}
/>
+255 -99
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import type { CreateUserResponse, MailUser, PasswordResetTriggerResponse } from "@internal-mail/shared";
import ResetLinkModal from "../components/ResetLinkModal";
import UserFormModal from "../components/UserFormModal";
@@ -16,6 +16,11 @@ type DashboardPageProps = {
onLogout: () => Promise<void>;
};
function initialOf(email: string): string {
const trimmed = email.trim();
return trimmed ? trimmed.charAt(0).toUpperCase() : "?";
}
export default function DashboardPage({
adminEmail,
users,
@@ -33,6 +38,9 @@ export default function DashboardPage({
const [modalBusy, setModalBusy] = useState(false);
const [inlineMessage, setInlineMessage] = useState<string | null>(null);
const [inlineError, setInlineError] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [linkBusyFor, setLinkBusyFor] = useState<string | null>(null);
const [resetLinkData, setResetLinkData] = useState<{
email: string;
resetLink: string;
@@ -40,9 +48,13 @@ export default function DashboardPage({
} | null>(null);
const normalizedQuery = query.trim().toLowerCase();
const filteredUsers = normalizedQuery
? users.filter((user) => user.email.includes(normalizedQuery))
: users;
const filteredUsers = useMemo(
() =>
normalizedQuery
? users.filter((user) => user.email.toLowerCase().includes(normalizedQuery))
: users,
[users, normalizedQuery]
);
const handleCreate = async ({ email }: { email?: string }) => {
if (!email) {
@@ -66,41 +78,125 @@ export default function DashboardPage({
}
};
const handleGenerateLink = async (email: string) => {
setInlineError(null);
setLinkBusyFor(email);
try {
const result = await onTriggerReset(email);
setResetLinkData({
email,
resetLink: result.resetLink,
expiresAt: result.expiresAt
});
setInlineMessage(`Temporary reset link generated for ${email}.`);
} catch (error) {
setInlineError(error instanceof Error ? error.message : "Could not generate reset link.");
} finally {
setLinkBusyFor(null);
}
};
const handleConfirmDelete = async () => {
if (!pendingDelete) {
return;
}
setDeleteBusy(true);
setInlineError(null);
try {
await onDeleteUser(pendingDelete);
setInlineMessage(`Deleted ${pendingDelete}.`);
setPendingDelete(null);
} catch (error) {
setInlineError(error instanceof Error ? error.message : "Could not delete user.");
} finally {
setDeleteBusy(false);
}
};
return (
<main className="page-shell">
<header className="page-header">
<div>
<div className="eyebrow">Admin Panel</div>
<h1>Mailbox users</h1>
<p className="page-copy">
Create and manage Docker Mailserver accounts.
</p>
<div className="brand-row">
<div className="logo-mark" aria-hidden="true">@</div>
<div className="brand-text">
<div className="eyebrow">Admin Panel</div>
<h1>Mailbox users</h1>
<p className="page-copy">Create and manage Docker Mailserver accounts.</p>
</div>
</div>
<div className="header-actions">
<div className="admin-pill">{adminEmail}</div>
<button className="ghost-button" onClick={onRefresh} disabled={busy}>
Refresh
</button>
<button className="ghost-button" onClick={onLogout} disabled={busy}>
Sign out
</button>
<div className="admin-pill" title={adminEmail}>
<span className="admin-avatar" aria-hidden="true">{initialOf(adminEmail)}</span>
<span className="admin-email">{adminEmail}</span>
</div>
<div className="header-icon-group">
<button
type="button"
className="icon-button"
onClick={onRefresh}
disabled={busy}
aria-label="Refresh users"
title="Refresh"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
</button>
<button
type="button"
className="icon-button"
onClick={onLogout}
disabled={busy}
aria-label="Sign out"
title="Sign out"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</svg>
</button>
</div>
</div>
</header>
<section className="toolbar-card">
<section className="toolbar-card" aria-label="Search and create">
<label className="search-field">
<span>Search</span>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by email"
/>
<span>Search users</span>
<div className="search-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search by email"
autoComplete="off"
enterKeyHint="search"
/>
{query ? (
<button
type="button"
className="search-clear"
onClick={() => setQuery("")}
aria-label="Clear search"
>
×
</button>
) : null}
</div>
</label>
<button className="primary-button" onClick={() => setCreateOpen(true)} disabled={busy}>
Create user
<div className="toolbar-meta">
<span className="count-badge">{filteredUsers.length} mailbox{filteredUsers.length === 1 ? "" : "es"}</span>
</div>
<button className="primary-button toolbar-create" onClick={() => setCreateOpen(true)} disabled={busy}>
+ Create user
</button>
</section>
@@ -109,88 +205,116 @@ export default function DashboardPage({
{inlineMessage ? <p className="notice success">{inlineMessage}</p> : null}
{inlineError ? <p className="notice error">{inlineError}</p> : null}
<section className="table-card">
<section className="table-card" aria-label="Users">
<div className="table-header">
<div>
<h2>Users</h2>
<p>{filteredUsers.length} mailbox{filteredUsers.length === 1 ? "" : "es"}</p>
<p>{filteredUsers.length} mailbox{filteredUsers.length === 1 ? "" : "es"}{normalizedQuery ? ` for “${query.trim()}”` : ""}</p>
</div>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Email</th>
<th>Local part</th>
<th>Domain</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filteredUsers.length === 0 ? (
<tr>
<td colSpan={4} className="empty-row">
No users found.
</td>
</tr>
) : (
filteredUsers.map((user) => (
<tr key={user.email}>
<td>{user.email}</td>
<td>{user.localPart}</td>
<td>{user.domain}</td>
<td>
<div className="table-actions">
<button
className="ghost-button"
onClick={async () => {
setInlineError(null);
try {
const result = await onTriggerReset(user.email);
setResetLinkData({
email: user.email,
resetLink: result.resetLink,
expiresAt: result.expiresAt
});
setInlineMessage(`Temporary reset link generated for ${user.email}.`);
} catch (error) {
setInlineError(error instanceof Error ? error.message : "Could not generate reset link.");
}
}}
disabled={busy}
>
Generate link
</button>
<button
className="danger-button"
onClick={async () => {
const confirmed = window.confirm(`Delete ${user.email}?`);
if (!confirmed) {
return;
}
setInlineError(null);
try {
await onDeleteUser(user.email);
setInlineMessage(`Deleted ${user.email}.`);
} catch (error) {
setInlineError(error instanceof Error ? error.message : "Could not delete user.");
}
}}
disabled={busy}
>
Delete
</button>
{filteredUsers.length === 0 ? (
<div className="empty-state">
<div className="empty-illust" aria-hidden="true">✉️</div>
<h2>{normalizedQuery ? "No match" : "No users yet"}</h2>
<p>{normalizedQuery ? "Try another email address, or create this mailbox directly." : "Create your first mailbox with the button below."}</p>
{normalizedQuery ? (
<button type="button" className="ghost-button" onClick={() => setQuery("")}>
Clear search
</button>
) : null}
</div>
) : (
<>
<ul className="user-cards">
{filteredUsers.map((user) => (
<li key={user.email} className="user-card">
<div className="user-card-top">
<span className="user-avatar" aria-hidden="true">{initialOf(user.email)}</span>
<div className="user-identity">
<div className="user-email">{user.email}</div>
<div className="user-sub">
<span className="domain-chip">@{user.domain}</span>
</div>
</td>
</div>
</div>
<div className="user-card-actions">
<button
type="button"
className="ghost-button"
onClick={() => handleGenerateLink(user.email)}
disabled={busy || linkBusyFor !== null}
>
{linkBusyFor === user.email ? "…" : "Reset link"}
</button>
<button
type="button"
className="danger-button"
onClick={() => setPendingDelete(user.email)}
disabled={busy}
>
Delete
</button>
</div>
</li>
))}
</ul>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Email</th>
<th>Local part</th>
<th>Domain</th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr>
))
)}
</tbody>
</table>
</div>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.email}>
<td>{user.email}</td>
<td>{user.localPart}</td>
<td>{user.domain}</td>
<td>
<div className="table-actions">
<button
type="button"
className="ghost-button"
onClick={() => handleGenerateLink(user.email)}
disabled={busy || linkBusyFor !== null}
>
{linkBusyFor === user.email ? "Generating…" : "Generate link"}
</button>
<button
type="button"
className="danger-button"
onClick={() => setPendingDelete(user.email)}
disabled={busy}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</section>
<button
type="button"
className="fab"
onClick={() => setCreateOpen(true)}
disabled={busy}
aria-label="Create user"
>
<span className="fab-plus" aria-hidden="true">+</span> New user
</button>
{createOpen ? (
<UserFormModal
busy={modalBusy}
@@ -208,6 +332,38 @@ export default function DashboardPage({
onClose={() => setResetLinkData(null)}
/>
) : null}
{pendingDelete ? (
<div className="modal-backdrop" role="presentation" onClick={() => !deleteBusy && setPendingDelete(null)}>
<div
className="modal-card"
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-title"
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">
<div>
<h2 id="delete-title">Delete mailbox?</h2>
<p className="danger-zone-text">
This will permanently delete <strong>{pendingDelete}</strong>. This cannot be undone.
</p>
</div>
<button type="button" className="modal-close" onClick={() => setPendingDelete(null)} disabled={deleteBusy} aria-label="Close">
✕
</button>
</div>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setPendingDelete(null)} disabled={deleteBusy}>
Cancel
</button>
<button type="button" className="danger-button" onClick={handleConfirmDelete} disabled={deleteBusy}>
{deleteBusy ? "Deleting…" : "Delete"}
</button>
</div>
</div>
</div>
) : null}
</main>
);
}
+36 -15
View File
@@ -8,6 +8,7 @@ type LoginPageProps = {
export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
const [accessKey, setAccessKey] = useState("");
const [showKey, setShowKey] = useState(false);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -18,32 +19,52 @@ export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
return (
<main className="auth-shell">
<section className="auth-card">
<div className="eyebrow">Mailserver</div>
<h1>User management</h1>
{/* <p className="auth-copy">
<section className="auth-card" aria-labelledby="login-title">
<div className="auth-top">
<div className="logo-mark" aria-hidden="true">@</div>
<div>
<div className="eyebrow">Mailserver</div>
<div style={{ fontSize: "0.85rem", color: "var(--text-muted)" }}>Internal admin access</div>
</div>
</div>
<h1 id="login-title">User management</h1>
<p className="auth-copy">
Sign in with the access key configured for this panel.
</p> */}
</p>
<form className="stack" onSubmit={handleSubmit}>
<label className="field">
<span>Access key</span>
<input
type="password"
value={accessKey}
onChange={(event) => setAccessKey(event.target.value)}
autoComplete="current-password"
autoFocus
disabled={busy}
/>
<div className="password-wrap">
<input
type={showKey ? "text" : "password"}
value={accessKey}
onChange={(event) => setAccessKey(event.target.value)}
autoComplete="current-password"
enterKeyHint="go"
autoFocus
disabled={busy}
placeholder="••••••••"
/>
<button
type="button"
className="password-toggle"
onClick={() => setShowKey((v) => !v)}
aria-label={showKey ? "Hide access key" : "Show access key"}
>
{showKey ? "Hide" : "Show"}
</button>
</div>
</label>
{error ? <p className="form-error">{error}</p> : null}
{error ? <p className="form-error" role="alert">{error}</p> : null}
<button type="submit" className="primary-button" disabled={busy}>
<button type="submit" className="primary-button" disabled={busy || !accessKey.trim()}>
{busy ? "Signing in..." : "Sign in"}
</button>
</form>
<div className="auth-foot">Protected panel — keys are never shown on screen.</div>
</section>
</main>
);
+32 -14
View File
@@ -12,6 +12,7 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
const [expiresAt, setExpiresAt] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPasswords, setShowPasswords] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -74,9 +75,12 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
return (
<main className="auth-shell">
<section className="auth-card">
<div className="eyebrow">{mode === "invite" ? "Mailbox setup" : "Secure reset"}</div>
<h1>{mode === "invite" ? "Create your password" : "Choose a new password"}</h1>
<section className="auth-card" aria-labelledby="reset-title">
<div className="auth-top">
<div className="logo-mark" aria-hidden="true">@</div>
<div className="eyebrow">{mode === "invite" ? "Mailbox setup" : "Secure reset"}</div>
</div>
<h1 id="reset-title">{mode === "invite" ? "Create your password" : "Choose a new password"}</h1>
{status === "loading" ? <p className="auth-copy">Checking your reset link…</p> : null}
@@ -87,7 +91,7 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
? "This setup link is no longer valid. Ask your administrator for a new setup link."
: "This link is no longer valid. Ask your administrator for a new reset link."}
</p>
{error ? <p className="form-error">{error}</p> : null}
{error ? <p className="form-error" role="alert">{error}</p> : null}
</>
) : null}
@@ -109,27 +113,41 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
<form className="stack" onSubmit={handleSubmit}>
<label className="field">
<span>New password</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoFocus
disabled={busy}
/>
<span>New password (min. 12 characters)</span>
<div className="password-wrap">
<input
type={showPasswords ? "text" : "password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
enterKeyHint="next"
autoFocus
disabled={busy}
/>
<button
type="button"
className="password-toggle"
onClick={() => setShowPasswords((v) => !v)}
aria-label={showPasswords ? "Hide passwords" : "Show passwords"}
>
{showPasswords ? "Hide" : "Show"}
</button>
</div>
</label>
<label className="field">
<span>Confirm password</span>
<input
type="password"
type={showPasswords ? "text" : "password"}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
enterKeyHint="done"
disabled={busy}
/>
</label>
{error ? <p className="form-error">{error}</p> : null}
{error ? <p className="form-error" role="alert">{error}</p> : null}
<button type="submit" className="primary-button" disabled={busy}>
{busy ? "Updating..." : "Set password"}
File diff suppressed because it is too large Load Diff