.
This commit is contained in:
@@ -12,5 +12,6 @@ HOSTED_DOMAINS=example.com
|
||||
SESSION_COOKIE_NAME=mail_admin_session
|
||||
SESSION_TTL_MINUTES=480
|
||||
RESET_TOKEN_TTL_MINUTES=60
|
||||
MAILBOX_INVITE_TTL_HOURS=48
|
||||
APP_ADMIN_EMAIL=admin@local
|
||||
APP_ADMIN_ACCESS_KEY=change-this-access-key
|
||||
|
||||
@@ -67,9 +67,7 @@ The backend is intentionally flat:
|
||||
- `apps/api/src/docker/`
|
||||
Wraps Docker CLI calls with fixed argument arrays only. No shell interpolation.
|
||||
- `apps/api/src/users/service.ts`
|
||||
Contains the mailbox user operations 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.
|
||||
Contains the mailbox user operations, pending mailbox invites, and the one-time link flow for password changes.
|
||||
- `apps/api/src/lib/`
|
||||
Holds config parsing, SQLite access, validation, HTTP helpers, and small security utilities.
|
||||
|
||||
@@ -98,24 +96,24 @@ The dashboard includes:
|
||||
- mailbox listing
|
||||
- simple search
|
||||
- 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
|
||||
- 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
|
||||
|
||||
The reset flow is built to stay secure without becoming heavy:
|
||||
|
||||
1. An admin generates a one-time link for an existing mailbox.
|
||||
2. The backend generates a random opaque token.
|
||||
3. Only the SHA-256 hash of that token is stored in SQLite.
|
||||
4. The admin shares the one-time link manually with the user.
|
||||
5. The reset page validates the token before showing the password form.
|
||||
6. On submit, the token is claimed for one use only.
|
||||
7. The backend updates the mailbox password through Docker Mailserver.
|
||||
8. Used or expired tokens are rejected.
|
||||
1. For a new mailbox, the admin generates a 48-hour setup link.
|
||||
2. The mailbox is not created yet.
|
||||
3. The backend stores only the SHA-256 hash of the invite token.
|
||||
4. The admin shares the one-time setup link manually with the user.
|
||||
5. When the user chooses a password through the link, the backend creates the mailbox in Docker Mailserver.
|
||||
6. For existing mailboxes, the same public page is reused for password reset links.
|
||||
7. Every link is one-time and expires automatically.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Admin-only routes require a valid session cookie.
|
||||
- Validation is applied to logins, emails, passwords, and reset payloads.
|
||||
@@ -166,6 +165,7 @@ Security choices here:
|
||||
- `MAILSERVER_CONTAINER`
|
||||
- `APP_ADMIN_EMAIL`
|
||||
- `APP_ADMIN_ACCESS_KEY`
|
||||
- `MAILBOX_INVITE_TTL_HOURS=48`
|
||||
3. Install dependencies:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -29,6 +29,7 @@ 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,
|
||||
mailboxInviteTtlMs: parseNumber(env.MAILBOX_INVITE_TTL_HOURS, 48) * 60 * 60_000,
|
||||
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]",
|
||||
|
||||
@@ -26,6 +26,15 @@ export type PasswordResetTokenRow = {
|
||||
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 });
|
||||
|
||||
const db = new Database(config.dbPath, { create: true });
|
||||
@@ -55,6 +64,15 @@ db.exec(`
|
||||
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
|
||||
ON app_sessions(expires_at);
|
||||
|
||||
@@ -63,6 +81,12 @@ db.exec(`
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_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");
|
||||
@@ -115,6 +139,46 @@ const deleteExpiredResetTokensStatement = db.query(`
|
||||
WHERE expires_at <= ?1
|
||||
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 =>
|
||||
(getAdminStatement.get(email) as AdminRow | null) ?? null;
|
||||
@@ -171,8 +235,44 @@ export const releasePasswordResetToken = (tokenHash: string): void => {
|
||||
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 => {
|
||||
const now = Date.now();
|
||||
deleteExpiredSessionsStatement.run(now);
|
||||
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 {
|
||||
claimPasswordResetToken,
|
||||
claimPendingMailboxInvite,
|
||||
createPasswordResetToken,
|
||||
createPendingMailboxInvite,
|
||||
deleteActivePasswordResetTokensForUser,
|
||||
deletePasswordResetToken,
|
||||
deleteActivePendingMailboxInvitesForUser,
|
||||
getActivePasswordResetToken,
|
||||
getActivePendingMailboxInvite,
|
||||
getActivePendingMailboxInviteForUser,
|
||||
releasePendingMailboxInvite,
|
||||
releasePasswordResetToken
|
||||
} from "../lib/db";
|
||||
import { HttpError } from "../lib/errors";
|
||||
import { createOpaqueToken, createTemporaryPassword, hashToken } from "../lib/security";
|
||||
import { sendPasswordResetEmail } from "../mail/service";
|
||||
import { createOpaqueToken, hashToken } from "../lib/security";
|
||||
|
||||
const maskEmail = (email: string): string => {
|
||||
const [rawLocalPart, rawDomain] = email.split("@");
|
||||
@@ -32,6 +36,13 @@ const maskEmail = (email: string): string => {
|
||||
const buildResetLink = (token: string): string =>
|
||||
`${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 (
|
||||
email: string,
|
||||
createdBy: string
|
||||
@@ -72,29 +83,32 @@ export const listUsers = async (): Promise<MailUser[]> =>
|
||||
listMailUsers();
|
||||
|
||||
export const createUser = async (email: string, createdBy: string): Promise<CreateUserResponse> => {
|
||||
const temporaryPassword = createTemporaryPassword();
|
||||
await addMailUser(email, temporaryPassword);
|
||||
await ensureUserDoesNotExist(email);
|
||||
|
||||
const user = await ensureUserExists(email);
|
||||
|
||||
try {
|
||||
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."
|
||||
};
|
||||
const activeInvite = getActivePendingMailboxInviteForUser(email);
|
||||
if (activeInvite) {
|
||||
throw new HttpError(409, "A setup link already exists for this mailbox.");
|
||||
}
|
||||
|
||||
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> => {
|
||||
@@ -110,12 +124,24 @@ export const triggerPasswordReset = async (
|
||||
};
|
||||
|
||||
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) {
|
||||
throw new HttpError(400, "This password reset link is invalid or expired.");
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "reset",
|
||||
emailHint: maskEmail(resetToken.user_email),
|
||||
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> => {
|
||||
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);
|
||||
|
||||
if (!resetToken) {
|
||||
|
||||
@@ -93,9 +93,7 @@ export default function App() {
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await api.createUser({ email });
|
||||
await loadUsers();
|
||||
return result;
|
||||
return await api.createUser({ email });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
type ResetLinkModalProps = {
|
||||
variant?: "invite" | "reset";
|
||||
email: string;
|
||||
resetLink: string;
|
||||
expiresAt: string;
|
||||
@@ -6,6 +7,7 @@ type ResetLinkModalProps = {
|
||||
};
|
||||
|
||||
export default function ResetLinkModal({
|
||||
variant = "reset",
|
||||
email,
|
||||
resetLink,
|
||||
expiresAt,
|
||||
@@ -19,14 +21,18 @@ export default function ResetLinkModal({
|
||||
}
|
||||
};
|
||||
|
||||
const isInvite = variant === "invite";
|
||||
|
||||
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>
|
||||
<h2>{isInvite ? "Mailbox setup link" : "Temporary reset link"}</h2>
|
||||
<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>
|
||||
</div>
|
||||
<button type="button" className="ghost-button" onClick={onClose}>
|
||||
@@ -36,7 +42,7 @@ export default function ResetLinkModal({
|
||||
|
||||
<div className="stack">
|
||||
<label className="field">
|
||||
<span>One-time link</span>
|
||||
<span>{isInvite ? "Setup link" : "One-time link"}</span>
|
||||
<input type="text" readOnly value={resetLink} />
|
||||
</label>
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function UserFormModal({
|
||||
<div>
|
||||
<h2>Create user</h2>
|
||||
<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>
|
||||
</div>
|
||||
<button type="button" className="ghost-button" onClick={onClose} disabled={busy}>
|
||||
|
||||
@@ -55,10 +55,12 @@ export default function DashboardPage({
|
||||
try {
|
||||
const result = await onCreateUser(email);
|
||||
setCreateOpen(false);
|
||||
setInlineMessage(
|
||||
result.warning ??
|
||||
`Mailbox created and reset email sent. Link expires at ${result.resetLinkExpiresAt}.`
|
||||
);
|
||||
setResetLinkData({
|
||||
email: result.email,
|
||||
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 {
|
||||
setModalBusy(false);
|
||||
}
|
||||
@@ -199,6 +201,7 @@ export default function DashboardPage({
|
||||
|
||||
{resetLinkData ? (
|
||||
<ResetLinkModal
|
||||
variant="invite"
|
||||
email={resetLinkData.email}
|
||||
resetLink={resetLinkData.resetLink}
|
||||
expiresAt={resetLinkData.expiresAt}
|
||||
|
||||
@@ -7,6 +7,7 @@ type ResetPasswordPageProps = {
|
||||
|
||||
export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "invalid" | "done">("loading");
|
||||
const [mode, setMode] = useState<"invite" | "reset">("reset");
|
||||
const [emailHint, setEmailHint] = useState("");
|
||||
const [expiresAt, setExpiresAt] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -23,6 +24,7 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setMode(result.mode);
|
||||
setEmailHint(result.emailHint);
|
||||
setExpiresAt(result.expiresAt);
|
||||
setStatus("ready");
|
||||
@@ -73,15 +75,17 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
|
||||
return (
|
||||
<main className="auth-shell">
|
||||
<section className="auth-card">
|
||||
<div className="eyebrow">Secure reset</div>
|
||||
<h1>Choose a new mailbox password</h1>
|
||||
<div className="eyebrow">{mode === "invite" ? "Mailbox setup" : "Secure reset"}</div>
|
||||
<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 === "invalid" ? (
|
||||
<>
|
||||
<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>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
</>
|
||||
@@ -89,14 +93,18 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
|
||||
|
||||
{status === "done" ? (
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
{status === "ready" ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<form className="stack" onSubmit={handleSubmit}>
|
||||
|
||||
@@ -22,10 +22,9 @@ export type CreateUserRequest = {
|
||||
};
|
||||
|
||||
export type CreateUserResponse = {
|
||||
user: MailUser;
|
||||
resetLinkSent: boolean;
|
||||
resetLinkExpiresAt: string | null;
|
||||
warning: string | null;
|
||||
email: string;
|
||||
setupLink: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type LoginRequest = {
|
||||
@@ -42,6 +41,7 @@ export type PasswordResetInspectRequest = {
|
||||
};
|
||||
|
||||
export type PasswordResetInspectResponse = {
|
||||
mode: "invite" | "reset";
|
||||
emailHint: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user