Compare commits
10
Commits
87198824b6
...
487aaad873
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
487aaad873 | ||
|
|
95cfbff731 | ||
|
|
7770243b14 | ||
|
|
1aa105ebb1 | ||
|
|
0f575efb76 | ||
|
|
31df5a4aa0 | ||
|
|
e4506815ef | ||
|
|
44ccbd92bd | ||
|
|
37159aa2ed | ||
|
|
59310b0b9b |
+2
-1
@@ -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
|
||||||
APP_ADMIN_EMAIL=panel-admin@local
|
MAILBOX_INVITE_TTL_HOURS=48
|
||||||
|
APP_ADMIN_EMAIL=admin@local
|
||||||
APP_ADMIN_ACCESS_KEY=change-this-access-key
|
APP_ADMIN_ACCESS_KEY=change-this-access-key
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ data/app.db
|
|||||||
data/app.db-shm
|
data/app.db-shm
|
||||||
data/app.db-wal
|
data/app.db-wal
|
||||||
coverage
|
coverage
|
||||||
|
bun.lock
|
||||||
|
not.md
|
||||||
|
.codex
|
||||||
@@ -16,7 +16,7 @@ Why this backend:
|
|||||||
|
|
||||||
- Bun keeps the runtime, package manager, and SQLite support simple.
|
- Bun keeps the runtime, package manager, and SQLite support simple.
|
||||||
- ElysiaJS is a good fit for a small internal API without extra framework weight.
|
- ElysiaJS is a good fit for a small internal API without extra framework weight.
|
||||||
- SQLite is enough for admin sessions and password reset tokens, and keeps deployment straightforward.
|
- SQLite is enough for admin sessions and a small amount of panel metadata, and keeps deployment straightforward.
|
||||||
- Docker CLI integration stays close to Docker Mailserver's real operational model instead of hiding it behind unnecessary layers.
|
- Docker CLI integration stays close to Docker Mailserver's real operational model instead of hiding it behind unnecessary layers.
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
@@ -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 password reset flow.
|
Contains the mailbox user operations, pending mailbox invites, and the one-time link flow for password changes.
|
||||||
- `apps/api/src/mail/service.ts`
|
|
||||||
Sends reset emails 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.
|
||||||
|
|
||||||
@@ -80,7 +78,6 @@ The backend is intentionally flat:
|
|||||||
- `POST /api/auth/logout`
|
- `POST /api/auth/logout`
|
||||||
- `GET /api/users`
|
- `GET /api/users`
|
||||||
- `POST /api/users`
|
- `POST /api/users`
|
||||||
- `PATCH /api/users/:email/password`
|
|
||||||
- `DELETE /api/users/:email`
|
- `DELETE /api/users/:email`
|
||||||
- `POST /api/users/:email/password-reset`
|
- `POST /api/users/:email/password-reset`
|
||||||
- `POST /api/password-resets/inspect`
|
- `POST /api/password-resets/inspect`
|
||||||
@@ -99,24 +96,24 @@ The dashboard includes:
|
|||||||
- mailbox listing
|
- mailbox listing
|
||||||
- simple search
|
- simple search
|
||||||
- create user modal
|
- create user modal
|
||||||
- edit password modal
|
- 48-hour setup link generation for new mailboxes
|
||||||
|
- temporary link generation for password changes on existing mailboxes
|
||||||
- delete action
|
- delete action
|
||||||
- send reset 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
|
## 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 triggers a reset 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 user receives an email with the one-time reset link.
|
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
|
||||||
|
|
||||||
@@ -140,12 +137,15 @@ Security choices here:
|
|||||||
|
|
||||||
- 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.
|
- Panel login uses a single access key instead of an email/password pair.
|
||||||
|
- 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.
|
||||||
- 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.
|
||||||
- Allowed origins are restricted by `APP_ORIGINS`.
|
- Allowed origins are restricted by `APP_ORIGINS`.
|
||||||
- Reset emails are sent from the backend only. No secrets are exposed to the frontend.
|
- Setup emails for new accounts are sent from the backend only. No secrets are exposed to the frontend.
|
||||||
- Docker Mailserver access is treated as privileged infrastructure access.
|
- Docker Mailserver access is treated as privileged infrastructure access.
|
||||||
|
|
||||||
## 8. Run locally
|
## 8. Run locally
|
||||||
@@ -165,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
|
||||||
|
|||||||
@@ -95,7 +95,11 @@ export const runDocker = async (
|
|||||||
throw new HttpError(504, "Docker Mailserver command timed out.");
|
throw new HttpError(504, "Docker Mailserver command timed out.");
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new HttpError(502, "Docker Mailserver is unreachable.");
|
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||||
|
throw new HttpError(502, `Docker binary '${config.dockerBinary}' was not found. Set DOCKER_BINARY to the absolute docker path on NixOS.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpError(502, error instanceof Error ? error.message : "Docker Mailserver is unreachable.");
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-8
@@ -3,7 +3,9 @@ import { loginAdmin, logoutAdmin, requireAdminSession, getSessionFromRequest, se
|
|||||||
import { cleanupDatabase } from "./lib/db";
|
import { cleanupDatabase } from "./lib/db";
|
||||||
import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
|
import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
|
||||||
import { config } from "./lib/config";
|
import { config } from "./lib/config";
|
||||||
|
import { HttpError } from "./lib/errors";
|
||||||
import { serveFrontendBuild } from "./lib/frontend";
|
import { serveFrontendBuild } from "./lib/frontend";
|
||||||
|
import { clearAccessKeyLoginRateLimit, enforceAggressiveLoginRateLimit, recordFailedAccessKeyLogin } from "./lib/security";
|
||||||
import {
|
import {
|
||||||
createUserSchema,
|
createUserSchema,
|
||||||
loginSchema,
|
loginSchema,
|
||||||
@@ -65,15 +67,25 @@ app.get(
|
|||||||
app.post(
|
app.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
route(async ({ request }) => {
|
route(async ({ request }) => {
|
||||||
const body = await parseJson(request, loginSchema);
|
enforceAggressiveLoginRateLimit(request);
|
||||||
const result = await loginAdmin(body.accessKey);
|
|
||||||
|
|
||||||
return jsonResponse(
|
try {
|
||||||
request,
|
const body = await parseJson(request, loginSchema);
|
||||||
200,
|
const result = await loginAdmin(body.accessKey);
|
||||||
{ session: result.session },
|
clearAccessKeyLoginRateLimit(request);
|
||||||
{ "set-cookie": result.cookie }
|
|
||||||
);
|
return jsonResponse(
|
||||||
|
request,
|
||||||
|
200,
|
||||||
|
{ session: result.session },
|
||||||
|
{ "set-cookie": result.cookie }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpError && error.status === 401) {
|
||||||
|
recordFailedAccessKeyLogin(request);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
|
readonly headers?: HeadersInit;
|
||||||
|
|
||||||
constructor(status: number, message: string) {
|
constructor(status: number, message: string, headers?: HeadersInit) {
|
||||||
super(message);
|
super(message);
|
||||||
this.status = status;
|
this.status = status;
|
||||||
|
this.headers = headers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const assertAllowedOrigin = (request: Request): void => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(config.appOrigins, origin)
|
||||||
if (!config.appOrigins.includes(origin.toLowerCase())) {
|
if (!config.appOrigins.includes(origin.toLowerCase())) {
|
||||||
throw new HttpError(403, "Origin is not allowed.");
|
throw new HttpError(403, "Origin is not allowed.");
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,7 @@ export const errorResponse = (request: Request, error: unknown): Response => {
|
|||||||
error: {
|
error: {
|
||||||
message: error.message
|
message: error.message
|
||||||
}
|
}
|
||||||
});
|
}, error.headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
@@ -115,4 +116,3 @@ export const errorResponse = (request: Request, error: unknown): Response => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,102 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { HttpError } from "./errors";
|
||||||
|
|
||||||
|
type LoginRateLimitState = {
|
||||||
|
blockedUntil: number;
|
||||||
|
lastSeenAt: number;
|
||||||
|
recentAttempts: number[];
|
||||||
|
failedAttempts: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginRateLimitStates = new Map<string, LoginRateLimitState>();
|
||||||
|
|
||||||
|
const LOGIN_BURST_WINDOW_MS = 15 * 60_000;
|
||||||
|
const LOGIN_BURST_LIMIT = 1;
|
||||||
|
const LOGIN_FAILURE_TRACK_MS = 365 * 24 * 60 * 60_000;
|
||||||
|
const LOGIN_STATE_TTL_MS = 400 * 24 * 60 * 60_000;
|
||||||
|
|
||||||
|
const getRetryAfterSeconds = (blockedUntil: number): number =>
|
||||||
|
Math.max(1, Math.ceil((blockedUntil - Date.now()) / 1000));
|
||||||
|
|
||||||
|
const getClientIp = (request: Request): string => {
|
||||||
|
const forwardedFor = request.headers.get("x-forwarded-for");
|
||||||
|
if (forwardedFor) {
|
||||||
|
const first = forwardedFor.split(",")[0]?.trim();
|
||||||
|
if (first) {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIp = request.headers.get("x-real-ip")?.trim();
|
||||||
|
if (realIp) {
|
||||||
|
return realIp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLoginRateLimitKey = (request: Request): string =>
|
||||||
|
getClientIp(request);
|
||||||
|
|
||||||
|
const pruneLoginRateLimitState = (state: LoginRateLimitState, now: number): void => {
|
||||||
|
state.recentAttempts = state.recentAttempts.filter((timestamp) => now - timestamp <= LOGIN_BURST_WINDOW_MS);
|
||||||
|
state.failedAttempts = state.failedAttempts.filter((timestamp) => now - timestamp <= LOGIN_FAILURE_TRACK_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLoginRateLimitState = (key: string, now: number): LoginRateLimitState => {
|
||||||
|
for (const [stateKey, state] of loginRateLimitStates.entries()) {
|
||||||
|
if (now - state.lastSeenAt > LOGIN_STATE_TTL_MS && now >= state.blockedUntil) {
|
||||||
|
loginRateLimitStates.delete(stateKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = loginRateLimitStates.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.lastSeenAt = now;
|
||||||
|
pruneLoginRateLimitState(existing, now);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState: LoginRateLimitState = {
|
||||||
|
blockedUntil: 0,
|
||||||
|
lastSeenAt: now,
|
||||||
|
recentAttempts: [],
|
||||||
|
failedAttempts: []
|
||||||
|
};
|
||||||
|
|
||||||
|
loginRateLimitStates.set(key, nextState);
|
||||||
|
return nextState;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRateLimitError = (blockedUntil: number): HttpError =>
|
||||||
|
new HttpError(
|
||||||
|
429,
|
||||||
|
"Too many access key login attempts. Login is locked for this client.",
|
||||||
|
{
|
||||||
|
"Retry-After": String(getRetryAfterSeconds(blockedUntil))
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyLoginFailurePenalty = (state: LoginRateLimitState, now: number): void => {
|
||||||
|
const failureCount = state.failedAttempts.length;
|
||||||
|
|
||||||
|
let blockMs = 0;
|
||||||
|
if (failureCount >= 5) {
|
||||||
|
blockMs = 365 * 24 * 60 * 60_000;
|
||||||
|
} else if (failureCount >= 4) {
|
||||||
|
blockMs = 180 * 24 * 60 * 60_000;
|
||||||
|
} else if (failureCount >= 3) {
|
||||||
|
blockMs = 30 * 24 * 60 * 60_000;
|
||||||
|
} else if (failureCount >= 2) {
|
||||||
|
blockMs = 7 * 24 * 60 * 60_000;
|
||||||
|
} else if (failureCount >= 1) {
|
||||||
|
blockMs = 24 * 60 * 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockMs > 0) {
|
||||||
|
state.blockedUntil = Math.max(state.blockedUntil, now + blockMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const hashToken = (value: string): string =>
|
export const hashToken = (value: string): string =>
|
||||||
createHash("sha256").update(value).digest("hex");
|
createHash("sha256").update(value).digest("hex");
|
||||||
@@ -54,3 +152,31 @@ export const readCookie = (request: Request, name: string): string | null => {
|
|||||||
export const sanitizeHeaderValue = (value: string): string =>
|
export const sanitizeHeaderValue = (value: string): string =>
|
||||||
value.replace(/[\r\n]+/g, " ").trim();
|
value.replace(/[\r\n]+/g, " ").trim();
|
||||||
|
|
||||||
|
export const enforceAggressiveLoginRateLimit = (request: Request): void => {
|
||||||
|
const now = Date.now();
|
||||||
|
const key = getLoginRateLimitKey(request);
|
||||||
|
const state = getLoginRateLimitState(key, now);
|
||||||
|
|
||||||
|
if (now < state.blockedUntil) {
|
||||||
|
throw createRateLimitError(state.blockedUntil);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.recentAttempts.push(now);
|
||||||
|
if (state.recentAttempts.length > LOGIN_BURST_LIMIT) {
|
||||||
|
state.blockedUntil = now + 24 * 60 * 60_000;
|
||||||
|
throw createRateLimitError(state.blockedUntil);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recordFailedAccessKeyLogin = (request: Request): void => {
|
||||||
|
const now = Date.now();
|
||||||
|
const key = getLoginRateLimitKey(request);
|
||||||
|
const state = getLoginRateLimitState(key, now);
|
||||||
|
|
||||||
|
state.failedAttempts.push(now);
|
||||||
|
applyLoginFailurePenalty(state, now);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAccessKeyLoginRateLimit = (request: Request): void => {
|
||||||
|
loginRateLimitStates.delete(getLoginRateLimitKey(request));
|
||||||
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+5
-3
@@ -2,11 +2,13 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>Mail Admin</title>
|
<meta name="theme-color" content="#0b0d10" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>Internal iHorizon Mail System</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Admin panel for Docker Mailserver user management."
|
content="www.ihorizon.org"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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,7 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type ResetLinkModalProps = {
|
type ResetLinkModalProps = {
|
||||||
|
variant?: "invite" | "reset";
|
||||||
email: string;
|
email: string;
|
||||||
resetLink: string;
|
resetLink: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
@@ -6,51 +9,82 @@ type ResetLinkModalProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ResetLinkModal({
|
export default function ResetLinkModal({
|
||||||
|
variant = "reset",
|
||||||
email,
|
email,
|
||||||
resetLink,
|
resetLink,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
onClose
|
onClose
|
||||||
}: ResetLinkModalProps) {
|
}: ResetLinkModalProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(resetLink);
|
await navigator.clipboard.writeText(resetLink);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
// The field stays visible even if clipboard access is unavailable.
|
// Fallback: select the field so the user can copy manually.
|
||||||
|
setCopied(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isInvite = variant === "invite";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" role="presentation">
|
<div className="modal-backdrop" role="presentation" onClick={onClose}>
|
||||||
<div className="modal-card" role="dialog" aria-modal="true">
|
<div
|
||||||
|
className="modal-card"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="reset-link-title"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Temporary reset link</h2>
|
<h2 id="reset-link-title">{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="modal-close" onClick={onClose} aria-label="Close">
|
||||||
Close
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<label className="field">
|
<div className="link-field">
|
||||||
<span>One-time link</span>
|
<label className="field">
|
||||||
<input type="text" readOnly value={resetLink} />
|
<span>{isInvite ? "Setup link" : "One-time link"}</span>
|
||||||
</label>
|
<div className="link-row">
|
||||||
|
<input type="text" readOnly value={resetLink} onFocus={(e) => e.target.select()} />
|
||||||
|
<button type="button" className="ghost-button" onClick={handleCopy}>
|
||||||
|
{copied ? "Copied ✓" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<span className="copy-feedback" aria-live="polite">{copied ? "Link copied to clipboard." : ""}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="modal-copy">
|
<p className="modal-copy">
|
||||||
This link expires at {expiresAt} and becomes invalid after first use.
|
This link expires at {expiresAt} and becomes invalid after first use.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button type="button" className="ghost-button" onClick={onClose}>
|
<button type="button" className="primary-button" onClick={onClose}>
|
||||||
Done
|
Done
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="primary-button" onClick={handleCopy}>
|
|
||||||
Copy link
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export default function UserFormModal({
|
|||||||
setError(null);
|
setError(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && !busy) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [busy, onClose]);
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -40,17 +50,23 @@ export default function UserFormModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" role="presentation">
|
<div className="modal-backdrop" role="presentation" onClick={() => !busy && onClose()}>
|
||||||
<div className="modal-card" role="dialog" aria-modal="true">
|
<div
|
||||||
|
className="modal-card"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="create-user-title"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Create user</h2>
|
<h2 id="create-user-title">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="modal-close" onClick={onClose} disabled={busy} aria-label="Close">
|
||||||
Close
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,6 +78,9 @@ export default function UserFormModal({
|
|||||||
value={nextEmail}
|
value={nextEmail}
|
||||||
onChange={(event) => setNextEmail(event.target.value)}
|
onChange={(event) => setNextEmail(event.target.value)}
|
||||||
placeholder="[email protected]"
|
placeholder="[email protected]"
|
||||||
|
autoComplete="email"
|
||||||
|
inputMode="email"
|
||||||
|
enterKeyHint="done"
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { CreateUserResponse, MailUser, PasswordResetTriggerResponse } from "@internal-mail/shared";
|
import type { CreateUserResponse, MailUser, PasswordResetTriggerResponse } from "@internal-mail/shared";
|
||||||
import ResetLinkModal from "../components/ResetLinkModal";
|
import ResetLinkModal from "../components/ResetLinkModal";
|
||||||
import UserFormModal from "../components/UserFormModal";
|
import UserFormModal from "../components/UserFormModal";
|
||||||
@@ -16,6 +16,11 @@ type DashboardPageProps = {
|
|||||||
onLogout: () => Promise<void>;
|
onLogout: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function initialOf(email: string): string {
|
||||||
|
const trimmed = email.trim();
|
||||||
|
return trimmed ? trimmed.charAt(0).toUpperCase() : "?";
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage({
|
export default function DashboardPage({
|
||||||
adminEmail,
|
adminEmail,
|
||||||
users,
|
users,
|
||||||
@@ -33,6 +38,9 @@ export default function DashboardPage({
|
|||||||
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 [pendingDelete, setPendingDelete] = useState<string | null>(null);
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
|
const [linkBusyFor, setLinkBusyFor] = useState<string | null>(null);
|
||||||
const [resetLinkData, setResetLinkData] = useState<{
|
const [resetLinkData, setResetLinkData] = useState<{
|
||||||
email: string;
|
email: string;
|
||||||
resetLink: string;
|
resetLink: string;
|
||||||
@@ -40,9 +48,13 @@ export default function DashboardPage({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const filteredUsers = normalizedQuery
|
const filteredUsers = useMemo(
|
||||||
? users.filter((user) => user.email.includes(normalizedQuery))
|
() =>
|
||||||
: users;
|
normalizedQuery
|
||||||
|
? users.filter((user) => user.email.toLowerCase().includes(normalizedQuery))
|
||||||
|
: users,
|
||||||
|
[users, normalizedQuery]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreate = async ({ email }: { email?: string }) => {
|
const handleCreate = async ({ email }: { email?: string }) => {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
@@ -55,50 +67,136 @@ 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateLink = async (email: string) => {
|
||||||
|
setInlineError(null);
|
||||||
|
setLinkBusyFor(email);
|
||||||
|
try {
|
||||||
|
const result = await onTriggerReset(email);
|
||||||
|
setResetLinkData({
|
||||||
|
email,
|
||||||
|
resetLink: result.resetLink,
|
||||||
|
expiresAt: result.expiresAt
|
||||||
|
});
|
||||||
|
setInlineMessage(`Temporary reset link generated for ${email}.`);
|
||||||
|
} catch (error) {
|
||||||
|
setInlineError(error instanceof Error ? error.message : "Could not generate reset link.");
|
||||||
|
} finally {
|
||||||
|
setLinkBusyFor(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!pendingDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleteBusy(true);
|
||||||
|
setInlineError(null);
|
||||||
|
try {
|
||||||
|
await onDeleteUser(pendingDelete);
|
||||||
|
setInlineMessage(`Deleted ${pendingDelete}.`);
|
||||||
|
setPendingDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
setInlineError(error instanceof Error ? error.message : "Could not delete user.");
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page-shell">
|
<main className="page-shell">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<div>
|
<div className="brand-row">
|
||||||
<div className="eyebrow">Mail Admin Panel</div>
|
<div className="logo-mark" aria-hidden="true">@</div>
|
||||||
<h1>Mailbox users</h1>
|
<div className="brand-text">
|
||||||
<p className="page-copy">
|
<div className="eyebrow">Admin Panel</div>
|
||||||
Create and manage Docker Mailserver accounts without touching the container by hand.
|
<h1>Mailbox users</h1>
|
||||||
</p>
|
<p className="page-copy">Create and manage Docker Mailserver accounts.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<div className="admin-pill">{adminEmail}</div>
|
<div className="admin-pill" title={adminEmail}>
|
||||||
<button className="ghost-button" onClick={onRefresh} disabled={busy}>
|
<span className="admin-avatar" aria-hidden="true">{initialOf(adminEmail)}</span>
|
||||||
Refresh
|
<span className="admin-email">{adminEmail}</span>
|
||||||
</button>
|
</div>
|
||||||
<button className="ghost-button" onClick={onLogout} disabled={busy}>
|
<div className="header-icon-group">
|
||||||
Sign out
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="Refresh users"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
|
||||||
|
<path d="M21 3v6h-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
onClick={onLogout}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="Sign out"
|
||||||
|
title="Sign out"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<path d="m16 17 5-5-5-5" />
|
||||||
|
<path d="M21 12H9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="toolbar-card">
|
<section className="toolbar-card" aria-label="Search and create">
|
||||||
<label className="search-field">
|
<label className="search-field">
|
||||||
<span>Search</span>
|
<span>Search users</span>
|
||||||
<input
|
<div className="search-wrap">
|
||||||
type="search"
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
value={query}
|
<circle cx="11" cy="11" r="7" />
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
<path d="m21 21-4.3-4.3" />
|
||||||
placeholder="Search by email"
|
</svg>
|
||||||
/>
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Search by email"
|
||||||
|
autoComplete="off"
|
||||||
|
enterKeyHint="search"
|
||||||
|
/>
|
||||||
|
{query ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="search-clear"
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button className="primary-button" onClick={() => setCreateOpen(true)} disabled={busy}>
|
<div className="toolbar-meta">
|
||||||
Create user
|
<span className="count-badge">{filteredUsers.length} mailbox{filteredUsers.length === 1 ? "" : "es"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="primary-button toolbar-create" onClick={() => setCreateOpen(true)} disabled={busy}>
|
||||||
|
+ Create user
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -107,88 +205,116 @@ export default function DashboardPage({
|
|||||||
{inlineMessage ? <p className="notice success">{inlineMessage}</p> : null}
|
{inlineMessage ? <p className="notice success">{inlineMessage}</p> : null}
|
||||||
{inlineError ? <p className="notice error">{inlineError}</p> : null}
|
{inlineError ? <p className="notice error">{inlineError}</p> : null}
|
||||||
|
|
||||||
<section className="table-card">
|
<section className="table-card" aria-label="Users">
|
||||||
<div className="table-header">
|
<div className="table-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
<p>{filteredUsers.length} mailbox{filteredUsers.length === 1 ? "" : "es"}</p>
|
<p>{filteredUsers.length} mailbox{filteredUsers.length === 1 ? "" : "es"}{normalizedQuery ? ` for “${query.trim()}”` : ""}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="table-wrap">
|
{filteredUsers.length === 0 ? (
|
||||||
<table>
|
<div className="empty-state">
|
||||||
<thead>
|
<div className="empty-illust" aria-hidden="true">✉️</div>
|
||||||
<tr>
|
<h2>{normalizedQuery ? "No match" : "No users yet"}</h2>
|
||||||
<th>Email</th>
|
<p>{normalizedQuery ? "Try another email address, or create this mailbox directly." : "Create your first mailbox with the button below."}</p>
|
||||||
<th>Local part</th>
|
{normalizedQuery ? (
|
||||||
<th>Domain</th>
|
<button type="button" className="ghost-button" onClick={() => setQuery("")}>
|
||||||
<th>Actions</th>
|
Clear search
|
||||||
</tr>
|
</button>
|
||||||
</thead>
|
) : null}
|
||||||
<tbody>
|
</div>
|
||||||
{filteredUsers.length === 0 ? (
|
) : (
|
||||||
<tr>
|
<>
|
||||||
<td colSpan={4} className="empty-row">
|
<ul className="user-cards">
|
||||||
No users found.
|
{filteredUsers.map((user) => (
|
||||||
</td>
|
<li key={user.email} className="user-card">
|
||||||
</tr>
|
<div className="user-card-top">
|
||||||
) : (
|
<span className="user-avatar" aria-hidden="true">{initialOf(user.email)}</span>
|
||||||
filteredUsers.map((user) => (
|
<div className="user-identity">
|
||||||
<tr key={user.email}>
|
<div className="user-email">{user.email}</div>
|
||||||
<td>{user.email}</td>
|
<div className="user-sub">
|
||||||
<td>{user.localPart}</td>
|
<span className="domain-chip">@{user.domain}</span>
|
||||||
<td>{user.domain}</td>
|
|
||||||
<td>
|
|
||||||
<div className="table-actions">
|
|
||||||
<button
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={async () => {
|
|
||||||
setInlineError(null);
|
|
||||||
try {
|
|
||||||
const result = await onTriggerReset(user.email);
|
|
||||||
setResetLinkData({
|
|
||||||
email: user.email,
|
|
||||||
resetLink: result.resetLink,
|
|
||||||
expiresAt: result.expiresAt
|
|
||||||
});
|
|
||||||
setInlineMessage(`Temporary reset link generated for ${user.email}.`);
|
|
||||||
} catch (error) {
|
|
||||||
setInlineError(error instanceof Error ? error.message : "Could not generate reset link.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Generate link
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="danger-button"
|
|
||||||
onClick={async () => {
|
|
||||||
const confirmed = window.confirm(`Delete ${user.email}?`);
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setInlineError(null);
|
|
||||||
try {
|
|
||||||
await onDeleteUser(user.email);
|
|
||||||
setInlineMessage(`Deleted ${user.email}.`);
|
|
||||||
} catch (error) {
|
|
||||||
setInlineError(error instanceof Error ? error.message : "Could not delete user.");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="user-card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => handleGenerateLink(user.email)}
|
||||||
|
disabled={busy || linkBusyFor !== null}
|
||||||
|
>
|
||||||
|
{linkBusyFor === user.email ? "…" : "Reset link"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger-button"
|
||||||
|
onClick={() => setPendingDelete(user.email)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Local part</th>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th style={{ textAlign: "right" }}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
</thead>
|
||||||
)}
|
<tbody>
|
||||||
</tbody>
|
{filteredUsers.map((user) => (
|
||||||
</table>
|
<tr key={user.email}>
|
||||||
</div>
|
<td>{user.email}</td>
|
||||||
|
<td>{user.localPart}</td>
|
||||||
|
<td>{user.domain}</td>
|
||||||
|
<td>
|
||||||
|
<div className="table-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => handleGenerateLink(user.email)}
|
||||||
|
disabled={busy || linkBusyFor !== null}
|
||||||
|
>
|
||||||
|
{linkBusyFor === user.email ? "Generating…" : "Generate link"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="danger-button"
|
||||||
|
onClick={() => setPendingDelete(user.email)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fab"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="Create user"
|
||||||
|
>
|
||||||
|
<span className="fab-plus" aria-hidden="true">+</span> New user
|
||||||
|
</button>
|
||||||
|
|
||||||
{createOpen ? (
|
{createOpen ? (
|
||||||
<UserFormModal
|
<UserFormModal
|
||||||
busy={modalBusy}
|
busy={modalBusy}
|
||||||
@@ -199,12 +325,45 @@ 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}
|
||||||
onClose={() => setResetLinkData(null)}
|
onClose={() => setResetLinkData(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{pendingDelete ? (
|
||||||
|
<div className="modal-backdrop" role="presentation" onClick={() => !deleteBusy && setPendingDelete(null)}>
|
||||||
|
<div
|
||||||
|
className="modal-card"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="delete-title"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="delete-title">Delete mailbox?</h2>
|
||||||
|
<p className="danger-zone-text">
|
||||||
|
This will permanently delete <strong>{pendingDelete}</strong>. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="modal-close" onClick={() => setPendingDelete(null)} disabled={deleteBusy} aria-label="Close">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button type="button" className="ghost-button" onClick={() => setPendingDelete(null)} disabled={deleteBusy}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" className="danger-button" onClick={handleConfirmDelete} disabled={deleteBusy}>
|
||||||
|
{deleteBusy ? "Deleting…" : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type LoginPageProps = {
|
|||||||
|
|
||||||
export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
|
export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
|
||||||
const [accessKey, setAccessKey] = useState("");
|
const [accessKey, setAccessKey] = useState("");
|
||||||
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -18,9 +19,15 @@ export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-shell">
|
<main className="auth-shell">
|
||||||
<section className="auth-card">
|
<section className="auth-card" aria-labelledby="login-title">
|
||||||
<div className="eyebrow">Docker Mailserver</div>
|
<div className="auth-top">
|
||||||
<h1>Mail user management</h1>
|
<div className="logo-mark" aria-hidden="true">@</div>
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow">Mailserver</div>
|
||||||
|
<div style={{ fontSize: "0.85rem", color: "var(--text-muted)" }}>Internal admin access</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 id="login-title">User management</h1>
|
||||||
<p className="auth-copy">
|
<p className="auth-copy">
|
||||||
Sign in with the access key configured for this panel.
|
Sign in with the access key configured for this panel.
|
||||||
</p>
|
</p>
|
||||||
@@ -28,22 +35,36 @@ export default function LoginPage({ busy, error, onSubmit }: LoginPageProps) {
|
|||||||
<form className="stack" onSubmit={handleSubmit}>
|
<form className="stack" onSubmit={handleSubmit}>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Access key</span>
|
<span>Access key</span>
|
||||||
<input
|
<div className="password-wrap">
|
||||||
type="password"
|
<input
|
||||||
value={accessKey}
|
type={showKey ? "text" : "password"}
|
||||||
onChange={(event) => setAccessKey(event.target.value)}
|
value={accessKey}
|
||||||
autoComplete="current-password"
|
onChange={(event) => setAccessKey(event.target.value)}
|
||||||
autoFocus
|
autoComplete="current-password"
|
||||||
disabled={busy}
|
enterKeyHint="go"
|
||||||
/>
|
autoFocus
|
||||||
|
disabled={busy}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="password-toggle"
|
||||||
|
onClick={() => setShowKey((v) => !v)}
|
||||||
|
aria-label={showKey ? "Hide access key" : "Show access key"}
|
||||||
|
>
|
||||||
|
{showKey ? "Hide" : "Show"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||||
|
|
||||||
<button type="submit" className="primary-button" disabled={busy}>
|
<button type="submit" className="primary-button" disabled={busy || !accessKey.trim()}>
|
||||||
{busy ? "Signing in..." : "Sign in"}
|
{busy ? "Signing in..." : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div className="auth-foot">Protected panel — keys are never shown on screen.</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ 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("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [showPasswords, setShowPasswords] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
@@ -23,6 +25,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");
|
||||||
@@ -72,56 +75,79 @@ export default function ResetPasswordPage({ token }: ResetPasswordPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-shell">
|
<main className="auth-shell">
|
||||||
<section className="auth-card">
|
<section className="auth-card" aria-labelledby="reset-title">
|
||||||
<div className="eyebrow">Secure reset</div>
|
<div className="auth-top">
|
||||||
<h1>Choose a new mailbox password</h1>
|
<div className="logo-mark" aria-hidden="true">@</div>
|
||||||
|
<div className="eyebrow">{mode === "invite" ? "Mailbox setup" : "Secure reset"}</div>
|
||||||
|
</div>
|
||||||
|
<h1 id="reset-title">{mode === "invite" ? "Create your password" : "Choose a new 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" role="alert">{error}</p> : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{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}>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>New password</span>
|
<span>New password (min. 12 characters)</span>
|
||||||
<input
|
<div className="password-wrap">
|
||||||
type="password"
|
<input
|
||||||
value={password}
|
type={showPasswords ? "text" : "password"}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
value={password}
|
||||||
autoFocus
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
disabled={busy}
|
autoComplete="new-password"
|
||||||
/>
|
enterKeyHint="next"
|
||||||
|
autoFocus
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="password-toggle"
|
||||||
|
onClick={() => setShowPasswords((v) => !v)}
|
||||||
|
aria-label={showPasswords ? "Hide passwords" : "Show passwords"}
|
||||||
|
>
|
||||||
|
{showPasswords ? "Hide" : "Show"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Confirm password</span>
|
<span>Confirm password</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showPasswords ? "text" : "password"}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
enterKeyHint="done"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{error ? <p className="form-error">{error}</p> : null}
|
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||||
|
|
||||||
<button type="submit" className="primary-button" disabled={busy}>
|
<button type="submit" className="primary-button" disabled={busy}>
|
||||||
{busy ? "Updating..." : "Set password"}
|
{busy ? "Updating..." : "Set password"}
|
||||||
|
|||||||
+932
-212
File diff suppressed because it is too large
Load Diff
@@ -1,328 +0,0 @@
|
|||||||
{
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
|
||||||
"": {
|
|
||||||
"name": "internal-mail-server",
|
|
||||||
},
|
|
||||||
"apps/api": {
|
|
||||||
"name": "@internal-mail/api",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"dependencies": {
|
|
||||||
"@internal-mail/shared": "workspace:*",
|
|
||||||
"elysia": "^1.3.10",
|
|
||||||
"zod": "^3.25.0",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"bun-types": "^1.3.10",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"apps/web": {
|
|
||||||
"name": "@internal-mail/web",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"dependencies": {
|
|
||||||
"@internal-mail/shared": "workspace:*",
|
|
||||||
"react": "^19.1.1",
|
|
||||||
"react-dom": "^19.1.1",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^19.1.3",
|
|
||||||
"@types/react-dom": "^19.1.3",
|
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
|
||||||
"bun-types": "^1.3.10",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"vite": "^7.0.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages/shared": {
|
|
||||||
"name": "@internal-mail/shared",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"devDependencies": {
|
|
||||||
"bun-types": "^1.3.10",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages": {
|
|
||||||
"@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
|
||||||
|
|
||||||
"@babel/compat-data": ["@babel/[email protected]", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
|
||||||
|
|
||||||
"@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
|
||||||
|
|
||||||
"@babel/generator": ["@babel/[email protected]", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
|
||||||
|
|
||||||
"@babel/helper-compilation-targets": ["@babel/[email protected]", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
|
||||||
|
|
||||||
"@babel/helper-globals": ["@babel/[email protected]", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
|
||||||
|
|
||||||
"@babel/helper-module-imports": ["@babel/[email protected]", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
|
||||||
|
|
||||||
"@babel/helper-module-transforms": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
|
||||||
|
|
||||||
"@babel/helper-plugin-utils": ["@babel/[email protected]", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
|
||||||
|
|
||||||
"@babel/helper-string-parser": ["@babel/[email protected]", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
|
||||||
|
|
||||||
"@babel/helper-validator-identifier": ["@babel/[email protected]", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
|
||||||
|
|
||||||
"@babel/helper-validator-option": ["@babel/[email protected]", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
|
||||||
|
|
||||||
"@babel/helpers": ["@babel/[email protected]", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
|
||||||
|
|
||||||
"@babel/parser": ["@babel/[email protected]", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
|
||||||
|
|
||||||
"@babel/plugin-transform-react-jsx-self": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
|
||||||
|
|
||||||
"@babel/plugin-transform-react-jsx-source": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
|
||||||
|
|
||||||
"@babel/template": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
|
||||||
|
|
||||||
"@babel/traverse": ["@babel/[email protected]", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
|
||||||
|
|
||||||
"@babel/types": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
|
||||||
|
|
||||||
"@borewit/text-codec": ["@borewit/[email protected]", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
|
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
|
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
|
|
||||||
|
|
||||||
"@esbuild/android-arm64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
|
|
||||||
|
|
||||||
"@esbuild/android-x64": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
|
|
||||||
|
|
||||||
"@esbuild/darwin-arm64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
|
|
||||||
|
|
||||||
"@esbuild/darwin-x64": ["@esbuild/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
|
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
|
|
||||||
|
|
||||||
"@esbuild/freebsd-x64": ["@esbuild/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-arm": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-arm64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-ia32": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-loong64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-mips64el": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-ppc64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-riscv64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-s390x": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-x64": ["@esbuild/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
|
|
||||||
|
|
||||||
"@esbuild/netbsd-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
|
|
||||||
|
|
||||||
"@esbuild/netbsd-x64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
|
|
||||||
|
|
||||||
"@esbuild/openbsd-arm64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
|
|
||||||
|
|
||||||
"@esbuild/openbsd-x64": ["@esbuild/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
|
|
||||||
|
|
||||||
"@esbuild/openharmony-arm64": ["@esbuild/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
|
|
||||||
|
|
||||||
"@esbuild/sunos-x64": ["@esbuild/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-arm64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-ia32": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
|
|
||||||
|
|
||||||
"@internal-mail/api": ["@internal-mail/api@workspace:apps/api"],
|
|
||||||
|
|
||||||
"@internal-mail/shared": ["@internal-mail/shared@workspace:packages/shared"],
|
|
||||||
|
|
||||||
"@internal-mail/web": ["@internal-mail/web@workspace:apps/web"],
|
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
|
||||||
|
|
||||||
"@jridgewell/resolve-uri": ["@jridgewell/[email protected]", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
|
||||||
|
|
||||||
"@jridgewell/sourcemap-codec": ["@jridgewell/[email protected]", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
|
||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
|
||||||
|
|
||||||
"@rolldown/pluginutils": ["@rolldown/[email protected]", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64": ["@rollup/[email protected]", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-darwin-arm64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-darwin-x64": ["@rollup/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-arm64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-x64": ["@rollup/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-musl": ["@rollup/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-openbsd-x64": ["@rollup/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-openharmony-arm64": ["@rollup/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
|
|
||||||
|
|
||||||
"@sinclair/typebox": ["@sinclair/[email protected]", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
|
|
||||||
|
|
||||||
"@tokenizer/inflate": ["@tokenizer/[email protected]", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
|
|
||||||
|
|
||||||
"@tokenizer/token": ["@tokenizer/[email protected]", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
|
||||||
|
|
||||||
"@types/babel__core": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
|
||||||
|
|
||||||
"@types/babel__generator": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
|
||||||
|
|
||||||
"@types/babel__template": ["@types/[email protected]", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
|
||||||
|
|
||||||
"@types/babel__traverse": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
|
||||||
|
|
||||||
"@types/estree": ["@types/[email protected]", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
|
||||||
|
|
||||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
|
||||||
|
|
||||||
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
|
||||||
|
|
||||||
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
|
||||||
|
|
||||||
"@vitejs/plugin-react": ["@vitejs/[email protected]", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
|
|
||||||
|
|
||||||
"baseline-browser-mapping": ["[email protected]", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="],
|
|
||||||
|
|
||||||
"browserslist": ["[email protected]", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
|
||||||
|
|
||||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
|
||||||
|
|
||||||
"caniuse-lite": ["[email protected]", "", {}, "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw=="],
|
|
||||||
|
|
||||||
"convert-source-map": ["[email protected]", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
|
||||||
|
|
||||||
"cookie": ["[email protected]", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
|
||||||
|
|
||||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
|
||||||
|
|
||||||
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
|
||||||
|
|
||||||
"electron-to-chromium": ["[email protected]", "", {}, "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w=="],
|
|
||||||
|
|
||||||
"elysia": ["[email protected]", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="],
|
|
||||||
|
|
||||||
"esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
|
||||||
|
|
||||||
"escalade": ["[email protected]", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
|
||||||
|
|
||||||
"exact-mirror": ["[email protected]", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
|
|
||||||
|
|
||||||
"fast-decode-uri-component": ["[email protected]", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
|
||||||
|
|
||||||
"fdir": ["[email protected]", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
|
||||||
|
|
||||||
"file-type": ["[email protected]", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-cmBmnYo8Zymabm2+qAP7jTFbKF10bQpYmxoGfuZbRFRcq00BRddJdGNH/P7GA1EMpJy5yQbqa9B7yROb3z8Ziw=="],
|
|
||||||
|
|
||||||
"fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
|
||||||
|
|
||||||
"gensync": ["[email protected]", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
|
||||||
|
|
||||||
"ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
|
||||||
|
|
||||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
|
||||||
|
|
||||||
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
|
||||||
|
|
||||||
"json5": ["[email protected]", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
|
||||||
|
|
||||||
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
|
||||||
|
|
||||||
"memoirist": ["[email protected]", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
|
||||||
|
|
||||||
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
|
||||||
|
|
||||||
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
|
||||||
|
|
||||||
"node-releases": ["[email protected]", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
|
||||||
|
|
||||||
"openapi-types": ["[email protected]", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
|
|
||||||
|
|
||||||
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
|
||||||
|
|
||||||
"picomatch": ["[email protected]", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
|
||||||
|
|
||||||
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
|
||||||
|
|
||||||
"react": ["[email protected]", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
|
||||||
|
|
||||||
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
|
||||||
|
|
||||||
"react-refresh": ["[email protected]", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
|
||||||
|
|
||||||
"rollup": ["[email protected]", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
|
||||||
|
|
||||||
"scheduler": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
|
||||||
|
|
||||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
|
||||||
|
|
||||||
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
|
||||||
|
|
||||||
"strtok3": ["[email protected]", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
|
|
||||||
|
|
||||||
"tinyglobby": ["[email protected]", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
|
||||||
|
|
||||||
"token-types": ["[email protected]", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
|
|
||||||
|
|
||||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
||||||
|
|
||||||
"uint8array-extras": ["[email protected]", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
|
||||||
|
|
||||||
"undici-types": ["[email protected]", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
|
||||||
|
|
||||||
"update-browserslist-db": ["[email protected]", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
|
||||||
|
|
||||||
"vite": ["[email protected]", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
|
||||||
|
|
||||||
"yallist": ["[email protected]", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
|
||||||
|
|
||||||
"zod": ["[email protected]", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "internal-mailserver",
|
||||||
|
cwd: path.join(__dirname, "apps/api"),
|
||||||
|
script: "bun",
|
||||||
|
args: "--env-file ../../.env dist/index.js",
|
||||||
|
interpreter: "none",
|
||||||
|
exec_mode: "fork",
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
time: true,
|
||||||
|
max_memory_restart: "512M",
|
||||||
|
env: {
|
||||||
|
NODE_ENV: "production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -2,17 +2,18 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
location / {
|
client_max_body_size 2m;
|
||||||
proxy_pass http://frontend:5173;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /api/ {
|
location / {
|
||||||
proxy_pass http://api:3001;
|
proxy_pass http://127.0.0.1:16731;
|
||||||
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
Fait moi un prompt pour mon AI qui doit faire sa: (en anglais btw):
|
|
||||||
|
|
||||||
Code moi un backend-frontend (Frontend: choix du framework): Backend ElysiaJS (avec Bun)
|
|
||||||
Le back utilise docker en CLI pour communiquer avec le docker mail-server
|
|
||||||
|
|
||||||
Le projet est un front pour gérer les utilisateurs emails d'un serveur dockermail-server.
|
|
||||||
|
|
||||||
Créer, ajouter, modifier, supprimer, lister des utilisateurs
|
|
||||||
|
|
||||||
Modifier le mot de passe avec un one-time link (à envoyer à l'utilisateur pour politique sécurité)
|
|
||||||
|
|
||||||
Le projet doit être KISS (Keep It Simple Stupid). Simple, pas de complexité nécessaire, le front-end doit être design sobre
|
|
||||||
La codebase doit être simple, propre. Pour pouvoir y ajoutez des futurs fonctionalité
|
|
||||||
@@ -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