.
This commit is contained in:
@@ -12,5 +12,6 @@ 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
|
||||||
|
MAILBOX_INVITE_TTL_HOURS=48
|
||||||
APP_ADMIN_EMAIL=admin@local
|
APP_ADMIN_EMAIL=admin@local
|
||||||
APP_ADMIN_ACCESS_KEY=change-this-access-key
|
APP_ADMIN_ACCESS_KEY=change-this-access-key
|
||||||
|
|||||||
@@ -67,9 +67,7 @@ The backend is intentionally flat:
|
|||||||
- `apps/api/src/docker/`
|
- `apps/api/src/docker/`
|
||||||
Wraps Docker CLI calls with fixed argument arrays only. No shell interpolation.
|
Wraps Docker CLI calls with fixed argument arrays only. No shell interpolation.
|
||||||
- `apps/api/src/users/service.ts`
|
- `apps/api/src/users/service.ts`
|
||||||
Contains the mailbox user operations and the one-time link flow for password changes.
|
Contains the mailbox user operations, pending mailbox invites, and the one-time link flow for password changes.
|
||||||
- `apps/api/src/mail/service.ts`
|
|
||||||
Sends setup emails for newly created mailboxes through `sendmail` inside the Docker Mailserver container.
|
|
||||||
- `apps/api/src/lib/`
|
- `apps/api/src/lib/`
|
||||||
Holds config parsing, SQLite access, validation, HTTP helpers, and small security utilities.
|
Holds config parsing, SQLite access, validation, HTTP helpers, and small security utilities.
|
||||||
|
|
||||||
@@ -98,24 +96,24 @@ The dashboard includes:
|
|||||||
- mailbox listing
|
- mailbox listing
|
||||||
- simple search
|
- simple search
|
||||||
- create user modal
|
- create user modal
|
||||||
- temporary link generation for password changes
|
- 48-hour setup link generation for new mailboxes
|
||||||
|
- temporary link generation for password changes on existing mailboxes
|
||||||
- delete action
|
- delete action
|
||||||
- no direct password change from the admin panel
|
- no direct password change from the admin panel
|
||||||
|
|
||||||
The create flow intentionally generates a temporary password on the server and immediately emails a one-time reset link to the user. That means the admin panel never needs to expose an initial mailbox password in the UI.
|
The create flow now generates a 48-hour setup link. The mailbox does not exist yet at that point. It is created only when the user opens the link and chooses a password.
|
||||||
|
|
||||||
## 5. Password reset flow
|
## 5. Password reset flow
|
||||||
|
|
||||||
The reset flow is built to stay secure without becoming heavy:
|
The reset flow is built to stay secure without becoming heavy:
|
||||||
|
|
||||||
1. An admin generates a one-time link for an existing mailbox.
|
1. For a new mailbox, the admin generates a 48-hour setup link.
|
||||||
2. The backend generates a random opaque token.
|
2. The mailbox is not created yet.
|
||||||
3. Only the SHA-256 hash of that token is stored in SQLite.
|
3. The backend stores only the SHA-256 hash of the invite token.
|
||||||
4. The admin shares the one-time link manually with the user.
|
4. The admin shares the one-time setup link manually with the user.
|
||||||
5. The reset page validates the token before showing the password form.
|
5. When the user chooses a password through the link, the backend creates the mailbox in Docker Mailserver.
|
||||||
6. On submit, the token is claimed for one use only.
|
6. For existing mailboxes, the same public page is reused for password reset links.
|
||||||
7. The backend updates the mailbox password through Docker Mailserver.
|
7. Every link is one-time and expires automatically.
|
||||||
8. Used or expired tokens are rejected.
|
|
||||||
|
|
||||||
## 6. Docker CLI integration strategy
|
## 6. Docker CLI integration strategy
|
||||||
|
|
||||||
@@ -142,6 +140,7 @@ Security choices here:
|
|||||||
- Access key login is protected by an intentionally aggressive in-memory rate limit with escalating lockouts.
|
- Access key login is protected by an intentionally aggressive in-memory rate limit with escalating lockouts.
|
||||||
- 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.
|
||||||
|
- New mailbox setup links are also one-time, time-limited, and stored only as hashes.
|
||||||
- The admin panel never changes mailbox passwords directly; it only generates one-time links.
|
- The admin panel never changes mailbox passwords directly; it only generates one-time links.
|
||||||
- Admin-only routes require a valid session cookie.
|
- Admin-only routes require a valid session cookie.
|
||||||
- Validation is applied to logins, emails, passwords, and reset payloads.
|
- Validation is applied to logins, emails, passwords, and reset payloads.
|
||||||
@@ -166,6 +165,7 @@ Security choices here:
|
|||||||
- `MAILSERVER_CONTAINER`
|
- `MAILSERVER_CONTAINER`
|
||||||
- `APP_ADMIN_EMAIL`
|
- `APP_ADMIN_EMAIL`
|
||||||
- `APP_ADMIN_ACCESS_KEY`
|
- `APP_ADMIN_ACCESS_KEY`
|
||||||
|
- `MAILBOX_INVITE_TTL_HOURS=48`
|
||||||
3. Install dependencies:
|
3. Install dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ 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,
|
||||||
|
mailboxInviteTtlMs: parseNumber(env.MAILBOX_INVITE_TTL_HOURS, 48) * 60 * 60_000,
|
||||||
appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "panel-admin@local",
|
appAdminEmail: env.APP_ADMIN_EMAIL?.trim().toLowerCase() ?? "panel-admin@local",
|
||||||
appAdminAccessKey: env.APP_ADMIN_ACCESS_KEY ?? "",
|
appAdminAccessKey: env.APP_ADMIN_ACCESS_KEY ?? "",
|
||||||
appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "[email protected]",
|
appMailFrom: env.APP_MAIL_FROM?.trim().toLowerCase() ?? "[email protected]",
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ export type PasswordResetTokenRow = {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PendingMailboxInviteRow = {
|
||||||
|
token_hash: string;
|
||||||
|
user_email: string;
|
||||||
|
created_by: string;
|
||||||
|
expires_at: number;
|
||||||
|
used_at: number | null;
|
||||||
|
created_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
mkdirSync(dirname(config.dbPath), { recursive: true });
|
mkdirSync(dirname(config.dbPath), { recursive: true });
|
||||||
|
|
||||||
const db = new Database(config.dbPath, { create: true });
|
const db = new Database(config.dbPath, { create: true });
|
||||||
@@ -55,6 +64,15 @@ db.exec(`
|
|||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_mailbox_invites (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
user_email TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
used_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_app_sessions_expires_at
|
CREATE INDEX IF NOT EXISTS idx_app_sessions_expires_at
|
||||||
ON app_sessions(expires_at);
|
ON app_sessions(expires_at);
|
||||||
|
|
||||||
@@ -63,6 +81,12 @@ db.exec(`
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at
|
||||||
ON password_reset_tokens(expires_at);
|
ON password_reset_tokens(expires_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pending_mailbox_invites_email
|
||||||
|
ON pending_mailbox_invites(user_email);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pending_mailbox_invites_expires_at
|
||||||
|
ON pending_mailbox_invites(expires_at);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const getAdminStatement = db.query("SELECT * FROM app_admins WHERE email = ?1");
|
const getAdminStatement = db.query("SELECT * FROM app_admins WHERE email = ?1");
|
||||||
@@ -115,6 +139,46 @@ const deleteExpiredResetTokensStatement = db.query(`
|
|||||||
WHERE expires_at <= ?1
|
WHERE expires_at <= ?1
|
||||||
OR (used_at IS NOT NULL AND used_at <= ?2)
|
OR (used_at IS NOT NULL AND used_at <= ?2)
|
||||||
`);
|
`);
|
||||||
|
const insertPendingMailboxInviteStatement = db.query(`
|
||||||
|
INSERT INTO pending_mailbox_invites (token_hash, user_email, created_by, expires_at, used_at, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, NULL, ?5)
|
||||||
|
`);
|
||||||
|
const getActivePendingMailboxInviteStatement = db.query(`
|
||||||
|
SELECT * FROM pending_mailbox_invites
|
||||||
|
WHERE token_hash = ?1
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND expires_at > ?2
|
||||||
|
`);
|
||||||
|
const getActivePendingMailboxInviteForUserStatement = db.query(`
|
||||||
|
SELECT * FROM pending_mailbox_invites
|
||||||
|
WHERE user_email = ?1
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND expires_at > ?2
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
const deleteActivePendingMailboxInvitesForUserStatement = db.query(`
|
||||||
|
DELETE FROM pending_mailbox_invites
|
||||||
|
WHERE user_email = ?1
|
||||||
|
AND used_at IS NULL
|
||||||
|
`);
|
||||||
|
const claimPendingMailboxInviteStatement = db.query(`
|
||||||
|
UPDATE pending_mailbox_invites
|
||||||
|
SET used_at = ?2
|
||||||
|
WHERE token_hash = ?1
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND expires_at > ?3
|
||||||
|
`);
|
||||||
|
const releasePendingMailboxInviteStatement = db.query(`
|
||||||
|
UPDATE pending_mailbox_invites
|
||||||
|
SET used_at = NULL
|
||||||
|
WHERE token_hash = ?1
|
||||||
|
`);
|
||||||
|
const deleteExpiredPendingMailboxInvitesStatement = db.query(`
|
||||||
|
DELETE FROM pending_mailbox_invites
|
||||||
|
WHERE expires_at <= ?1
|
||||||
|
OR (used_at IS NOT NULL AND used_at <= ?2)
|
||||||
|
`);
|
||||||
|
|
||||||
export const getAdmin = (email: string): AdminRow | null =>
|
export const getAdmin = (email: string): AdminRow | null =>
|
||||||
(getAdminStatement.get(email) as AdminRow | null) ?? null;
|
(getAdminStatement.get(email) as AdminRow | null) ?? null;
|
||||||
@@ -171,8 +235,44 @@ export const releasePasswordResetToken = (tokenHash: string): void => {
|
|||||||
releaseResetTokenStatement.run(tokenHash);
|
releaseResetTokenStatement.run(tokenHash);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createPendingMailboxInvite = (input: {
|
||||||
|
tokenHash: string;
|
||||||
|
userEmail: string;
|
||||||
|
createdBy: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}): void => {
|
||||||
|
insertPendingMailboxInviteStatement.run(
|
||||||
|
input.tokenHash,
|
||||||
|
input.userEmail,
|
||||||
|
input.createdBy,
|
||||||
|
input.expiresAt,
|
||||||
|
Date.now()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActivePendingMailboxInvite = (tokenHash: string): PendingMailboxInviteRow | null =>
|
||||||
|
(getActivePendingMailboxInviteStatement.get(tokenHash, Date.now()) as PendingMailboxInviteRow | null) ?? null;
|
||||||
|
|
||||||
|
export const getActivePendingMailboxInviteForUser = (userEmail: string): PendingMailboxInviteRow | null =>
|
||||||
|
(getActivePendingMailboxInviteForUserStatement.get(userEmail, Date.now()) as PendingMailboxInviteRow | null) ?? null;
|
||||||
|
|
||||||
|
export const deleteActivePendingMailboxInvitesForUser = (userEmail: string): void => {
|
||||||
|
deleteActivePendingMailboxInvitesForUserStatement.run(userEmail);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const claimPendingMailboxInvite = (tokenHash: string): boolean => {
|
||||||
|
const usedAt = Date.now();
|
||||||
|
const result = claimPendingMailboxInviteStatement.run(tokenHash, usedAt, usedAt);
|
||||||
|
return result.changes > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const releasePendingMailboxInvite = (tokenHash: string): void => {
|
||||||
|
releasePendingMailboxInviteStatement.run(tokenHash);
|
||||||
|
};
|
||||||
|
|
||||||
export const cleanupDatabase = (): void => {
|
export const cleanupDatabase = (): void => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
deleteExpiredSessionsStatement.run(now);
|
deleteExpiredSessionsStatement.run(now);
|
||||||
deleteExpiredResetTokensStatement.run(now, now - 86_400_000);
|
deleteExpiredResetTokensStatement.run(now, now - 86_400_000);
|
||||||
|
deleteExpiredPendingMailboxInvitesStatement.run(now, now - 86_400_000);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,15 +8,19 @@ import {
|
|||||||
import { config } from "../lib/config";
|
import { config } from "../lib/config";
|
||||||
import {
|
import {
|
||||||
claimPasswordResetToken,
|
claimPasswordResetToken,
|
||||||
|
claimPendingMailboxInvite,
|
||||||
createPasswordResetToken,
|
createPasswordResetToken,
|
||||||
|
createPendingMailboxInvite,
|
||||||
deleteActivePasswordResetTokensForUser,
|
deleteActivePasswordResetTokensForUser,
|
||||||
deletePasswordResetToken,
|
deleteActivePendingMailboxInvitesForUser,
|
||||||
getActivePasswordResetToken,
|
getActivePasswordResetToken,
|
||||||
|
getActivePendingMailboxInvite,
|
||||||
|
getActivePendingMailboxInviteForUser,
|
||||||
|
releasePendingMailboxInvite,
|
||||||
releasePasswordResetToken
|
releasePasswordResetToken
|
||||||
} from "../lib/db";
|
} from "../lib/db";
|
||||||
import { HttpError } from "../lib/errors";
|
import { HttpError } from "../lib/errors";
|
||||||
import { createOpaqueToken, createTemporaryPassword, hashToken } from "../lib/security";
|
import { createOpaqueToken, hashToken } from "../lib/security";
|
||||||
import { sendPasswordResetEmail } from "../mail/service";
|
|
||||||
|
|
||||||
const maskEmail = (email: string): string => {
|
const maskEmail = (email: string): string => {
|
||||||
const [rawLocalPart, rawDomain] = email.split("@");
|
const [rawLocalPart, rawDomain] = email.split("@");
|
||||||
@@ -32,6 +36,13 @@ 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 ensureUserDoesNotExist = async (email: string): Promise<void> => {
|
||||||
|
const users = await listMailUsers();
|
||||||
|
if (users.some((item) => item.email === email)) {
|
||||||
|
throw new HttpError(409, "A mailbox with this email already exists.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createPasswordResetLink = async (
|
const createPasswordResetLink = async (
|
||||||
email: string,
|
email: string,
|
||||||
createdBy: string
|
createdBy: string
|
||||||
@@ -72,29 +83,32 @@ export const listUsers = async (): Promise<MailUser[]> =>
|
|||||||
listMailUsers();
|
listMailUsers();
|
||||||
|
|
||||||
export const createUser = async (email: string, createdBy: string): Promise<CreateUserResponse> => {
|
export const createUser = async (email: string, createdBy: string): Promise<CreateUserResponse> => {
|
||||||
const temporaryPassword = createTemporaryPassword();
|
await ensureUserDoesNotExist(email);
|
||||||
await addMailUser(email, temporaryPassword);
|
|
||||||
|
|
||||||
const user = await ensureUserExists(email);
|
const activeInvite = getActivePendingMailboxInviteForUser(email);
|
||||||
|
if (activeInvite) {
|
||||||
try {
|
throw new HttpError(409, "A setup link already exists for this mailbox.");
|
||||||
const reset = await createPasswordResetLink(email, createdBy);
|
|
||||||
await sendPasswordResetEmail(email, reset.resetLink, reset.expiresAt);
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
resetLinkSent: true,
|
|
||||||
resetLinkExpiresAt: reset.expiresAt,
|
|
||||||
warning: null
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
resetLinkSent: false,
|
|
||||||
resetLinkExpiresAt: null,
|
|
||||||
warning: "Mailbox created, but the setup email could not be sent. Trigger another reset link after mail delivery is fixed."
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteActivePendingMailboxInvitesForUser(email);
|
||||||
|
|
||||||
|
const rawToken = createOpaqueToken();
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
const expiresAtMs = Date.now() + config.mailboxInviteTtlMs;
|
||||||
|
const expiresAt = new Date(expiresAtMs).toISOString();
|
||||||
|
|
||||||
|
createPendingMailboxInvite({
|
||||||
|
tokenHash,
|
||||||
|
userEmail: email,
|
||||||
|
createdBy,
|
||||||
|
expiresAt: expiresAtMs
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
email,
|
||||||
|
setupLink: buildResetLink(rawToken),
|
||||||
|
expiresAt
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteUser = async (email: string): Promise<void> => {
|
export const deleteUser = async (email: string): Promise<void> => {
|
||||||
@@ -110,12 +124,24 @@ export const triggerPasswordReset = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const inspectPasswordReset = async (token: string): Promise<PasswordResetInspectResponse> => {
|
export const inspectPasswordReset = async (token: string): Promise<PasswordResetInspectResponse> => {
|
||||||
const resetToken = getActivePasswordResetToken(hashToken(token));
|
const tokenHash = hashToken(token);
|
||||||
|
const pendingInvite = getActivePendingMailboxInvite(tokenHash);
|
||||||
|
|
||||||
|
if (pendingInvite) {
|
||||||
|
return {
|
||||||
|
mode: "invite",
|
||||||
|
emailHint: pendingInvite.user_email,
|
||||||
|
expiresAt: new Date(pendingInvite.expires_at).toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToken = getActivePasswordResetToken(tokenHash);
|
||||||
if (!resetToken) {
|
if (!resetToken) {
|
||||||
throw new HttpError(400, "This password reset link is invalid or expired.");
|
throw new HttpError(400, "This password reset link is invalid or expired.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
mode: "reset",
|
||||||
emailHint: maskEmail(resetToken.user_email),
|
emailHint: maskEmail(resetToken.user_email),
|
||||||
expiresAt: new Date(resetToken.expires_at).toISOString()
|
expiresAt: new Date(resetToken.expires_at).toISOString()
|
||||||
};
|
};
|
||||||
@@ -123,6 +149,23 @@ export const inspectPasswordReset = async (token: string): Promise<PasswordReset
|
|||||||
|
|
||||||
export const consumePasswordReset = async (token: string, password: string): Promise<void> => {
|
export const consumePasswordReset = async (token: string, password: string): Promise<void> => {
|
||||||
const tokenHash = hashToken(token);
|
const tokenHash = hashToken(token);
|
||||||
|
const pendingInvite = getActivePendingMailboxInvite(tokenHash);
|
||||||
|
|
||||||
|
if (pendingInvite) {
|
||||||
|
const claimedInvite = claimPendingMailboxInvite(tokenHash);
|
||||||
|
if (!claimedInvite) {
|
||||||
|
throw new HttpError(400, "This setup link is invalid or already used.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addMailUser(pendingInvite.user_email, password);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
releasePendingMailboxInvite(tokenHash);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resetToken = getActivePasswordResetToken(tokenHash);
|
const resetToken = getActivePasswordResetToken(tokenHash);
|
||||||
|
|
||||||
if (!resetToken) {
|
if (!resetToken) {
|
||||||
|
|||||||
@@ -93,9 +93,7 @@ export default function App() {
|
|||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.createUser({ email });
|
return await api.createUser({ email });
|
||||||
await loadUsers();
|
|
||||||
return result;
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
type ResetLinkModalProps = {
|
type ResetLinkModalProps = {
|
||||||
|
variant?: "invite" | "reset";
|
||||||
email: string;
|
email: string;
|
||||||
resetLink: string;
|
resetLink: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
@@ -6,6 +7,7 @@ type ResetLinkModalProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ResetLinkModal({
|
export default function ResetLinkModal({
|
||||||
|
variant = "reset",
|
||||||
email,
|
email,
|
||||||
resetLink,
|
resetLink,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
@@ -19,14 +21,18 @@ export default function ResetLinkModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isInvite = variant === "invite";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" role="presentation">
|
<div className="modal-backdrop" role="presentation">
|
||||||
<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>Temporary reset link</h2>
|
<h2>{isInvite ? "Mailbox setup link" : "Temporary reset link"}</h2>
|
||||||
<p>
|
<p>
|
||||||
No email was sent for <strong>{email}</strong>. Share this one-time link yourself.
|
{isInvite
|
||||||
|
? <>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.</>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="ghost-button" onClick={onClose}>
|
<button type="button" className="ghost-button" onClick={onClose}>
|
||||||
@@ -36,7 +42,7 @@ export default function ResetLinkModal({
|
|||||||
|
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>One-time link</span>
|
<span>{isInvite ? "Setup link" : "One-time link"}</span>
|
||||||
<input type="text" readOnly value={resetLink} />
|
<input type="text" readOnly value={resetLink} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function UserFormModal({
|
|||||||
<div>
|
<div>
|
||||||
<h2>Create user</h2>
|
<h2>Create user</h2>
|
||||||
<p>
|
<p>
|
||||||
A temporary password will be generated and a reset link will be emailed automatically.
|
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="ghost-button" onClick={onClose} disabled={busy}>
|
||||||
|
|||||||
@@ -55,10 +55,12 @@ export default function DashboardPage({
|
|||||||
try {
|
try {
|
||||||
const result = await onCreateUser(email);
|
const result = await onCreateUser(email);
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setInlineMessage(
|
setResetLinkData({
|
||||||
result.warning ??
|
email: result.email,
|
||||||
`Mailbox created and reset email sent. Link expires at ${result.resetLinkExpiresAt}.`
|
resetLink: result.setupLink,
|
||||||
);
|
expiresAt: result.expiresAt
|
||||||
|
});
|
||||||
|
setInlineMessage(`Setup link generated for ${result.email}. The mailbox will be created only after the user sets a password.`);
|
||||||
} finally {
|
} finally {
|
||||||
setModalBusy(false);
|
setModalBusy(false);
|
||||||
}
|
}
|
||||||
@@ -199,6 +201,7 @@ export default function DashboardPage({
|
|||||||
|
|
||||||
{resetLinkData ? (
|
{resetLinkData ? (
|
||||||
<ResetLinkModal
|
<ResetLinkModal
|
||||||
|
variant="invite"
|
||||||
email={resetLinkData.email}
|
email={resetLinkData.email}
|
||||||
resetLink={resetLinkData.resetLink}
|
resetLink={resetLinkData.resetLink}
|
||||||
expiresAt={resetLinkData.expiresAt}
|
expiresAt={resetLinkData.expiresAt}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type ResetPasswordPageProps = {
|
|||||||
|
|
||||||
export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
|
export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
|
||||||
const [status, setStatus] = useState<"loading" | "ready" | "invalid" | "done">("loading");
|
const [status, setStatus] = useState<"loading" | "ready" | "invalid" | "done">("loading");
|
||||||
|
const [mode, setMode] = useState<"invite" | "reset">("reset");
|
||||||
const [emailHint, setEmailHint] = useState("");
|
const [emailHint, setEmailHint] = useState("");
|
||||||
const [expiresAt, setExpiresAt] = useState("");
|
const [expiresAt, setExpiresAt] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -23,6 +24,7 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
|
|||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setMode(result.mode);
|
||||||
setEmailHint(result.emailHint);
|
setEmailHint(result.emailHint);
|
||||||
setExpiresAt(result.expiresAt);
|
setExpiresAt(result.expiresAt);
|
||||||
setStatus("ready");
|
setStatus("ready");
|
||||||
@@ -73,15 +75,17 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
|
|||||||
return (
|
return (
|
||||||
<main className="auth-shell">
|
<main className="auth-shell">
|
||||||
<section className="auth-card">
|
<section className="auth-card">
|
||||||
<div className="eyebrow">Secure reset</div>
|
<div className="eyebrow">{mode === "invite" ? "Mailbox setup" : "Secure reset"}</div>
|
||||||
<h1>Choose a new mailbox password</h1>
|
<h1>{mode === "invite" ? "Create your mailbox password" : "Choose a new mailbox 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}
|
||||||
|
|
||||||
{status === "invalid" ? (
|
{status === "invalid" ? (
|
||||||
<>
|
<>
|
||||||
<p className="auth-copy">
|
<p className="auth-copy">
|
||||||
This link is no longer valid. Ask your administrator for a new reset email.
|
{mode === "invite"
|
||||||
|
? "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."}
|
||||||
</p>
|
</p>
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error">{error}</p> : null}
|
||||||
</>
|
</>
|
||||||
@@ -89,14 +93,18 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
|
|||||||
|
|
||||||
{status === "done" ? (
|
{status === "done" ? (
|
||||||
<p className="notice success">
|
<p className="notice success">
|
||||||
Your mailbox password has been updated. You can close this page and sign in with the new password.
|
{mode === "invite"
|
||||||
|
? "Your mailbox has been created. You can close this page and sign in with the password you just chose."
|
||||||
|
: "Your mailbox password has been updated. You can close this page and sign in with the new password."}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{status === "ready" ? (
|
{status === "ready" ? (
|
||||||
<>
|
<>
|
||||||
<p className="auth-copy">
|
<p className="auth-copy">
|
||||||
Resetting the password for <strong>{emailHint}</strong>. This link expires at {expiresAt}.
|
{mode === "invite"
|
||||||
|
? <>Creating the mailbox for <strong>{emailHint}</strong>. This setup link expires at {expiresAt}.</>
|
||||||
|
: <>Resetting the password for <strong>{emailHint}</strong>. This link expires at {expiresAt}.</>}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form className="stack" onSubmit={handleSubmit}>
|
<form className="stack" onSubmit={handleSubmit}>
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ export type CreateUserRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CreateUserResponse = {
|
export type CreateUserResponse = {
|
||||||
user: MailUser;
|
email: string;
|
||||||
resetLinkSent: boolean;
|
setupLink: string;
|
||||||
resetLinkExpiresAt: string | null;
|
expiresAt: string;
|
||||||
warning: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoginRequest = {
|
export type LoginRequest = {
|
||||||
@@ -42,6 +41,7 @@ export type PasswordResetInspectRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type PasswordResetInspectResponse = {
|
export type PasswordResetInspectResponse = {
|
||||||
|
mode: "invite" | "reset";
|
||||||
emailHint: string;
|
emailHint: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user