fixes
This commit is contained in:
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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>
|
<title>Internal iHorizon Mail System</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type ResetLinkModalProps = {
|
type ResetLinkModalProps = {
|
||||||
variant?: "invite" | "reset";
|
variant?: "invite" | "reset";
|
||||||
email: string;
|
email: string;
|
||||||
@@ -13,50 +15,76 @@ export default function ResetLinkModal({
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
onClose
|
onClose
|
||||||
}: ResetLinkModalProps) {
|
}: 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 () => {
|
const handleCopy = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(resetLink);
|
await navigator.clipboard.writeText(resetLink);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 2000);
|
||||||
} catch {
|
} 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";
|
const isInvite = variant === "invite";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" role="presentation">
|
<div className="modal-backdrop" role="presentation" onClick={onClose}>
|
||||||
<div className="modal-card" role="dialog" aria-modal="true">
|
<div
|
||||||
|
className="modal-card"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="reset-link-title"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>{isInvite ? "Mailbox setup link" : "Temporary reset link"}</h2>
|
<h2 id="reset-link-title">{isInvite ? "Mailbox setup link" : "Temporary reset link"}</h2>
|
||||||
<p>
|
<p>
|
||||||
{isInvite
|
{isInvite
|
||||||
? <>Share this one-time setup link for <strong>{email}</strong>. The mailbox will only be created after the user chooses a password.</>
|
? <>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.</>}
|
: <>No email was sent for <strong>{email}</strong>. Share this one-time link yourself.</>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="ghost-button" onClick={onClose}>
|
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
|
||||||
Close
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
|
<div className="link-field">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{isInvite ? "Setup link" : "One-time link"}</span>
|
<span>{isInvite ? "Setup link" : "One-time link"}</span>
|
||||||
<input type="text" readOnly value={resetLink} />
|
<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>
|
</label>
|
||||||
|
<span className="copy-feedback" aria-live="polite">{copied ? "Link copied to clipboard." : ""}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="modal-copy">
|
<p className="modal-copy">
|
||||||
This link expires at {expiresAt} and becomes invalid after first use.
|
This link expires at {expiresAt} and becomes invalid after first use.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button type="button" className="ghost-button" onClick={onClose}>
|
<button type="button" className="primary-button" onClick={onClose}>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="primary-button" onClick={handleCopy}>
|
|
||||||
Copy link
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export default function UserFormModal({
|
|||||||
setError(null);
|
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>) => {
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -40,17 +50,23 @@ export default function UserFormModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" role="presentation">
|
<div className="modal-backdrop" role="presentation" onClick={() => !busy && onClose()}>
|
||||||
<div className="modal-card" role="dialog" aria-modal="true">
|
<div
|
||||||
|
className="modal-card"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="create-user-title"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Create user</h2>
|
<h2 id="create-user-title">Create user</h2>
|
||||||
<p>
|
<p>
|
||||||
A 48-hour setup link will be generated. The mailbox will only be created after the user chooses a password through that link.
|
A 48-hour setup link will be generated. The mailbox will only be created after the user chooses a password through that link.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="ghost-button" onClick={onClose} disabled={busy}>
|
<button type="button" className="modal-close" onClick={onClose} disabled={busy} aria-label="Close">
|
||||||
Close
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,6 +78,9 @@ export default function UserFormModal({
|
|||||||
value={nextEmail}
|
value={nextEmail}
|
||||||
onChange={(event) => setNextEmail(event.target.value)}
|
onChange={(event) => setNextEmail(event.target.value)}
|
||||||
placeholder="[email protected]"
|
placeholder="[email protected]"
|
||||||
|
autoComplete="email"
|
||||||
|
inputMode="email"
|
||||||
|
enterKeyHint="done"
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { CreateUserResponse, MailUser, PasswordResetTriggerResponse } from "@internal-mail/shared";
|
import type { CreateUserResponse, MailUser, PasswordResetTriggerResponse } from "@internal-mail/shared";
|
||||||
import ResetLinkModal from "../components/ResetLinkModal";
|
import ResetLinkModal from "../components/ResetLinkModal";
|
||||||
import UserFormModal from "../components/UserFormModal";
|
import UserFormModal from "../components/UserFormModal";
|
||||||
@@ -16,6 +16,11 @@ type DashboardPageProps = {
|
|||||||
onLogout: () => Promise<void>;
|
onLogout: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function initialOf(email: string): string {
|
||||||
|
const trimmed = email.trim();
|
||||||
|
return trimmed ? trimmed.charAt(0).toUpperCase() : "?";
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage({
|
export default function DashboardPage({
|
||||||
adminEmail,
|
adminEmail,
|
||||||
users,
|
users,
|
||||||
@@ -33,6 +38,9 @@ export default function DashboardPage({
|
|||||||
const [modalBusy, setModalBusy] = useState(false);
|
const [modalBusy, setModalBusy] = useState(false);
|
||||||
const [inlineMessage, setInlineMessage] = useState<string | null>(null);
|
const [inlineMessage, setInlineMessage] = useState<string | null>(null);
|
||||||
const [inlineError, setInlineError] = 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<{
|
const [resetLinkData, setResetLinkData] = useState<{
|
||||||
email: string;
|
email: string;
|
||||||
resetLink: string;
|
resetLink: string;
|
||||||
@@ -40,9 +48,13 @@ export default function DashboardPage({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const filteredUsers = normalizedQuery
|
const filteredUsers = useMemo(
|
||||||
? users.filter((user) => user.email.includes(normalizedQuery))
|
() =>
|
||||||
: users;
|
normalizedQuery
|
||||||
|
? users.filter((user) => user.email.toLowerCase().includes(normalizedQuery))
|
||||||
|
: users,
|
||||||
|
[users, normalizedQuery]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreate = async ({ email }: { email?: string }) => {
|
const handleCreate = async ({ email }: { email?: string }) => {
|
||||||
if (!email) {
|
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 (
|
return (
|
||||||
<main className="page-shell">
|
<main className="page-shell">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<div>
|
<div className="brand-row">
|
||||||
|
<div className="logo-mark" aria-hidden="true">@</div>
|
||||||
|
<div className="brand-text">
|
||||||
<div className="eyebrow">Admin Panel</div>
|
<div className="eyebrow">Admin Panel</div>
|
||||||
<h1>Mailbox users</h1>
|
<h1>Mailbox users</h1>
|
||||||
<p className="page-copy">
|
<p className="page-copy">Create and manage Docker Mailserver accounts.</p>
|
||||||
Create and manage Docker Mailserver accounts.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<div className="admin-pill">{adminEmail}</div>
|
<div className="admin-pill" title={adminEmail}>
|
||||||
<button className="ghost-button" onClick={onRefresh} disabled={busy}>
|
<span className="admin-avatar" aria-hidden="true">{initialOf(adminEmail)}</span>
|
||||||
Refresh
|
<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>
|
||||||
<button className="ghost-button" onClick={onLogout} disabled={busy}>
|
<button
|
||||||
Sign out
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="toolbar-card">
|
<section className="toolbar-card" aria-label="Search and create">
|
||||||
<label className="search-field">
|
<label className="search-field">
|
||||||
<span>Search</span>
|
<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
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
placeholder="Search by email"
|
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>
|
</label>
|
||||||
|
|
||||||
<button className="primary-button" onClick={() => setCreateOpen(true)} disabled={busy}>
|
<div className="toolbar-meta">
|
||||||
Create user
|
<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>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -109,14 +205,61 @@ export default function DashboardPage({
|
|||||||
{inlineMessage ? <p className="notice success">{inlineMessage}</p> : null}
|
{inlineMessage ? <p className="notice success">{inlineMessage}</p> : null}
|
||||||
{inlineError ? <p className="notice error">{inlineError}</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 className="table-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Users</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
</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">
|
<div className="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -124,18 +267,11 @@ export default function DashboardPage({
|
|||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Local part</th>
|
<th>Local part</th>
|
||||||
<th>Domain</th>
|
<th>Domain</th>
|
||||||
<th>Actions</th>
|
<th style={{ textAlign: "right" }}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredUsers.length === 0 ? (
|
{filteredUsers.map((user) => (
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="empty-row">
|
|
||||||
No users found.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredUsers.map((user) => (
|
|
||||||
<tr key={user.email}>
|
<tr key={user.email}>
|
||||||
<td>{user.email}</td>
|
<td>{user.email}</td>
|
||||||
<td>{user.localPart}</td>
|
<td>{user.localPart}</td>
|
||||||
@@ -143,40 +279,17 @@ export default function DashboardPage({
|
|||||||
<td>
|
<td>
|
||||||
<div className="table-actions">
|
<div className="table-actions">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="ghost-button"
|
className="ghost-button"
|
||||||
onClick={async () => {
|
onClick={() => handleGenerateLink(user.email)}
|
||||||
setInlineError(null);
|
disabled={busy || linkBusyFor !== 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
|
{linkBusyFor === user.email ? "Generating…" : "Generate link"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="danger-button"
|
className="danger-button"
|
||||||
onClick={async () => {
|
onClick={() => setPendingDelete(user.email)}
|
||||||
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}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -184,13 +297,24 @@ export default function DashboardPage({
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</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 ? (
|
{createOpen ? (
|
||||||
<UserFormModal
|
<UserFormModal
|
||||||
busy={modalBusy}
|
busy={modalBusy}
|
||||||
@@ -208,6 +332,38 @@ export default function DashboardPage({
|
|||||||
onClose={() => setResetLinkData(null)}
|
onClose={() => setResetLinkData(null)}
|
||||||
/>
|
/>
|
||||||
) : 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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type LoginPageProps = {
|
|||||||
|
|
||||||
export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
|
export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
|
||||||
const [accessKey, setAccessKey] = useState("");
|
const [accessKey, setAccessKey] = useState("");
|
||||||
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -18,32 +19,52 @@ export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-shell">
|
<main className="auth-shell">
|
||||||
<section className="auth-card">
|
<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 className="eyebrow">Mailserver</div>
|
||||||
<h1>User management</h1>
|
<div style={{ fontSize: "0.85rem", color: "var(--text-muted)" }}>Internal admin access</div>
|
||||||
{/* <p className="auth-copy">
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 id="login-title">User management</h1>
|
||||||
|
<p className="auth-copy">
|
||||||
Sign in with the access key configured for this panel.
|
Sign in with the access key configured for this panel.
|
||||||
</p> */}
|
</p>
|
||||||
|
|
||||||
<form className="stack" onSubmit={handleSubmit}>
|
<form className="stack" onSubmit={handleSubmit}>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Access key</span>
|
<span>Access key</span>
|
||||||
|
<div className="password-wrap">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showKey ? "text" : "password"}
|
||||||
value={accessKey}
|
value={accessKey}
|
||||||
onChange={(event) => setAccessKey(event.target.value)}
|
onChange={(event) => setAccessKey(event.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
enterKeyHint="go"
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={busy}
|
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>
|
</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"}
|
{busy ? "Signing in..." : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div className="auth-foot">Protected panel — keys are never shown on screen.</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
|
|||||||
const [expiresAt, setExpiresAt] = useState("");
|
const [expiresAt, setExpiresAt] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [showPasswords, setShowPasswords] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
@@ -74,9 +75,12 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-shell">
|
<main className="auth-shell">
|
||||||
<section className="auth-card">
|
<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 className="eyebrow">{mode === "invite" ? "Mailbox setup" : "Secure reset"}</div>
|
||||||
<h1>{mode === "invite" ? "Create your password" : "Choose a new password"}</h1>
|
</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}
|
{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 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."}
|
: "This link is no longer valid. Ask your administrator for a new reset link."}
|
||||||
</p>
|
</p>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -109,27 +113,41 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
|
|||||||
|
|
||||||
<form className="stack" onSubmit={handleSubmit}>
|
<form className="stack" onSubmit={handleSubmit}>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>New password</span>
|
<span>New password (min. 12 characters)</span>
|
||||||
|
<div className="password-wrap">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showPasswords ? "text" : "password"}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
enterKeyHint="next"
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={busy}
|
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>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Confirm password</span>
|
<span>Confirm password</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showPasswords ? "text" : "password"}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
enterKeyHint="done"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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}>
|
||||||
{busy ? "Updating..." : "Set password"}
|
{busy ? "Updating..." : "Set password"}
|
||||||
|
|||||||
+910
-190
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user