From 87198824b6bda7466b10319ab3ed52cac3fa75db Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 30 Mar 2026 14:24:39 +0000 Subject: [PATCH] . --- .env.example | 5 +- README.md | 6 +- apps/api/src/auth/service.ts | 25 ++++--- apps/api/src/index.ts | 19 +----- apps/api/src/lib/config.ts | 4 +- apps/api/src/lib/validation.ts | 8 ++- apps/api/src/users/service.ts | 58 ++++++++-------- apps/web/src/App.tsx | 19 +----- apps/web/src/api/client.ts | 7 -- apps/web/src/components/ResetLinkModal.tsx | 59 ++++++++++++++++ apps/web/src/components/UserFormModal.tsx | 79 ++++++---------------- apps/web/src/pages/DashboardPage.tsx | 60 +++++++--------- apps/web/src/pages/LoginPage.tsx | 29 +++----- apps/web/src/styles/app.css | 7 +- packages/shared/src/index.ts | 9 +-- 15 files changed, 178 insertions(+), 216 deletions(-) create mode 100644 apps/web/src/components/ResetLinkModal.tsx diff --git a/.env.example b/.env.example index 6f1c791..9c071f4 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,5 @@ HOSTED_DOMAINS=example.com SESSION_COOKIE_NAME=mail_admin_session SESSION_TTL_MINUTES=480 RESET_TOKEN_TTL_MINUTES=60 -APP_ADMIN_EMAIL=admin@example.com -APP_ADMIN_PASSWORD=change-me-now -APP_MAIL_FROM=no-reply@example.com +APP_ADMIN_EMAIL=panel-admin@local +APP_ADMIN_ACCESS_KEY=change-this-access-key diff --git a/README.md b/README.md index b748fad..3c91186 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ Security choices here: ## 7. Security notes - 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`. - Password reset tokens are random, one-time, time-limited, and stored only as hashes. - Admin-only routes require a valid session cookie. @@ -163,8 +164,7 @@ Security choices here: - `HOSTED_DOMAINS` - `MAILSERVER_CONTAINER` - `APP_ADMIN_EMAIL` - - `APP_ADMIN_PASSWORD` - - `APP_MAIL_FROM` + - `APP_ADMIN_ACCESS_KEY` 3. Install dependencies: ```bash @@ -218,5 +218,5 @@ Common fixes: ## Notes and assumptions - 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. diff --git a/apps/api/src/auth/service.ts b/apps/api/src/auth/service.ts index fcb63fd..d2eb1a0 100644 --- a/apps/api/src/auth/service.ts +++ b/apps/api/src/auth/service.ts @@ -18,16 +18,16 @@ const sessionView = (adminEmail: string | null): AdminSession => ({ }); export const seedAdminAccount = async (): Promise => { - 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 => { 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); } }; - diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2ef59d3..aac7371 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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 }) => { diff --git a/apps/api/src/lib/config.ts b/apps/api/src/lib/config.ts index 703155a..9489ee5 100644 --- a/apps/api/src/lib/config.ts +++ b/apps/api/src/lib/config.ts @@ -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() ?? "no-reply@example.com", webDistPath: resolve(process.cwd(), env.WEB_DIST_PATH ?? "../web/dist"), isProduction: (env.NODE_ENV ?? "development") === "production" diff --git a/apps/api/src/lib/validation.ts b/apps/api/src/lib/validation.ts index b84a20d..94e8c36 100644 --- a/apps/api/src/lib/validation.ts +++ b/apps/api/src/lib/validation.ts @@ -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; - diff --git a/apps/api/src/users/service.ts b/apps/api/src/users/service.ts index f5d79d4..f746bb8 100644 --- a/apps/api/src/users/service.ts +++ b/apps/api/src/users/service.ts @@ -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 => { + 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 => { 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 => { - await ensureUserExists(email); - await updateMailUserPassword(email, password); -}; - export const deleteUser = async (email: string): Promise => { await ensureUserExists(email); await deleteMailUser(email); @@ -84,29 +106,7 @@ export const triggerPasswordReset = async ( email: string, createdBy: string ): Promise => { - 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 => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 65be3a7..e942b72 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 556598b..e263dfc 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -7,7 +7,6 @@ import type { PasswordResetInspectResponse, PasswordResetTriggerResponse, SessionResponse, - UpdateUserPasswordRequest, UsersResponse } from "@internal-mail/shared"; @@ -57,11 +56,6 @@ export const api = { listUsers: () => request("/api/users"), createUser: (body: CreateUserRequest) => request("/api/users", { method: "POST", body }), - updatePassword: (email: string, body: UpdateUserPasswordRequest) => - request(`/api/users/${encodeURIComponent(email)}/password`, { - method: "PATCH", - body - }), deleteUser: (email: string) => request(`/api/users/${encodeURIComponent(email)}`, { method: "DELETE" @@ -86,4 +80,3 @@ export const api = { }; export { ApiError }; - diff --git a/apps/web/src/components/ResetLinkModal.tsx b/apps/web/src/components/ResetLinkModal.tsx new file mode 100644 index 0000000..daf722b --- /dev/null +++ b/apps/web/src/components/ResetLinkModal.tsx @@ -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 ( +
+
+
+
+

Temporary reset link

+

+ No email was sent for {email}. Share this one-time link yourself. +

+
+ +
+ +
+ + +

+ This link expires at {expiresAt} and becomes invalid after first use. +

+ +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/UserFormModal.tsx b/apps/web/src/components/UserFormModal.tsx index ec21bca..0d3cd72 100644 --- a/apps/web/src/components/UserFormModal.tsx +++ b/apps/web/src/components/UserFormModal.tsx @@ -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; + onSubmit: (payload: { email?: string }) => Promise; }; 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(null); useEffect(() => { - setNextEmail(email ?? ""); - setPassword(""); + setNextEmail(""); setError(null); - }, [email, mode]); - - const isCreate = mode === "create"; + }, []); const handleSubmit = async (event: FormEvent) => { 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({
-

{isCreate ? "Create user" : "Change password"}

+

Create user

- {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.

- {isCreate ? ( - - ) : ( - <> - - - - )} + {error ?

{error}

: null} @@ -113,7 +74,7 @@ export default function UserFormModal({ Cancel
diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index f5f9197..3153da3 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -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; - onUpdatePassword: (email: string, password: string) => Promise; onDeleteUser: (email: string) => Promise; - onTriggerReset: (email: string) => Promise; + onTriggerReset: (email: string) => Promise; onRefresh: () => Promise; onLogout: () => Promise; }; @@ -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(null); const [modalBusy, setModalBusy] = useState(false); const [inlineMessage, setInlineMessage] = useState(null); const [inlineError, setInlineError] = useState(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 (
@@ -154,23 +140,25 @@ export default function DashboardPage({ {user.domain}
-
diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 2e7d756..a9526fe 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -3,18 +3,16 @@ import { useState, type FormEvent } from "react"; type LoginPageProps = { busy: boolean; error: string | null; - onSubmit: (payload: { email: string; password: string }) => Promise; + onSubmit: (payload: { accessKey: string }) => Promise; }; 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) => { event.preventDefault(); await onSubmit({ - email: email.trim().toLowerCase(), - password + accessKey: accessKey.trim() }); }; @@ -24,29 +22,18 @@ export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
Docker Mailserver

Mail user management

- Sign in with the admin account configured for this panel. + Sign in with the access key configured for this panel.

- - diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index d033466..3d4f108 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -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, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7b48cc1..7939c5a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -28,16 +28,12 @@ export type CreateUserResponse = { warning: string | null; }; -export type UpdateUserPasswordRequest = { - password: string; -}; - export type LoginRequest = { - email: string; - password: string; + accessKey: string; }; export type PasswordResetTriggerResponse = { + resetLink: string; expiresAt: string; }; @@ -60,4 +56,3 @@ export type ApiErrorBody = { message: string; }; }; -