Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 730c03370d | |||
| a63045a685 | |||
| 575a7a793a | |||
| 70a138a98f | |||
| 756ffc3b0a | |||
| c44d3228c6 | |||
| cd2bbe9b7d | |||
| 3f0e770d21 | |||
| 479df311ba | |||
| 62219a372a | |||
| f097f96d06 | |||
| ffe2204597 | |||
| 830e52a4fa | |||
| 85b608e3d4 | |||
| 31b3fd8c9f | |||
| b03c257de1 | |||
| c4acdb2a66 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
frontend.old
|
||||||
@@ -1,17 +1,45 @@
|
|||||||
FROM node:22-bookworm AS build
|
# syntax=docker/dockerfile:1
|
||||||
|
# ============================================================
|
||||||
|
# Stage 1: Build the React frontend (Vite)
|
||||||
|
# ============================================================
|
||||||
|
FROM node:22-bookworm AS frontend-build
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend ./
|
||||||
|
RUN npm run build
|
||||||
|
# Output: /app/frontend/dist
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Stage 2: Build the TypeScript backend
|
||||||
|
# ============================================================
|
||||||
|
FROM node:22-bookworm AS backend-build
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY backend ./
|
COPY backend ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
# Output: /app/backend/dist + node_modules
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Stage 3: Slim runtime image
|
||||||
|
# ============================================================
|
||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends docker.io awscli jq bash coreutils ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
RUN apt-get update \
|
||||||
COPY --from=build /app/backend/dist ./backend/dist
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
docker.io awscli jq bash coreutils ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Backend runtime
|
||||||
|
COPY --from=backend-build /app/backend/node_modules ./backend/node_modules
|
||||||
|
COPY --from=backend-build /app/backend/dist ./backend/dist
|
||||||
COPY backend/migrations ./backend/migrations
|
COPY backend/migrations ./backend/migrations
|
||||||
COPY frontend ./frontend
|
|
||||||
|
# Frontend bundle (served by express.static via PUBLIC_DIR)
|
||||||
|
COPY --from=frontend-build /app/frontend/dist ./frontend
|
||||||
|
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
ENV NODE_ENV=production PUBLIC_DIR=/app/frontend
|
ENV NODE_ENV=production PUBLIC_DIR=/app/frontend
|
||||||
CMD ["node", "dist/server.js"]
|
CMD ["node", "dist/server.js"]
|
||||||
|
|||||||
26
backend/migrations/002_admin_users_self_service.sql
Normal file
26
backend/migrations/002_admin_users_self_service.sql
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 002_admin_users_self_service.sql
|
||||||
|
-- Phase 2: Domain-Admin support.
|
||||||
|
--
|
||||||
|
-- The admin_users table already has all required columns from 001_init.sql:
|
||||||
|
-- - role TEXT NOT NULL DEFAULT 'super_admin'
|
||||||
|
-- - allowed_domains TEXT[] NOT NULL DEFAULT '{}'
|
||||||
|
-- - active BOOLEAN NOT NULL DEFAULT true
|
||||||
|
--
|
||||||
|
-- This migration just adds a check constraint on role and an index
|
||||||
|
-- for fast role/active lookups.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Drop any old/legacy variants of the constraint to make this migration
|
||||||
|
-- idempotent across environments.
|
||||||
|
ALTER TABLE admin_users DROP CONSTRAINT IF EXISTS admin_users_role_chk;
|
||||||
|
ALTER TABLE admin_users DROP CONSTRAINT IF EXISTS admin_users_role_check;
|
||||||
|
|
||||||
|
-- Reapply the canonical check constraint.
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD CONSTRAINT admin_users_role_check
|
||||||
|
CHECK (role IN ('super_admin', 'domain_admin'));
|
||||||
|
|
||||||
|
-- Useful index for role-based filtering.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_users_role_active
|
||||||
|
ON admin_users(role, active);
|
||||||
59
backend/migrations/003_mailbox_billing_events.sql
Normal file
59
backend/migrations/003_mailbox_billing_events.sql
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 003_mailbox_billing_events.sql
|
||||||
|
-- Append-only event log for inbox billing.
|
||||||
|
--
|
||||||
|
-- Each row records that a mailbox was created or deleted at a
|
||||||
|
-- specific point in time. The aggregation per month is computed
|
||||||
|
-- on the fly from these events.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mailbox_billing_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL CHECK (action IN ('created', 'deleted')),
|
||||||
|
actor_email TEXT,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_events_domain_time
|
||||||
|
ON mailbox_billing_events(domain, occurred_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_billing_events_email
|
||||||
|
ON mailbox_billing_events(email);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Backfill: synthesize 'created' events for every mailbox that
|
||||||
|
-- already exists when this migration runs, so we have correct
|
||||||
|
-- historical data right from the first deploy.
|
||||||
|
--
|
||||||
|
-- We use the mailbox's created_at as the event timestamp.
|
||||||
|
-- For mailboxes that are already soft-deleted, also synthesize
|
||||||
|
-- the matching 'deleted' event using deleted_at.
|
||||||
|
--
|
||||||
|
-- The WHERE NOT EXISTS guards make this migration idempotent
|
||||||
|
-- in case it is re-run.
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT INTO mailbox_billing_events (occurred_at, domain, email, action, actor_email, notes)
|
||||||
|
SELECT m.created_at, m.domain, m.email_address, 'created', NULL, 'backfill from migration 003'
|
||||||
|
FROM mailboxes m
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM mailbox_billing_events b
|
||||||
|
WHERE b.email = m.email_address
|
||||||
|
AND b.action = 'created'
|
||||||
|
AND b.occurred_at = m.created_at
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO mailbox_billing_events (occurred_at, domain, email, action, actor_email, notes)
|
||||||
|
SELECT m.deleted_at, m.domain, m.email_address, 'deleted', NULL, 'backfill from migration 003'
|
||||||
|
FROM mailboxes m
|
||||||
|
WHERE m.status = 'deleted'
|
||||||
|
AND m.deleted_at IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM mailbox_billing_events b
|
||||||
|
WHERE b.email = m.email_address
|
||||||
|
AND b.action = 'deleted'
|
||||||
|
AND b.occurred_at = m.deleted_at
|
||||||
|
);
|
||||||
19
backend/migrations/004_domain_health_status.sql
Normal file
19
backend/migrations/004_domain_health_status.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 004_domain_health_status.sql
|
||||||
|
-- Persistent storage of the most recent health check result per domain.
|
||||||
|
-- Updated whenever the user clicks "Check health".
|
||||||
|
--
|
||||||
|
-- The 'has_problems' boolean drives the banner in the mailbox view.
|
||||||
|
-- The 'details' JSONB column stores the full report so the modal can
|
||||||
|
-- show last-known state without re-running the checks.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_health_status (
|
||||||
|
domain TEXT PRIMARY KEY,
|
||||||
|
checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
has_problems BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
details JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domain_health_problems
|
||||||
|
ON domain_health_status(has_problems, checked_at DESC);
|
||||||
@@ -156,7 +156,7 @@ async function seedInitialAdmin() {
|
|||||||
* bcryptjs is already used by the auth route in the MVP.
|
* bcryptjs is already used by the auth route in the MVP.
|
||||||
* Dynamic import avoids requiring it before DB startup.
|
* Dynamic import avoids requiring it before DB startup.
|
||||||
*/
|
*/
|
||||||
const bcrypt = await import('bcryptjs');
|
const { default: bcrypt } = await import('bcryptjs');
|
||||||
const passwordHash = await bcrypt.hash(adminPassword, 12);
|
const passwordHash = await bcrypt.hash(adminPassword, 12);
|
||||||
|
|
||||||
await pool.query(
|
await pool.query(
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requireSuperAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.user.role !== 'super_admin') {
|
||||||
|
res.status(403).json({ error: 'Forbidden: super_admin role required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
export function canAccessDomain(user: AuthUser, domain: string): boolean {
|
export function canAccessDomain(user: AuthUser, domain: string): boolean {
|
||||||
return user.role === 'super_admin' || user.allowed_domains.includes(domain.toLowerCase());
|
return user.role === 'super_admin' || user.allowed_domains.includes(domain.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|||||||
158
backend/src/routes/admins.ts
Normal file
158
backend/src/routes/admins.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
import { requireAuth, requireSuperAdmin } from '../middleware/auth.js';
|
||||||
|
import { audit } from '../services/audit.js';
|
||||||
|
|
||||||
|
export const adminsRouter = Router();
|
||||||
|
adminsRouter.use(requireAuth);
|
||||||
|
adminsRouter.use(requireSuperAdmin);
|
||||||
|
|
||||||
|
// All routes here are super_admin only — no exceptions.
|
||||||
|
|
||||||
|
adminsRouter.get('/', async (_req, res) => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, email, role, allowed_domains, active, created_at, updated_at
|
||||||
|
FROM admin_users
|
||||||
|
ORDER BY role, email`
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
role: z.enum(['super_admin', 'domain_admin']),
|
||||||
|
// For domain_admin this should not be empty; we enforce that below.
|
||||||
|
allowed_domains: z.array(z.string().min(1)).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
adminsRouter.post('/', async (req, res) => {
|
||||||
|
const body = createSchema.parse(req.body);
|
||||||
|
const email = body.email.toLowerCase();
|
||||||
|
const allowed = (body.allowed_domains ?? []).map((d) => d.trim().toLowerCase()).filter(Boolean);
|
||||||
|
|
||||||
|
if (body.role === 'domain_admin' && allowed.length === 0) {
|
||||||
|
res.status(400).json({ error: 'A domain_admin must have at least one allowed domain' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (body.role === 'super_admin' && allowed.length > 0) {
|
||||||
|
// super_admin implicitly has access to everything; reject ambiguous input.
|
||||||
|
res.status(400).json({ error: 'super_admin must not have allowed_domains set' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await pool.query(`SELECT id FROM admin_users WHERE email=$1`, [email]);
|
||||||
|
if ((existing.rowCount ?? 0) > 0) {
|
||||||
|
res.status(409).json({ error: 'An admin with this email already exists' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(body.password, 12);
|
||||||
|
const inserted = await pool.query(
|
||||||
|
`INSERT INTO admin_users (email, password_hash, role, allowed_domains, active)
|
||||||
|
VALUES ($1, $2, $3, $4, true)
|
||||||
|
RETURNING id, email, role, allowed_domains, active, created_at, updated_at`,
|
||||||
|
[email, passwordHash, body.role, allowed],
|
||||||
|
);
|
||||||
|
await audit(req.user!.email, 'admin.create', 'admin', email, { role: body.role, allowed_domains: allowed }, req.ip);
|
||||||
|
res.status(201).json(inserted.rows[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
// Email is the natural id but we allow editing the rest.
|
||||||
|
password: z.string().min(8).optional(),
|
||||||
|
role: z.enum(['super_admin', 'domain_admin']).optional(),
|
||||||
|
allowed_domains: z.array(z.string().min(1)).optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
adminsRouter.put('/:email', async (req, res) => {
|
||||||
|
const targetEmail = String(req.params.email || '').toLowerCase();
|
||||||
|
const body = updateSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Safety: don't let the super_admin demote or deactivate themselves and
|
||||||
|
// lock the system out.
|
||||||
|
if (targetEmail === req.user!.email.toLowerCase()) {
|
||||||
|
if (body.role && body.role !== 'super_admin') {
|
||||||
|
res.status(400).json({ error: 'You cannot change your own role' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (body.active === false) {
|
||||||
|
res.status(400).json({ error: 'You cannot deactivate yourself' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await pool.query(`SELECT id, role FROM admin_users WHERE email=$1`, [targetEmail]);
|
||||||
|
const row = existing.rows[0];
|
||||||
|
if (!row) { res.status(404).json({ error: 'Admin not found' }); return; }
|
||||||
|
|
||||||
|
// Determine the effective new role and allowed_domains for validation.
|
||||||
|
const newRole = body.role ?? row.role;
|
||||||
|
const newAllowed = body.allowed_domains?.map((d) => d.trim().toLowerCase()).filter(Boolean);
|
||||||
|
|
||||||
|
if (newRole === 'domain_admin' && newAllowed !== undefined && newAllowed.length === 0) {
|
||||||
|
res.status(400).json({ error: 'A domain_admin must have at least one allowed domain' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newRole === 'super_admin' && newAllowed !== undefined && newAllowed.length > 0) {
|
||||||
|
res.status(400).json({ error: 'super_admin must not have allowed_domains set' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dynamic SET clause.
|
||||||
|
const sets: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const push = (frag: string, val: unknown) => {
|
||||||
|
params.push(val);
|
||||||
|
sets.push(`${frag}=$${params.length}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.password !== undefined) {
|
||||||
|
push('password_hash', await bcrypt.hash(body.password, 12));
|
||||||
|
}
|
||||||
|
if (body.role !== undefined) push('role', body.role);
|
||||||
|
if (newAllowed !== undefined) push('allowed_domains', newAllowed);
|
||||||
|
if (body.active !== undefined) push('active', body.active);
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
res.json({ ok: true, unchanged: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sets.push(`updated_at=now()`);
|
||||||
|
params.push(targetEmail);
|
||||||
|
|
||||||
|
const updated = await pool.query(
|
||||||
|
`UPDATE admin_users SET ${sets.join(', ')} WHERE email=$${params.length}
|
||||||
|
RETURNING id, email, role, allowed_domains, active, created_at, updated_at`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build a sanitized audit detail (no password leakage).
|
||||||
|
const auditDetails: Record<string, unknown> = {};
|
||||||
|
if (body.role !== undefined) auditDetails.role = body.role;
|
||||||
|
if (newAllowed !== undefined) auditDetails.allowed_domains = newAllowed;
|
||||||
|
if (body.active !== undefined) auditDetails.active = body.active;
|
||||||
|
if (body.password !== undefined) auditDetails.password_changed = true;
|
||||||
|
|
||||||
|
await audit(req.user!.email, 'admin.update', 'admin', targetEmail, auditDetails, req.ip);
|
||||||
|
res.json(updated.rows[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
adminsRouter.delete('/:email', async (req, res) => {
|
||||||
|
const targetEmail = String(req.params.email || '').toLowerCase();
|
||||||
|
if (targetEmail === req.user!.email.toLowerCase()) {
|
||||||
|
res.status(400).json({ error: 'You cannot delete yourself' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await pool.query(`DELETE FROM admin_users WHERE email=$1`, [targetEmail]);
|
||||||
|
if ((result.rowCount ?? 0) === 0) {
|
||||||
|
res.status(404).json({ error: 'Admin not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await audit(req.user!.email, 'admin.delete', 'admin', targetEmail, {}, req.ip);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { pool } from '../db.js';
|
import { pool } from '../db.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth, requireSuperAdmin } from '../middleware/auth.js';
|
||||||
|
|
||||||
export const auditRouter = Router();
|
export const auditRouter = Router();
|
||||||
auditRouter.use(requireAuth);
|
auditRouter.use(requireAuth);
|
||||||
|
auditRouter.use(requireSuperAdmin);
|
||||||
|
|
||||||
auditRouter.get('/', async (_req, res) => {
|
auditRouter.get('/', async (_req, res) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|||||||
import { pool } from '../db.js';
|
import { pool } from '../db.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { requireAuth, signUser } from '../middleware/auth.js';
|
import { requireAuth, signUser } from '../middleware/auth.js';
|
||||||
|
import { audit } from '../services/audit.js';
|
||||||
|
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
|
|
||||||
@@ -38,3 +39,30 @@ authRouter.post('/logout', (_req, res) => {
|
|||||||
authRouter.get('/me', requireAuth, (req, res) => {
|
authRouter.get('/me', requireAuth, (req, res) => {
|
||||||
res.json(req.user);
|
res.json(req.user);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Self-service password change. Requires the current password to prevent
|
||||||
|
// session hijacking from changing the password silently.
|
||||||
|
const changePwSchema = z.object({
|
||||||
|
current_password: z.string().min(1),
|
||||||
|
new_password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post('/change-password', requireAuth, async (req, res) => {
|
||||||
|
const body = changePwSchema.parse(req.body);
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, password_hash FROM admin_users WHERE email=$1 AND active=true`,
|
||||||
|
[req.user!.email.toLowerCase()],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row || !(await bcrypt.compare(body.current_password, row.password_hash))) {
|
||||||
|
res.status(401).json({ error: 'Current password is incorrect' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newHash = await bcrypt.hash(body.new_password, 12);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE admin_users SET password_hash=$1, updated_at=now() WHERE id=$2`,
|
||||||
|
[newHash, row.id],
|
||||||
|
);
|
||||||
|
await audit(req.user!.email, 'admin.self_password_change', 'admin', req.user!.email, {}, req.ip);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|||||||
76
backend/src/routes/billing.ts
Normal file
76
backend/src/routes/billing.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth, requireSuperAdmin } from '../middleware/auth.js';
|
||||||
|
import { computeMonthlyBilling, listBillingEvents, PRICE_PER_INBOX } from '../services/billing.js';
|
||||||
|
import {
|
||||||
|
getDomainVolumeForMonth,
|
||||||
|
getVolumeOverview,
|
||||||
|
currentYm,
|
||||||
|
previousYm,
|
||||||
|
} from '../services/ses-events.js';
|
||||||
|
|
||||||
|
export const billingRouter = Router();
|
||||||
|
billingRouter.use(requireAuth);
|
||||||
|
billingRouter.use(requireSuperAdmin);
|
||||||
|
|
||||||
|
billingRouter.get('/summary', async (req, res) => {
|
||||||
|
const domain = req.query.domain ? String(req.query.domain).toLowerCase() : undefined;
|
||||||
|
const months = await computeMonthlyBilling({ domain });
|
||||||
|
res.json({ price_per_inbox: PRICE_PER_INBOX, months });
|
||||||
|
});
|
||||||
|
|
||||||
|
billingRouter.get('/events', async (req, res) => {
|
||||||
|
const events = await listBillingEvents({
|
||||||
|
domain: req.query.domain ? String(req.query.domain).toLowerCase() : undefined,
|
||||||
|
fromIso: req.query.from ? String(req.query.from) : undefined,
|
||||||
|
toIso: req.query.to ? String(req.query.to) : undefined,
|
||||||
|
limit: req.query.limit ? Number(req.query.limit) : 500,
|
||||||
|
});
|
||||||
|
res.json(events);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/billing/volume?domain=foo.com&ym=2026-04
|
||||||
|
* Per-inbox drilldown for a single domain.
|
||||||
|
*/
|
||||||
|
billingRouter.get('/volume', async (req, res) => {
|
||||||
|
const domain = req.query.domain ? String(req.query.domain).toLowerCase() : '';
|
||||||
|
if (!domain) {
|
||||||
|
res.status(400).json({ error: 'domain query parameter is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ym = req.query.ym ? String(req.query.ym) : currentYm();
|
||||||
|
if (!/^\d{4}-\d{2}$/.test(ym)) {
|
||||||
|
res.status(400).json({ error: 'ym must be in YYYY-MM format' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(await getDomainVolumeForMonth(domain, ym));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/billing/volume-overview?ym=2026-04
|
||||||
|
* Cross-domain overview for super admin. One DynamoDB scan, aggregates
|
||||||
|
* per domain. ym defaults to the current month.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* {
|
||||||
|
* ym: 'YYYY-MM',
|
||||||
|
* total_send_count, total_bounce_count, total_complaint_count, total_inbox_count,
|
||||||
|
* rows: [{ domain, send_count, bytes_total, bounce_count, complaint_count, inbox_count }, ...]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
billingRouter.get('/volume-overview', async (req, res) => {
|
||||||
|
// Limit to current/previous month per product decision — no point in
|
||||||
|
// accepting arbitrary ym values from the client right now.
|
||||||
|
const ym = req.query.ym ? String(req.query.ym) : currentYm();
|
||||||
|
const allowed = new Set([currentYm(), previousYm()]);
|
||||||
|
if (!allowed.has(ym)) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: `ym must be one of: ${[...allowed].join(', ')}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(await getVolumeOverview(ym));
|
||||||
|
});
|
||||||
52
backend/src/routes/health.ts
Normal file
52
backend/src/routes/health.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth, canAccessDomain } from '../middleware/auth.js';
|
||||||
|
import { runDomainHealthChecks, getPersistedHealth } from '../services/health.js';
|
||||||
|
import { audit } from '../services/audit.js';
|
||||||
|
|
||||||
|
export const healthRouter = Router();
|
||||||
|
healthRouter.use(requireAuth);
|
||||||
|
|
||||||
|
function ensureDomain(req: any, domain: string): void {
|
||||||
|
if (!canAccessDomain(req.user, domain)) {
|
||||||
|
throw Object.assign(new Error('Forbidden'), { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/health/domains/:domain
|
||||||
|
* Read the last persisted health status without re-running checks.
|
||||||
|
* Used by the mailbox view to decide whether to show the banner.
|
||||||
|
*
|
||||||
|
* Returns { domain, checked_at, has_problems, summary } or 404 if
|
||||||
|
* the domain has never been checked.
|
||||||
|
*/
|
||||||
|
healthRouter.get('/domains/:domain', async (req, res) => {
|
||||||
|
const domain = String(req.params.domain).toLowerCase();
|
||||||
|
ensureDomain(req, domain);
|
||||||
|
const status = await getPersistedHealth(domain);
|
||||||
|
if (!status) {
|
||||||
|
res.status(404).json({ error: 'No health check has been performed yet' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/health/domains/:domain/check
|
||||||
|
* Run all health checks now. Persists the result and returns the
|
||||||
|
* full report for the modal.
|
||||||
|
*/
|
||||||
|
healthRouter.post('/domains/:domain/check', async (req, res) => {
|
||||||
|
const domain = String(req.params.domain).toLowerCase();
|
||||||
|
ensureDomain(req, domain);
|
||||||
|
const report = await runDomainHealthChecks(domain);
|
||||||
|
await audit(
|
||||||
|
req.user!.email,
|
||||||
|
'domain.health_check',
|
||||||
|
'domain',
|
||||||
|
domain,
|
||||||
|
{ has_problems: report.has_problems },
|
||||||
|
req.ip,
|
||||||
|
);
|
||||||
|
res.json(report);
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { DmsService } from '../services/dms.js';
|
|||||||
import { SyncService } from '../services/sync.js';
|
import { SyncService } from '../services/sync.js';
|
||||||
import { DynamoRulesService } from '../services/dynamodb.js';
|
import { DynamoRulesService } from '../services/dynamodb.js';
|
||||||
import { audit } from '../services/audit.js';
|
import { audit } from '../services/audit.js';
|
||||||
|
import { recordBillingEvent } from '../services/billing.js';
|
||||||
import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js';
|
import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js';
|
||||||
|
|
||||||
export const mailboxesRouter = Router();
|
export const mailboxesRouter = Router();
|
||||||
@@ -71,6 +72,7 @@ async function refreshQuotaForDomain(req: any, domain: string): Promise<number>
|
|||||||
mailboxesRouter.get('/', async (req, res) => {
|
mailboxesRouter.get('/', async (req, res) => {
|
||||||
const domain = String(req.query.domain ?? '').toLowerCase();
|
const domain = String(req.query.domain ?? '').toLowerCase();
|
||||||
const refreshQuota = String(req.query.refreshQuota ?? '').toLowerCase() === 'true';
|
const refreshQuota = String(req.query.refreshQuota ?? '').toLowerCase() === 'true';
|
||||||
|
const includeDeleted = String(req.query.includeDeleted ?? '').toLowerCase() === 'true';
|
||||||
|
|
||||||
if (domain) {
|
if (domain) {
|
||||||
ensureDomain(req, domain);
|
ensureDomain(req, domain);
|
||||||
@@ -82,6 +84,7 @@ mailboxesRouter.get('/', async (req, res) => {
|
|||||||
|
|
||||||
const params: unknown[] = [config.nodeName];
|
const params: unknown[] = [config.nodeName];
|
||||||
let where = 'WHERE node_name=$1';
|
let where = 'WHERE node_name=$1';
|
||||||
|
if (!includeDeleted) where += ` AND status <> 'deleted'`;
|
||||||
if (domain) { params.push(domain); where += ` AND domain=$${params.length}`; }
|
if (domain) { params.push(domain); where += ` AND domain=$${params.length}`; }
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
@@ -100,6 +103,7 @@ mailboxesRouter.post('/', async (req, res) => {
|
|||||||
await sync.syncFromDms();
|
await sync.syncFromDms();
|
||||||
await refreshQuotaForMailbox(email).catch((err) => console.warn(`Could not refresh quota for ${email}:`, err));
|
await refreshQuotaForMailbox(email).catch((err) => console.warn(`Could not refresh quota for ${email}:`, err));
|
||||||
await audit(req.user!.email, 'mailbox.create', 'mailbox', email, { domain }, req.ip);
|
await audit(req.user!.email, 'mailbox.create', 'mailbox', email, { domain }, req.ip);
|
||||||
|
await recordBillingEvent('created', domain, email, req.user!.email);
|
||||||
res.status(201).json({ email, domain, local_part: localPartFromEmail(email) });
|
res.status(201).json({ email, domain, local_part: localPartFromEmail(email) });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,6 +115,7 @@ mailboxesRouter.delete('/:email', async (req, res) => {
|
|||||||
await dms.syncSesDomain(domain).catch(() => undefined);
|
await dms.syncSesDomain(domain).catch(() => undefined);
|
||||||
await pool.query(`UPDATE mailboxes SET status='deleted', deleted_at=now(), updated_at=now() WHERE email_address=$1`, [email]);
|
await pool.query(`UPDATE mailboxes SET status='deleted', deleted_at=now(), updated_at=now() WHERE email_address=$1`, [email]);
|
||||||
await audit(req.user!.email, 'mailbox.delete', 'mailbox', email, { domain }, req.ip);
|
await audit(req.user!.email, 'mailbox.delete', 'mailbox', email, { domain }, req.ip);
|
||||||
|
await recordBillingEvent('deleted', domain, email, req.user!.email);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,6 +128,16 @@ mailboxesRouter.post('/:email/password', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mailboxesRouter.post('/:email/quota', async (req, res) => {
|
||||||
|
const body = z.object({ quota_gb: z.number().int().min(1).max(1024) }).parse(req.body);
|
||||||
|
const email = normalizeEmail(req.params.email);
|
||||||
|
ensureDomain(req, domainFromEmail(email));
|
||||||
|
await dms.setQuota(email, body.quota_gb);
|
||||||
|
await refreshQuotaForMailbox(email).catch((err) => console.warn(`Could not refresh quota for ${email}:`, err));
|
||||||
|
await audit(req.user!.email, 'mailbox.quota_set', 'mailbox', email, { quota_gb: body.quota_gb }, req.ip);
|
||||||
|
res.json({ ok: true, email, quota_gb: body.quota_gb });
|
||||||
|
});
|
||||||
|
|
||||||
mailboxesRouter.get('/:email/rules', async (req, res) => {
|
mailboxesRouter.get('/:email/rules', async (req, res) => {
|
||||||
const email = normalizeEmail(req.params.email);
|
const email = normalizeEmail(req.params.email);
|
||||||
ensureDomain(req, domainFromEmail(email));
|
ensureDomain(req, domainFromEmail(email));
|
||||||
@@ -132,8 +147,19 @@ mailboxesRouter.get('/:email/rules', async (req, res) => {
|
|||||||
mailboxesRouter.put('/:email/rules', async (req, res) => {
|
mailboxesRouter.put('/:email/rules', async (req, res) => {
|
||||||
const email = normalizeEmail(req.params.email);
|
const email = normalizeEmail(req.params.email);
|
||||||
ensureDomain(req, domainFromEmail(email));
|
ensureDomain(req, domainFromEmail(email));
|
||||||
const body = z.object({ ooo_active: z.boolean().optional(), ooo_message: z.string().optional(), forwards: z.array(z.string().email()).optional() }).parse(req.body);
|
const body = z.object({
|
||||||
const saved = await dynamo.putRules({ email_address: email, ooo_active: body.ooo_active, ooo_message: body.ooo_message, forwards: body.forwards });
|
ooo_active: z.boolean().optional(),
|
||||||
|
ooo_message: z.string().optional(),
|
||||||
|
ooo_content_type: z.enum(['text', 'html']).optional(),
|
||||||
|
forwards: z.array(z.string().email()).optional(),
|
||||||
|
}).parse(req.body);
|
||||||
|
const saved = await dynamo.putRules({
|
||||||
|
email_address: email,
|
||||||
|
ooo_active: body.ooo_active,
|
||||||
|
ooo_message: body.ooo_message,
|
||||||
|
ooo_content_type: body.ooo_content_type,
|
||||||
|
forwards: body.forwards,
|
||||||
|
});
|
||||||
await audit(req.user!.email, 'mailbox.rules_update', 'mailbox', email, saved, req.ip);
|
await audit(req.user!.email, 'mailbox.rules_update', 'mailbox', email, saved, req.ip);
|
||||||
res.json(saved);
|
res.json(saved);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { authRouter } from './routes/auth.js';
|
|||||||
import { domainsRouter } from './routes/domains.js';
|
import { domainsRouter } from './routes/domains.js';
|
||||||
import { mailboxesRouter } from './routes/mailboxes.js';
|
import { mailboxesRouter } from './routes/mailboxes.js';
|
||||||
import { auditRouter } from './routes/audit.js';
|
import { auditRouter } from './routes/audit.js';
|
||||||
|
import { adminsRouter } from './routes/admins.js';
|
||||||
|
import { billingRouter } from './routes/billing.js';
|
||||||
|
import { healthRouter } from './routes/health.js';
|
||||||
import { SyncService } from './services/sync.js';
|
import { SyncService } from './services/sync.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -41,6 +44,9 @@ app.use('/api/auth', authRouter);
|
|||||||
app.use('/api/domains', domainsRouter);
|
app.use('/api/domains', domainsRouter);
|
||||||
app.use('/api/mailboxes', mailboxesRouter);
|
app.use('/api/mailboxes', mailboxesRouter);
|
||||||
app.use('/api/audit', auditRouter);
|
app.use('/api/audit', auditRouter);
|
||||||
|
app.use('/api/admins', adminsRouter);
|
||||||
|
app.use('/api/billing', billingRouter);
|
||||||
|
app.use('/api/health', healthRouter);
|
||||||
|
|
||||||
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
const status = err.status ?? err.statusCode ?? 500;
|
const status = err.status ?? err.statusCode ?? 500;
|
||||||
@@ -59,7 +65,6 @@ const publicDir = config.publicDir.startsWith('/')
|
|||||||
|
|
||||||
console.log(`Serving frontend from: ${publicDir}`);
|
console.log(`Serving frontend from: ${publicDir}`);
|
||||||
|
|
||||||
// Avoid stale frontend JS while we are actively developing the MVP.
|
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
if (
|
if (
|
||||||
req.path.endsWith('.js') ||
|
req.path.endsWith('.js') ||
|
||||||
|
|||||||
208
backend/src/services/billing.ts
Normal file
208
backend/src/services/billing.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { pool } from '../db.js';
|
||||||
|
|
||||||
|
const PRICE_PER_INBOX_USD = 5;
|
||||||
|
|
||||||
|
export interface BillingEvent {
|
||||||
|
id: number;
|
||||||
|
occurred_at: string;
|
||||||
|
domain: string;
|
||||||
|
email: string;
|
||||||
|
action: 'created' | 'deleted';
|
||||||
|
actor_email: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyDomainBilling {
|
||||||
|
domain: string;
|
||||||
|
year: number;
|
||||||
|
month: number; // 1-12
|
||||||
|
ym: string; // 'YYYY-MM'
|
||||||
|
inbox_count: number;
|
||||||
|
amount_usd: number;
|
||||||
|
inbox_emails: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a billing event. Called from the create/delete mailbox routes.
|
||||||
|
* Failures are logged but never thrown — billing must not block the
|
||||||
|
* actual mailbox operation that just succeeded.
|
||||||
|
*/
|
||||||
|
export async function recordBillingEvent(
|
||||||
|
action: 'created' | 'deleted',
|
||||||
|
domain: string,
|
||||||
|
email: string,
|
||||||
|
actorEmail: string | null,
|
||||||
|
notes?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO mailbox_billing_events (domain, email, action, actor_email, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
[domain.toLowerCase(), email.toLowerCase(), action, actorEmail, notes ?? null],
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[billing] failed to record event:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get raw events for a domain (or all domains) within a time range.
|
||||||
|
*/
|
||||||
|
export async function listBillingEvents(opts: {
|
||||||
|
domain?: string;
|
||||||
|
fromIso?: string;
|
||||||
|
toIso?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<BillingEvent[]> {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const where: string[] = [];
|
||||||
|
|
||||||
|
if (opts.domain) {
|
||||||
|
params.push(opts.domain.toLowerCase());
|
||||||
|
where.push(`domain = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (opts.fromIso) {
|
||||||
|
params.push(opts.fromIso);
|
||||||
|
where.push(`occurred_at >= $${params.length}`);
|
||||||
|
}
|
||||||
|
if (opts.toIso) {
|
||||||
|
params.push(opts.toIso);
|
||||||
|
where.push(`occurred_at < $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(Math.max(opts.limit ?? 500, 1), 5000);
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT id, occurred_at, domain, email, action, actor_email, notes
|
||||||
|
FROM mailbox_billing_events
|
||||||
|
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||||
|
ORDER BY occurred_at DESC, id DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(sql, params);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute monthly billing aggregation across all months that contain
|
||||||
|
* at least one mailbox in the given domain.
|
||||||
|
*
|
||||||
|
* Rule: a mailbox is billable in a month if it existed at any point
|
||||||
|
* during that month — i.e. it was created on or before the last day
|
||||||
|
* of the month, AND (still active OR was deleted on or after the
|
||||||
|
* first day of that same month).
|
||||||
|
*
|
||||||
|
* In other words: every month between the create and the delete
|
||||||
|
* (inclusive on both ends) counts. If still active, every month
|
||||||
|
* since creation up to and including the current month counts.
|
||||||
|
*/
|
||||||
|
export async function computeMonthlyBilling(opts: {
|
||||||
|
domain?: string;
|
||||||
|
}): Promise<MonthlyDomainBilling[]> {
|
||||||
|
// Pull events. We need the full history, but for performance we
|
||||||
|
// could later add an "earliest month" cutoff. With <100k events
|
||||||
|
// this is well under 100ms.
|
||||||
|
const rows = await listBillingEvents({ domain: opts.domain, limit: 5000 });
|
||||||
|
|
||||||
|
// Group events per (domain, email) so we can pair create/delete
|
||||||
|
// and reconstruct lifespans.
|
||||||
|
const byMailbox = new Map<string, BillingEvent[]>();
|
||||||
|
for (const ev of rows) {
|
||||||
|
const key = `${ev.domain}\u0000${ev.email}`;
|
||||||
|
if (!byMailbox.has(key)) byMailbox.set(key, []);
|
||||||
|
byMailbox.get(key)!.push(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// For each domain+email, walk through events sorted ascending and
|
||||||
|
// build a list of (createdAt, deletedAt|null) intervals. A mailbox
|
||||||
|
// can be re-created after deletion, so multiple intervals per
|
||||||
|
// (domain, email) are possible.
|
||||||
|
type Interval = { domain: string; email: string; from: Date; to: Date | null };
|
||||||
|
const intervals: Interval[] = [];
|
||||||
|
|
||||||
|
for (const [key, evs] of byMailbox) {
|
||||||
|
const [domain, email] = key.split('\u0000');
|
||||||
|
const sorted = [...evs].sort((a, b) =>
|
||||||
|
new Date(a.occurred_at).getTime() - new Date(b.occurred_at).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
let openCreatedAt: Date | null = null;
|
||||||
|
|
||||||
|
for (const ev of sorted) {
|
||||||
|
if (ev.action === 'created') {
|
||||||
|
// If we already had an open interval (no matching delete), we
|
||||||
|
// close it implicitly on the new create. This shouldn't happen
|
||||||
|
// in practice but guards against malformed data.
|
||||||
|
if (openCreatedAt) {
|
||||||
|
intervals.push({ domain, email, from: openCreatedAt, to: new Date(ev.occurred_at) });
|
||||||
|
}
|
||||||
|
openCreatedAt = new Date(ev.occurred_at);
|
||||||
|
} else if (ev.action === 'deleted' && openCreatedAt) {
|
||||||
|
intervals.push({ domain, email, from: openCreatedAt, to: new Date(ev.occurred_at) });
|
||||||
|
openCreatedAt = null;
|
||||||
|
}
|
||||||
|
// delete without prior create is ignored (defensive).
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openCreatedAt) {
|
||||||
|
// Still active: open-ended interval up to "now".
|
||||||
|
intervals.push({ domain, email, from: openCreatedAt, to: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now bucket each interval into the months it covers.
|
||||||
|
// Per (domain, ym) we keep a Set of emails to dedupe and to expose
|
||||||
|
// the actual list in the UI.
|
||||||
|
type Bucket = { domain: string; ym: string; year: number; month: number; emails: Set<string> };
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
const ensureBucket = (domain: string, year: number, month: number): Bucket => {
|
||||||
|
const ym = `${year}-${String(month).padStart(2, '0')}`;
|
||||||
|
const key = `${domain}\u0000${ym}`;
|
||||||
|
let b = buckets.get(key);
|
||||||
|
if (!b) {
|
||||||
|
b = { domain, ym, year, month, emails: new Set() };
|
||||||
|
buckets.set(key, b);
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const iv of intervals) {
|
||||||
|
const fromY = iv.from.getUTCFullYear();
|
||||||
|
const fromM = iv.from.getUTCMonth() + 1;
|
||||||
|
const end = iv.to ?? now;
|
||||||
|
const toY = end.getUTCFullYear();
|
||||||
|
const toM = end.getUTCMonth() + 1;
|
||||||
|
|
||||||
|
let y = fromY;
|
||||||
|
let m = fromM;
|
||||||
|
while (y < toY || (y === toY && m <= toM)) {
|
||||||
|
ensureBucket(iv.domain, y, m).emails.add(iv.email);
|
||||||
|
m++;
|
||||||
|
if (m > 12) { m = 1; y++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: MonthlyDomainBilling[] = [...buckets.values()].map((b) => ({
|
||||||
|
domain: b.domain,
|
||||||
|
year: b.year,
|
||||||
|
month: b.month,
|
||||||
|
ym: b.ym,
|
||||||
|
inbox_count: b.emails.size,
|
||||||
|
amount_usd: b.emails.size * PRICE_PER_INBOX_USD,
|
||||||
|
inbox_emails: [...b.emails].sort(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Newest months first, then domain name.
|
||||||
|
result.sort((a, b) => {
|
||||||
|
if (a.ym !== b.ym) return a.ym < b.ym ? 1 : -1;
|
||||||
|
return a.domain.localeCompare(b.domain);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRICE_PER_INBOX = PRICE_PER_INBOX_USD;
|
||||||
@@ -179,6 +179,32 @@ export class DmsService {
|
|||||||
console.log(`[dms] password updated for mailbox: ${normalized}`);
|
console.log(`[dms] password updated for mailbox: ${normalized}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the storage quota for a mailbox via the docker-mailserver
|
||||||
|
* `setup quota set` subcommand. quotaGb must be a positive integer.
|
||||||
|
* Equivalent to: docker exec mailserver setup quota set user@example.com 35G
|
||||||
|
*/
|
||||||
|
async setQuota(email: string, quotaGb: number): Promise<void> {
|
||||||
|
const normalized = normalizeEmail(email);
|
||||||
|
const gb = Math.floor(Number(quotaGb));
|
||||||
|
|
||||||
|
if (!Number.isFinite(gb) || gb <= 0 || gb > 1024) {
|
||||||
|
throw new Error(`Invalid quota size: ${quotaGb}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[dms] setting quota for ${normalized} to ${gb}G`);
|
||||||
|
|
||||||
|
await this.assertDockerAccess();
|
||||||
|
|
||||||
|
await run(
|
||||||
|
'docker',
|
||||||
|
['exec', config.dmsContainer, 'setup', 'quota', 'set', normalized, `${gb}G`],
|
||||||
|
120000,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[dms] quota set for ${normalized}: ${gb}G`);
|
||||||
|
}
|
||||||
|
|
||||||
async syncSesDomain(domain: string): Promise<void> {
|
async syncSesDomain(domain: string): Promise<void> {
|
||||||
const normalizedDomain = domain.toLowerCase();
|
const normalizedDomain = domain.toLowerCase();
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export interface EmailRule {
|
|||||||
email_address: string;
|
email_address: string;
|
||||||
ooo_active?: boolean;
|
ooo_active?: boolean;
|
||||||
ooo_message?: string;
|
ooo_message?: string;
|
||||||
ooo_content_type?: string;
|
// 'text' or 'html' — kept consistent with the config-email app that
|
||||||
|
// shares the same DynamoDB table so both apps interpret it the same way.
|
||||||
|
ooo_content_type?: 'text' | 'html';
|
||||||
forwards?: string[];
|
forwards?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +18,13 @@ export interface BlockList {
|
|||||||
blocked_patterns: string[];
|
blocked_patterns: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tolerate legacy values that may already exist in DynamoDB.
|
||||||
|
function normalizeContentType(v: unknown): 'text' | 'html' {
|
||||||
|
const s = String(v ?? '').toLowerCase();
|
||||||
|
if (s === 'html' || s === 'text/html') return 'html';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
export class DynamoRulesService {
|
export class DynamoRulesService {
|
||||||
private doc = DynamoDBDocumentClient.from(new DynamoDBClient({ region: config.awsRegion }), {
|
private doc = DynamoDBDocumentClient.from(new DynamoDBClient({ region: config.awsRegion }), {
|
||||||
marshallOptions: { removeUndefinedValues: true },
|
marshallOptions: { removeUndefinedValues: true },
|
||||||
@@ -24,7 +33,14 @@ export class DynamoRulesService {
|
|||||||
async getRules(email: string): Promise<EmailRule> {
|
async getRules(email: string): Promise<EmailRule> {
|
||||||
const email_address = normalizeEmail(email);
|
const email_address = normalizeEmail(email);
|
||||||
const resp = await this.doc.send(new GetCommand({ TableName: config.rulesTable, Key: { email_address } }));
|
const resp = await this.doc.send(new GetCommand({ TableName: config.rulesTable, Key: { email_address } }));
|
||||||
return (resp.Item as EmailRule) ?? { email_address, ooo_active: false, ooo_message: '', ooo_content_type: 'text/plain', forwards: [] };
|
const item = resp.Item as EmailRule | undefined;
|
||||||
|
if (!item) {
|
||||||
|
return { email_address, ooo_active: false, ooo_message: '', ooo_content_type: 'text', forwards: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
ooo_content_type: normalizeContentType(item.ooo_content_type),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async putRules(rule: EmailRule): Promise<EmailRule> {
|
async putRules(rule: EmailRule): Promise<EmailRule> {
|
||||||
@@ -32,7 +48,7 @@ export class DynamoRulesService {
|
|||||||
email_address: normalizeEmail(rule.email_address),
|
email_address: normalizeEmail(rule.email_address),
|
||||||
ooo_active: !!rule.ooo_active,
|
ooo_active: !!rule.ooo_active,
|
||||||
ooo_message: rule.ooo_message ?? '',
|
ooo_message: rule.ooo_message ?? '',
|
||||||
ooo_content_type: rule.ooo_content_type ?? 'text/plain',
|
ooo_content_type: normalizeContentType(rule.ooo_content_type),
|
||||||
forwards: (rule.forwards ?? []).map(normalizeEmail).filter(Boolean),
|
forwards: (rule.forwards ?? []).map(normalizeEmail).filter(Boolean),
|
||||||
};
|
};
|
||||||
await this.doc.send(new PutCommand({ TableName: config.rulesTable, Item: item }));
|
await this.doc.send(new PutCommand({ TableName: config.rulesTable, Item: item }));
|
||||||
|
|||||||
395
backend/src/services/health.ts
Normal file
395
backend/src/services/health.ts
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
import dns from 'node:dns/promises';
|
||||||
|
import type { MxRecord } from 'node:dns';
|
||||||
|
import tls from 'node:tls';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export type HealthLevel = 'ok' | 'warn' | 'fail' | 'unknown';
|
||||||
|
|
||||||
|
export interface HealthFinding {
|
||||||
|
level: HealthLevel;
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthCheck {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
level: HealthLevel;
|
||||||
|
findings: HealthFinding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainHealthReport {
|
||||||
|
domain: string;
|
||||||
|
checked_at: string;
|
||||||
|
has_problems: boolean;
|
||||||
|
checks: HealthCheck[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const SUBDOMAINS_FOR_CADDY = ['mail', 'webmail', 'imap', 'smtp'];
|
||||||
|
|
||||||
|
// "warn" level for cert expiring within 14 days, "fail" for already expired.
|
||||||
|
const CERT_WARN_DAYS = 14;
|
||||||
|
|
||||||
|
// Aggregate child finding levels into a parent level (worst wins).
|
||||||
|
function worstLevel(levels: HealthLevel[]): HealthLevel {
|
||||||
|
if (levels.includes('fail')) return 'fail';
|
||||||
|
if (levels.includes('warn')) return 'warn';
|
||||||
|
if (levels.includes('unknown')) return 'unknown';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`Timeout: ${label}`)), ms);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([p, timeout]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1) DMS check
|
||||||
|
// ============================================================
|
||||||
|
async function checkDms(domain: string): Promise<HealthCheck> {
|
||||||
|
const findings: HealthFinding[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT count(*)::int AS n
|
||||||
|
FROM mailboxes
|
||||||
|
WHERE domain=$1 AND status='active'`,
|
||||||
|
[domain],
|
||||||
|
);
|
||||||
|
const count = result.rows[0]?.n ?? 0;
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
findings.push({
|
||||||
|
level: 'fail',
|
||||||
|
label: 'No active mailboxes',
|
||||||
|
detail: 'This domain has no active mailboxes in DMS.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
findings.push({
|
||||||
|
level: 'ok',
|
||||||
|
label: `${count} active mailbox${count === 1 ? '' : 'es'}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
findings.push({
|
||||||
|
level: 'unknown',
|
||||||
|
label: 'Could not query DMS state',
|
||||||
|
detail: err?.message ?? String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'dms',
|
||||||
|
title: 'DMS',
|
||||||
|
level: worstLevel(findings.map((f) => f.level)),
|
||||||
|
findings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 2) DNS check
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function dnsResolve(host: string, type: 'A' | 'AAAA' | 'MX' | 'TXT' | 'CNAME'): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
if (type === 'A') return await withTimeout<string[]>(dns.resolve4(host), 5000, `A ${host}`);
|
||||||
|
if (type === 'AAAA') return await withTimeout<string[]>(dns.resolve6(host), 5000, `AAAA ${host}`);
|
||||||
|
if (type === 'MX') {
|
||||||
|
const mx = await withTimeout<MxRecord[]>(dns.resolveMx(host), 5000, `MX ${host}`);
|
||||||
|
return mx.map((m) => m.exchange);
|
||||||
|
}
|
||||||
|
if (type === 'TXT') {
|
||||||
|
const txt = await withTimeout<string[][]>(dns.resolveTxt(host), 5000, `TXT ${host}`);
|
||||||
|
return txt.map((parts) => parts.join(''));
|
||||||
|
}
|
||||||
|
if (type === 'CNAME') return await withTimeout<string[]>(dns.resolveCname(host), 5000, `CNAME ${host}`);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkDns(domain: string): Promise<HealthCheck> {
|
||||||
|
const findings: HealthFinding[] = [];
|
||||||
|
|
||||||
|
// ---- MX ----
|
||||||
|
const mx = await dnsResolve(domain, 'MX');
|
||||||
|
if (mx.length === 0) {
|
||||||
|
findings.push({ level: 'fail', label: 'MX', detail: 'No MX record found.' });
|
||||||
|
} else {
|
||||||
|
const sesMx = mx.find((m) => /amazonaws\.com\.?$/i.test(m));
|
||||||
|
if (sesMx) {
|
||||||
|
findings.push({ level: 'ok', label: 'MX', detail: `points to SES (${sesMx})` });
|
||||||
|
} else {
|
||||||
|
findings.push({
|
||||||
|
level: 'warn',
|
||||||
|
label: 'MX',
|
||||||
|
detail: `Not an SES MX record: ${mx.join(', ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SPF (TXT on root) ----
|
||||||
|
const txt = await dnsResolve(domain, 'TXT');
|
||||||
|
const spf = txt.find((t) => /^v=spf1\b/i.test(t));
|
||||||
|
if (!spf) {
|
||||||
|
findings.push({ level: 'fail', label: 'SPF', detail: 'No SPF record found.' });
|
||||||
|
} else if (/include:amazonses\.com/i.test(spf)) {
|
||||||
|
findings.push({ level: 'ok', label: 'SPF', detail: 'includes amazonses.com' });
|
||||||
|
} else {
|
||||||
|
findings.push({
|
||||||
|
level: 'warn',
|
||||||
|
label: 'SPF',
|
||||||
|
detail: `SPF found but does not include amazonses.com: ${spf.slice(0, 100)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DMARC ----
|
||||||
|
const dmarc = await dnsResolve(`_dmarc.${domain}`, 'TXT');
|
||||||
|
const dmarcRecord = dmarc.find((t) => /^v=DMARC1\b/i.test(t));
|
||||||
|
if (!dmarcRecord) {
|
||||||
|
findings.push({ level: 'warn', label: 'DMARC', detail: 'No DMARC record found.' });
|
||||||
|
} else {
|
||||||
|
findings.push({ level: 'ok', label: 'DMARC', detail: dmarcRecord.slice(0, 80) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DKIM (SES uses 3 selectors named "<token>._domainkey") ----
|
||||||
|
// We don't know the SES tokens up front, so we just check whether
|
||||||
|
// there is _ANY_ resolvable DKIM-like CNAME under _domainkey.
|
||||||
|
// Common SES DKIM convention: 3 CNAMEs at <token1|2|3>._domainkey.
|
||||||
|
// We try Amazon's classic pattern first, then fall back to "no info".
|
||||||
|
// This check is best-effort; "unknown" is acceptable.
|
||||||
|
// Note: there's no clean way to enumerate _domainkey subdomains via DNS,
|
||||||
|
// so we record "unknown" rather than making up false positives.
|
||||||
|
findings.push({
|
||||||
|
level: 'unknown',
|
||||||
|
label: 'DKIM',
|
||||||
|
detail: 'Cannot verify automatically — confirm in SES console that 3 DKIM CNAMEs are published.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Subdomains for Caddy (must resolve, content doesn't matter) ----
|
||||||
|
for (const sub of SUBDOMAINS_FOR_CADDY) {
|
||||||
|
const host = `${sub}.${domain}`;
|
||||||
|
const a = await dnsResolve(host, 'A');
|
||||||
|
const aaaa = a.length === 0 ? await dnsResolve(host, 'AAAA') : [];
|
||||||
|
const cname = a.length === 0 && aaaa.length === 0 ? await dnsResolve(host, 'CNAME') : [];
|
||||||
|
|
||||||
|
if (a.length > 0) {
|
||||||
|
findings.push({ level: 'ok', label: `DNS ${host}`, detail: `A → ${a[0]}` });
|
||||||
|
} else if (aaaa.length > 0) {
|
||||||
|
findings.push({ level: 'ok', label: `DNS ${host}`, detail: `AAAA → ${aaaa[0]}` });
|
||||||
|
} else if (cname.length > 0) {
|
||||||
|
findings.push({ level: 'ok', label: `DNS ${host}`, detail: `CNAME → ${cname[0]}` });
|
||||||
|
} else {
|
||||||
|
findings.push({
|
||||||
|
level: 'fail',
|
||||||
|
label: `DNS ${host}`,
|
||||||
|
detail: 'Does not resolve. Caddy cannot issue a cert without DNS pointing here.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'dns',
|
||||||
|
title: 'DNS',
|
||||||
|
level: worstLevel(findings.map((f) => f.level)),
|
||||||
|
findings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 3) Caddy cert check
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface CertResult {
|
||||||
|
validFrom: Date | null;
|
||||||
|
validTo: Date | null;
|
||||||
|
cn: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCertOnce(host: string, port = 443, timeoutMs = 7000): Promise<CertResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (r: CertResult) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
try { socket.destroy(); } catch { /* ignore */ }
|
||||||
|
resolve(r);
|
||||||
|
};
|
||||||
|
|
||||||
|
const socket = tls.connect({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
servername: host,
|
||||||
|
// We DO want to inspect even bad certs (e.g. self-signed) so we
|
||||||
|
// can report useful info instead of just "connection failed".
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
}, () => {
|
||||||
|
try {
|
||||||
|
const cert = socket.getPeerCertificate();
|
||||||
|
if (!cert || Object.keys(cert).length === 0) {
|
||||||
|
finish({ validFrom: null, validTo: null, cn: null, error: 'No peer certificate returned' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validFrom = cert.valid_from ? new Date(cert.valid_from) : null;
|
||||||
|
const validTo = cert.valid_to ? new Date(cert.valid_to) : null;
|
||||||
|
const rawCn = cert.subject?.CN;
|
||||||
|
const cn = Array.isArray(rawCn) ? (rawCn[0] ?? null) : (rawCn ?? null);
|
||||||
|
|
||||||
|
finish({ validFrom, validTo, cn, error: null });
|
||||||
|
} catch (e: any) {
|
||||||
|
finish({ validFrom: null, validTo: null, cn: null, error: e?.message ?? 'parse error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (e) => finish({ validFrom: null, validTo: null, cn: null, error: e.message }));
|
||||||
|
socket.on('timeout', () => finish({ validFrom: null, validTo: null, cn: null, error: 'TLS handshake timed out' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkCaddyCerts(domain: string): Promise<HealthCheck> {
|
||||||
|
const findings: HealthFinding[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const sub of SUBDOMAINS_FOR_CADDY) {
|
||||||
|
const host = `${sub}.${domain}`;
|
||||||
|
const r = await checkCertOnce(host);
|
||||||
|
|
||||||
|
if (r.error || !r.validTo) {
|
||||||
|
findings.push({
|
||||||
|
level: 'fail',
|
||||||
|
label: host,
|
||||||
|
detail: r.error ?? 'No cert info available',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysLeft = Math.floor((r.validTo.getTime() - now) / (1000 * 60 * 60 * 24));
|
||||||
|
const expIso = r.validTo.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (daysLeft < 0) {
|
||||||
|
findings.push({
|
||||||
|
level: 'fail',
|
||||||
|
label: host,
|
||||||
|
detail: `Cert EXPIRED on ${expIso} (${Math.abs(daysLeft)} days ago)`,
|
||||||
|
});
|
||||||
|
} else if (daysLeft <= CERT_WARN_DAYS) {
|
||||||
|
findings.push({
|
||||||
|
level: 'warn',
|
||||||
|
label: host,
|
||||||
|
detail: `Cert expires in ${daysLeft} days (${expIso})`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
findings.push({
|
||||||
|
level: 'ok',
|
||||||
|
label: host,
|
||||||
|
detail: `Cert valid until ${expIso} (${daysLeft} days)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'caddy',
|
||||||
|
title: 'TLS certificates',
|
||||||
|
level: worstLevel(findings.map((f) => f.level)),
|
||||||
|
findings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Public: run all checks for a domain
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function runDomainHealthChecks(domain: string): Promise<DomainHealthReport> {
|
||||||
|
const d = domain.toLowerCase();
|
||||||
|
|
||||||
|
const [dmsResult, dnsResult, caddyResult] = await Promise.all([
|
||||||
|
checkDms(d),
|
||||||
|
checkDns(d),
|
||||||
|
checkCaddyCerts(d),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const checks: HealthCheck[] = [dmsResult, dnsResult, caddyResult];
|
||||||
|
const overall = worstLevel(checks.map((c) => c.level));
|
||||||
|
const has_problems = overall === 'fail' || overall === 'warn';
|
||||||
|
|
||||||
|
const report: DomainHealthReport = {
|
||||||
|
domain: d,
|
||||||
|
checked_at: new Date().toISOString(),
|
||||||
|
has_problems,
|
||||||
|
checks,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Persist for the banner.
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO domain_health_status (domain, checked_at, has_problems, details)
|
||||||
|
VALUES ($1, now(), $2, $3::jsonb)
|
||||||
|
ON CONFLICT (domain) DO UPDATE SET
|
||||||
|
checked_at = EXCLUDED.checked_at,
|
||||||
|
has_problems = EXCLUDED.has_problems,
|
||||||
|
details = EXCLUDED.details`,
|
||||||
|
[d, has_problems, JSON.stringify(report)],
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[health] could not persist health status:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Public: load last persisted status (used by mailbox view banner)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface PersistedHealth {
|
||||||
|
domain: string;
|
||||||
|
checked_at: string;
|
||||||
|
has_problems: boolean;
|
||||||
|
summary: { fail: number; warn: number; unknown: number; ok: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPersistedHealth(domain: string): Promise<PersistedHealth | null> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT domain, checked_at, has_problems, details
|
||||||
|
FROM domain_health_status WHERE domain=$1`,
|
||||||
|
[domain.toLowerCase()],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
// Build a quick summary of finding counts so the banner can say
|
||||||
|
// "2 problems detected" without needing to rehydrate the whole modal.
|
||||||
|
const counts = { fail: 0, warn: 0, unknown: 0, ok: 0 };
|
||||||
|
const details = row.details as DomainHealthReport;
|
||||||
|
for (const c of details?.checks ?? []) {
|
||||||
|
for (const f of c.findings) {
|
||||||
|
counts[f.level] = (counts[f.level] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain: row.domain,
|
||||||
|
checked_at: row.checked_at instanceof Date ? row.checked_at.toISOString() : row.checked_at,
|
||||||
|
has_problems: row.has_problems,
|
||||||
|
summary: counts,
|
||||||
|
};
|
||||||
|
}
|
||||||
268
backend/src/services/ses-events.ts
Normal file
268
backend/src/services/ses-events.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
||||||
|
import { DynamoDBDocumentClient, QueryCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
|
||||||
|
const TABLE_NAME = 'ses-events';
|
||||||
|
|
||||||
|
const doc = DynamoDBDocumentClient.from(
|
||||||
|
new DynamoDBClient({ region: config.awsRegion }),
|
||||||
|
{ marshallOptions: { removeUndefinedValues: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface InboxVolume {
|
||||||
|
email: string;
|
||||||
|
domain: string;
|
||||||
|
ym: string;
|
||||||
|
send_count: number;
|
||||||
|
bytes_total: number;
|
||||||
|
bounce_count: number;
|
||||||
|
complaint_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainVolume {
|
||||||
|
domain: string;
|
||||||
|
ym: string;
|
||||||
|
send_count: number;
|
||||||
|
bytes_total: number;
|
||||||
|
bounce_count: number;
|
||||||
|
complaint_count: number;
|
||||||
|
per_inbox: InboxVolume[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawEventRow {
|
||||||
|
pk?: string;
|
||||||
|
event_type: 'send' | 'bounce' | 'complaint';
|
||||||
|
size_bytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentYm(): string {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previousYm(): string {
|
||||||
|
const now = new Date();
|
||||||
|
// Construct first day of previous month in UTC, then format.
|
||||||
|
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
||||||
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Single-domain volume (used for the per-inbox drilldown)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function aggregateInbox(domain: string, email: string, ym: string): Promise<InboxVolume> {
|
||||||
|
const pk = `${domain}#${email}#${ym}`;
|
||||||
|
|
||||||
|
let lastEvaluatedKey: Record<string, unknown> | undefined;
|
||||||
|
let send_count = 0;
|
||||||
|
let bytes_total = 0;
|
||||||
|
let bounce_count = 0;
|
||||||
|
let complaint_count = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const resp = await doc.send(new QueryCommand({
|
||||||
|
TableName: TABLE_NAME,
|
||||||
|
KeyConditionExpression: 'pk = :pk',
|
||||||
|
ExpressionAttributeValues: { ':pk': pk },
|
||||||
|
ExclusiveStartKey: lastEvaluatedKey,
|
||||||
|
ProjectionExpression: 'event_type, size_bytes',
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const row of (resp.Items ?? []) as RawEventRow[]) {
|
||||||
|
if (row.event_type === 'send') {
|
||||||
|
send_count++;
|
||||||
|
bytes_total += Number(row.size_bytes ?? 0);
|
||||||
|
} else if (row.event_type === 'bounce') {
|
||||||
|
bounce_count++;
|
||||||
|
} else if (row.event_type === 'complaint') {
|
||||||
|
complaint_count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastEvaluatedKey = resp.LastEvaluatedKey;
|
||||||
|
} while (lastEvaluatedKey);
|
||||||
|
|
||||||
|
return { email, domain, ym, send_count, bytes_total, bounce_count, complaint_count };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDomainVolumeForMonth(domain: string, ym: string): Promise<DomainVolume> {
|
||||||
|
const d = domain.toLowerCase();
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT email_address FROM mailboxes WHERE domain=$1`,
|
||||||
|
[d],
|
||||||
|
);
|
||||||
|
const emailsFromPg: string[] = result.rows.map((r: any) => String(r.email_address));
|
||||||
|
|
||||||
|
// Also discover any email addresses that have events in DynamoDB but are
|
||||||
|
// no longer in the local PostgreSQL (e.g. mailboxes that were created on
|
||||||
|
// a different node, or hard-deleted from PostgreSQL but still have events).
|
||||||
|
// We do a targeted Scan filtered by the domain prefix on pk.
|
||||||
|
const emailsFromDdb = await listEmailsForDomain(d, ym);
|
||||||
|
|
||||||
|
const allEmails = Array.from(new Set([...emailsFromPg, ...emailsFromDdb]));
|
||||||
|
|
||||||
|
const perInbox = await Promise.all(allEmails.map((e) => aggregateInbox(d, e, ym)));
|
||||||
|
|
||||||
|
const nonEmpty = perInbox.filter(
|
||||||
|
(v) => v.send_count > 0 || v.bounce_count > 0 || v.complaint_count > 0,
|
||||||
|
);
|
||||||
|
nonEmpty.sort((a, b) => b.send_count - a.send_count || a.email.localeCompare(b.email));
|
||||||
|
|
||||||
|
const totals = nonEmpty.reduce(
|
||||||
|
(acc, v) => ({
|
||||||
|
send_count: acc.send_count + v.send_count,
|
||||||
|
bytes_total: acc.bytes_total + v.bytes_total,
|
||||||
|
bounce_count: acc.bounce_count + v.bounce_count,
|
||||||
|
complaint_count: acc.complaint_count + v.complaint_count,
|
||||||
|
}),
|
||||||
|
{ send_count: 0, bytes_total: 0, bounce_count: 0, complaint_count: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return { domain: d, ym, ...totals, per_inbox: nonEmpty };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the set of email addresses that have any event for the given
|
||||||
|
* (domain, ym) bucket. Used to ensure the per-inbox drilldown surfaces
|
||||||
|
* historical mailboxes that no longer exist in the local PostgreSQL.
|
||||||
|
*
|
||||||
|
* Uses a Scan with a begins_with filter on pk because we don't have
|
||||||
|
* a GSI on domain. For typical event volumes this is fast enough; a
|
||||||
|
* GSI can be added later if it becomes a hot spot.
|
||||||
|
*/
|
||||||
|
async function listEmailsForDomain(domain: string, ym: string): Promise<string[]> {
|
||||||
|
const prefix = `${domain}#`;
|
||||||
|
const suffix = `#${ym}`;
|
||||||
|
const emails = new Set<string>();
|
||||||
|
let lastEvaluatedKey: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const resp = await doc.send(new ScanCommand({
|
||||||
|
TableName: TABLE_NAME,
|
||||||
|
FilterExpression: 'begins_with(pk, :p)',
|
||||||
|
ExpressionAttributeValues: { ':p': prefix },
|
||||||
|
ProjectionExpression: 'pk',
|
||||||
|
ExclusiveStartKey: lastEvaluatedKey,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const row of (resp.Items ?? []) as { pk?: string }[]) {
|
||||||
|
if (!row.pk) continue;
|
||||||
|
// pk format: "domain#email#YYYY-MM"
|
||||||
|
if (!row.pk.endsWith(suffix)) continue;
|
||||||
|
const inner = row.pk.slice(prefix.length, row.pk.length - suffix.length);
|
||||||
|
if (inner) emails.add(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastEvaluatedKey = resp.LastEvaluatedKey;
|
||||||
|
} while (lastEvaluatedKey);
|
||||||
|
|
||||||
|
return [...emails];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// All-domains overview (single Scan, no per-domain Query loop)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface DomainOverviewRow {
|
||||||
|
domain: string;
|
||||||
|
send_count: number;
|
||||||
|
bytes_total: number;
|
||||||
|
bounce_count: number;
|
||||||
|
complaint_count: number;
|
||||||
|
inbox_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VolumeOverview {
|
||||||
|
ym: string;
|
||||||
|
total_send_count: number;
|
||||||
|
total_bounce_count: number;
|
||||||
|
total_complaint_count: number;
|
||||||
|
total_inbox_count: number;
|
||||||
|
rows: DomainOverviewRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-domain overview. One Scan reads all events for the given month,
|
||||||
|
* aggregated per domain. We rely on the fact that pk encodes both the
|
||||||
|
* domain and the ym, so a server-side FilterExpression cuts the scanned
|
||||||
|
* set down to just this month.
|
||||||
|
*
|
||||||
|
* Note: DynamoDB Scan still reads the entire table for billing, but the
|
||||||
|
* filter reduces network bytes returned. For our event volumes that's
|
||||||
|
* acceptable. If the table grows past a few hundred thousand items per
|
||||||
|
* month we should add a GSI keyed on (ym).
|
||||||
|
*/
|
||||||
|
export async function getVolumeOverview(ym: string): Promise<VolumeOverview> {
|
||||||
|
const suffix = `#${ym}`;
|
||||||
|
let lastEvaluatedKey: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
// Per-domain accumulators with a Set for unique emails.
|
||||||
|
const acc = new Map<string, {
|
||||||
|
send: number; bytes: number; bounce: number; complaint: number; inboxes: Set<string>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
do {
|
||||||
|
const resp = await doc.send(new ScanCommand({
|
||||||
|
TableName: TABLE_NAME,
|
||||||
|
// Filter at the server: only pk values ending in this month.
|
||||||
|
// DynamoDB doesn't support "ends_with" on a key, so we use
|
||||||
|
// contains() — works because '#2026-04' is unique enough.
|
||||||
|
FilterExpression: 'contains(pk, :ym)',
|
||||||
|
ExpressionAttributeValues: { ':ym': suffix },
|
||||||
|
ProjectionExpression: 'pk, event_type, size_bytes',
|
||||||
|
ExclusiveStartKey: lastEvaluatedKey,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const row of (resp.Items ?? []) as RawEventRow[]) {
|
||||||
|
if (!row.pk || !row.pk.endsWith(suffix)) continue;
|
||||||
|
// Strip the trailing "#ym" then split into [domain, email].
|
||||||
|
const head = row.pk.slice(0, row.pk.length - suffix.length);
|
||||||
|
const sep = head.indexOf('#');
|
||||||
|
if (sep < 0) continue;
|
||||||
|
const domain = head.slice(0, sep);
|
||||||
|
const email = head.slice(sep + 1);
|
||||||
|
|
||||||
|
let entry = acc.get(domain);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { send: 0, bytes: 0, bounce: 0, complaint: 0, inboxes: new Set() };
|
||||||
|
acc.set(domain, entry);
|
||||||
|
}
|
||||||
|
entry.inboxes.add(email);
|
||||||
|
|
||||||
|
if (row.event_type === 'send') {
|
||||||
|
entry.send++;
|
||||||
|
entry.bytes += Number(row.size_bytes ?? 0);
|
||||||
|
} else if (row.event_type === 'bounce') {
|
||||||
|
entry.bounce++;
|
||||||
|
} else if (row.event_type === 'complaint') {
|
||||||
|
entry.complaint++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastEvaluatedKey = resp.LastEvaluatedKey;
|
||||||
|
} while (lastEvaluatedKey);
|
||||||
|
|
||||||
|
const rows: DomainOverviewRow[] = [...acc.entries()].map(([domain, e]) => ({
|
||||||
|
domain,
|
||||||
|
send_count: e.send,
|
||||||
|
bytes_total: e.bytes,
|
||||||
|
bounce_count: e.bounce,
|
||||||
|
complaint_count: e.complaint,
|
||||||
|
inbox_count: e.inboxes.size,
|
||||||
|
})).sort((a, b) => b.send_count - a.send_count || a.domain.localeCompare(b.domain));
|
||||||
|
|
||||||
|
const totals = rows.reduce(
|
||||||
|
(s, r) => ({
|
||||||
|
total_send_count: s.total_send_count + r.send_count,
|
||||||
|
total_bounce_count: s.total_bounce_count + r.bounce_count,
|
||||||
|
total_complaint_count: s.total_complaint_count + r.complaint_count,
|
||||||
|
total_inbox_count: s.total_inbox_count + r.inbox_count,
|
||||||
|
}),
|
||||||
|
{ total_send_count: 0, total_bounce_count: 0, total_complaint_count: 0, total_inbox_count: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ym, ...totals, rows };
|
||||||
|
}
|
||||||
@@ -5,12 +5,18 @@ import { DmsService } from './dms.js';
|
|||||||
export class SyncService {
|
export class SyncService {
|
||||||
constructor(private dms = new DmsService()) {}
|
constructor(private dms = new DmsService()) {}
|
||||||
|
|
||||||
async syncFromDms(): Promise<{ domains: number; mailboxes: number }> {
|
async syncFromDms(): Promise<{
|
||||||
|
domains: number;
|
||||||
|
mailboxes: number;
|
||||||
|
removed_mailboxes: number;
|
||||||
|
removed_domains: number;
|
||||||
|
}> {
|
||||||
const accounts = await this.dms.listAccounts();
|
const accounts = await this.dms.listAccounts();
|
||||||
const domains = [...new Set(accounts.map((a) => a.domain))].sort();
|
const domains = [...new Set(accounts.map((a) => a.domain))].sort();
|
||||||
|
|
||||||
await pool.query('BEGIN');
|
await pool.query('BEGIN');
|
||||||
try {
|
try {
|
||||||
|
// 1. Upsert every domain that currently exists in DMS as 'active'.
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO domains(domain, current_node, status, last_seen_at, last_synced_at)
|
`INSERT INTO domains(domain, current_node, status, last_seen_at, last_synced_at)
|
||||||
@@ -25,6 +31,7 @@ export class SyncService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Upsert every mailbox that currently exists in DMS as 'active'.
|
||||||
const localEmails = accounts.map((a) => a.email);
|
const localEmails = accounts.map((a) => a.email);
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
@@ -41,22 +48,69 @@ export class SyncService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await pool.query(
|
// 3. Find mailboxes that this node previously owned but that are
|
||||||
`UPDATE domains
|
// no longer in DMS (or never were). They get hard-deleted.
|
||||||
SET status='missing_on_node', last_synced_at=now(), updated_at=now()
|
// We collect (email, domain) first so we can emit billing events
|
||||||
WHERE current_node=$1 AND NOT (domain = ANY($2::text[]))`,
|
// for any of them that don't already have a 'deleted' event.
|
||||||
[config.nodeName, domains],
|
const toRemoveResult = await pool.query<{ email_address: string; domain: string; previous_status: string }>(
|
||||||
|
`SELECT email_address, domain, status AS previous_status
|
||||||
|
FROM mailboxes
|
||||||
|
WHERE node_name=$1 AND NOT (email_address = ANY($2::text[]))`,
|
||||||
|
[config.nodeName, localEmails],
|
||||||
);
|
);
|
||||||
|
const toRemove = toRemoveResult.rows;
|
||||||
|
|
||||||
|
// 3a. Emit a synthetic 'deleted' billing event for any mailbox
|
||||||
|
// that was previously active and doesn't already have one.
|
||||||
|
// This keeps billing history correct even though we hard-delete
|
||||||
|
// the mailbox row itself in the next step.
|
||||||
|
for (const row of toRemove) {
|
||||||
|
if (row.previous_status === 'active' || row.previous_status === 'missing_on_node') {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE mailboxes
|
`INSERT INTO mailbox_billing_events (domain, email, action, actor_email, notes)
|
||||||
SET status='missing_on_node', updated_at=now()
|
SELECT $1, $2, 'deleted', NULL, 'removed via DMS resync (was ' || $3 || ')'
|
||||||
WHERE node_name=$1 AND status='active' AND NOT (email_address = ANY($2::text[]))`,
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM mailbox_billing_events b
|
||||||
|
WHERE b.email = $2
|
||||||
|
AND b.action = 'deleted'
|
||||||
|
AND b.occurred_at >= (
|
||||||
|
SELECT COALESCE(MAX(occurred_at), '1970-01-01'::timestamptz)
|
||||||
|
FROM mailbox_billing_events
|
||||||
|
WHERE email = $2 AND action = 'created'
|
||||||
|
)
|
||||||
|
)`,
|
||||||
|
[row.domain, row.email_address, row.previous_status],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3b. Hard-delete the mailbox rows themselves.
|
||||||
|
const removedMailboxes = await pool.query(
|
||||||
|
`DELETE FROM mailboxes
|
||||||
|
WHERE node_name=$1 AND NOT (email_address = ANY($2::text[]))`,
|
||||||
[config.nodeName, localEmails],
|
[config.nodeName, localEmails],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 4. Hard-delete domains that no longer have any mailbox on this node.
|
||||||
|
// We restrict to current_node so we don't touch domains owned by
|
||||||
|
// other nodes if that ever becomes a thing.
|
||||||
|
const removedDomains = await pool.query(
|
||||||
|
`DELETE FROM domains
|
||||||
|
WHERE current_node=$1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM mailboxes m
|
||||||
|
WHERE m.domain = domains.domain AND m.node_name = $1
|
||||||
|
)`,
|
||||||
|
[config.nodeName],
|
||||||
|
);
|
||||||
|
|
||||||
await pool.query('COMMIT');
|
await pool.query('COMMIT');
|
||||||
return { domains: domains.length, mailboxes: accounts.length };
|
return {
|
||||||
|
domains: domains.length,
|
||||||
|
mailboxes: accounts.length,
|
||||||
|
removed_mailboxes: removedMailboxes.rowCount ?? 0,
|
||||||
|
removed_domains: removedDomains.rowCount ?? 0,
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await pool.query('ROLLBACK');
|
await pool.query('ROLLBACK');
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ services:
|
|||||||
retries: 20
|
retries: 20
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
networks:
|
networks:
|
||||||
- mailadmin_network
|
- mailadmin_network
|
||||||
|
|
||||||
@@ -36,6 +38,8 @@ services:
|
|||||||
COOKIE_SECURE: "true"
|
COOKIE_SECURE: "true"
|
||||||
ADMIN_EMAIL: ${MAILADMIN_ADMIN_EMAIL:-admin@example.com}
|
ADMIN_EMAIL: ${MAILADMIN_ADMIN_EMAIL:-admin@example.com}
|
||||||
ADMIN_PASSWORD: ${MAILADMIN_ADMIN_PASSWORD:-ChangeMe123!}
|
ADMIN_PASSWORD: ${MAILADMIN_ADMIN_PASSWORD:-ChangeMe123!}
|
||||||
|
MAILADMIN_ADMIN_EMAIL: ${MAILADMIN_ADMIN_EMAIL}
|
||||||
|
MAILADMIN_ADMIN_PASSWORD: ${MAILADMIN_ADMIN_PASSWORD}
|
||||||
NODE_NAME: ${NODE_NAME:-node1}
|
NODE_NAME: ${NODE_NAME:-node1}
|
||||||
NODE_HOSTNAME: ${NODE_HOSTNAME:-node1.email-srvr.com}
|
NODE_HOSTNAME: ${NODE_HOSTNAME:-node1.email-srvr.com}
|
||||||
DMS_CONTAINER: ${DMS_CONTAINER:-mailserver}
|
DMS_CONTAINER: ${DMS_CONTAINER:-mailserver}
|
||||||
|
|||||||
10
frontend/.gitignore
vendored
Normal file
10
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
302
frontend/app.js
302
frontend/app.js
@@ -1,302 +0,0 @@
|
|||||||
const app = document.getElementById('app');
|
|
||||||
const state = { user: null, domains: [], selectedDomain: null, mailboxes: [], audit: [], message: '', error: '' };
|
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
|
||||||
const res = await fetch(path, { credentials: 'include', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options });
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(body.error || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
function bytes(n) {
|
|
||||||
n = Number(n || 0);
|
|
||||||
if (n <= 0) return '0 B';
|
|
||||||
const u = ['B','KB','MB','GB','TB']; let i = 0;
|
|
||||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
|
||||||
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function percent(n) {
|
|
||||||
if (n === null || n === undefined || Number.isNaN(Number(n))) return null;
|
|
||||||
const value = Number(n);
|
|
||||||
if (value < 1 && value > 0) return value.toFixed(1);
|
|
||||||
return value.toFixed(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function usageInfo(m) {
|
|
||||||
const used = Number(m.used_bytes || 0);
|
|
||||||
const limit = m.quota_bytes === null || m.quota_bytes === undefined ? null : Number(m.quota_bytes);
|
|
||||||
const calculated = limit && limit > 0 ? (used / limit) * 100 : null;
|
|
||||||
const rawPct = m.quota_percent === null || m.quota_percent === undefined ? calculated : Number(m.quota_percent);
|
|
||||||
const pct = rawPct === null || Number.isNaN(rawPct) ? null : Math.max(0, Math.min(100, rawPct));
|
|
||||||
|
|
||||||
return {
|
|
||||||
used,
|
|
||||||
limit,
|
|
||||||
pct,
|
|
||||||
label: limit ? `${bytes(used)} / ${bytes(limit)} (${percent(pct)}%)` : `${bytes(used)} / unlimited`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(s) { return String(s ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); }
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
try { state.user = await api('/api/auth/me'); await loadDomains(true); }
|
|
||||||
catch { state.user = null; }
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDomains(resync = false) {
|
|
||||||
state.domains = await api(`/api/domains${resync ? '?resync=true' : ''}`);
|
|
||||||
if (!state.selectedDomain && state.domains.length) state.selectedDomain = state.domains[0].domain;
|
|
||||||
if (state.selectedDomain) await loadMailboxes(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMailboxes(refreshQuota = false) {
|
|
||||||
if (!state.selectedDomain) {
|
|
||||||
state.mailboxes = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.mailboxes = await api(`/api/mailboxes?domain=${encodeURIComponent(state.selectedDomain)}${refreshQuota ? '&refreshQuota=true' : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAudit() {
|
|
||||||
state.audit = await api('/api/audit');
|
|
||||||
renderAuditModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderUsage(m) {
|
|
||||||
const usage = usageInfo(m);
|
|
||||||
const barWidth = usage.pct === null ? 0 : usage.pct;
|
|
||||||
return `
|
|
||||||
<div class="usage-cell">
|
|
||||||
<div class="usage-label">Disk Usage: ${esc(usage.label)}</div>
|
|
||||||
<div class="usage-bar" aria-label="Disk Usage ${esc(usage.label)}">
|
|
||||||
<div class="usage-bar-fill" style="width:${barWidth}%"></div>
|
|
||||||
</div>
|
|
||||||
<div class="muted">
|
|
||||||
${m.usage_scanned_at ? `quota checked ${new Date(m.usage_scanned_at).toLocaleString()}` : 'quota not checked yet'}
|
|
||||||
${m.message_count !== null && m.message_count !== undefined ? ` · ${Number(m.message_count)} messages` : ''}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
if (!state.user) return renderLogin();
|
|
||||||
app.innerHTML = `
|
|
||||||
<div class="header">
|
|
||||||
<div><div class="brand">MailAdmin</div><div class="muted">${esc(state.user.email)} · ${esc(state.user.role)}</div></div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="secondary" id="auditBtn">Audit Log</button>
|
|
||||||
<button class="secondary" id="resyncBtn">DMS Resync</button>
|
|
||||||
<button class="ghost" id="logoutBtn">Logout</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="container">
|
|
||||||
${state.error ? `<div class="error">${esc(state.error)}</div>` : ''}
|
|
||||||
${state.message ? `<div class="success">${esc(state.message)}</div>` : ''}
|
|
||||||
<div class="grid">
|
|
||||||
<section class="card">
|
|
||||||
<h2>Domains on this node</h2>
|
|
||||||
<div class="muted" style="margin-bottom:12px">Domains are discovered dynamically from DMS accounts.</div>
|
|
||||||
<div class="list">
|
|
||||||
${state.domains.map(d => `
|
|
||||||
<div class="list-item ${d.domain === state.selectedDomain ? 'active' : ''}" data-domain="${esc(d.domain)}">
|
|
||||||
<strong>${esc(d.domain)}</strong><br>
|
|
||||||
<span class="muted">${d.active_mailboxes || 0} inboxes · ${bytes(d.used_bytes)}</span><br>
|
|
||||||
<span class="pill">${esc(d.current_node)}</span> <span class="pill">${esc(d.status)}</span>
|
|
||||||
</div>`).join('') || '<div class="muted">No domains found yet.</div>'}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="card">
|
|
||||||
<div style="display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:14px">
|
|
||||||
<div><h2 style="margin:0">${esc(state.selectedDomain || 'Mailboxes')}</h2><div class="muted">Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.</div></div>
|
|
||||||
<div class="actions"><button id="newMailboxBtn">New mailbox</button></div>
|
|
||||||
</div>
|
|
||||||
<table class="table">
|
|
||||||
<thead><tr><th>Email</th><th>Status</th><th>Usage</th><th>Updated</th><th>Actions</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
${state.mailboxes.map(m => `
|
|
||||||
<tr>
|
|
||||||
<td><strong>${esc(m.email_address)}</strong><div class="muted">${esc(m.node_name)}</div></td>
|
|
||||||
<td><span class="pill">${esc(m.status)}</span></td>
|
|
||||||
<td>${renderUsage(m)}</td>
|
|
||||||
<td class="muted">${new Date(m.updated_at).toLocaleString()}</td>
|
|
||||||
<td><div class="actions">
|
|
||||||
<button class="secondary" data-rules="${esc(m.email_address)}">Rules</button>
|
|
||||||
<button class="secondary" data-blocks="${esc(m.email_address)}">Blocklist</button>
|
|
||||||
<button class="secondary" data-password="${esc(m.email_address)}">Password</button>
|
|
||||||
<button class="danger" data-delete="${esc(m.email_address)}">Delete</button>
|
|
||||||
</div></td>
|
|
||||||
</tr>`).join('') || '<tr><td colspan="5" class="muted">No mailboxes for this domain.</td></tr>'}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
document.getElementById('logoutBtn').onclick = async () => { await api('/api/auth/logout', { method:'POST' }); state.user = null; render(); };
|
|
||||||
document.getElementById('resyncBtn').onclick = guard(async () => { await api('/api/domains/resync', { method:'POST' }); await loadDomains(false); state.message = 'DMS sync completed.'; render(); });
|
|
||||||
document.getElementById('auditBtn').onclick = guard(loadAudit);
|
|
||||||
document.getElementById('newMailboxBtn').onclick = renderCreateMailboxModal;
|
|
||||||
document.querySelectorAll('[data-domain]').forEach(el => el.onclick = guard(async () => { state.selectedDomain = el.dataset.domain; await loadMailboxes(true); render(); }));
|
|
||||||
document.querySelectorAll('[data-delete]').forEach(el => el.onclick = () => renderDeleteModal(el.dataset.delete));
|
|
||||||
document.querySelectorAll('[data-password]').forEach(el => el.onclick = () => renderPasswordModal(el.dataset.password));
|
|
||||||
document.querySelectorAll('[data-rules]').forEach(el => el.onclick = () => renderRulesModal(el.dataset.rules));
|
|
||||||
document.querySelectorAll('[data-blocks]').forEach(el => el.onclick = () => renderBlocklistModal(el.dataset.blocks));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLogin() {
|
|
||||||
app.innerHTML = `<div class="login"><div class="card"><h2>MailAdmin Login</h2>${state.error ? `<div class="error">${esc(state.error)}</div>` : ''}<form id="loginForm"><label>Email<br><input name="email" type="email" required></label><br><br><label>Password<br><input name="password" type="password" required></label><br><br><button>Login</button></form></div></div>`;
|
|
||||||
document.getElementById('loginForm').onsubmit = guard(async e => {
|
|
||||||
e.preventDefault(); const f = new FormData(e.target);
|
|
||||||
state.user = await api('/api/auth/login', { method:'POST', body: JSON.stringify({ email: f.get('email'), password: f.get('password') }) });
|
|
||||||
await loadDomains(true); render();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function modal(html) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'modal-backdrop';
|
|
||||||
div.innerHTML = `<div class="card modal">${html}<div style="margin-top:14px"><button class="ghost" data-close>Close</button></div></div>`;
|
|
||||||
document.body.appendChild(div);
|
|
||||||
div.querySelector('[data-close]').onclick = () => div.remove();
|
|
||||||
div.onclick = e => { if (e.target === div) div.remove(); };
|
|
||||||
return div;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCreateMailboxModal() {
|
|
||||||
const domain = state.selectedDomain || '';
|
|
||||||
|
|
||||||
const d = modal(`
|
|
||||||
<h2>New mailbox</h2>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<label>
|
|
||||||
Email
|
|
||||||
<input
|
|
||||||
id="createEmail"
|
|
||||||
type="email"
|
|
||||||
value="@${esc(domain)}"
|
|
||||||
placeholder="@${esc(domain)}"
|
|
||||||
autocomplete="off"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input
|
|
||||||
id="createPassword"
|
|
||||||
type="password"
|
|
||||||
value=""
|
|
||||||
minlength="8"
|
|
||||||
autocomplete="new-password"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button type="button" id="createMailboxSubmit">Create</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
const emailInput = d.querySelector('#createEmail');
|
|
||||||
const passwordInput = d.querySelector('#createPassword');
|
|
||||||
const submitButton = d.querySelector('#createMailboxSubmit');
|
|
||||||
|
|
||||||
emailInput.focus();
|
|
||||||
|
|
||||||
// Cursor before the @domain, so you can directly type "test".
|
|
||||||
try {
|
|
||||||
emailInput.setSelectionRange(0, 0);
|
|
||||||
} catch {
|
|
||||||
// Some browsers do not allow selection on email inputs.
|
|
||||||
}
|
|
||||||
|
|
||||||
const createMailbox = guard(async () => {
|
|
||||||
const email = String(emailInput.value || '').trim().toLowerCase();
|
|
||||||
const password = String(passwordInput.value || '');
|
|
||||||
|
|
||||||
if (!email || !email.includes('@')) {
|
|
||||||
throw new Error('Please enter a valid email address.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!email.endsWith(`@${domain}`)) {
|
|
||||||
throw new Error(`Mailbox must belong to ${domain}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 8) {
|
|
||||||
throw new Error('Password must have at least 8 characters.');
|
|
||||||
}
|
|
||||||
|
|
||||||
submitButton.disabled = true;
|
|
||||||
submitButton.textContent = 'Creating...';
|
|
||||||
|
|
||||||
await api('/api/mailboxes', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
d.remove();
|
|
||||||
|
|
||||||
state.message = `Mailbox created: ${email}`;
|
|
||||||
|
|
||||||
await loadDomains(false);
|
|
||||||
await loadMailboxes(true);
|
|
||||||
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
|
|
||||||
submitButton.onclick = createMailbox;
|
|
||||||
|
|
||||||
passwordInput.addEventListener('keydown', (event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
createMailbox();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
emailInput.addEventListener('keydown', (event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
passwordInput.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDeleteModal(email) {
|
|
||||||
const d = modal(`<h2>Delete mailbox</h2><p>Delete <strong>${esc(email)}</strong> from DMS?</p><button class="danger" id="confirmDelete">Delete</button>`);
|
|
||||||
d.querySelector('#confirmDelete').onclick = guard(async () => { await api(`/api/mailboxes/${encodeURIComponent(email)}`, { method:'DELETE' }); d.remove(); await loadMailboxes(true); render(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPasswordModal(email) {
|
|
||||||
const d = modal(`<h2>Reset password</h2><form id="pwForm"><label>New password<input name="password" type="password" minlength="8" required></label><br><br><button>Update password</button></form>`);
|
|
||||||
d.querySelector('#pwForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/password`, { method:'POST', body: JSON.stringify({ password:f.get('password') }) }); d.remove(); state.message = `Password updated for ${email}.`; render(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderRulesModal(email) {
|
|
||||||
const rules = await api(`/api/mailboxes/${encodeURIComponent(email)}/rules`);
|
|
||||||
const d = modal(`<h2>Rules for ${esc(email)}</h2><form id="rulesForm"><label><input type="checkbox" name="ooo_active" ${rules.ooo_active ? 'checked' : ''} style="width:auto"> Auto reply active</label><br><br><label>Auto reply message<textarea name="ooo_message">${esc(rules.ooo_message || '')}</textarea></label><br><br><label>Forwards, one email per line<textarea name="forwards">${esc((rules.forwards || []).join('\n'))}</textarea></label><br><br><button>Save rules</button></form>`);
|
|
||||||
d.querySelector('#rulesForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/rules`, { method:'PUT', body: JSON.stringify({ ooo_active: !!f.get('ooo_active'), ooo_message:f.get('ooo_message'), forwards:String(f.get('forwards')||'').split(/\n|,/).map(x=>x.trim()).filter(Boolean) }) }); d.remove(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderBlocklistModal(email) {
|
|
||||||
const block = await api(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`);
|
|
||||||
const d = modal(`<h2>Blocklist for ${esc(email)}</h2><form id="blockForm"><label>Patterns, one per line<br><span class="muted">Examples: spam@example.com, *@bad-domain.com</span><textarea name="blocked_patterns">${esc((block.blocked_patterns || []).join('\n'))}</textarea></label><br><br><button>Save blocklist</button></form>`);
|
|
||||||
d.querySelector('#blockForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`, { method:'PUT', body: JSON.stringify({ blocked_patterns:String(f.get('blocked_patterns')||'').split('\n').map(x=>x.trim()).filter(Boolean) }) }); d.remove(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAuditModal() {
|
|
||||||
modal(`<h2>Audit Log</h2><table class="table"><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Target</th></tr></thead><tbody>${state.audit.map(a => `<tr><td class="muted">${new Date(a.created_at).toLocaleString()}</td><td>${esc(a.actor_email)}</td><td>${esc(a.action)}</td><td>${esc(a.target_id)}</td></tr>`).join('')}</tbody></table>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function guard(fn) { return async function(...args) { try { state.error=''; state.message=''; await fn.apply(this,args); } catch(e) { state.error = e.message; render(); } }; }
|
|
||||||
|
|
||||||
init();
|
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<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" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<title>MailAdmin</title>
|
<title>MailAdmin</title>
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/app.js"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "mailadmin-ui",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"description": "MailAdmin React UI",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host --port 3009",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"react-icons": "^4.12.0",
|
||||||
|
"recharts":"^2.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
507
frontend/src/App.jsx
Normal file
507
frontend/src/App.jsx
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
|
||||||
|
FiUsers, FiUser, FiHardDrive, FiDollarSign, FiActivity,
|
||||||
|
} from 'react-icons/fi';
|
||||||
|
|
||||||
|
import Login from './components/Login';
|
||||||
|
import Toast from './components/Toast';
|
||||||
|
import LoadingOverlay from './components/LoadingOverlay';
|
||||||
|
import UsageBar, { formatBytes } from './components/UsageBar';
|
||||||
|
import MailboxSettingsModal from './components/MailboxSettingsModal';
|
||||||
|
import NewMailboxModal from './components/NewMailboxModal';
|
||||||
|
import PasswordResetModal from './components/PasswordResetModal';
|
||||||
|
import ConfirmDialog from './components/ConfirmDialog';
|
||||||
|
import AuditLogModal from './components/AuditLogModal';
|
||||||
|
import AdminUsersModal from './components/AdminUsersModal';
|
||||||
|
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
|
||||||
|
import DomainQuotaModal from './components/DomainQuotaModal';
|
||||||
|
import BillingModal from './components/BillingModal';
|
||||||
|
import HealthModal from './components/HealthModal';
|
||||||
|
import HealthBanner from './components/HealthBanner';
|
||||||
|
|
||||||
|
import { authAPI, domainsAPI, mailboxesAPI, healthAPI } from './services/api';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [bootChecked, setBootChecked] = useState(false);
|
||||||
|
|
||||||
|
const [domains, setDomains] = useState([]);
|
||||||
|
const [selectedDomain, setSelectedDomain] = useState(null);
|
||||||
|
const [mailboxes, setMailboxes] = useState([]);
|
||||||
|
|
||||||
|
const [busyMessage, setBusyMessage] = useState('');
|
||||||
|
const [toast, setToast] = useState(null);
|
||||||
|
|
||||||
|
const [settingsTarget, setSettingsTarget] = useState(null);
|
||||||
|
const [pwTarget, setPwTarget] = useState(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const [showAudit, setShowAudit] = useState(false);
|
||||||
|
const [showAdmins, setShowAdmins] = useState(false);
|
||||||
|
const [showChangePw, setShowChangePw] = useState(false);
|
||||||
|
const [showDomainQuota, setShowDomainQuota] = useState(false);
|
||||||
|
const [showBilling, setShowBilling] = useState(false);
|
||||||
|
const [showHealth, setShowHealth] = useState(false);
|
||||||
|
|
||||||
|
// Persisted health status for the currently selected domain (drives the banner).
|
||||||
|
const [healthStatus, setHealthStatus] = useState(null);
|
||||||
|
|
||||||
|
const showToast = useCallback((message, type = 'success') => {
|
||||||
|
setToast({ message, type });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isSuperAdmin = user?.role === 'super_admin';
|
||||||
|
const hideDomainList = !isSuperAdmin && domains.length <= 1;
|
||||||
|
|
||||||
|
const loadDomains = useCallback(async (resync = false) => {
|
||||||
|
const list = await domainsAPI.list(resync);
|
||||||
|
setDomains(list);
|
||||||
|
return list;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMailboxes = useCallback(async (domain, refreshQuota = false) => {
|
||||||
|
if (!domain) { setMailboxes([]); return; }
|
||||||
|
const list = await mailboxesAPI.list(domain, refreshQuota);
|
||||||
|
setMailboxes(list);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load (or re-load) the persisted health status for a domain.
|
||||||
|
// Cheap call — just reads from PostgreSQL, no checks are run.
|
||||||
|
const loadHealthStatus = useCallback(async (domain) => {
|
||||||
|
if (!domain) { setHealthStatus(null); return; }
|
||||||
|
try {
|
||||||
|
const status = await healthAPI.getStatus(domain);
|
||||||
|
setHealthStatus(status); // null if never checked, that's fine
|
||||||
|
} catch (err) {
|
||||||
|
// Silent: don't block the UI just because health status load failed.
|
||||||
|
console.warn('Failed to load health status:', err);
|
||||||
|
setHealthStatus(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const me = await authAPI.me();
|
||||||
|
setUser(me);
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setBootChecked(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
(async () => {
|
||||||
|
setBusyMessage('Loading domains...');
|
||||||
|
try {
|
||||||
|
const list = await loadDomains(isSuperAdmin);
|
||||||
|
const first = list[0]?.domain || null;
|
||||||
|
setSelectedDomain(first);
|
||||||
|
if (first) {
|
||||||
|
setBusyMessage('Refreshing quotas...');
|
||||||
|
await loadMailboxes(first, true);
|
||||||
|
await loadHealthStatus(first);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Failed to load: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setBusyMessage('');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const selectDomain = async (domain) => {
|
||||||
|
if (domain === selectedDomain) return;
|
||||||
|
setSelectedDomain(domain);
|
||||||
|
setBusyMessage('Loading mailboxes...');
|
||||||
|
try {
|
||||||
|
await loadMailboxes(domain, true);
|
||||||
|
await loadHealthStatus(domain);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Failed to load mailboxes: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setBusyMessage('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResync = async () => {
|
||||||
|
setBusyMessage('Re-syncing from DMS...');
|
||||||
|
try {
|
||||||
|
await domainsAPI.resync();
|
||||||
|
const list = await loadDomains(false);
|
||||||
|
if (selectedDomain && !list.find((d) => d.domain === selectedDomain)) {
|
||||||
|
const first = list[0]?.domain || null;
|
||||||
|
setSelectedDomain(first);
|
||||||
|
if (first) {
|
||||||
|
await loadMailboxes(first, false);
|
||||||
|
await loadHealthStatus(first);
|
||||||
|
}
|
||||||
|
} else if (selectedDomain) {
|
||||||
|
await loadMailboxes(selectedDomain, false);
|
||||||
|
}
|
||||||
|
showToast('DMS sync complete', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Sync failed: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setBusyMessage('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try { await authAPI.logout(); } catch { /* ignore */ }
|
||||||
|
setUser(null);
|
||||||
|
setDomains([]);
|
||||||
|
setMailboxes([]);
|
||||||
|
setSelectedDomain(null);
|
||||||
|
setHealthStatus(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await mailboxesAPI.remove(deleteTarget);
|
||||||
|
showToast(`Deleted ${deleteTarget}`, 'success');
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await loadMailboxes(selectedDomain, false);
|
||||||
|
await loadDomains(false);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Delete failed: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMailboxCreated = async () => {
|
||||||
|
setBusyMessage('Refreshing...');
|
||||||
|
try {
|
||||||
|
await loadDomains(false);
|
||||||
|
await loadMailboxes(selectedDomain, true);
|
||||||
|
} finally {
|
||||||
|
setBusyMessage('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuotaApplied = async () => {
|
||||||
|
setBusyMessage('Refreshing quotas...');
|
||||||
|
try {
|
||||||
|
await loadMailboxes(selectedDomain, true);
|
||||||
|
} finally {
|
||||||
|
setBusyMessage('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Called by HealthModal after a fresh check completes — re-load the
|
||||||
|
// persisted summary so the banner reflects the new state.
|
||||||
|
const handleHealthChecked = async () => {
|
||||||
|
if (selectedDomain) await loadHealthStatus(selectedDomain);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!bootChecked) {
|
||||||
|
return <div className="min-h-screen flex items-center justify-center text-gray-400">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Login onLogin={setUser} />
|
||||||
|
{toast && <Toast {...toast} onClose={() => setToast(null)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">MailAdmin</h1>
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{user.email} · <span className="font-medium">{user.role}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||||
|
<button onClick={() => setShowChangePw(true)} className="btn-secondary" title="Change my password">
|
||||||
|
<FiUser className="w-4 h-4 mr-2" />
|
||||||
|
My Password
|
||||||
|
</button>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setShowBilling(true)} className="btn-secondary">
|
||||||
|
<FiDollarSign className="w-4 h-4 mr-2" />
|
||||||
|
Billing
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowAudit(true)} className="btn-secondary">
|
||||||
|
<FiList className="w-4 h-4 mr-2" />
|
||||||
|
Audit Log
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowAdmins(true)} className="btn-secondary">
|
||||||
|
<FiUsers className="w-4 h-4 mr-2" />
|
||||||
|
Admins
|
||||||
|
</button>
|
||||||
|
<button onClick={handleResync} className="btn-secondary">
|
||||||
|
<FiRefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
DMS Resync
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button onClick={handleLogout} className="btn-ghost">
|
||||||
|
<FiLogOut className="w-4 h-4 mr-2" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-6 py-6">
|
||||||
|
<div className={`grid grid-cols-1 ${hideDomainList ? '' : 'lg:grid-cols-[320px_1fr]'} gap-6 items-start`}>
|
||||||
|
{!hideDomainList && (
|
||||||
|
<section className="card">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900">Domains on this node</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-1 mb-4">
|
||||||
|
{isSuperAdmin
|
||||||
|
? 'Domains are discovered dynamically from DMS accounts.'
|
||||||
|
: 'Your assigned domains.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{domains.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No domains available.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{domains.map((d) => {
|
||||||
|
const active = d.domain === selectedDomain;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.domain}
|
||||||
|
onClick={() => selectDomain(d.domain)}
|
||||||
|
className={`text-left p-3 rounded-lg border transition-colors ${
|
||||||
|
active
|
||||||
|
? 'border-primary-500 bg-primary-50'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-gray-900">{d.domain}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{d.active_mailboxes || 0} inboxes · {formatBytes(d.used_bytes)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
<span className="pill">{d.current_node || d.node_name}</span>
|
||||||
|
<span className={d.status === 'active' ? 'pill-success' : 'pill'}>
|
||||||
|
{d.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="card relative">
|
||||||
|
{/* Health banner (shows only when last check found problems) */}
|
||||||
|
<HealthBanner status={healthStatus} onOpen={() => setShowHealth(true)} />
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-5 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-gray-900">
|
||||||
|
{selectedDomain || 'Mailboxes'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Create/delete mailboxes, reset passwords, edit rules. Quotas are refreshed when you open a domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHealth(true)}
|
||||||
|
disabled={!selectedDomain}
|
||||||
|
className="btn-secondary"
|
||||||
|
title="Run DNS, DMS and TLS cert checks for this domain"
|
||||||
|
>
|
||||||
|
<FiActivity className="w-4 h-4 mr-2" />
|
||||||
|
Check health
|
||||||
|
</button>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDomainQuota(true)}
|
||||||
|
disabled={!selectedDomain || mailboxes.length === 0}
|
||||||
|
className="btn-secondary"
|
||||||
|
title="Set quota for all mailboxes in this domain"
|
||||||
|
>
|
||||||
|
<FiHardDrive className="w-4 h-4 mr-2" />
|
||||||
|
Set quota
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNew(true)}
|
||||||
|
disabled={!selectedDomain}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
<FiPlus className="w-4 h-4 mr-2" />
|
||||||
|
New mailbox
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mailboxes.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg">
|
||||||
|
<FiInbox className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-600 font-medium">No mailboxes for this domain</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{selectedDomain ? 'Click "New mailbox" to create the first one.' : 'Select a domain first.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
|
||||||
|
<th className="py-3 pr-4 font-semibold">Email</th>
|
||||||
|
<th className="py-3 pr-4 font-semibold">Status</th>
|
||||||
|
<th className="py-3 pr-4 font-semibold">Usage</th>
|
||||||
|
<th className="py-3 pr-4 font-semibold whitespace-nowrap">Updated</th>
|
||||||
|
<th className="py-3 pr-2 font-semibold">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{mailboxes.map((m) => (
|
||||||
|
<tr key={m.email_address} className="align-top hover:bg-gray-50">
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<div className="font-semibold text-gray-900">{m.email_address}</div>
|
||||||
|
<div className="text-xs text-gray-500">{m.node_name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<span className={m.status === 'active' ? 'pill-success' : 'pill'}>
|
||||||
|
{m.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4"><UsageBar mailbox={m} /></td>
|
||||||
|
<td className="py-3 pr-4 text-xs text-gray-500 whitespace-nowrap">
|
||||||
|
{new Date(m.updated_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-2">
|
||||||
|
<div className="flex flex-row flex-nowrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSettingsTarget({ email: m.email_address, tab: 'fwd' })}
|
||||||
|
className="btn-secondary"
|
||||||
|
title="Forwarding & Auto-Reply"
|
||||||
|
>
|
||||||
|
<FiSettings className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPwTarget(m.email_address)}
|
||||||
|
className="btn-secondary"
|
||||||
|
title="Reset password"
|
||||||
|
>
|
||||||
|
<FiKey className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
Password
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(m.email_address)}
|
||||||
|
className="btn-danger"
|
||||||
|
title="Delete mailbox"
|
||||||
|
>
|
||||||
|
<FiTrash2 className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<MailboxSettingsModal
|
||||||
|
open={!!settingsTarget}
|
||||||
|
email={settingsTarget?.email}
|
||||||
|
initialTab={settingsTarget?.tab}
|
||||||
|
onClose={() => setSettingsTarget(null)}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
<NewMailboxModal
|
||||||
|
open={showNew}
|
||||||
|
domain={selectedDomain}
|
||||||
|
onClose={() => setShowNew(false)}
|
||||||
|
onCreated={handleMailboxCreated}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
<PasswordResetModal
|
||||||
|
open={!!pwTarget}
|
||||||
|
email={pwTarget}
|
||||||
|
onClose={() => setPwTarget(null)}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
title="Delete mailbox"
|
||||||
|
message={
|
||||||
|
deleteTarget
|
||||||
|
? `Delete ${deleteTarget} from DMS? This cannot be undone.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
danger
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<AuditLogModal
|
||||||
|
open={showAudit}
|
||||||
|
onClose={() => setShowAudit(false)}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<AdminUsersModal
|
||||||
|
open={showAdmins}
|
||||||
|
currentUser={user}
|
||||||
|
onClose={() => setShowAdmins(false)}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<DomainQuotaModal
|
||||||
|
open={showDomainQuota}
|
||||||
|
domain={selectedDomain}
|
||||||
|
mailboxes={mailboxes}
|
||||||
|
onClose={() => setShowDomainQuota(false)}
|
||||||
|
onApplied={handleQuotaApplied}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<BillingModal
|
||||||
|
open={showBilling}
|
||||||
|
domains={domains}
|
||||||
|
onClose={() => setShowBilling(false)}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<HealthModal
|
||||||
|
open={showHealth}
|
||||||
|
domain={selectedDomain}
|
||||||
|
onClose={() => setShowHealth(false)}
|
||||||
|
onCheckedReport={handleHealthChecked}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
<ChangeMyPasswordModal
|
||||||
|
open={showChangePw}
|
||||||
|
onClose={() => setShowChangePw(false)}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{busyMessage && <LoadingOverlay message={busyMessage} fullscreen />}
|
||||||
|
{toast && <Toast {...toast} onClose={() => setToast(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
494
frontend/src/components/AdminUsersModal.jsx
Normal file
494
frontend/src/components/AdminUsersModal.jsx
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { FiPlus, FiTrash2, FiEdit2, FiUser, FiShield, FiArrowLeft } from 'react-icons/fi';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import LoadingOverlay from './LoadingOverlay';
|
||||||
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
|
import { adminsAPI, domainsAPI } from '../services/api';
|
||||||
|
|
||||||
|
const AdminUsersModal = ({ open, currentUser, onClose, onToast }) => {
|
||||||
|
const [view, setView] = useState('list');
|
||||||
|
const [admins, setAdmins] = useState([]);
|
||||||
|
const [domains, setDomains] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editTarget, setEditTarget] = useState(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
|
||||||
|
const reload = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [a, d] = await Promise.all([
|
||||||
|
adminsAPI.list(),
|
||||||
|
domainsAPI.list(false),
|
||||||
|
]);
|
||||||
|
setAdmins(a);
|
||||||
|
setDomains(d);
|
||||||
|
} catch (err) {
|
||||||
|
onToast?.(`Failed to load admins: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setView('list');
|
||||||
|
setEditTarget(null);
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await adminsAPI.remove(deleteTarget);
|
||||||
|
onToast?.(`Deleted ${deleteTarget}`, 'success');
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
onToast?.(`Delete failed: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={view === 'list' ? 'Manage admins' : (editTarget ? 'Edit admin' : 'New admin')}
|
||||||
|
subtitle={view === 'list' ? 'Super admins and per-domain admins' : null}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="relative min-h-[300px]">
|
||||||
|
{loading && <LoadingOverlay message="Loading..." />}
|
||||||
|
|
||||||
|
{view === 'list' && !loading && (
|
||||||
|
<AdminList
|
||||||
|
admins={admins}
|
||||||
|
currentUserEmail={currentUser?.email}
|
||||||
|
onCreate={() => { setEditTarget(null); setView('form'); }}
|
||||||
|
onEdit={(a) => { setEditTarget(a); setView('form'); }}
|
||||||
|
onDelete={(email) => setDeleteTarget(email)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'form' && !loading && (
|
||||||
|
<AdminForm
|
||||||
|
target={editTarget}
|
||||||
|
domains={domains}
|
||||||
|
currentUserEmail={currentUser?.email}
|
||||||
|
onCancel={() => { setView('list'); setEditTarget(null); }}
|
||||||
|
onSaved={async () => {
|
||||||
|
await reload();
|
||||||
|
setView('list');
|
||||||
|
setEditTarget(null);
|
||||||
|
}}
|
||||||
|
onToast={onToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
title="Delete admin"
|
||||||
|
message={deleteTarget ? `Permanently delete admin ${deleteTarget}?` : ''}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
danger
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sub: AdminList
|
||||||
|
// ============================================================
|
||||||
|
const AdminList = ({ admins, currentUserEmail, onCreate, onEdit, onDelete }) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button onClick={onCreate} className="btn-primary">
|
||||||
|
<FiPlus className="w-4 h-4 mr-2" />
|
||||||
|
New admin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{admins.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-8">No admins yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
|
||||||
|
<th className="py-2 pr-4 font-semibold">Email</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Role</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Domains</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Status</th>
|
||||||
|
<th className="py-2 pr-2 font-semibold">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{admins.map((a) => {
|
||||||
|
const isMe = a.email === currentUserEmail;
|
||||||
|
return (
|
||||||
|
<tr key={a.email} className="align-top hover:bg-gray-50">
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{a.role === 'super_admin'
|
||||||
|
? <FiShield className="w-4 h-4 text-primary-600" />
|
||||||
|
: <FiUser className="w-4 h-4 text-gray-400" />}
|
||||||
|
<span className="font-semibold text-gray-900">{a.email}</span>
|
||||||
|
{isMe && <span className="pill">you</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
{a.role === 'super_admin'
|
||||||
|
? <span className="pill-success">super_admin</span>
|
||||||
|
: <span className="pill">domain_admin</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
{a.role === 'super_admin' ? (
|
||||||
|
<span className="text-xs text-gray-400 italic">all domains</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(a.allowed_domains || []).map((d) => (
|
||||||
|
<span key={d} className="pill">{d}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4">
|
||||||
|
<span className={a.active ? 'pill-success' : 'pill-warn'}>
|
||||||
|
{a.active ? 'active' : 'disabled'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-2">
|
||||||
|
<div className="flex flex-row flex-nowrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(a)}
|
||||||
|
className="btn-secondary"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<FiEdit2 className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(a.email)}
|
||||||
|
className="btn-danger"
|
||||||
|
disabled={isMe}
|
||||||
|
title={isMe ? "You can't delete yourself" : 'Delete'}
|
||||||
|
>
|
||||||
|
<FiTrash2 className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sub: AdminForm
|
||||||
|
// ============================================================
|
||||||
|
const AdminForm = ({ target, domains, currentUserEmail, onCancel, onSaved, onToast }) => {
|
||||||
|
const isEdit = !!target;
|
||||||
|
const isSelf = isEdit && target.email === currentUserEmail;
|
||||||
|
|
||||||
|
const [email, setEmail] = useState(target?.email || '');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPw, setConfirmPw] = useState('');
|
||||||
|
const [role, setRole] = useState(target?.role || 'domain_admin');
|
||||||
|
const [allowedDomains, setAllowedDomains] = useState(
|
||||||
|
new Set((target?.allowed_domains || []).map((d) => d.toLowerCase()))
|
||||||
|
);
|
||||||
|
const [active, setActive] = useState(target?.active ?? true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const [emailTouched, setEmailTouched] = useState(isEdit);
|
||||||
|
const emailRef = useRef(null);
|
||||||
|
|
||||||
|
const sortedDomainNames = useMemo(
|
||||||
|
() => [...domains].map((d) => d.domain).sort(),
|
||||||
|
[domains]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-fill email with "@<domain>" when creating a NEW domain_admin
|
||||||
|
// and exactly one domain is selected.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit) return;
|
||||||
|
if (role !== 'domain_admin') return;
|
||||||
|
if (emailTouched) return;
|
||||||
|
if (allowedDomains.size === 1) {
|
||||||
|
const [onlyDomain] = [...allowedDomains];
|
||||||
|
setEmail(`@${onlyDomain}`);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = emailRef.current;
|
||||||
|
if (el && document.activeElement === el) {
|
||||||
|
try { el.setSelectionRange(0, 0); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (allowedDomains.size === 0) {
|
||||||
|
setEmail('');
|
||||||
|
}
|
||||||
|
}, [allowedDomains, role, isEdit, emailTouched]);
|
||||||
|
|
||||||
|
const handleEmailFocus = () => {
|
||||||
|
const el = emailRef.current;
|
||||||
|
if (el && !emailTouched && email.startsWith('@')) {
|
||||||
|
try { el.setSelectionRange(0, 0); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDomain = (d) => {
|
||||||
|
const next = new Set(allowedDomains);
|
||||||
|
if (next.has(d)) next.delete(d); else next.add(d);
|
||||||
|
setAllowedDomains(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordOk = !password || (password.length >= 8 && password === confirmPw);
|
||||||
|
const passwordMismatch = password.length > 0 && confirmPw.length > 0 && password !== confirmPw;
|
||||||
|
const passwordTooShort = password.length > 0 && password.length < 8;
|
||||||
|
|
||||||
|
const passwordRequired = !isEdit;
|
||||||
|
const passwordProvided = password.length > 0;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!email.trim() || !email.includes('@')) {
|
||||||
|
setError('Please enter a valid email.'); return;
|
||||||
|
}
|
||||||
|
if (passwordRequired && !passwordProvided) {
|
||||||
|
setError('Password is required for new admins.'); return;
|
||||||
|
}
|
||||||
|
if (passwordProvided && !passwordOk) {
|
||||||
|
if (passwordTooShort) setError('Password must have at least 8 characters.');
|
||||||
|
else if (passwordMismatch) setError('Passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (role === 'domain_admin' && allowedDomains.size === 0) {
|
||||||
|
setError('A domain admin needs at least one allowed domain.'); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const allowedList = role === 'super_admin' ? [] : [...allowedDomains];
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
const payload = { role, allowed_domains: allowedList, active };
|
||||||
|
if (passwordProvided) payload.password = password;
|
||||||
|
if (isSelf) {
|
||||||
|
delete payload.role;
|
||||||
|
delete payload.active;
|
||||||
|
}
|
||||||
|
await adminsAPI.update(target.email, payload);
|
||||||
|
onToast?.(`Updated ${target.email}`, 'success');
|
||||||
|
} else {
|
||||||
|
await adminsAPI.create({
|
||||||
|
email: email.trim().toLowerCase(),
|
||||||
|
password,
|
||||||
|
role,
|
||||||
|
allowed_domains: allowedList,
|
||||||
|
});
|
||||||
|
onToast?.(`Created ${email.trim().toLowerCase()}`, 'success');
|
||||||
|
}
|
||||||
|
onSaved();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Wrap fields in a "fake form" with autoComplete="off" so that
|
||||||
|
// browsers don't autofill the user's own login credentials into the
|
||||||
|
// new-admin form. We also use uncommon name= values and a hidden
|
||||||
|
// dummy field as a decoy — Chromium ignores autoComplete="off" on
|
||||||
|
// login-shaped inputs unless tricks like these are used.
|
||||||
|
<div className="space-y-5">
|
||||||
|
<button onClick={onCancel} className="btn-ghost -ml-2">
|
||||||
|
<FiArrowLeft className="w-4 h-4 mr-1.5" />
|
||||||
|
Back to list
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Decoy fields to absorb Chrome's autofill attempts. */}
|
||||||
|
<div style={{ display: 'none' }} aria-hidden="true">
|
||||||
|
<input type="text" name="username" autoComplete="username" tabIndex={-1} />
|
||||||
|
<input type="password" name="password" autoComplete="current-password" tabIndex={-1} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
|
||||||
|
<input
|
||||||
|
ref={emailRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="email"
|
||||||
|
name="admin_create_email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => { setEmail(e.target.value); setEmailTouched(true); }}
|
||||||
|
onFocus={handleEmailFocus}
|
||||||
|
className="input-field"
|
||||||
|
disabled={isEdit}
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
spellCheck="false"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
/>
|
||||||
|
{isEdit && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Email cannot be changed after creation.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
{isEdit ? 'Set new password (leave empty to keep current)' : 'Password'}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="admin_create_pw_new"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className={`input-field ${passwordTooShort ? 'border-red-500' : ''}`}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
autoComplete="new-password"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="admin_create_pw_confirm"
|
||||||
|
value={confirmPw}
|
||||||
|
onChange={(e) => setConfirmPw(e.target.value)}
|
||||||
|
className={`input-field ${passwordMismatch ? 'border-red-500' : ''}`}
|
||||||
|
placeholder="Confirm"
|
||||||
|
autoComplete="new-password"
|
||||||
|
data-form-type="other"
|
||||||
|
data-lpignore="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{passwordTooShort && <p className="mt-1 text-xs text-red-600">Minimum 8 characters.</p>}
|
||||||
|
{!passwordTooShort && passwordMismatch && (
|
||||||
|
<p className="mt-1 text-xs text-red-600">Passwords do not match.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">Role</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRole('domain_admin')}
|
||||||
|
disabled={isSelf}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-lg border-2 transition-all ${
|
||||||
|
role === 'domain_admin'
|
||||||
|
? 'border-primary-600 bg-primary-50 text-primary-700 font-semibold'
|
||||||
|
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||||
|
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
<FiUser className="inline w-4 h-4 mr-2" />
|
||||||
|
Domain admin
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRole('super_admin')}
|
||||||
|
disabled={isSelf}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-lg border-2 transition-all ${
|
||||||
|
role === 'super_admin'
|
||||||
|
? 'border-primary-600 bg-primary-50 text-primary-700 font-semibold'
|
||||||
|
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||||
|
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
<FiShield className="inline w-4 h-4 mr-2" />
|
||||||
|
Super admin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isSelf && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">You can't change your own role.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{role === 'domain_admin' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Allowed domains ({allowedDomains.size}/{sortedDomainNames.length})
|
||||||
|
</label>
|
||||||
|
{sortedDomainNames.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 italic">No domains in the system yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="border border-gray-200 rounded-lg max-h-48 overflow-y-auto custom-scrollbar p-2 grid grid-cols-2 gap-1">
|
||||||
|
{sortedDomainNames.map((d) => (
|
||||||
|
<label
|
||||||
|
key={d}
|
||||||
|
className="flex items-center gap-2 p-2 rounded hover:bg-gray-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allowedDomains.has(d)}
|
||||||
|
onChange={() => toggleDomain(d)}
|
||||||
|
className="w-4 h-4 text-primary-600 rounded border-gray-300 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-800">{d}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isEdit && allowedDomains.size === 1 && !emailTouched && (
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
Email pre-filled with the selected domain. You can still edit it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEdit && !isSelf && (
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm">Account active</h3>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
Disabled accounts cannot log in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActive(!active)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
active ? 'bg-primary-600' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
active ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200">
|
||||||
|
<button onClick={onCancel} className="btn-secondary px-4 py-2" disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onClick={submit} className="btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Saving...' : (isEdit ? 'Save changes' : 'Create admin')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminUsersModal;
|
||||||
65
frontend/src/components/AuditLogModal.jsx
Normal file
65
frontend/src/components/AuditLogModal.jsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import LoadingOverlay from './LoadingOverlay';
|
||||||
|
import { auditAPI } from '../services/api';
|
||||||
|
|
||||||
|
const AuditLogModal = ({ open, onClose, onToast }) => {
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await auditAPI.list();
|
||||||
|
if (!cancelled) setRows(data || []);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) onToast?.(`Failed to load audit log: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [open, onToast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Audit Log" subtitle="Most recent 200 actions" size="lg">
|
||||||
|
<div className="relative min-h-[300px]">
|
||||||
|
{loading && <LoadingOverlay message="Loading..." />}
|
||||||
|
{!loading && rows.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-12">No audit entries yet.</p>
|
||||||
|
)}
|
||||||
|
{!loading && rows.length > 0 && (
|
||||||
|
<div className="overflow-x-auto max-h-[60vh] custom-scrollbar">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Time</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Actor</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Action</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Target</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{rows.map((a) => (
|
||||||
|
<tr key={a.id} className="hover:bg-gray-50">
|
||||||
|
<td className="py-2 pr-4 text-gray-500 whitespace-nowrap">
|
||||||
|
{new Date(a.created_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-gray-900">{a.actor_email}</td>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs text-gray-700">{a.action}</td>
|
||||||
|
<td className="py-2 pr-4 text-gray-700">{a.target_id}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuditLogModal;
|
||||||
590
frontend/src/components/BillingModal.jsx
Normal file
590
frontend/src/components/BillingModal.jsx
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
FiDollarSign, FiList, FiPlus, FiMinus, FiInbox, FiSend,
|
||||||
|
FiAlertCircle, FiAlertTriangle, FiArrowLeft, FiArrowRight,
|
||||||
|
} from 'react-icons/fi';
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||||
|
} from 'recharts';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import LoadingOverlay from './LoadingOverlay';
|
||||||
|
import { billingAPI } from '../services/api';
|
||||||
|
|
||||||
|
const MONTH_NAMES = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
];
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'summary', label: 'Monthly summary', icon: FiDollarSign },
|
||||||
|
{ id: 'volume', label: 'SES volume', icon: FiSend },
|
||||||
|
{ id: 'events', label: 'Event log', icon: FiList },
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentYm = () => {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
const previousYm = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
||||||
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
const ymLabel = (ym) => {
|
||||||
|
const [y, m] = ym.split('-').map(Number);
|
||||||
|
return `${MONTH_NAMES[m - 1]} ${y}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BillingModal = ({ open, domains, onClose, onToast }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState('summary');
|
||||||
|
const [domainFilter, setDomainFilter] = useState(''); // for summary/events
|
||||||
|
const [ymFilter, setYmFilter] = useState(currentYm()); // for volume
|
||||||
|
const [summary, setSummary] = useState(null);
|
||||||
|
const [events, setEvents] = useState([]);
|
||||||
|
|
||||||
|
// Volume tab state: overview vs drilldown
|
||||||
|
const [overview, setOverview] = useState(null);
|
||||||
|
const [drilldownDomain, setDrilldownDomain] = useState(null);
|
||||||
|
const [drilldownData, setDrilldownData] = useState(null);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const sortedDomains = useMemo(
|
||||||
|
() => [...domains].map((d) => d.domain).sort(),
|
||||||
|
[domains]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Volume tab is restricted to current + previous month (per product decision).
|
||||||
|
const ymOptions = useMemo(() => ([
|
||||||
|
{ ym: currentYm(), label: ymLabel(currentYm()) },
|
||||||
|
{ ym: previousYm(), label: ymLabel(previousYm()) },
|
||||||
|
]), []);
|
||||||
|
|
||||||
|
const reloadSummary = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setSummary(await billingAPI.summary(domainFilter || undefined));
|
||||||
|
} catch (err) { onToast?.(`Failed to load: ${err.message}`, 'error'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
const reloadEvents = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setEvents(await billingAPI.events({ domain: domainFilter || undefined, limit: 500 }));
|
||||||
|
} catch (err) { onToast?.(`Failed to load: ${err.message}`, 'error'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
const reloadOverview = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setOverview(await billingAPI.volumeOverview({ ym: ymFilter }));
|
||||||
|
} catch (err) { onToast?.(`Failed to load: ${err.message}`, 'error'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
const reloadDrilldown = async () => {
|
||||||
|
if (!drilldownDomain) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setDrilldownData(await billingAPI.volume({ domain: drilldownDomain, ym: ymFilter }));
|
||||||
|
} catch (err) { onToast?.(`Failed to load: ${err.message}`, 'error'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset everything when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setActiveTab('summary');
|
||||||
|
setDomainFilter('');
|
||||||
|
setYmFilter(currentYm());
|
||||||
|
setSummary(null);
|
||||||
|
setEvents([]);
|
||||||
|
setOverview(null);
|
||||||
|
setDrilldownDomain(null);
|
||||||
|
setDrilldownData(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Load data depending on which view is active
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (activeTab === 'summary') reloadSummary();
|
||||||
|
else if (activeTab === 'events') reloadEvents();
|
||||||
|
else if (activeTab === 'volume') {
|
||||||
|
if (drilldownDomain) reloadDrilldown();
|
||||||
|
else reloadOverview();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, activeTab, domainFilter, ymFilter, drilldownDomain]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Inbox billing"
|
||||||
|
subtitle="$5 per active inbox per month · SES outbound volume"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="relative min-h-[400px]">
|
||||||
|
<div className="border-b border-gray-200 mb-4">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TABS.map((t) => {
|
||||||
|
const Icon = t.icon;
|
||||||
|
const isActive = activeTab === t.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab(t.id);
|
||||||
|
// Always reset drilldown when switching tabs.
|
||||||
|
setDrilldownDomain(null);
|
||||||
|
setDrilldownData(null);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'border-b-2 border-primary-600 text-primary-700 -mb-px'
|
||||||
|
: 'border-b-2 border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||||
|
{(activeTab === 'summary' || activeTab === 'events') && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm font-semibold text-gray-700">Domain:</label>
|
||||||
|
<select
|
||||||
|
value={domainFilter}
|
||||||
|
onChange={(e) => setDomainFilter(e.target.value)}
|
||||||
|
className="input-field max-w-xs py-2"
|
||||||
|
>
|
||||||
|
<option value="">All domains</option>
|
||||||
|
{sortedDomains.map((d) => (
|
||||||
|
<option key={d} value={d}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'volume' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm font-semibold text-gray-700">Month:</label>
|
||||||
|
<select
|
||||||
|
value={ymFilter}
|
||||||
|
onChange={(e) => setYmFilter(e.target.value)}
|
||||||
|
className="input-field max-w-xs py-2"
|
||||||
|
>
|
||||||
|
{ymOptions.map((o) => (
|
||||||
|
<option key={o.ym} value={o.ym}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <LoadingOverlay message="Loading..." />}
|
||||||
|
|
||||||
|
{!loading && activeTab === 'summary' && <SummaryView summary={summary} />}
|
||||||
|
{!loading && activeTab === 'events' && <EventsView events={events} />}
|
||||||
|
|
||||||
|
{!loading && activeTab === 'volume' && !drilldownDomain && (
|
||||||
|
<OverviewView
|
||||||
|
overview={overview}
|
||||||
|
ymFilter={ymFilter}
|
||||||
|
onPickDomain={(d) => setDrilldownDomain(d)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!loading && activeTab === 'volume' && drilldownDomain && (
|
||||||
|
<DrilldownView
|
||||||
|
volume={drilldownData}
|
||||||
|
domain={drilldownDomain}
|
||||||
|
ymFilter={ymFilter}
|
||||||
|
onBack={() => { setDrilldownDomain(null); setDrilldownData(null); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sub: Monthly summary (unchanged)
|
||||||
|
// ============================================================
|
||||||
|
const SummaryView = ({ summary }) => {
|
||||||
|
if (!summary) return null;
|
||||||
|
const { months, price_per_inbox } = summary;
|
||||||
|
if (!months || months.length === 0) {
|
||||||
|
return <p className="text-sm text-gray-500 text-center py-12">No billable activity yet.</p>;
|
||||||
|
}
|
||||||
|
const byYm = new Map();
|
||||||
|
for (const row of months) {
|
||||||
|
if (!byYm.has(row.ym)) byYm.set(row.ym, []);
|
||||||
|
byYm.get(row.ym).push(row);
|
||||||
|
}
|
||||||
|
const sortedYms = [...byYm.keys()].sort().reverse();
|
||||||
|
const latestYm = sortedYms[0];
|
||||||
|
const latestRows = byYm.get(latestYm) || [];
|
||||||
|
const latestTotalInboxes = latestRows.reduce((s, r) => s + r.inbox_count, 0);
|
||||||
|
const latestTotalUsd = latestRows.reduce((s, r) => s + r.amount_usd, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{latestYm && (
|
||||||
|
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-primary-700 font-semibold">
|
||||||
|
{ymLabel(latestYm)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-baseline gap-3">
|
||||||
|
<span className="text-3xl font-bold text-gray-900">${latestTotalUsd}</span>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
({latestTotalInboxes} inbox{latestTotalInboxes !== 1 ? 'es' : ''} × ${price_per_inbox})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{sortedYms.map((ym) => {
|
||||||
|
const rows = byYm.get(ym);
|
||||||
|
const monthTotalInboxes = rows.reduce((s, r) => s + r.inbox_count, 0);
|
||||||
|
const monthTotalUsd = rows.reduce((s, r) => s + r.amount_usd, 0);
|
||||||
|
return (
|
||||||
|
<div key={ym}>
|
||||||
|
<div className="flex items-baseline justify-between mb-2 border-b border-gray-200 pb-1">
|
||||||
|
<h3 className="text-sm font-bold text-gray-800">{ymLabel(ym)}</h3>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
<span className="font-mono">{monthTotalInboxes}</span> inboxes ·{' '}
|
||||||
|
<span className="font-bold text-gray-900">${monthTotalUsd}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{rows
|
||||||
|
.sort((a, b) => b.amount_usd - a.amount_usd || a.domain.localeCompare(b.domain))
|
||||||
|
.map((r) => (
|
||||||
|
<tr key={`${r.ym}-${r.domain}`} className="hover:bg-gray-50">
|
||||||
|
<td className="py-2 pr-3"><span className="font-medium text-gray-900">{r.domain}</span></td>
|
||||||
|
<td className="py-2 pr-3 text-gray-600 text-xs whitespace-nowrap">
|
||||||
|
<FiInbox className="inline w-3 h-3 mr-1 mb-0.5" />
|
||||||
|
{r.inbox_count} inbox{r.inbox_count !== 1 ? 'es' : ''}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-right font-mono font-semibold text-gray-900 whitespace-nowrap">
|
||||||
|
${r.amount_usd}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sub: Event log (unchanged)
|
||||||
|
// ============================================================
|
||||||
|
const EventsView = ({ events }) => {
|
||||||
|
if (!events || events.length === 0) {
|
||||||
|
return <p className="text-sm text-gray-500 text-center py-12">No events yet.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto max-h-[60vh] custom-scrollbar">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200 sticky top-0 bg-white">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 pr-4 font-semibold">When</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Action</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Mailbox</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Domain</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold">Actor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{events.map((ev) => (
|
||||||
|
<tr key={ev.id} className="hover:bg-gray-50 align-top">
|
||||||
|
<td className="py-2 pr-4 text-gray-500 whitespace-nowrap">
|
||||||
|
{new Date(ev.occurred_at).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
{ev.action === 'created' ? (
|
||||||
|
<span className="inline-flex items-center gap-1 pill-success">
|
||||||
|
<FiPlus className="w-3 h-3" /> created
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 pill-warn">
|
||||||
|
<FiMinus className="w-3 h-3" /> deleted
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs text-gray-800">{ev.email}</td>
|
||||||
|
<td className="py-2 pr-4 text-gray-700">{ev.domain}</td>
|
||||||
|
<td className="py-2 pr-4 text-gray-500 text-xs">
|
||||||
|
{ev.actor_email || (ev.notes ? <em>{ev.notes}</em> : '—')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sub: Volume Overview — Chart + sortable table across all domains
|
||||||
|
// ============================================================
|
||||||
|
const OverviewView = ({ overview, ymFilter, onPickDomain }) => {
|
||||||
|
const [sortKey, setSortKey] = useState('send_count');
|
||||||
|
const [sortDir, setSortDir] = useState('desc');
|
||||||
|
|
||||||
|
if (!overview) return null;
|
||||||
|
|
||||||
|
const monthLabel = ymLabel(ymFilter);
|
||||||
|
|
||||||
|
if (!overview.rows || overview.rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Headline ym={monthLabel} totals={overview} />
|
||||||
|
<p className="text-sm text-gray-500 text-center py-12">
|
||||||
|
No SES events recorded for {monthLabel}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-10 for the chart (always sorted by send_count desc)
|
||||||
|
const top10 = [...overview.rows]
|
||||||
|
.sort((a, b) => b.send_count - a.send_count)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const sorted = [...overview.rows].sort((a, b) => {
|
||||||
|
const av = a[sortKey] ?? 0;
|
||||||
|
const bv = b[sortKey] ?? 0;
|
||||||
|
if (typeof av === 'string') {
|
||||||
|
return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||||
|
}
|
||||||
|
return sortDir === 'asc' ? av - bv : bv - av;
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerCell = (key, label, alignRight = false) => (
|
||||||
|
<th
|
||||||
|
onClick={() => {
|
||||||
|
if (sortKey === key) setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
|
||||||
|
else { setSortKey(key); setSortDir('desc'); }
|
||||||
|
}}
|
||||||
|
className={`py-2 pr-4 font-semibold cursor-pointer select-none hover:text-gray-900 ${alignRight ? 'text-right' : ''}`}
|
||||||
|
title="Click to sort"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{sortKey === key && <span className="ml-1 text-gray-400">{sortDir === 'asc' ? '↑' : '↓'}</span>}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Headline ym={monthLabel} totals={overview} />
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs uppercase tracking-wide text-gray-500 font-semibold mb-2">
|
||||||
|
Top {Math.min(10, top10.length)} domains by send volume
|
||||||
|
</h4>
|
||||||
|
<div className="h-64 w-full">
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<BarChart data={top10} layout="vertical" margin={{ top: 4, right: 24, bottom: 4, left: 8 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#e5e7eb" />
|
||||||
|
<XAxis type="number" stroke="#6b7280" fontSize={12} />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="domain"
|
||||||
|
stroke="#6b7280"
|
||||||
|
fontSize={12}
|
||||||
|
width={140}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ borderRadius: 8, border: '1px solid #e5e7eb', fontSize: 12 }}
|
||||||
|
formatter={(value, name) => [Number(value).toLocaleString(), 'Sends']}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="send_count" fill="#0284c7" radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs uppercase tracking-wide text-gray-500 font-semibold mb-2">
|
||||||
|
All domains ({overview.rows.length})
|
||||||
|
</h4>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
|
||||||
|
{headerCell('domain', 'Domain')}
|
||||||
|
{headerCell('inbox_count', 'Inboxes', true)}
|
||||||
|
{headerCell('send_count', 'Sent', true)}
|
||||||
|
{headerCell('bounce_count', 'Bounces', true)}
|
||||||
|
{headerCell('complaint_count', 'Complaints', true)}
|
||||||
|
<th className="py-2 pr-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{sorted.map((r) => (
|
||||||
|
<tr key={r.domain} className="hover:bg-gray-50">
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<span className="font-medium text-gray-900">{r.domain}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right font-mono text-gray-700">
|
||||||
|
{r.inbox_count.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right font-mono">{r.send_count.toLocaleString()}</td>
|
||||||
|
<td className={`py-2 pr-4 text-right font-mono ${r.bounce_count > 0 ? 'text-amber-700' : 'text-gray-400'}`}>
|
||||||
|
{r.bounce_count.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className={`py-2 pr-4 text-right font-mono ${r.complaint_count > 0 ? 'text-red-700 font-semibold' : 'text-gray-400'}`}>
|
||||||
|
{r.complaint_count.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => onPickDomain(r.domain)}
|
||||||
|
className="btn-ghost px-2 py-1"
|
||||||
|
title="Show per-inbox breakdown"
|
||||||
|
>
|
||||||
|
Details <FiArrowRight className="inline w-3.5 h-3.5 ml-1" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Headline = ({ ym, totals }) => (
|
||||||
|
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-primary-700 font-semibold">
|
||||||
|
{ym} · all domains
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||||
|
<Stat label="Inboxes" value={(totals.total_inbox_count ?? 0).toLocaleString()} icon={FiInbox} />
|
||||||
|
<Stat label="Sent" value={(totals.total_send_count ?? 0).toLocaleString()} icon={FiSend} />
|
||||||
|
<Stat
|
||||||
|
label="Bounces"
|
||||||
|
value={(totals.total_bounce_count ?? 0).toLocaleString()}
|
||||||
|
icon={FiAlertCircle}
|
||||||
|
color={totals.total_bounce_count > 0 ? 'text-amber-600' : ''}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Complaints"
|
||||||
|
value={(totals.total_complaint_count ?? 0).toLocaleString()}
|
||||||
|
icon={FiAlertTriangle}
|
||||||
|
color={totals.total_complaint_count > 0 ? 'text-red-600' : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sub: Drilldown — per-inbox view for one domain
|
||||||
|
// ============================================================
|
||||||
|
const DrilldownView = ({ volume, domain, ymFilter, onBack }) => {
|
||||||
|
if (!volume) return null;
|
||||||
|
const monthLabel = ymLabel(ymFilter);
|
||||||
|
|
||||||
|
const isProblematic = (v) => {
|
||||||
|
if (v.send_count === 0) return false;
|
||||||
|
return (v.bounce_count / v.send_count) > 0.05 || (v.complaint_count / v.send_count) > 0.001;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<button onClick={onBack} className="btn-ghost -ml-2">
|
||||||
|
<FiArrowLeft className="w-4 h-4 mr-1.5" />
|
||||||
|
Back to overview
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-primary-700 font-semibold">
|
||||||
|
{domain} · {monthLabel}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<Stat label="Sent" value={volume.send_count.toLocaleString()} icon={FiSend} />
|
||||||
|
<Stat
|
||||||
|
label="Bounces"
|
||||||
|
value={volume.bounce_count.toLocaleString()}
|
||||||
|
icon={FiAlertCircle}
|
||||||
|
color={volume.bounce_count > 0 ? 'text-amber-600' : ''}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Complaints"
|
||||||
|
value={volume.complaint_count.toLocaleString()}
|
||||||
|
icon={FiAlertTriangle}
|
||||||
|
color={volume.complaint_count > 0 ? 'text-red-600' : ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{volume.per_inbox.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-8">
|
||||||
|
No SES events recorded for {domain} in {monthLabel}.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wide text-gray-500 border-b border-gray-200">
|
||||||
|
<th className="py-2 pr-4 font-semibold">Mailbox</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold text-right">Sent</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold text-right">Bounces</th>
|
||||||
|
<th className="py-2 pr-4 font-semibold text-right">Complaints</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{volume.per_inbox.map((v) => (
|
||||||
|
<tr key={v.email} className="hover:bg-gray-50">
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-gray-800">{v.email}</span>
|
||||||
|
{isProblematic(v) && (
|
||||||
|
<FiAlertTriangle className="w-3.5 h-3.5 text-amber-500" title="High bounce or complaint rate" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right font-mono">{v.send_count.toLocaleString()}</td>
|
||||||
|
<td className={`py-2 pr-4 text-right font-mono ${v.bounce_count > 0 ? 'text-amber-700' : 'text-gray-400'}`}>
|
||||||
|
{v.bounce_count.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className={`py-2 pr-4 text-right font-mono ${v.complaint_count > 0 ? 'text-red-700 font-semibold' : 'text-gray-400'}`}>
|
||||||
|
{v.complaint_count.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stat = ({ label, value, icon: Icon, color = '' }) => (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs uppercase text-gray-500 tracking-wide mb-1">{label}</div>
|
||||||
|
<div className={`text-2xl font-bold ${color || 'text-gray-900'} flex items-center gap-2`}>
|
||||||
|
{Icon && <Icon className={`w-5 h-5 ${color || 'text-gray-400'}`} />}
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default BillingModal;
|
||||||
158
frontend/src/components/BlockedSenders.jsx
Normal file
158
frontend/src/components/BlockedSenders.jsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FiSlash, FiPlus, FiTrash2, FiShield } from 'react-icons/fi';
|
||||||
|
|
||||||
|
const BlockedSenders = ({ blocklist, onSave }) => {
|
||||||
|
const [patterns, setPatterns] = useState(blocklist?.blocked_patterns || []);
|
||||||
|
const [newPattern, setNewPattern] = useState('');
|
||||||
|
const [inputMessage, setInputMessage] = useState({ text: '', kind: '' });
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const validatePattern = (p) => p.length >= 3;
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!newPattern.trim()) {
|
||||||
|
setInputMessage({ text: 'Please enter a pattern', kind: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = newPattern
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const accepted = [];
|
||||||
|
let invalid = 0;
|
||||||
|
let dupes = 0;
|
||||||
|
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (!validatePattern(p)) { invalid++; continue; }
|
||||||
|
if (patterns.includes(p) || accepted.includes(p)) { dupes++; continue; }
|
||||||
|
accepted.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accepted.length === 0) {
|
||||||
|
if (invalid > 0 && candidates.length === invalid) {
|
||||||
|
setInputMessage({ text: 'All entered patterns were too short (min. 3 chars).', kind: 'error' });
|
||||||
|
} else if (dupes > 0) {
|
||||||
|
setInputMessage({ text: 'All entered patterns are already in the list.', kind: 'error' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPatterns([...patterns, ...accepted]);
|
||||||
|
setNewPattern('');
|
||||||
|
|
||||||
|
if (invalid > 0 || dupes > 0) {
|
||||||
|
setInputMessage({
|
||||||
|
text: `Added ${accepted.length} patterns. (${invalid} invalid, ${dupes} duplicates skipped)`,
|
||||||
|
kind: 'success',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setInputMessage({ text: '', kind: '' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (p) => setPatterns(patterns.filter((x) => x !== p));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(patterns);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="block-pattern" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Block Sender Pattern(s)
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
id="block-pattern"
|
||||||
|
type="text"
|
||||||
|
value={newPattern}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewPattern(e.target.value);
|
||||||
|
if (inputMessage.kind === 'error') setInputMessage({ text: '', kind: '' });
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
|
||||||
|
placeholder="spam@*.com, *@badsite.org (comma separated)"
|
||||||
|
className={`input-field ${inputMessage.kind === 'error' ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||||
|
/>
|
||||||
|
{inputMessage.text && (
|
||||||
|
<p className={`mt-1 text-sm ${inputMessage.kind === 'success' ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{inputMessage.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={handleAdd} className="btn-danger whitespace-nowrap px-4 py-2">
|
||||||
|
<FiPlus className="w-4 h-4 mr-2" />
|
||||||
|
Block
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
Paste a comma-separated list to add multiple entries at once. Supports wildcards (*).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<label className="block text-sm font-semibold text-gray-700">
|
||||||
|
Blocked Patterns ({patterns.length})
|
||||||
|
</label>
|
||||||
|
{patterns.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPatterns([])}
|
||||||
|
className="text-xs text-red-600 hover:text-red-800 hover:underline"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{patterns.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg">
|
||||||
|
<FiSlash className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-600 font-medium">No blocked senders configured</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Add a pattern above to block incoming emails</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1 custom-scrollbar">
|
||||||
|
{patterns.map((pattern, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${pattern}-${idx}`}
|
||||||
|
className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg hover:border-red-300 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 bg-red-100 rounded-full">
|
||||||
|
<FiShield className="w-4 h-4 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-gray-900 font-mono text-sm">{pattern}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(pattern)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
title="Remove block"
|
||||||
|
>
|
||||||
|
<FiTrash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||||
|
<button onClick={handleSave} disabled={isSaving} className="btn-primary">
|
||||||
|
{isSaving ? 'Saving...' : 'Save Block List'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlockedSenders;
|
||||||
97
frontend/src/components/ChangeMyPasswordModal.jsx
Normal file
97
frontend/src/components/ChangeMyPasswordModal.jsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import { authAPI } from '../services/api';
|
||||||
|
|
||||||
|
const ChangeMyPasswordModal = ({ open, onClose, onToast }) => {
|
||||||
|
const [currentPw, setCurrentPw] = useState('');
|
||||||
|
const [newPw, setNewPw] = useState('');
|
||||||
|
const [confirmPw, setConfirmPw] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setCurrentPw(''); setNewPw(''); setConfirmPw(''); setError('');
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const tooShort = newPw.length > 0 && newPw.length < 8;
|
||||||
|
const mismatch = confirmPw.length > 0 && newPw !== confirmPw;
|
||||||
|
const canSubmit =
|
||||||
|
currentPw.length > 0 && newPw.length >= 8 && newPw === confirmPw;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!canSubmit) {
|
||||||
|
if (!currentPw) setError('Please enter your current password.');
|
||||||
|
else if (newPw.length < 8) setError('New password must have at least 8 characters.');
|
||||||
|
else if (newPw !== confirmPw) setError('Passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
await authAPI.changePassword(currentPw, newPw);
|
||||||
|
onToast?.('Your password has been updated.', 'success');
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Change my password" size="sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">Current password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPw}
|
||||||
|
onChange={(e) => setCurrentPw(e.target.value)}
|
||||||
|
className="input-field"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">New password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPw}
|
||||||
|
onChange={(e) => setNewPw(e.target.value)}
|
||||||
|
className={`input-field ${tooShort ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
{tooShort && <p className="mt-1 text-xs text-red-600">Minimum 8 characters.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">Confirm new password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPw}
|
||||||
|
onChange={(e) => setConfirmPw(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }}
|
||||||
|
className={`input-field ${mismatch ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
{mismatch && <p className="mt-1 text-xs text-red-600">Passwords do not match.</p>}
|
||||||
|
{!mismatch && confirmPw.length > 0 && newPw === confirmPw && newPw.length >= 8 && (
|
||||||
|
<p className="mt-1 text-xs text-green-600">Passwords match.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2 border-t border-gray-200">
|
||||||
|
<button onClick={onClose} className="btn-secondary px-4 py-2" disabled={busy}>Cancel</button>
|
||||||
|
<button onClick={submit} disabled={busy || !canSubmit} className="btn-primary">
|
||||||
|
{busy ? 'Updating...' : 'Update password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChangeMyPasswordModal;
|
||||||
37
frontend/src/components/ConfirmDialog.jsx
Normal file
37
frontend/src/components/ConfirmDialog.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FiAlertTriangle } from 'react-icons/fi';
|
||||||
|
import Modal from './Modal';
|
||||||
|
|
||||||
|
const ConfirmDialog = ({ open, title, message, confirmLabel = 'Confirm', danger = false, onConfirm, onClose }) => {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { await onConfirm(); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={title} size="sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{danger && (
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-red-100 flex-shrink-0">
|
||||||
|
<FiAlertTriangle className="w-5 h-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-gray-700 flex-1 pt-1">{message}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-4 mt-4 border-t border-gray-200">
|
||||||
|
<button onClick={onClose} className="btn-secondary px-4 py-2" disabled={busy}>Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={busy}
|
||||||
|
className={danger ? 'btn-danger px-4 py-2' : 'btn-primary'}
|
||||||
|
>
|
||||||
|
{busy ? 'Working...' : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmDialog;
|
||||||
153
frontend/src/components/DomainQuotaModal.jsx
Normal file
153
frontend/src/components/DomainQuotaModal.jsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { FiHardDrive, FiAlertTriangle } from 'react-icons/fi';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import { mailboxesAPI } from '../services/api';
|
||||||
|
|
||||||
|
const QUOTA_OPTIONS = [5, 10, 15, 20, 25, 30, 35, 40];
|
||||||
|
|
||||||
|
const DomainQuotaModal = ({ open, domain, mailboxes, onClose, onApplied, onToast }) => {
|
||||||
|
const [selectedGb, setSelectedGb] = useState(10);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [progress, setProgress] = useState({ done: 0, total: 0, current: '' });
|
||||||
|
const [errors, setErrors] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelectedGb(10);
|
||||||
|
setBusy(false);
|
||||||
|
setProgress({ done: 0, total: 0, current: '' });
|
||||||
|
setErrors([]);
|
||||||
|
}
|
||||||
|
}, [open, domain]);
|
||||||
|
|
||||||
|
const activeMailboxes = (mailboxes || []).filter((m) => m.status === 'active');
|
||||||
|
|
||||||
|
const apply = async () => {
|
||||||
|
if (activeMailboxes.length === 0) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErrors([]);
|
||||||
|
setProgress({ done: 0, total: activeMailboxes.length, current: '' });
|
||||||
|
|
||||||
|
const failed = [];
|
||||||
|
|
||||||
|
// Sequential to avoid hammering the DMS container with parallel
|
||||||
|
// docker exec calls and to give the user honest progress.
|
||||||
|
for (let i = 0; i < activeMailboxes.length; i++) {
|
||||||
|
const m = activeMailboxes[i];
|
||||||
|
setProgress({ done: i, total: activeMailboxes.length, current: m.email_address });
|
||||||
|
try {
|
||||||
|
await mailboxesAPI.setQuota(m.email_address, selectedGb);
|
||||||
|
} catch (err) {
|
||||||
|
failed.push({ email: m.email_address, message: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress({ done: activeMailboxes.length, total: activeMailboxes.length, current: '' });
|
||||||
|
|
||||||
|
if (failed.length === 0) {
|
||||||
|
onToast?.(`Quota set to ${selectedGb} GB for ${activeMailboxes.length} mailboxes.`, 'success');
|
||||||
|
await onApplied?.();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setErrors(failed);
|
||||||
|
onToast?.(
|
||||||
|
`${activeMailboxes.length - failed.length}/${activeMailboxes.length} succeeded. See dialog for failures.`,
|
||||||
|
'warning',
|
||||||
|
);
|
||||||
|
// Refresh anyway so the user sees what worked.
|
||||||
|
await onApplied?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={busy ? () => {} : onClose}
|
||||||
|
title="Set quota for all mailboxes"
|
||||||
|
subtitle={domain}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-start gap-3 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||||
|
<FiAlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm text-amber-900">
|
||||||
|
This applies the selected quota to <strong>all {activeMailboxes.length} active mailboxes</strong> in
|
||||||
|
this domain. Existing per-mailbox overrides will be replaced.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">Quota size</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{QUOTA_OPTIONS.map((gb) => {
|
||||||
|
const isSel = selectedGb === gb;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={gb}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedGb(gb)}
|
||||||
|
disabled={busy}
|
||||||
|
className={`px-4 py-3 rounded-lg border-2 font-semibold transition-all ${
|
||||||
|
isSel
|
||||||
|
? 'border-primary-600 bg-primary-50 text-primary-700'
|
||||||
|
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||||
|
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
{gb} GB
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{busy && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs text-gray-600">
|
||||||
|
<span className="truncate">
|
||||||
|
{progress.current ? `Processing: ${progress.current}` : 'Finishing...'}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono whitespace-nowrap">
|
||||||
|
{progress.done} / {progress.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary-600 transition-all duration-200"
|
||||||
|
style={{ width: `${progress.total ? (progress.done / progress.total) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div className="border border-red-200 rounded-lg bg-red-50 p-3">
|
||||||
|
<p className="text-sm font-semibold text-red-800 mb-1">
|
||||||
|
{errors.length} mailbox{errors.length > 1 ? 'es' : ''} failed:
|
||||||
|
</p>
|
||||||
|
<ul className="text-xs text-red-700 space-y-1 max-h-32 overflow-y-auto custom-scrollbar">
|
||||||
|
{errors.map((e) => (
|
||||||
|
<li key={e.email}>
|
||||||
|
<span className="font-mono">{e.email}</span>: {e.message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200">
|
||||||
|
<button onClick={onClose} className="btn-secondary px-4 py-2" disabled={busy}>
|
||||||
|
{errors.length > 0 ? 'Close' : 'Cancel'}
|
||||||
|
</button>
|
||||||
|
<button onClick={apply} className="btn-primary" disabled={busy || activeMailboxes.length === 0}>
|
||||||
|
<FiHardDrive className="w-4 h-4 mr-2" />
|
||||||
|
{busy ? 'Applying...' : `Apply ${selectedGb} GB to ${activeMailboxes.length} mailboxes`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DomainQuotaModal;
|
||||||
118
frontend/src/components/Forwarding.jsx
Normal file
118
frontend/src/components/Forwarding.jsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FiMail, FiPlus, FiTrash2, FiCheck } from 'react-icons/fi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forwarding tab for one mailbox.
|
||||||
|
* Receives the current rule object and onSave callback that updates the
|
||||||
|
* mailadmin /rules endpoint (which expects { ooo_active, ooo_message, forwards }).
|
||||||
|
* onSave is called with just the updated `forwards` field; the parent
|
||||||
|
* merges it with the existing rule before persisting.
|
||||||
|
*/
|
||||||
|
const Forwarding = ({ rule, onSave }) => {
|
||||||
|
const [forwards, setForwards] = useState(rule?.forwards || []);
|
||||||
|
const [newEmail, setNewEmail] = useState('');
|
||||||
|
const [emailError, setEmailError] = useState('');
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const validateEmail = (email) =>
|
||||||
|
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const trimmed = newEmail.trim().toLowerCase();
|
||||||
|
if (!trimmed) { setEmailError('Email address is required'); return; }
|
||||||
|
if (!validateEmail(trimmed)) { setEmailError('Please enter a valid email address'); return; }
|
||||||
|
if (forwards.includes(trimmed)) { setEmailError('Already in the list'); return; }
|
||||||
|
setForwards([...forwards, trimmed]);
|
||||||
|
setNewEmail('');
|
||||||
|
setEmailError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (email) => setForwards(forwards.filter((e) => e !== email));
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({ forwards });
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="fwd-email" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Add Forward Address
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
id="fwd-email"
|
||||||
|
type="email"
|
||||||
|
value={newEmail}
|
||||||
|
onChange={(e) => { setNewEmail(e.target.value); setEmailError(''); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
className={`input-field ${emailError ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||||
|
/>
|
||||||
|
{emailError && <p className="mt-1 text-sm text-red-600">{emailError}</p>}
|
||||||
|
</div>
|
||||||
|
<button onClick={handleAdd} className="btn-primary whitespace-nowrap">
|
||||||
|
<FiPlus className="w-4 h-4 mr-2" />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
All emails sent to this mailbox will also be delivered to the addresses below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<label className="block text-sm font-semibold text-gray-700">
|
||||||
|
Forward Addresses ({forwards.length})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{forwards.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg">
|
||||||
|
<FiMail className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-600 font-medium">No forward addresses configured</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Add an email address above to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1 custom-scrollbar">
|
||||||
|
{forwards.map((email, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${email}-${idx}`}
|
||||||
|
className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg hover:border-primary-300 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 bg-primary-100 rounded-full">
|
||||||
|
<FiCheck className="w-4 h-4 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-gray-900">{email}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(email)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
title="Remove forward"
|
||||||
|
>
|
||||||
|
<FiTrash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||||
|
<button onClick={handleSave} disabled={isSaving} className="btn-primary">
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Forwarding;
|
||||||
56
frontend/src/components/HealthBanner.jsx
Normal file
56
frontend/src/components/HealthBanner.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FiAlertTriangle, FiXCircle } from 'react-icons/fi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders nothing if status is null, has no problems, or there are no
|
||||||
|
* fail/warn findings. Otherwise shows a small banner with a "View details"
|
||||||
|
* action that opens the HealthModal.
|
||||||
|
*/
|
||||||
|
const HealthBanner = ({ status, onOpen }) => {
|
||||||
|
if (!status || !status.has_problems) return null;
|
||||||
|
|
||||||
|
const fails = status.summary?.fail ?? 0;
|
||||||
|
const warns = status.summary?.warn ?? 0;
|
||||||
|
|
||||||
|
const isCritical = fails > 0;
|
||||||
|
const Icon = isCritical ? FiXCircle : FiAlertTriangle;
|
||||||
|
|
||||||
|
const wrapperColor = isCritical
|
||||||
|
? 'bg-red-50 border-red-200'
|
||||||
|
: 'bg-amber-50 border-amber-200';
|
||||||
|
const iconColor = isCritical ? 'text-red-600' : 'text-amber-600';
|
||||||
|
const buttonColor = isCritical
|
||||||
|
? 'text-red-700 hover:text-red-900 hover:bg-red-100'
|
||||||
|
: 'text-amber-700 hover:text-amber-900 hover:bg-amber-100';
|
||||||
|
|
||||||
|
let headline;
|
||||||
|
if (fails > 0 && warns > 0) {
|
||||||
|
headline = `${fails} problem${fails === 1 ? '' : 's'} and ${warns} warning${warns === 1 ? '' : 's'} detected`;
|
||||||
|
} else if (fails > 0) {
|
||||||
|
headline = `${fails} problem${fails === 1 ? '' : 's'} detected`;
|
||||||
|
} else {
|
||||||
|
headline = `${warns} warning${warns === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-between gap-3 ${wrapperColor} border rounded-lg p-3 mb-4`}>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Icon className={`w-5 h-5 ${iconColor} flex-shrink-0`} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-gray-900 truncate">{headline}</p>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
Last checked {new Date(status.checked_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onOpen}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${buttonColor}`}
|
||||||
|
>
|
||||||
|
View details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HealthBanner;
|
||||||
151
frontend/src/components/HealthModal.jsx
Normal file
151
frontend/src/components/HealthModal.jsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { FiCheckCircle, FiAlertTriangle, FiXCircle, FiHelpCircle, FiRefreshCw } from 'react-icons/fi';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import LoadingOverlay from './LoadingOverlay';
|
||||||
|
import { healthAPI } from '../services/api';
|
||||||
|
|
||||||
|
const LEVEL_STYLES = {
|
||||||
|
ok: { icon: FiCheckCircle, color: 'text-green-600', bg: 'bg-green-50', pill: 'pill-success', label: 'OK' },
|
||||||
|
warn: { icon: FiAlertTriangle, color: 'text-amber-600', bg: 'bg-amber-50', pill: 'pill-warn', label: 'Warning' },
|
||||||
|
fail: { icon: FiXCircle, color: 'text-red-600', bg: 'bg-red-50', pill: 'pill', label: 'Problem' },
|
||||||
|
unknown: { icon: FiHelpCircle, color: 'text-gray-500', bg: 'bg-gray-50', pill: 'pill', label: 'Unknown' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const HealthModal = ({ open, domain, onClose, onCheckedReport, onToast }) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [report, setReport] = useState(null);
|
||||||
|
|
||||||
|
// When the modal opens, run a fresh check immediately (this is on-demand
|
||||||
|
// mode — the user clicked the "Check health" button, so they expect a
|
||||||
|
// current run, not a cached one).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !domain) { setReport(null); return; }
|
||||||
|
runCheck();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, domain]);
|
||||||
|
|
||||||
|
const runCheck = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await healthAPI.runCheck(domain);
|
||||||
|
setReport(data);
|
||||||
|
onCheckedReport?.(data);
|
||||||
|
} catch (err) {
|
||||||
|
onToast?.(`Health check failed: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Domain health"
|
||||||
|
subtitle={domain}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="relative min-h-[350px]">
|
||||||
|
{loading && <LoadingOverlay message="Running checks..." />}
|
||||||
|
|
||||||
|
{!loading && report && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Overall summary */}
|
||||||
|
<OverallBanner report={report} />
|
||||||
|
|
||||||
|
{/* Per check */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{report.checks.map((check) => (
|
||||||
|
<CheckCard key={check.id} check={check} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Re-run button */}
|
||||||
|
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Last checked: {new Date(report.checked_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<button onClick={runCheck} className="btn-secondary">
|
||||||
|
<FiRefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Run check again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OverallBanner = ({ report }) => {
|
||||||
|
// Compute counts from findings to give a "X problems / Y warnings" headline.
|
||||||
|
const counts = { fail: 0, warn: 0, unknown: 0, ok: 0 };
|
||||||
|
for (const c of report.checks) {
|
||||||
|
for (const f of c.findings) counts[f.level] = (counts[f.level] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let headline, sub, style;
|
||||||
|
if (counts.fail > 0) {
|
||||||
|
headline = `${counts.fail} problem${counts.fail === 1 ? '' : 's'} detected`;
|
||||||
|
sub = counts.warn > 0 ? `Plus ${counts.warn} warning${counts.warn === 1 ? '' : 's'}.` : 'Action required.';
|
||||||
|
style = LEVEL_STYLES.fail;
|
||||||
|
} else if (counts.warn > 0) {
|
||||||
|
headline = `${counts.warn} warning${counts.warn === 1 ? '' : 's'}`;
|
||||||
|
sub = 'No critical problems, but worth a look.';
|
||||||
|
style = LEVEL_STYLES.warn;
|
||||||
|
} else {
|
||||||
|
headline = 'All checks passed';
|
||||||
|
sub = counts.unknown > 0
|
||||||
|
? `${counts.unknown} item${counts.unknown === 1 ? '' : 's'} could not be verified automatically.`
|
||||||
|
: 'Domain looks healthy.';
|
||||||
|
style = LEVEL_STYLES.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = style.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-start gap-3 ${style.bg} border border-gray-200 rounded-lg p-4`}>
|
||||||
|
<Icon className={`w-6 h-6 ${style.color} flex-shrink-0 mt-0.5`} />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{headline}</h3>
|
||||||
|
<p className="text-sm text-gray-700 mt-0.5">{sub}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CheckCard = ({ check }) => {
|
||||||
|
const style = LEVEL_STYLES[check.level] || LEVEL_STYLES.unknown;
|
||||||
|
const Icon = style.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className={`w-5 h-5 ${style.color}`} />
|
||||||
|
<h4 className="font-semibold text-gray-900">{check.title}</h4>
|
||||||
|
</div>
|
||||||
|
<span className={style.pill}>{style.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{check.findings.map((f, idx) => {
|
||||||
|
const fStyle = LEVEL_STYLES[f.level] || LEVEL_STYLES.unknown;
|
||||||
|
const FIcon = fStyle.icon;
|
||||||
|
return (
|
||||||
|
<div key={idx} className="flex items-start gap-3 px-4 py-2.5">
|
||||||
|
<FIcon className={`w-4 h-4 ${fStyle.color} flex-shrink-0 mt-0.5`} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-800">{f.label}</div>
|
||||||
|
{f.detail && (
|
||||||
|
<div className="text-xs text-gray-600 mt-0.5 break-words">{f.detail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HealthModal;
|
||||||
23
frontend/src/components/LoadingOverlay.jsx
Normal file
23
frontend/src/components/LoadingOverlay.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FiLoader } from 'react-icons/fi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block UI overlay. Pass `fullscreen` for app-level blocking,
|
||||||
|
* otherwise it covers the nearest positioned (relative) parent.
|
||||||
|
*/
|
||||||
|
const LoadingOverlay = ({ message = 'Loading...', fullscreen = false }) => {
|
||||||
|
const positioning = fullscreen
|
||||||
|
? 'fixed inset-0 z-40'
|
||||||
|
: 'absolute inset-0 z-20 rounded-xl';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${positioning} bg-white/70 backdrop-blur-sm flex items-center justify-center`}>
|
||||||
|
<div className="flex flex-col items-center gap-3 bg-white rounded-xl shadow-lg border border-gray-200 px-6 py-5">
|
||||||
|
<FiLoader className="w-7 h-7 text-primary-600 animate-spin" />
|
||||||
|
<p className="text-sm font-medium text-gray-700">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingOverlay;
|
||||||
73
frontend/src/components/Login.jsx
Normal file
73
frontend/src/components/Login.jsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FiMail, FiLock } from 'react-icons/fi';
|
||||||
|
import { authAPI } from '../services/api';
|
||||||
|
|
||||||
|
const Login = ({ onLogin }) => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
const me = await authAPI.login(email.trim().toLowerCase(), password);
|
||||||
|
onLogin(me);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-primary-50 p-6">
|
||||||
|
<div className="card w-full max-w-md">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">MailAdmin</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Sign in to manage your mail server</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
|
||||||
|
<div className="relative">
|
||||||
|
<FiMail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="input-field pl-10"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<FiLock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="input-field pl-10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={busy} className="btn-primary w-full">
|
||||||
|
{busy ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
138
frontend/src/components/MailboxSettingsModal.jsx
Normal file
138
frontend/src/components/MailboxSettingsModal.jsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { FiCornerUpRight, FiCalendar, FiSlash } from 'react-icons/fi';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import LoadingOverlay from './LoadingOverlay';
|
||||||
|
import Forwarding from './Forwarding';
|
||||||
|
import OutOfOffice from './OutOfOffice';
|
||||||
|
import BlockedSenders from './BlockedSenders';
|
||||||
|
import { mailboxesAPI } from '../services/api';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'fwd', label: 'Forwarding', icon: FiCornerUpRight },
|
||||||
|
{ id: 'ooo', label: 'Out of Office', icon: FiCalendar },
|
||||||
|
{ id: 'block', label: 'Blocklist', icon: FiSlash },
|
||||||
|
];
|
||||||
|
|
||||||
|
const emptyRule = (email) => ({
|
||||||
|
email_address: email,
|
||||||
|
ooo_active: false,
|
||||||
|
ooo_message: '',
|
||||||
|
ooo_content_type: 'text',
|
||||||
|
forwards: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToast }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState(initialTab);
|
||||||
|
const [rule, setRule] = useState(null);
|
||||||
|
const [blocklist, setBlocklist] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { setActiveTab(initialTab); }, [initialTab, email]);
|
||||||
|
|
||||||
|
// Load both /rules and /blocklist in parallel when the modal opens.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !email) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [r, b] = await Promise.all([
|
||||||
|
mailboxesAPI.getRules(email).catch(() => emptyRule(email)),
|
||||||
|
mailboxesAPI.getBlocklist(email).catch(() => ({
|
||||||
|
email_address: email, blocked_patterns: [],
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setRule(r ? { ...emptyRule(email), ...r } : emptyRule(email));
|
||||||
|
setBlocklist(b || { email_address: email, blocked_patterns: [] });
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) onToast?.(`Failed to load settings: ${err.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [open, email, onToast]);
|
||||||
|
|
||||||
|
// Merge updates with existing rule and persist.
|
||||||
|
const saveRule = async (updates) => {
|
||||||
|
const base = rule || emptyRule(email);
|
||||||
|
const merged = {
|
||||||
|
ooo_active: base.ooo_active ?? false,
|
||||||
|
ooo_message: base.ooo_message ?? '',
|
||||||
|
ooo_content_type: base.ooo_content_type ?? 'text',
|
||||||
|
forwards: base.forwards ?? [],
|
||||||
|
...updates,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const saved = await mailboxesAPI.putRules(email, merged);
|
||||||
|
setRule({ email_address: email, ...merged, ...saved });
|
||||||
|
onToast?.('Rule saved', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
onToast?.(`Failed to save: ${err.message}`, 'error');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveBlocklist = async (patterns) => {
|
||||||
|
try {
|
||||||
|
const saved = await mailboxesAPI.putBlocklist(email, patterns);
|
||||||
|
setBlocklist({ email_address: email, blocked_patterns: patterns, ...saved });
|
||||||
|
onToast?.('Block list saved', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
onToast?.(`Failed to save: ${err.message}`, 'error');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={email || 'Mailbox settings'}
|
||||||
|
subtitle="Forwarding, auto-reply and blocklist"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<div className="relative min-h-[400px]">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-gray-200 mb-6">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TABS.map((t) => {
|
||||||
|
const Icon = t.icon;
|
||||||
|
const isActive = activeTab === t.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setActiveTab(t.id)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'border-b-2 border-primary-600 text-primary-700 -mb-px'
|
||||||
|
: 'border-b-2 border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{loading || !rule || !blocklist ? (
|
||||||
|
<div className="py-16 flex items-center justify-center">
|
||||||
|
<LoadingOverlay message="Loading settings..." />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{activeTab === 'fwd' && <Forwarding rule={rule} onSave={saveRule} />}
|
||||||
|
{activeTab === 'ooo' && <OutOfOffice rule={rule} onSave={saveRule} />}
|
||||||
|
{activeTab === 'block' && <BlockedSenders blocklist={blocklist} onSave={saveBlocklist} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MailboxSettingsModal;
|
||||||
50
frontend/src/components/Modal.jsx
Normal file
50
frontend/src/components/Modal.jsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { FiX } from 'react-icons/fi';
|
||||||
|
|
||||||
|
const Modal = ({ open, onClose, title, subtitle, children, size = 'md' }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handler);
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const widths = {
|
||||||
|
sm: 'max-w-md',
|
||||||
|
md: 'max-w-2xl',
|
||||||
|
lg: 'max-w-4xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: clicking the backdrop does NOT close the modal anymore.
|
||||||
|
// Use the X button or the Escape key instead. This avoids accidental
|
||||||
|
// dismissal when the user is in the middle of editing settings.
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-30 bg-gray-900/40 backdrop-blur-sm flex items-start justify-center p-4 sm:p-8 overflow-y-auto">
|
||||||
|
<div className={`bg-white rounded-xl shadow-xl border border-gray-200 w-full ${widths[size]} my-8`}>
|
||||||
|
<div className="flex items-start justify-between px-6 py-4 border-b border-gray-100">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||||
|
{subtitle && <p className="text-sm text-gray-500 mt-0.5">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-700 p-1 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
title="Close (Esc)"
|
||||||
|
>
|
||||||
|
<FiX className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
91
frontend/src/components/NewMailboxModal.jsx
Normal file
91
frontend/src/components/NewMailboxModal.jsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import { mailboxesAPI } from '../services/api';
|
||||||
|
|
||||||
|
const NewMailboxModal = ({ open, domain, onClose, onCreated, onToast }) => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const localPartRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && domain) {
|
||||||
|
setEmail(`@${domain}`);
|
||||||
|
setPassword('');
|
||||||
|
setError('');
|
||||||
|
// Focus the input and put the cursor at position 0 so the user types
|
||||||
|
// the local part before the existing @domain suffix.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = localPartRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.focus();
|
||||||
|
try { el.setSelectionRange(0, 0); } catch { /* some browsers don't allow this */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, domain]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const e = email.trim().toLowerCase();
|
||||||
|
if (!e || !e.includes('@')) { setError('Please enter a valid email address.'); return; }
|
||||||
|
if (!e.endsWith(`@${domain}`)) { setError(`Mailbox must belong to ${domain}.`); return; }
|
||||||
|
if (password.length < 8) { setError('Password must have at least 8 characters.'); return; }
|
||||||
|
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
await mailboxesAPI.create(e, password);
|
||||||
|
onToast?.(`Mailbox created: ${e}`, 'success');
|
||||||
|
onCreated?.();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="New mailbox" subtitle={domain} size="sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
|
||||||
|
<input
|
||||||
|
ref={localPartRef}
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); document.getElementById('new-mb-pw')?.focus(); } }}
|
||||||
|
className="input-field"
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">Password</label>
|
||||||
|
<input
|
||||||
|
id="new-mb-pw"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }}
|
||||||
|
className="input-field"
|
||||||
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-2 pt-2 border-t border-gray-200">
|
||||||
|
<button onClick={onClose} className="btn-secondary px-4 py-2">Cancel</button>
|
||||||
|
<button onClick={submit} disabled={busy} className="btn-primary">
|
||||||
|
{busy ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewMailboxModal;
|
||||||
144
frontend/src/components/OutOfOffice.jsx
Normal file
144
frontend/src/components/OutOfOffice.jsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FiCalendar, FiFileText } from 'react-icons/fi';
|
||||||
|
|
||||||
|
const OutOfOffice = ({ rule, onSave }) => {
|
||||||
|
const [isActive, setIsActive] = useState(rule?.ooo_active || false);
|
||||||
|
const [message, setMessage] = useState(rule?.ooo_message || '');
|
||||||
|
const [contentType, setContentType] = useState(
|
||||||
|
rule?.ooo_content_type === 'html' ? 'html' : 'text'
|
||||||
|
);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
ooo_active: isActive,
|
||||||
|
ooo_message: message,
|
||||||
|
ooo_content_type: contentType,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Toggle Active/Inactive */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FiCalendar className="w-5 h-5 text-gray-600" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">Out of Office Status</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{isActive ? 'Auto-reply is currently active' : 'Auto-reply is currently inactive'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsActive(!isActive)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
isActive ? 'bg-primary-600' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
isActive ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Type Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Message Format
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setContentType('text')}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-lg border-2 transition-all ${
|
||||||
|
contentType === 'text'
|
||||||
|
? 'border-primary-600 bg-primary-50 text-primary-700 font-semibold'
|
||||||
|
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FiFileText className="inline w-4 h-4 mr-2" />
|
||||||
|
Plain Text
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setContentType('html')}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-lg border-2 transition-all ${
|
||||||
|
contentType === 'html'
|
||||||
|
? 'border-primary-600 bg-primary-50 text-primary-700 font-semibold'
|
||||||
|
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm mr-2"></></span>
|
||||||
|
HTML
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Editor */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="ooo-message" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Auto-Reply Message
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="ooo-message"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
rows={8}
|
||||||
|
placeholder={
|
||||||
|
contentType === 'html'
|
||||||
|
? '<p>I am currently out of office until [date].</p>\n<p>Best regards,<br>Your Name</p>'
|
||||||
|
: 'I am currently out of office until [date].\n\nBest regards,\nYour Name'
|
||||||
|
}
|
||||||
|
className="input-field font-mono text-sm resize-none"
|
||||||
|
disabled={!isActive}
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
{contentType === 'html'
|
||||||
|
? 'You can use HTML tags for formatting. The reply will be sent as text/html.'
|
||||||
|
: 'Plain text message that gets returned automatically to senders.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Preview */}
|
||||||
|
{isActive && message && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Message Preview
|
||||||
|
</label>
|
||||||
|
<div className="p-4 bg-gray-50 border border-gray-200 rounded-lg">
|
||||||
|
{contentType === 'html' ? (
|
||||||
|
<div
|
||||||
|
className="prose prose-sm max-w-none text-sm text-gray-800"
|
||||||
|
dangerouslySetInnerHTML={{ __html: message }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className="text-sm text-gray-800 whitespace-pre-wrap font-sans">{message}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || (isActive && !message.trim())}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OutOfOffice;
|
||||||
100
frontend/src/components/PasswordResetModal.jsx
Normal file
100
frontend/src/components/PasswordResetModal.jsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import { mailboxesAPI } from '../services/api';
|
||||||
|
|
||||||
|
const PasswordResetModal = ({ open, email, onClose, onToast }) => {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) { setPassword(''); setConfirm(''); setError(''); }
|
||||||
|
}, [open, email]);
|
||||||
|
|
||||||
|
// Live mismatch hint, only after the user has typed something in the second field.
|
||||||
|
const mismatch = confirm.length > 0 && password !== confirm;
|
||||||
|
const tooShort = password.length > 0 && password.length < 8;
|
||||||
|
const canSubmit = password.length >= 8 && password === confirm;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!canSubmit) {
|
||||||
|
if (password.length < 8) setError('Password must have at least 8 characters.');
|
||||||
|
else if (password !== confirm) setError('Passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
await mailboxesAPI.setPassword(email, password);
|
||||||
|
onToast?.(`Password updated for ${email}`, 'success');
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Reset password" subtitle={email} size="sm">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">New password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('pw-confirm')?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`input-field ${tooShort ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||||
|
minLength={8}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{tooShort && (
|
||||||
|
<p className="mt-1 text-xs text-red-600">Minimum 8 characters.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">Confirm password</label>
|
||||||
|
<input
|
||||||
|
id="pw-confirm"
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } }}
|
||||||
|
className={`input-field ${mismatch ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
{mismatch && (
|
||||||
|
<p className="mt-1 text-xs text-red-600">Passwords do not match.</p>
|
||||||
|
)}
|
||||||
|
{!mismatch && confirm.length > 0 && password === confirm && password.length >= 8 && (
|
||||||
|
<p className="mt-1 text-xs text-green-600">Passwords match.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2 border-t border-gray-200">
|
||||||
|
<button onClick={onClose} className="btn-secondary px-4 py-2" disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy || !canSubmit}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{busy ? 'Updating...' : 'Update password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PasswordResetModal;
|
||||||
30
frontend/src/components/Toast.jsx
Normal file
30
frontend/src/components/Toast.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { FiCheckCircle, FiXCircle, FiAlertCircle, FiX } from 'react-icons/fi';
|
||||||
|
|
||||||
|
const Toast = ({ message, type = 'success', onClose }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(onClose, 3500);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
success: { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-800', icon: <FiCheckCircle className="w-5 h-5 text-green-600" /> },
|
||||||
|
error: { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-800', icon: <FiXCircle className="w-5 h-5 text-red-600" /> },
|
||||||
|
warning: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-800', icon: <FiAlertCircle className="w-5 h-5 text-amber-600" /> },
|
||||||
|
};
|
||||||
|
const s = styles[type] || styles.success;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 right-4 z-50 animate-slide-in">
|
||||||
|
<div className={`flex items-start gap-3 ${s.bg} ${s.border} border rounded-lg shadow-lg p-4 min-w-[300px] max-w-md`}>
|
||||||
|
{s.icon}
|
||||||
|
<p className={`flex-1 text-sm font-medium ${s.text}`}>{message}</p>
|
||||||
|
<button onClick={onClose} className={`${s.text} opacity-60 hover:opacity-100`}>
|
||||||
|
<FiX className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toast;
|
||||||
61
frontend/src/components/UsageBar.jsx
Normal file
61
frontend/src/components/UsageBar.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const bytes = (n) => {
|
||||||
|
n = Number(n || 0);
|
||||||
|
if (n <= 0) return '0 B';
|
||||||
|
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let i = 0;
|
||||||
|
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||||
|
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtPercent = (n) => {
|
||||||
|
if (n === null || n === undefined || Number.isNaN(Number(n))) return null;
|
||||||
|
const v = Number(n);
|
||||||
|
if (v < 1 && v > 0) return v.toFixed(1);
|
||||||
|
return v.toFixed(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatBytes = bytes;
|
||||||
|
|
||||||
|
const UsageBar = ({ mailbox }) => {
|
||||||
|
const used = Number(mailbox.used_bytes || 0);
|
||||||
|
const limit = mailbox.quota_bytes === null || mailbox.quota_bytes === undefined
|
||||||
|
? null : Number(mailbox.quota_bytes);
|
||||||
|
const calc = limit && limit > 0 ? (used / limit) * 100 : null;
|
||||||
|
const raw = mailbox.quota_percent === null || mailbox.quota_percent === undefined
|
||||||
|
? calc : Number(mailbox.quota_percent);
|
||||||
|
const pct = raw === null || Number.isNaN(raw) ? null : Math.max(0, Math.min(100, raw));
|
||||||
|
const label = limit
|
||||||
|
? `${bytes(used)} / ${bytes(limit)} (${fmtPercent(pct)}%)`
|
||||||
|
: `${bytes(used)} / unlimited`;
|
||||||
|
|
||||||
|
const fillColor = pct === null
|
||||||
|
? 'bg-primary-500'
|
||||||
|
: pct >= 90 ? 'bg-red-500'
|
||||||
|
: pct >= 75 ? 'bg-amber-500'
|
||||||
|
: 'bg-emerald-500';
|
||||||
|
|
||||||
|
const hasMessageCount =
|
||||||
|
mailbox.message_count !== null && mailbox.message_count !== undefined;
|
||||||
|
const messageCount = hasMessageCount ? Number(mailbox.message_count) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-[200px]">
|
||||||
|
<div className="text-xs font-semibold text-gray-700 mb-1.5">Disk Usage: {label}</div>
|
||||||
|
<div className="w-full h-2 rounded-full bg-gray-200 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${fillColor} transition-all duration-300`}
|
||||||
|
style={{ width: `${pct ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hasMessageCount && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
{messageCount} {messageCount === 1 ? 'message' : 'messages'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsageBar;
|
||||||
55
frontend/src/index.css
Normal file
55
frontend/src/index.css
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 font-sans antialiased text-gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn-primary {
|
||||||
|
@apply px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 active:bg-primary-800 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply px-3 py-1.5 bg-gray-100 text-gray-800 rounded-lg text-sm font-medium hover:bg-gray-200 active:bg-gray-300 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center whitespace-nowrap;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
@apply px-3 py-1.5 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 active:bg-red-800 transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center whitespace-nowrap;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
@apply px-3 py-1.5 bg-transparent text-primary-700 rounded-lg text-sm font-medium hover:bg-primary-50 transition-colors duration-150 inline-flex items-center justify-center whitespace-nowrap;
|
||||||
|
}
|
||||||
|
.input-field {
|
||||||
|
@apply w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-shadow duration-150 outline-none disabled:bg-gray-50 disabled:text-gray-500;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-xl shadow-sm border border-gray-100 p-6;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700;
|
||||||
|
}
|
||||||
|
.pill-success {
|
||||||
|
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700;
|
||||||
|
}
|
||||||
|
.pill-warn {
|
||||||
|
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
@keyframes slide-in {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
.animate-slide-in { animation: slide-in 0.3s ease-out; }
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
|
||||||
|
}
|
||||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
117
frontend/src/services/api.js
Normal file
117
frontend/src/services/api.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/',
|
||||||
|
withCredentials: true,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(r) => r,
|
||||||
|
(err) => {
|
||||||
|
const status = err.response?.status;
|
||||||
|
const body = err.response?.data;
|
||||||
|
const message = body?.error || err.message || 'Request failed';
|
||||||
|
const wrapped = new Error(message);
|
||||||
|
wrapped.statusCode = status;
|
||||||
|
wrapped.original = err;
|
||||||
|
return Promise.reject(wrapped);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const authAPI = {
|
||||||
|
me: async () => (await api.get('/api/auth/me')).data,
|
||||||
|
login: async (email, password) =>
|
||||||
|
(await api.post('/api/auth/login', { email, password })).data,
|
||||||
|
logout: async () => (await api.post('/api/auth/logout')).data,
|
||||||
|
changePassword: async (current_password, new_password) =>
|
||||||
|
(await api.post('/api/auth/change-password', { current_password, new_password })).data,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const domainsAPI = {
|
||||||
|
list: async (resync = false) =>
|
||||||
|
(await api.get(`/api/domains${resync ? '?resync=true' : ''}`)).data,
|
||||||
|
resync: async () => (await api.post('/api/domains/resync')).data,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mailboxesAPI = {
|
||||||
|
list: async (domain, refreshQuota = false) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (domain) params.set('domain', domain);
|
||||||
|
if (refreshQuota) params.set('refreshQuota', 'true');
|
||||||
|
return (await api.get(`/api/mailboxes?${params.toString()}`)).data;
|
||||||
|
},
|
||||||
|
create: async (email, password) =>
|
||||||
|
(await api.post('/api/mailboxes', { email, password })).data,
|
||||||
|
remove: async (email) =>
|
||||||
|
(await api.delete(`/api/mailboxes/${encodeURIComponent(email)}`)).data,
|
||||||
|
setPassword: async (email, password) =>
|
||||||
|
(await api.post(`/api/mailboxes/${encodeURIComponent(email)}/password`, { password })).data,
|
||||||
|
setQuota: async (email, quota_gb) =>
|
||||||
|
(await api.post(`/api/mailboxes/${encodeURIComponent(email)}/quota`, { quota_gb })).data,
|
||||||
|
getRules: async (email) =>
|
||||||
|
(await api.get(`/api/mailboxes/${encodeURIComponent(email)}/rules`)).data,
|
||||||
|
putRules: async (email, payload) =>
|
||||||
|
(await api.put(`/api/mailboxes/${encodeURIComponent(email)}/rules`, payload)).data,
|
||||||
|
getBlocklist: async (email) =>
|
||||||
|
(await api.get(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`)).data,
|
||||||
|
putBlocklist: async (email, blocked_patterns) =>
|
||||||
|
(await api.put(`/api/mailboxes/${encodeURIComponent(email)}/blocklist`, { blocked_patterns })).data,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const auditAPI = {
|
||||||
|
list: async () => (await api.get('/api/audit')).data,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminsAPI = {
|
||||||
|
list: async () => (await api.get('/api/admins')).data,
|
||||||
|
create: async ({ email, password, role, allowed_domains }) =>
|
||||||
|
(await api.post('/api/admins', { email, password, role, allowed_domains })).data,
|
||||||
|
update: async (email, payload) =>
|
||||||
|
(await api.put(`/api/admins/${encodeURIComponent(email)}`, payload)).data,
|
||||||
|
remove: async (email) =>
|
||||||
|
(await api.delete(`/api/admins/${encodeURIComponent(email)}`)).data,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const billingAPI = {
|
||||||
|
summary: async (domain) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (domain) params.set('domain', domain);
|
||||||
|
const qs = params.toString();
|
||||||
|
return (await api.get(`/api/billing/summary${qs ? '?' + qs : ''}`)).data;
|
||||||
|
},
|
||||||
|
events: async ({ domain, from, to, limit } = {}) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (domain) params.set('domain', domain);
|
||||||
|
if (from) params.set('from', from);
|
||||||
|
if (to) params.set('to', to);
|
||||||
|
if (limit) params.set('limit', String(limit));
|
||||||
|
const qs = params.toString();
|
||||||
|
return (await api.get(`/api/billing/events${qs ? '?' + qs : ''}`)).data;
|
||||||
|
},
|
||||||
|
volume: async ({ domain, ym }) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('domain', domain);
|
||||||
|
if (ym) params.set('ym', ym);
|
||||||
|
return (await api.get(`/api/billing/volume?${params.toString()}`)).data;
|
||||||
|
},
|
||||||
|
volumeOverview: async ({ ym } = {}) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (ym) params.set('ym', ym);
|
||||||
|
const qs = params.toString();
|
||||||
|
return (await api.get(`/api/billing/volume-overview${qs ? '?' + qs : ''}`)).data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const healthAPI = {
|
||||||
|
getStatus: async (domain) => {
|
||||||
|
try {
|
||||||
|
return (await api.get(`/api/health/domains/${encodeURIComponent(domain)}`)).data;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.statusCode === 404) return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
runCheck: async (domain) =>
|
||||||
|
(await api.post(`/api/health/domains/${encodeURIComponent(domain)}/check`)).data,
|
||||||
|
};
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
:root { --bg:#f6f7fb; --card:#fff; --line:#e5e7eb; --text:#111827; --muted:#6b7280; --accent:#2563eb; --danger:#dc2626; --ok:#059669; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, sans-serif; background: var(--bg); color: var(--text); }
|
|
||||||
button, input, textarea, select { font: inherit; }
|
|
||||||
button { border: 0; border-radius: 10px; padding: 10px 14px; background: var(--accent); color: white; cursor: pointer; }
|
|
||||||
button.secondary { background: #eef2ff; color: #1e3a8a; }
|
|
||||||
button.danger { background: var(--danger); }
|
|
||||||
button.ghost { background: transparent; color: var(--accent); }
|
|
||||||
input, textarea, select { width: 100%; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: white; }
|
|
||||||
textarea { min-height: 110px; }
|
|
||||||
.header { display:flex; justify-content:space-between; align-items:center; padding: 18px 28px; background: white; border-bottom:1px solid var(--line); position: sticky; top: 0; z-index: 5; }
|
|
||||||
.brand { font-weight: 800; font-size: 20px; }
|
|
||||||
.container { max-width: 1280px; margin: 0 auto; padding: 24px; }
|
|
||||||
.grid { display:grid; grid-template-columns: 320px 1fr; gap: 20px; align-items:start; }
|
|
||||||
.card { background: var(--card); border:1px solid var(--line); border-radius: 18px; padding: 18px; box-shadow: 0 10px 25px rgba(15,23,42,.04); }
|
|
||||||
.card h2 { margin:0 0 14px; font-size: 18px; }
|
|
||||||
.list { display:flex; flex-direction:column; gap: 8px; }
|
|
||||||
.list-item { border:1px solid var(--line); border-radius: 14px; padding: 12px; background:#fff; cursor:pointer; }
|
|
||||||
.list-item.active { border-color: var(--accent); background:#eff6ff; }
|
|
||||||
.muted { color: var(--muted); font-size: 13px; }
|
|
||||||
.row { display:flex; gap: 10px; align-items:center; }
|
|
||||||
.row > * { flex: 1; }
|
|
||||||
.table { width:100%; border-collapse: collapse; }
|
|
||||||
.table th, .table td { text-align:left; padding: 10px; border-bottom:1px solid var(--line); vertical-align: top; }
|
|
||||||
.table th { font-size:12px; color:var(--muted); text-transform: uppercase; letter-spacing:.04em; }
|
|
||||||
.pill { display:inline-flex; align-items:center; padding: 3px 8px; border-radius: 999px; background:#f3f4f6; font-size:12px; }
|
|
||||||
.actions { display:flex; flex-wrap:wrap; gap:8px; }
|
|
||||||
.login { min-height: 100vh; display:grid; place-items:center; padding:24px; }
|
|
||||||
.login .card { width:min(420px, 100%); }
|
|
||||||
.error { color: var(--danger); margin: 10px 0; }
|
|
||||||
.success { color: #047857; margin: 10px 0; }
|
|
||||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(15,23,42,.35); display:grid; place-items:center; padding: 20px; z-index: 10; }
|
|
||||||
.modal { width:min(720px, 100%); max-height: 90vh; overflow:auto; }
|
|
||||||
.form-grid { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
|
|
||||||
.usage-cell { min-width: 220px; }
|
|
||||||
.usage-label { font-size: 13px; font-weight: 650; margin-bottom: 6px; }
|
|
||||||
.usage-bar { width: 100%; height: 10px; border-radius: 999px; background: #e5e7eb; overflow: hidden; border: 1px solid #d1d5db; }
|
|
||||||
.usage-bar-fill { height: 100%; border-radius: 999px; background: var(--ok); transition: width .25s ease; }
|
|
||||||
@media (max-width: 900px) { .grid { grid-template-columns:1fr; } .form-grid { grid-template-columns:1fr; } .header { padding:14px; } .container { padding:14px; } }
|
|
||||||
29
frontend/tailwind.config.js
Normal file
29
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#f0f9ff',
|
||||||
|
100: '#e0f2fe',
|
||||||
|
200: '#bae6fd',
|
||||||
|
300: '#7dd3fc',
|
||||||
|
400: '#38bdf8',
|
||||||
|
500: '#0ea5e9',
|
||||||
|
600: '#0284c7',
|
||||||
|
700: '#0369a1',
|
||||||
|
800: '#075985',
|
||||||
|
900: '#0c4a6e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
20
frontend/vite.config.js
Normal file
20
frontend/vite.config.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 3009,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
1939
mailadmin.txt
Normal file
1939
mailadmin.txt
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user