fixes
This commit is contained in:
+3
-1
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"}
|
||||
|
||||
+932
-212
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user