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"> <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"
+42 -14
View File
@@ -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">
<label className="field"> <div className="link-field">
<span>{isInvite ? "Setup link" : "One-time link"}</span> <label className="field">
<input type="text" readOnly value={resetLink} /> <span>{isInvite ? "Setup link" : "One-time link"}</span>
</label> <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"> <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>
+24 -5
View File
@@ -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}
/> />
+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 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="eyebrow">Admin Panel</div> <div className="logo-mark" aria-hidden="true">@</div>
<h1>Mailbox users</h1> <div className="brand-text">
<p className="page-copy"> <div className="eyebrow">Admin Panel</div>
Create and manage Docker Mailserver accounts. <h1>Mailbox users</h1>
</p> <p className="page-copy">Create and manage Docker Mailserver accounts.</p>
</div>
</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>
</button> </div>
<button className="ghost-button" onClick={onLogout} disabled={busy}> <div className="header-icon-group">
Sign out <button
</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> </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>
<input <div className="search-wrap">
type="search" <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
value={query} <circle cx="11" cy="11" r="7" />
onChange={(event) => setQuery(event.target.value)} <path d="m21 21-4.3-4.3" />
placeholder="Search by email" </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> </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,88 +205,116 @@ 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>
<div className="table-wrap"> {filteredUsers.length === 0 ? (
<table> <div className="empty-state">
<thead> <div className="empty-illust" aria-hidden="true">✉️</div>
<tr> <h2>{normalizedQuery ? "No match" : "No users yet"}</h2>
<th>Email</th> <p>{normalizedQuery ? "Try another email address, or create this mailbox directly." : "Create your first mailbox with the button below."}</p>
<th>Local part</th> {normalizedQuery ? (
<th>Domain</th> <button type="button" className="ghost-button" onClick={() => setQuery("")}>
<th>Actions</th> Clear search
</tr> </button>
</thead> ) : null}
<tbody> </div>
{filteredUsers.length === 0 ? ( ) : (
<tr> <>
<td colSpan={4} className="empty-row"> <ul className="user-cards">
No users found. {filteredUsers.map((user) => (
</td> <li key={user.email} className="user-card">
</tr> <div className="user-card-top">
) : ( <span className="user-avatar" aria-hidden="true">{initialOf(user.email)}</span>
filteredUsers.map((user) => ( <div className="user-identity">
<tr key={user.email}> <div className="user-email">{user.email}</div>
<td>{user.email}</td> <div className="user-sub">
<td>{user.localPart}</td> <span className="domain-chip">@{user.domain}</span>
<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>
</div> </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> </tr>
)) </thead>
)} <tbody>
</tbody> {filteredUsers.map((user) => (
</table> <tr key={user.email}>
</div> <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> </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>
); );
} }
+36 -15
View File
@@ -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="eyebrow">Mailserver</div> <div className="auth-top">
<h1>User management</h1> <div className="logo-mark" aria-hidden="true">@</div>
{/* <p className="auth-copy"> <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. 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>
<input <div className="password-wrap">
type="password" <input
value={accessKey} type={showKey ? "text" : "password"}
onChange={(event) => setAccessKey(event.target.value)} value={accessKey}
autoComplete="current-password" onChange={(event) => setAccessKey(event.target.value)}
autoFocus autoComplete="current-password"
disabled={busy} 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> </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>
); );
+32 -14
View File
@@ -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="eyebrow">{mode === "invite" ? "Mailbox setup" : "Secure reset"}</div> <div className="auth-top">
<h1>{mode === "invite" ? "Create your password" : "Choose a new password"}</h1> <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} {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>
<input <div className="password-wrap">
type="password" <input
value={password} type={showPasswords ? "text" : "password"}
onChange={(event) => setPassword(event.target.value)} value={password}
autoFocus onChange={(event) => setPassword(event.target.value)}
disabled={busy} 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>
<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"}
File diff suppressed because it is too large Load Diff