This commit is contained in:
Debian
2026-03-30 14:24:39 +00:00
parent dec232212d
commit 87198824b6
15 changed files with 178 additions and 216 deletions
+2 -17
View File
@@ -56,7 +56,7 @@ export default function App() {
});
}, [path]);
const handleLogin = async (payload: { email: string; password: string }) => {
const handleLogin = async (payload: { accessKey: string }) => {
setBusy(true);
setLoginError(null);
@@ -101,19 +101,6 @@ export default function App() {
}
};
const handleUpdatePassword = async (email: string, password: string) => {
setBusy(true);
setFeedback(null);
setErrorMessage(null);
try {
await api.updatePassword(email, { password });
await loadUsers();
} finally {
setBusy(false);
}
};
const handleDeleteUser = async (email: string) => {
setBusy(true);
setFeedback(null);
@@ -133,8 +120,7 @@ export default function App() {
setErrorMessage(null);
try {
const result = await api.triggerPasswordReset(email);
return result.expiresAt;
return await api.triggerPasswordReset(email);
} finally {
setBusy(false);
}
@@ -186,7 +172,6 @@ export default function App() {
feedback={feedback}
errorMessage={errorMessage}
onCreateUser={handleCreateUser}
onUpdatePassword={handleUpdatePassword}
onDeleteUser={handleDeleteUser}
onTriggerReset={handleTriggerReset}
onRefresh={handleRefresh}
-7
View File
@@ -7,7 +7,6 @@ import type {
PasswordResetInspectResponse,
PasswordResetTriggerResponse,
SessionResponse,
UpdateUserPasswordRequest,
UsersResponse
} from "@internal-mail/shared";
@@ -57,11 +56,6 @@ export const api = {
listUsers: () => request<UsersResponse>("/api/users"),
createUser: (body: CreateUserRequest) =>
request<CreateUserResponse>("/api/users", { method: "POST", body }),
updatePassword: (email: string, body: UpdateUserPasswordRequest) =>
request<void>(`/api/users/${encodeURIComponent(email)}/password`, {
method: "PATCH",
body
}),
deleteUser: (email: string) =>
request<void>(`/api/users/${encodeURIComponent(email)}`, {
method: "DELETE"
@@ -86,4 +80,3 @@ export const api = {
};
export { ApiError };
@@ -0,0 +1,59 @@
type ResetLinkModalProps = {
email: string;
resetLink: string;
expiresAt: string;
onClose: () => void;
};
export default function ResetLinkModal({
email,
resetLink,
expiresAt,
onClose
}: ResetLinkModalProps) {
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(resetLink);
} catch {
// The field stays visible even if clipboard access is unavailable.
}
};
return (
<div className="modal-backdrop" role="presentation">
<div className="modal-card" role="dialog" aria-modal="true">
<div className="modal-header">
<div>
<h2>Temporary reset link</h2>
<p>
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>
</div>
<div className="stack">
<label className="field">
<span>One-time link</span>
<input type="text" readOnly value={resetLink} />
</label>
<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}>
Done
</button>
<button type="button" className="primary-button" onClick={handleCopy}>
Copy link
</button>
</div>
</div>
</div>
</div>
);
}
+20 -59
View File
@@ -1,52 +1,35 @@
import { useEffect, useState, type FormEvent } from "react";
type UserFormModalProps = {
mode: "create" | "edit";
email?: string;
busy: boolean;
onClose: () => void;
onSubmit: (payload: { email?: string; password?: string }) => Promise<void>;
onSubmit: (payload: { email?: string }) => Promise<void>;
};
export default function UserFormModal({
mode,
email,
busy,
onClose,
onSubmit
}: UserFormModalProps) {
const [nextEmail, setNextEmail] = useState(email ?? "");
const [password, setPassword] = useState("");
const [nextEmail, setNextEmail] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setNextEmail(email ?? "");
setPassword("");
setNextEmail("");
setError(null);
}, [email, mode]);
const isCreate = mode === "create";
}, []);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
if (isCreate && !nextEmail.trim()) {
if (!nextEmail.trim()) {
setError("Email is required.");
return;
}
if (!isCreate && password.length < 12) {
setError("Password must contain at least 12 characters.");
return;
}
try {
await onSubmit(
isCreate
? { email: nextEmail.trim().toLowerCase() }
: { password }
);
await onSubmit({ email: nextEmail.trim().toLowerCase() });
} catch (submitError) {
setError(
submitError instanceof Error
@@ -61,11 +44,9 @@ export default function UserFormModal({
<div className="modal-card" role="dialog" aria-modal="true">
<div className="modal-header">
<div>
<h2>{isCreate ? "Create user" : "Change password"}</h2>
<h2>Create user</h2>
<p>
{isCreate
? "A temporary password will be generated and a reset link will be emailed automatically."
: "Use this only for direct admin rotation. Regular password setup should go through reset links."}
A temporary password will be generated and a reset link will be emailed automatically.
</p>
</div>
<button type="button" className="ghost-button" onClick={onClose} disabled={busy}>
@@ -74,37 +55,17 @@ export default function UserFormModal({
</div>
<form className="stack" onSubmit={handleSubmit}>
{isCreate ? (
<label className="field">
<span>Email address</span>
<input
type="email"
value={nextEmail}
onChange={(event) => setNextEmail(event.target.value)}
placeholder="[email protected]"
autoFocus
disabled={busy}
/>
</label>
) : (
<>
<label className="field">
<span>User</span>
<input type="text" value={email ?? ""} disabled />
</label>
<label className="field">
<span>New password</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="At least 12 characters"
autoFocus
disabled={busy}
/>
</label>
</>
)}
<label className="field">
<span>Email address</span>
<input
type="email"
value={nextEmail}
onChange={(event) => setNextEmail(event.target.value)}
placeholder="[email protected]"
autoFocus
disabled={busy}
/>
</label>
{error ? <p className="form-error">{error}</p> : null}
@@ -113,7 +74,7 @@ export default function UserFormModal({
Cancel
</button>
<button type="submit" className="primary-button" disabled={busy}>
{busy ? "Saving..." : isCreate ? "Create user" : "Update password"}
{busy ? "Saving..." : "Create user"}
</button>
</div>
</form>
+23 -37
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import type { CreateUserResponse, MailUser } from "@internal-mail/shared";
import type { CreateUserResponse, MailUser, PasswordResetTriggerResponse } from "@internal-mail/shared";
import ResetLinkModal from "../components/ResetLinkModal";
import UserFormModal from "../components/UserFormModal";
type DashboardPageProps = {
@@ -9,9 +10,8 @@ type DashboardPageProps = {
feedback: string | null;
errorMessage: string | null;
onCreateUser: (email: string) => Promise<CreateUserResponse>;
onUpdatePassword: (email: string, password: string) => Promise<void>;
onDeleteUser: (email: string) => Promise<void>;
onTriggerReset: (email: string) => Promise<string>;
onTriggerReset: (email: string) => Promise<PasswordResetTriggerResponse>;
onRefresh: () => Promise<void>;
onLogout: () => Promise<void>;
};
@@ -23,7 +23,6 @@ export default function DashboardPage({
feedback,
errorMessage,
onCreateUser,
onUpdatePassword,
onDeleteUser,
onTriggerReset,
onRefresh,
@@ -31,10 +30,14 @@ export default function DashboardPage({
}: DashboardPageProps) {
const [query, setQuery] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [editEmail, setEditEmail] = useState<string | null>(null);
const [modalBusy, setModalBusy] = useState(false);
const [inlineMessage, setInlineMessage] = useState<string | null>(null);
const [inlineError, setInlineError] = useState<string | null>(null);
const [resetLinkData, setResetLinkData] = useState<{
email: string;
resetLink: string;
expiresAt: string;
} | null>(null);
const normalizedQuery = query.trim().toLowerCase();
const filteredUsers = normalizedQuery
@@ -61,23 +64,6 @@ export default function DashboardPage({
}
};
const handlePasswordUpdate = async ({ password }: { password?: string }) => {
if (!editEmail || !password) {
return;
}
setModalBusy(true);
setInlineError(null);
try {
await onUpdatePassword(editEmail, password);
setEditEmail(null);
setInlineMessage(`Password updated for ${editEmail}.`);
} finally {
setModalBusy(false);
}
};
return (
<main className="page-shell">
<header className="page-header">
@@ -154,23 +140,25 @@ export default function DashboardPage({
<td>{user.domain}</td>
<td>
<div className="table-actions">
<button className="ghost-button" onClick={() => setEditEmail(user.email)} disabled={busy}>
Edit
</button>
<button
className="ghost-button"
onClick={async () => {
setInlineError(null);
try {
const expiresAt = await onTriggerReset(user.email);
setInlineMessage(`Reset email sent to ${user.email}. Link expires at ${expiresAt}.`);
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 send reset email.");
setInlineError(error instanceof Error ? error.message : "Could not generate reset link.");
}
}}
disabled={busy}
>
Send reset
Generate link
</button>
<button
className="danger-button"
@@ -203,20 +191,18 @@ export default function DashboardPage({
{createOpen ? (
<UserFormModal
mode="create"
busy={modalBusy}
onClose={() => setCreateOpen(false)}
onSubmit={handleCreate}
/>
) : null}
{editEmail ? (
<UserFormModal
mode="edit"
email={editEmail}
busy={modalBusy}
onClose={() => setEditEmail(null)}
onSubmit={handlePasswordUpdate}
{resetLinkData ? (
<ResetLinkModal
email={resetLinkData.email}
resetLink={resetLinkData.resetLink}
expiresAt={resetLinkData.expiresAt}
onClose={() => setResetLinkData(null)}
/>
) : null}
</main>
+8 -21
View File
@@ -3,18 +3,16 @@ import { useState, type FormEvent } from "react";
type LoginPageProps = {
busy: boolean;
error: string | null;
onSubmit: (payload: { email: string; password: string }) => Promise<void>;
onSubmit: (payload: { accessKey: string }) => Promise<void>;
};
export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [accessKey, setAccessKey] = useState("");
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
await onSubmit({
email: email.trim().toLowerCase(),
password
accessKey: accessKey.trim()
});
};
@@ -24,29 +22,18 @@ export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
<div className="eyebrow">Docker Mailserver</div>
<h1>Mail user management</h1>
<p className="auth-copy">
Sign in with the admin account configured for this panel.
Sign in with the access key configured for this panel.
</p>
<form className="stack" onSubmit={handleSubmit}>
<label className="field">
<span>Admin email</span>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="username"
autoFocus
disabled={busy}
/>
</label>
<label className="field">
<span>Password</span>
<span>Access key</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
value={accessKey}
onChange={(event) => setAccessKey(event.target.value)}
autoComplete="current-password"
autoFocus
disabled={busy}
/>
</label>
+6 -1
View File
@@ -137,7 +137,8 @@ h2 {
.auth-copy,
.page-copy,
.modal-header p,
.table-header p {
.table-header p,
.modal-copy {
color: var(--text-muted);
}
@@ -352,6 +353,10 @@ tbody tr:hover {
justify-content: flex-end;
}
.modal-copy {
font-size: 0.92rem;
}
@media (max-width: 840px) {
.page-header,
.toolbar-card,