.
This commit is contained in:
+2
-3
@@ -12,6 +12,5 @@ HOSTED_DOMAINS=example.com
|
|||||||
SESSION_COOKIE_NAME=mail_admin_session
|
SESSION_COOKIE_NAME=mail_admin_session
|
||||||
SESSION_TTL_MINUTES=480
|
SESSION_TTL_MINUTES=480
|
||||||
RESET_TOKEN_TTL_MINUTES=60
|
RESET_TOKEN_TTL_MINUTES=60
|
||||||
APP_ADMIN_EMAIL=[email protected]
|
APP_ADMIN_EMAIL=panel-admin@local
|
||||||
APP_ADMIN_PASSWORD=change-me-now
|
APP_ADMIN_ACCESS_KEY=change-this-access-key
|
||||||
APP_MAIL_FROM=[email protected]
|
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ Security choices here:
|
|||||||
## 7. Security notes
|
## 7. Security notes
|
||||||
|
|
||||||
- Admin authentication uses an Argon2id-hashed bootstrap account from environment variables.
|
- Admin authentication uses an Argon2id-hashed bootstrap account from environment variables.
|
||||||
|
- Panel login uses a single access key instead of an email/password pair.
|
||||||
- Sessions are server-side, opaque, and stored in an `HttpOnly` cookie with `SameSite=Strict`.
|
- Sessions are server-side, opaque, and stored in an `HttpOnly` cookie with `SameSite=Strict`.
|
||||||
- Password reset tokens are random, one-time, time-limited, and stored only as hashes.
|
- Password reset tokens are random, one-time, time-limited, and stored only as hashes.
|
||||||
- Admin-only routes require a valid session cookie.
|
- Admin-only routes require a valid session cookie.
|
||||||
@@ -163,8 +164,7 @@ Security choices here:
|
|||||||
- `HOSTED_DOMAINS`
|
- `HOSTED_DOMAINS`
|
||||||
- `MAILSERVER_CONTAINER`
|
- `MAILSERVER_CONTAINER`
|
||||||
- `APP_ADMIN_EMAIL`
|
- `APP_ADMIN_EMAIL`
|
||||||
- `APP_ADMIN_PASSWORD`
|
- `APP_ADMIN_ACCESS_KEY`
|
||||||
- `APP_MAIL_FROM`
|
|
||||||
3. Install dependencies:
|
3. Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -218,5 +218,5 @@ Common fixes:
|
|||||||
## Notes and assumptions
|
## Notes and assumptions
|
||||||
|
|
||||||
- This panel assumes Docker Mailserver is using the file-based account provisioner and supports the standard `setup email` commands.
|
- This panel assumes Docker Mailserver is using the file-based account provisioner and supports the standard `setup email` commands.
|
||||||
- The admin account for the panel is separate from mailbox users.
|
- `APP_ADMIN_EMAIL` is now an internal admin identity for sessions and audit trails. The actual panel login uses `APP_ADMIN_ACCESS_KEY`.
|
||||||
- The app stores only panel-specific metadata in SQLite. Mailbox accounts themselves remain managed by Docker Mailserver.
|
- The app stores only panel-specific metadata in SQLite. Mailbox accounts themselves remain managed by Docker Mailserver.
|
||||||
|
|||||||
@@ -18,16 +18,16 @@ const sessionView = (adminEmail: string | null): AdminSession => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const seedAdminAccount = async (): Promise<void> => {
|
export const seedAdminAccount = async (): Promise<void> => {
|
||||||
if (!config.appAdminEmail || !config.appAdminPassword) {
|
if (!config.appAdminEmail || !config.appAdminAccessKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = getAdmin(config.appAdminEmail);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await Bun.password.hash(config.appAdminPassword, {
|
const passwordHash = await Bun.password.hash(config.appAdminAccessKey, {
|
||||||
algorithm: "argon2id",
|
algorithm: "argon2id",
|
||||||
memoryCost: 19_456,
|
memoryCost: 19_456,
|
||||||
timeCost: 2
|
timeCost: 2
|
||||||
@@ -36,18 +36,22 @@ export const seedAdminAccount = async (): Promise<void> => {
|
|||||||
upsertAdmin(config.appAdminEmail, passwordHash);
|
upsertAdmin(config.appAdminEmail, passwordHash);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loginAdmin = async (email: string, password: string): Promise<{
|
export const loginAdmin = async (accessKey: string): Promise<{
|
||||||
session: AdminSession;
|
session: AdminSession;
|
||||||
cookie: string;
|
cookie: string;
|
||||||
}> => {
|
}> => {
|
||||||
const admin = getAdmin(email);
|
if (!config.appAdminAccessKey) {
|
||||||
if (!admin) {
|
throw new HttpError(503, "Admin access key is not configured on the server.");
|
||||||
throw new HttpError(401, "Invalid email or password.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPassword = await Bun.password.verify(password, admin.password_hash);
|
const admin = getAdmin(config.appAdminEmail);
|
||||||
if (!validPassword) {
|
if (!admin) {
|
||||||
throw new HttpError(401, "Invalid email or password.");
|
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();
|
const rawToken = createOpaqueToken();
|
||||||
@@ -97,4 +101,3 @@ export const getSessionFromRequest = (request: Request): AdminSession => {
|
|||||||
return sessionView(null);
|
return sessionView(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+3
-16
@@ -9,8 +9,7 @@ import {
|
|||||||
loginSchema,
|
loginSchema,
|
||||||
parseRouteEmail,
|
parseRouteEmail,
|
||||||
resetPasswordSchema,
|
resetPasswordSchema,
|
||||||
resetTokenSchema,
|
resetTokenSchema
|
||||||
updatePasswordSchema
|
|
||||||
} from "./lib/validation";
|
} from "./lib/validation";
|
||||||
import {
|
import {
|
||||||
consumePasswordReset,
|
consumePasswordReset,
|
||||||
@@ -18,8 +17,7 @@ import {
|
|||||||
deleteUser,
|
deleteUser,
|
||||||
inspectPasswordReset,
|
inspectPasswordReset,
|
||||||
listUsers,
|
listUsers,
|
||||||
triggerPasswordReset,
|
triggerPasswordReset
|
||||||
updateUserPassword
|
|
||||||
} from "./users/service";
|
} from "./users/service";
|
||||||
|
|
||||||
const app = new Elysia();
|
const app = new Elysia();
|
||||||
@@ -68,7 +66,7 @@ app.post(
|
|||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
route(async ({ request }) => {
|
route(async ({ request }) => {
|
||||||
const body = await parseJson(request, loginSchema);
|
const body = await parseJson(request, loginSchema);
|
||||||
const result = await loginAdmin(body.email, body.password);
|
const result = await loginAdmin(body.accessKey);
|
||||||
|
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
request,
|
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(
|
app.delete(
|
||||||
"/api/users/:email",
|
"/api/users/:email",
|
||||||
route(async ({ request, params }) => {
|
route(async ({ request, params }) => {
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export const config = {
|
|||||||
sessionCookieName: env.SESSION_COOKIE_NAME ?? "mail_admin_session",
|
sessionCookieName: env.SESSION_COOKIE_NAME ?? "mail_admin_session",
|
||||||
sessionTtlMs: parseNumber(env.SESSION_TTL_MINUTES, 480) * 60_000,
|
sessionTtlMs: parseNumber(env.SESSION_TTL_MINUTES, 480) * 60_000,
|
||||||
resetTokenTtlMs: parseNumber(env.RESET_TOKEN_TTL_MINUTES, 60) * 60_000,
|
resetTokenTtlMs: parseNumber(env.RESET_TOKEN_TTL_MINUTES, 60) * 60_000,
|
||||||
appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "",
|
appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "panel-admin@local",
|
||||||
appAdminPassword: env.APP_ADMIN_PASSWORD ?? "",
|
appAdminAccessKey: env.APP_ADMIN_ACCESS_KEY ?? "",
|
||||||
appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "[email protected]",
|
appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "[email protected]",
|
||||||
webDistPath: resolve(process.cwd(), env.WEB_DIST_PATH ?? "../web/dist"),
|
webDistPath: resolve(process.cwd(), env.WEB_DIST_PATH ?? "../web/dist"),
|
||||||
isProduction: (env.NODE_ENV ?? "development") === "production"
|
isProduction: (env.NODE_ENV ?? "development") === "production"
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ export const passwordSchema = z
|
|||||||
.max(128, "Password is too long.");
|
.max(128, "Password is too long.");
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
export const loginSchema = z.object({
|
||||||
email: baseEmailSchema,
|
accessKey: z
|
||||||
password: z.string().min(1, "Password is required.")
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(12, "Access key is required.")
|
||||||
|
.max(256, "Access key is too long.")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createUserSchema = z.object({
|
export const createUserSchema = z.object({
|
||||||
@@ -52,4 +55,3 @@ export const routeEmailSchema = z.object({
|
|||||||
|
|
||||||
export const parseRouteEmail = (value: string): string =>
|
export const parseRouteEmail = (value: string): string =>
|
||||||
routeEmailSchema.parse({ email: decodeURIComponent(value) }).email;
|
routeEmailSchema.parse({ email: decodeURIComponent(value) }).email;
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,32 @@ const maskEmail = (email: string): string => {
|
|||||||
const buildResetLink = (token: string): string =>
|
const buildResetLink = (token: string): string =>
|
||||||
`${config.externalBaseUrl}/reset-password?token=${encodeURIComponent(token)}`;
|
`${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 ensureUserExists = async (email: string): Promise<MailUser> => {
|
||||||
const users = await listMailUsers();
|
const users = await listMailUsers();
|
||||||
const existing = users.find((item) => item.email === email);
|
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);
|
const user = await ensureUserExists(email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const reset = await triggerPasswordReset(email, createdBy);
|
const reset = await createPasswordResetLink(email, createdBy);
|
||||||
|
await sendPasswordResetEmail(email, reset.resetLink, reset.expiresAt);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
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> => {
|
export const deleteUser = async (email: string): Promise<void> => {
|
||||||
await ensureUserExists(email);
|
await ensureUserExists(email);
|
||||||
await deleteMailUser(email);
|
await deleteMailUser(email);
|
||||||
@@ -84,29 +106,7 @@ export const triggerPasswordReset = async (
|
|||||||
email: string,
|
email: string,
|
||||||
createdBy: string
|
createdBy: string
|
||||||
): Promise<PasswordResetTriggerResponse> => {
|
): Promise<PasswordResetTriggerResponse> => {
|
||||||
await ensureUserExists(email);
|
return createPasswordResetLink(email, createdBy);
|
||||||
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 };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const inspectPasswordReset = async (token: string): Promise<PasswordResetInspectResponse> => {
|
export const inspectPasswordReset = async (token: string): Promise<PasswordResetInspectResponse> => {
|
||||||
|
|||||||
+2
-17
@@ -56,7 +56,7 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}, [path]);
|
}, [path]);
|
||||||
|
|
||||||
const handleLogin = async (payload: { email: string; password: string }) => {
|
const handleLogin = async (payload: { accessKey: string }) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setLoginError(null);
|
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) => {
|
const handleDeleteUser = async (email: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
@@ -133,8 +120,7 @@ export default function App() {
|
|||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.triggerPasswordReset(email);
|
return await api.triggerPasswordReset(email);
|
||||||
return result.expiresAt;
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -186,7 +172,6 @@ export default function App() {
|
|||||||
feedback={feedback}
|
feedback={feedback}
|
||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
onCreateUser={handleCreateUser}
|
onCreateUser={handleCreateUser}
|
||||||
onUpdatePassword={handleUpdatePassword}
|
|
||||||
onDeleteUser={handleDeleteUser}
|
onDeleteUser={handleDeleteUser}
|
||||||
onTriggerReset={handleTriggerReset}
|
onTriggerReset={handleTriggerReset}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type {
|
|||||||
PasswordResetInspectResponse,
|
PasswordResetInspectResponse,
|
||||||
PasswordResetTriggerResponse,
|
PasswordResetTriggerResponse,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
UpdateUserPasswordRequest,
|
|
||||||
UsersResponse
|
UsersResponse
|
||||||
} from "@internal-mail/shared";
|
} from "@internal-mail/shared";
|
||||||
|
|
||||||
@@ -57,11 +56,6 @@ export const api = {
|
|||||||
listUsers: () => request<UsersResponse>("/api/users"),
|
listUsers: () => request<UsersResponse>("/api/users"),
|
||||||
createUser: (body: CreateUserRequest) =>
|
createUser: (body: CreateUserRequest) =>
|
||||||
request<CreateUserResponse>("/api/users", { method: "POST", body }),
|
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) =>
|
deleteUser: (email: string) =>
|
||||||
request<void>(`/api/users/${encodeURIComponent(email)}`, {
|
request<void>(`/api/users/${encodeURIComponent(email)}`, {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
@@ -86,4 +80,3 @@ export const api = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export { ApiError };
|
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";
|
import { useEffect, useState, type FormEvent } from "react";
|
||||||
|
|
||||||
type UserFormModalProps = {
|
type UserFormModalProps = {
|
||||||
mode: "create" | "edit";
|
|
||||||
email?: string;
|
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: (payload: { email?: string; password?: string }) => Promise<void>;
|
onSubmit: (payload: { email?: string }) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UserFormModal({
|
export default function UserFormModal({
|
||||||
mode,
|
|
||||||
email,
|
|
||||||
busy,
|
busy,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit
|
onSubmit
|
||||||
}: UserFormModalProps) {
|
}: UserFormModalProps) {
|
||||||
const [nextEmail, setNextEmail] = useState(email ?? "");
|
const [nextEmail, setNextEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNextEmail(email ?? "");
|
setNextEmail("");
|
||||||
setPassword("");
|
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [email, mode]);
|
}, []);
|
||||||
|
|
||||||
const isCreate = mode === "create";
|
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (isCreate && !nextEmail.trim()) {
|
if (!nextEmail.trim()) {
|
||||||
setError("Email is required.");
|
setError("Email is required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isCreate && password.length < 12) {
|
|
||||||
setError("Password must contain at least 12 characters.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit(
|
await onSubmit({ email: nextEmail.trim().toLowerCase() });
|
||||||
isCreate
|
|
||||||
? { email: nextEmail.trim().toLowerCase() }
|
|
||||||
: { password }
|
|
||||||
);
|
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError(
|
setError(
|
||||||
submitError instanceof Error
|
submitError instanceof Error
|
||||||
@@ -61,11 +44,9 @@ export default function UserFormModal({
|
|||||||
<div className="modal-card" role="dialog" aria-modal="true">
|
<div className="modal-card" role="dialog" aria-modal="true">
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>{isCreate ? "Create user" : "Change password"}</h2>
|
<h2>Create user</h2>
|
||||||
<p>
|
<p>
|
||||||
{isCreate
|
A temporary password will be generated and a reset link will be emailed automatically.
|
||||||
? "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."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="ghost-button" onClick={onClose} disabled={busy}>
|
<button type="button" className="ghost-button" onClick={onClose} disabled={busy}>
|
||||||
@@ -74,7 +55,6 @@ export default function UserFormModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="stack" onSubmit={handleSubmit}>
|
<form className="stack" onSubmit={handleSubmit}>
|
||||||
{isCreate ? (
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Email address</span>
|
<span>Email address</span>
|
||||||
<input
|
<input
|
||||||
@@ -86,25 +66,6 @@ export default function UserFormModal({
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
|
|
||||||
@@ -113,7 +74,7 @@ export default function UserFormModal({
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="primary-button" disabled={busy}>
|
<button type="submit" className="primary-button" disabled={busy}>
|
||||||
{busy ? "Saving..." : isCreate ? "Create user" : "Update password"}
|
{busy ? "Saving..." : "Create user"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
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";
|
import UserFormModal from "../components/UserFormModal";
|
||||||
|
|
||||||
type DashboardPageProps = {
|
type DashboardPageProps = {
|
||||||
@@ -9,9 +10,8 @@ type DashboardPageProps = {
|
|||||||
feedback: string | null;
|
feedback: string | null;
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
onCreateUser: (email: string) => Promise<CreateUserResponse>;
|
onCreateUser: (email: string) => Promise<CreateUserResponse>;
|
||||||
onUpdatePassword: (email: string, password: string) => Promise<void>;
|
|
||||||
onDeleteUser: (email: string) => Promise<void>;
|
onDeleteUser: (email: string) => Promise<void>;
|
||||||
onTriggerReset: (email: string) => Promise<string>;
|
onTriggerReset: (email: string) => Promise<PasswordResetTriggerResponse>;
|
||||||
onRefresh: () => Promise<void>;
|
onRefresh: () => Promise<void>;
|
||||||
onLogout: () => Promise<void>;
|
onLogout: () => Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -23,7 +23,6 @@ export default function DashboardPage({
|
|||||||
feedback,
|
feedback,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
onCreateUser,
|
onCreateUser,
|
||||||
onUpdatePassword,
|
|
||||||
onDeleteUser,
|
onDeleteUser,
|
||||||
onTriggerReset,
|
onTriggerReset,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -31,10 +30,14 @@ export default function DashboardPage({
|
|||||||
}: DashboardPageProps) {
|
}: DashboardPageProps) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editEmail, setEditEmail] = useState<string | null>(null);
|
|
||||||
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 [resetLinkData, setResetLinkData] = useState<{
|
||||||
|
email: string;
|
||||||
|
resetLink: string;
|
||||||
|
expiresAt: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const filteredUsers = normalizedQuery
|
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 (
|
return (
|
||||||
<main className="page-shell">
|
<main className="page-shell">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
@@ -154,23 +140,25 @@ export default function DashboardPage({
|
|||||||
<td>{user.domain}</td>
|
<td>{user.domain}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="table-actions">
|
<div className="table-actions">
|
||||||
<button className="ghost-button" onClick={() => setEditEmail(user.email)} disabled={busy}>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="ghost-button"
|
className="ghost-button"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
try {
|
try {
|
||||||
const expiresAt = await onTriggerReset(user.email);
|
const result = await onTriggerReset(user.email);
|
||||||
setInlineMessage(`Reset email sent to ${user.email}. Link expires at ${expiresAt}.`);
|
setResetLinkData({
|
||||||
|
email: user.email,
|
||||||
|
resetLink: result.resetLink,
|
||||||
|
expiresAt: result.expiresAt
|
||||||
|
});
|
||||||
|
setInlineMessage(`Temporary reset link generated for ${user.email}.`);
|
||||||
} catch (error) {
|
} 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}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
Send reset
|
Generate link
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="danger-button"
|
className="danger-button"
|
||||||
@@ -203,20 +191,18 @@ export default function DashboardPage({
|
|||||||
|
|
||||||
{createOpen ? (
|
{createOpen ? (
|
||||||
<UserFormModal
|
<UserFormModal
|
||||||
mode="create"
|
|
||||||
busy={modalBusy}
|
busy={modalBusy}
|
||||||
onClose={() => setCreateOpen(false)}
|
onClose={() => setCreateOpen(false)}
|
||||||
onSubmit={handleCreate}
|
onSubmit={handleCreate}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{editEmail ? (
|
{resetLinkData ? (
|
||||||
<UserFormModal
|
<ResetLinkModal
|
||||||
mode="edit"
|
email={resetLinkData.email}
|
||||||
email={editEmail}
|
resetLink={resetLinkData.resetLink}
|
||||||
busy={modalBusy}
|
expiresAt={resetLinkData.expiresAt}
|
||||||
onClose={() => setEditEmail(null)}
|
onClose={() => setResetLinkData(null)}
|
||||||
onSubmit={handlePasswordUpdate}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -3,18 +3,16 @@ import { useState, type FormEvent } from "react";
|
|||||||
type LoginPageProps = {
|
type LoginPageProps = {
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
error: string | null;
|
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) {
|
export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
|
||||||
const [email, setEmail] = useState("");
|
const [accessKey, setAccessKey] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
email: email.trim().toLowerCase(),
|
accessKey: accessKey.trim()
|
||||||
password
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,29 +22,18 @@ export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
|
|||||||
<div className="eyebrow">Docker Mailserver</div>
|
<div className="eyebrow">Docker Mailserver</div>
|
||||||
<h1>Mail user management</h1>
|
<h1>Mail user management</h1>
|
||||||
<p className="auth-copy">
|
<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>
|
</p>
|
||||||
|
|
||||||
<form className="stack" onSubmit={handleSubmit}>
|
<form className="stack" onSubmit={handleSubmit}>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Admin email</span>
|
<span>Access key</span>
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
autoFocus
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>Password</span>
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={accessKey}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setAccessKey(event.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
autoFocus
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ h2 {
|
|||||||
.auth-copy,
|
.auth-copy,
|
||||||
.page-copy,
|
.page-copy,
|
||||||
.modal-header p,
|
.modal-header p,
|
||||||
.table-header p {
|
.table-header p,
|
||||||
|
.modal-copy {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +353,10 @@ tbody tr:hover {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-copy {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 840px) {
|
@media (max-width: 840px) {
|
||||||
.page-header,
|
.page-header,
|
||||||
.toolbar-card,
|
.toolbar-card,
|
||||||
|
|||||||
@@ -28,16 +28,12 @@ export type CreateUserResponse = {
|
|||||||
warning: string | null;
|
warning: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateUserPasswordRequest = {
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LoginRequest = {
|
export type LoginRequest = {
|
||||||
email: string;
|
accessKey: string;
|
||||||
password: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PasswordResetTriggerResponse = {
|
export type PasswordResetTriggerResponse = {
|
||||||
|
resetLink: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,4 +56,3 @@ export type ApiErrorBody = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user