.
This commit is contained in:
@@ -18,16 +18,16 @@ const sessionView = (adminEmail: string | null): AdminSession => ({
|
||||
});
|
||||
|
||||
export const seedAdminAccount = async (): Promise<void> => {
|
||||
if (!config.appAdminEmail || !config.appAdminPassword) {
|
||||
if (!config.appAdminEmail || !config.appAdminAccessKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = getAdmin(config.appAdminEmail);
|
||||
if (existing && (await Bun.password.verify(config.appAdminPassword, existing.password_hash))) {
|
||||
if (existing && (await Bun.password.verify(config.appAdminAccessKey, existing.password_hash))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await Bun.password.hash(config.appAdminPassword, {
|
||||
const passwordHash = await Bun.password.hash(config.appAdminAccessKey, {
|
||||
algorithm: "argon2id",
|
||||
memoryCost: 19_456,
|
||||
timeCost: 2
|
||||
@@ -36,18 +36,22 @@ export const seedAdminAccount = async (): Promise<void> => {
|
||||
upsertAdmin(config.appAdminEmail, passwordHash);
|
||||
};
|
||||
|
||||
export const loginAdmin = async (email: string, password: string): Promise<{
|
||||
export const loginAdmin = async (accessKey: string): Promise<{
|
||||
session: AdminSession;
|
||||
cookie: string;
|
||||
}> => {
|
||||
const admin = getAdmin(email);
|
||||
if (!admin) {
|
||||
throw new HttpError(401, "Invalid email or password.");
|
||||
if (!config.appAdminAccessKey) {
|
||||
throw new HttpError(503, "Admin access key is not configured on the server.");
|
||||
}
|
||||
|
||||
const validPassword = await Bun.password.verify(password, admin.password_hash);
|
||||
if (!validPassword) {
|
||||
throw new HttpError(401, "Invalid email or password.");
|
||||
const admin = getAdmin(config.appAdminEmail);
|
||||
if (!admin) {
|
||||
throw new HttpError(401, "Invalid access key.");
|
||||
}
|
||||
|
||||
const validAccessKey = await Bun.password.verify(accessKey, admin.password_hash);
|
||||
if (!validAccessKey) {
|
||||
throw new HttpError(401, "Invalid access key.");
|
||||
}
|
||||
|
||||
const rawToken = createOpaqueToken();
|
||||
@@ -97,4 +101,3 @@ export const getSessionFromRequest = (request: Request): AdminSession => {
|
||||
return sessionView(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+3
-16
@@ -9,8 +9,7 @@ import {
|
||||
loginSchema,
|
||||
parseRouteEmail,
|
||||
resetPasswordSchema,
|
||||
resetTokenSchema,
|
||||
updatePasswordSchema
|
||||
resetTokenSchema
|
||||
} from "./lib/validation";
|
||||
import {
|
||||
consumePasswordReset,
|
||||
@@ -18,8 +17,7 @@ import {
|
||||
deleteUser,
|
||||
inspectPasswordReset,
|
||||
listUsers,
|
||||
triggerPasswordReset,
|
||||
updateUserPassword
|
||||
triggerPasswordReset
|
||||
} from "./users/service";
|
||||
|
||||
const app = new Elysia();
|
||||
@@ -68,7 +66,7 @@ app.post(
|
||||
"/api/auth/login",
|
||||
route(async ({ request }) => {
|
||||
const body = await parseJson(request, loginSchema);
|
||||
const result = await loginAdmin(body.email, body.password);
|
||||
const result = await loginAdmin(body.accessKey);
|
||||
|
||||
return jsonResponse(
|
||||
request,
|
||||
@@ -109,17 +107,6 @@ app.post(
|
||||
})
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/api/users/:email/password",
|
||||
route(async ({ request, params }) => {
|
||||
requireAdminSession(request);
|
||||
const email = parseRouteEmail(params.email);
|
||||
const body = await parseJson(request, updatePasswordSchema);
|
||||
await updateUserPassword(email, body.password);
|
||||
return emptyResponse(request, 204);
|
||||
})
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/api/users/:email",
|
||||
route(async ({ request, params }) => {
|
||||
|
||||
@@ -29,8 +29,8 @@ export const config = {
|
||||
sessionCookieName: env.SESSION_COOKIE_NAME ?? "mail_admin_session",
|
||||
sessionTtlMs: parseNumber(env.SESSION_TTL_MINUTES, 480) * 60_000,
|
||||
resetTokenTtlMs: parseNumber(env.RESET_TOKEN_TTL_MINUTES, 60) * 60_000,
|
||||
appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "",
|
||||
appAdminPassword: env.APP_ADMIN_PASSWORD ?? "",
|
||||
appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "panel-admin@local",
|
||||
appAdminAccessKey: env.APP_ADMIN_ACCESS_KEY ?? "",
|
||||
appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "[email protected]",
|
||||
webDistPath: resolve(process.cwd(), env.WEB_DIST_PATH ?? "../web/dist"),
|
||||
isProduction: (env.NODE_ENV ?? "development") === "production"
|
||||
|
||||
@@ -25,8 +25,11 @@ export const passwordSchema = z
|
||||
.max(128, "Password is too long.");
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: baseEmailSchema,
|
||||
password: z.string().min(1, "Password is required.")
|
||||
accessKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(12, "Access key is required.")
|
||||
.max(256, "Access key is too long.")
|
||||
});
|
||||
|
||||
export const createUserSchema = z.object({
|
||||
@@ -52,4 +55,3 @@ export const routeEmailSchema = z.object({
|
||||
|
||||
export const parseRouteEmail = (value: string): string =>
|
||||
routeEmailSchema.parse({ email: decodeURIComponent(value) }).email;
|
||||
|
||||
|
||||
@@ -32,6 +32,32 @@ const maskEmail = (email: string): string => {
|
||||
const buildResetLink = (token: string): string =>
|
||||
`${config.externalBaseUrl}/reset-password?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const createPasswordResetLink = async (
|
||||
email: string,
|
||||
createdBy: string
|
||||
): Promise<PasswordResetTriggerResponse> => {
|
||||
await ensureUserExists(email);
|
||||
deleteActivePasswordResetTokensForUser(email);
|
||||
|
||||
const rawToken = createOpaqueToken();
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const expiresAtMs = Date.now() + config.resetTokenTtlMs;
|
||||
const expiresAt = new Date(expiresAtMs).toISOString();
|
||||
const resetLink = buildResetLink(rawToken);
|
||||
|
||||
createPasswordResetToken({
|
||||
tokenHash,
|
||||
userEmail: email,
|
||||
createdBy,
|
||||
expiresAt: expiresAtMs
|
||||
});
|
||||
|
||||
return {
|
||||
resetLink,
|
||||
expiresAt
|
||||
};
|
||||
};
|
||||
|
||||
const ensureUserExists = async (email: string): Promise<MailUser> => {
|
||||
const users = await listMailUsers();
|
||||
const existing = users.find((item) => item.email === email);
|
||||
@@ -52,7 +78,8 @@ export const createUser = async (email: string, createdBy: string): Promise<Crea
|
||||
const user = await ensureUserExists(email);
|
||||
|
||||
try {
|
||||
const reset = await triggerPasswordReset(email, createdBy);
|
||||
const reset = await createPasswordResetLink(email, createdBy);
|
||||
await sendPasswordResetEmail(email, reset.resetLink, reset.expiresAt);
|
||||
|
||||
return {
|
||||
user,
|
||||
@@ -70,11 +97,6 @@ export const createUser = async (email: string, createdBy: string): Promise<Crea
|
||||
}
|
||||
};
|
||||
|
||||
export const updateUserPassword = async (email: string, password: string): Promise<void> => {
|
||||
await ensureUserExists(email);
|
||||
await updateMailUserPassword(email, password);
|
||||
};
|
||||
|
||||
export const deleteUser = async (email: string): Promise<void> => {
|
||||
await ensureUserExists(email);
|
||||
await deleteMailUser(email);
|
||||
@@ -84,29 +106,7 @@ export const triggerPasswordReset = async (
|
||||
email: string,
|
||||
createdBy: string
|
||||
): Promise<PasswordResetTriggerResponse> => {
|
||||
await ensureUserExists(email);
|
||||
deleteActivePasswordResetTokensForUser(email);
|
||||
|
||||
const rawToken = createOpaqueToken();
|
||||
const tokenHash = hashToken(rawToken);
|
||||
const expiresAtMs = Date.now() + config.resetTokenTtlMs;
|
||||
const expiresAt = new Date(expiresAtMs).toISOString();
|
||||
|
||||
createPasswordResetToken({
|
||||
tokenHash,
|
||||
userEmail: email,
|
||||
createdBy,
|
||||
expiresAt: expiresAtMs
|
||||
});
|
||||
|
||||
try {
|
||||
await sendPasswordResetEmail(email, buildResetLink(rawToken), expiresAt);
|
||||
} catch (error) {
|
||||
deletePasswordResetToken(tokenHash);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { expiresAt };
|
||||
return createPasswordResetLink(email, createdBy);
|
||||
};
|
||||
|
||||
export const inspectPasswordReset = async (token: string): Promise<PasswordResetInspectResponse> => {
|
||||
|
||||
+2
-17
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user