changes
This commit is contained in:
@@ -1,63 +1,147 @@
|
|||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS nodes (
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT UNIQUE NOT NULL,
|
name TEXT UNIQUE NOT NULL,
|
||||||
hostname TEXT NOT NULL,
|
hostname TEXT NOT NULL,
|
||||||
is_current BOOLEAN NOT NULL DEFAULT false,
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS domains (
|
CREATE TABLE IF NOT EXISTS domains (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id SERIAL PRIMARY KEY,
|
||||||
domain TEXT UNIQUE NOT NULL,
|
domain TEXT UNIQUE NOT NULL,
|
||||||
current_node TEXT NOT NULL,
|
node_name TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
first_seen_at TIMESTAMPTZ DEFAULT now(),
|
||||||
last_seen_at TIMESTAMPTZ,
|
last_seen_at TIMESTAMPTZ DEFAULT now()
|
||||||
last_synced_at TIMESTAMPTZ,
|
|
||||||
notes TEXT DEFAULT '',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS mailboxes (
|
CREATE TABLE IF NOT EXISTS mailboxes (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id SERIAL PRIMARY KEY,
|
||||||
email_address TEXT UNIQUE NOT NULL,
|
email_address TEXT UNIQUE NOT NULL,
|
||||||
local_part TEXT NOT NULL,
|
domain TEXT NOT NULL,
|
||||||
domain TEXT NOT NULL REFERENCES domains(domain) ON DELETE CASCADE,
|
|
||||||
node_name TEXT NOT NULL,
|
node_name TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
used_bytes BIGINT NOT NULL DEFAULT 0,
|
used_bytes BIGINT DEFAULT 0,
|
||||||
usage_scanned_at TIMESTAMPTZ,
|
last_usage_scan_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
first_seen_at TIMESTAMPTZ DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
last_seen_at TIMESTAMPTZ DEFAULT now()
|
||||||
deleted_at TIMESTAMPTZ
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_users (
|
CREATE TABLE IF NOT EXISTS admin_users (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id SERIAL PRIMARY KEY,
|
||||||
email TEXT UNIQUE NOT NULL,
|
email TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'super_admin',
|
role TEXT NOT NULL DEFAULT 'super_admin',
|
||||||
allowed_domains TEXT[] NOT NULL DEFAULT '{}',
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
active BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id SERIAL PRIMARY KEY,
|
||||||
actor_email TEXT,
|
actor_email TEXT,
|
||||||
action TEXT NOT NULL,
|
action TEXT NOT NULL,
|
||||||
target_type TEXT NOT NULL,
|
target TEXT,
|
||||||
target_id TEXT NOT NULL,
|
details JSONB,
|
||||||
details JSONB NOT NULL DEFAULT '{}',
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
ip_address TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_domains_node ON domains(current_node);
|
-- ============================================================
|
||||||
CREATE INDEX IF NOT EXISTS idx_mailboxes_domain ON mailboxes(domain);
|
-- Upgrade existing MVP database
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE nodes
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE nodes
|
||||||
|
ADD COLUMN IF NOT EXISTS is_current BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD COLUMN IF NOT EXISTS notes TEXT DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE mailboxes
|
||||||
|
ADD COLUMN IF NOT EXISTS local_part TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE mailboxes
|
||||||
|
ADD COLUMN IF NOT EXISTS quota_bytes BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE mailboxes
|
||||||
|
ADD COLUMN IF NOT EXISTS quota_percent NUMERIC(8,3);
|
||||||
|
|
||||||
|
ALTER TABLE mailboxes
|
||||||
|
ADD COLUMN IF NOT EXISTS message_count BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE mailboxes
|
||||||
|
ADD COLUMN IF NOT EXISTS message_limit BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE mailboxes
|
||||||
|
ADD COLUMN IF NOT EXISTS usage_scanned_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE mailboxes
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE mailboxes
|
||||||
|
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD COLUMN IF NOT EXISTS allowed_domains TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
|
||||||
|
|
||||||
|
ALTER TABLE audit_log
|
||||||
|
ADD COLUMN IF NOT EXISTS target_type TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE audit_log
|
||||||
|
ADD COLUMN IF NOT EXISTS target_id TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE audit_log
|
||||||
|
ADD COLUMN IF NOT EXISTS ip_address TEXT;
|
||||||
|
|
||||||
|
-- details existed already, but make it safer for newer code
|
||||||
|
ALTER TABLE audit_log
|
||||||
|
ALTER COLUMN details SET DEFAULT '{}';
|
||||||
|
|
||||||
|
-- Fill local_part for existing rows
|
||||||
|
UPDATE mailboxes
|
||||||
|
SET local_part = split_part(email_address, '@', 1)
|
||||||
|
WHERE local_part IS NULL
|
||||||
|
AND email_address LIKE '%@%';
|
||||||
|
|
||||||
|
-- Keep old and new usage timestamp columns in sync initially
|
||||||
|
UPDATE mailboxes
|
||||||
|
SET usage_scanned_at = last_usage_scan_at
|
||||||
|
WHERE usage_scanned_at IS NULL
|
||||||
|
AND last_usage_scan_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- Backfill new audit target columns from old target column
|
||||||
|
UPDATE audit_log
|
||||||
|
SET target_id = target
|
||||||
|
WHERE target_id IS NULL
|
||||||
|
AND target IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE audit_log
|
||||||
|
SET target_type = 'unknown'
|
||||||
|
WHERE target_type IS NULL;
|
||||||
|
|
||||||
|
-- Useful indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domains_node_name
|
||||||
|
ON domains(node_name);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mailboxes_domain
|
||||||
|
ON mailboxes(domain);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mailboxes_node_name
|
||||||
|
ON mailboxes(node_name);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_created
|
||||||
|
ON audit_log(created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_users_allowed_domains
|
||||||
|
ON admin_users USING GIN(allowed_domains);
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
|
|
||||||
@@ -10,73 +13,188 @@ function sleep(ms: number) {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBackendRootDir(): string {
|
||||||
|
const currentFile = fileURLToPath(import.meta.url);
|
||||||
|
const currentDir = path.dirname(currentFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* During development:
|
||||||
|
* backend/src/db.ts
|
||||||
|
*
|
||||||
|
* After TypeScript build:
|
||||||
|
* backend/dist/db.js
|
||||||
|
*
|
||||||
|
* We want:
|
||||||
|
* backend/
|
||||||
|
*/
|
||||||
|
if (currentDir.endsWith('/src') || currentDir.endsWith('\\src')) {
|
||||||
|
return path.resolve(currentDir, '..');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDir.endsWith('/dist') || currentDir.endsWith('\\dist')) {
|
||||||
|
return path.resolve(currentDir, '..');
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.resolve(currentDir, '..');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tableExists(tableName: string): Promise<boolean> {
|
||||||
|
const result = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = $1
|
||||||
|
) AS exists
|
||||||
|
`,
|
||||||
|
[tableName],
|
||||||
|
);
|
||||||
|
|
||||||
|
return Boolean(result.rows[0]?.exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMigrations() {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename TEXT UNIQUE NOT NULL,
|
||||||
|
executed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const backendRoot = getBackendRootDir();
|
||||||
|
const migrationsDir = path.join(backendRoot, 'migrations');
|
||||||
|
|
||||||
|
let files: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
files = await fs.readdir(migrationsDir);
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new Error(
|
||||||
|
`Migrations directory not found: ${migrationsDir}. ` +
|
||||||
|
`Make sure backend/migrations is copied into the Docker image. Original error: ${err.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlFiles = files
|
||||||
|
.filter((file) => file.endsWith('.sql'))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
for (const file of sqlFiles) {
|
||||||
|
const alreadyExecuted = await pool.query(
|
||||||
|
`SELECT 1 FROM schema_migrations WHERE filename = $1`,
|
||||||
|
[file],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Special case:
|
||||||
|
* The first MVP version created tables inline in db.ts and had no migration system.
|
||||||
|
* Therefore 001_init.sql must still run once to apply ALTER TABLE upgrades.
|
||||||
|
*
|
||||||
|
* For later migrations this mechanism prevents duplicate execution.
|
||||||
|
*/
|
||||||
|
const shouldRun =
|
||||||
|
alreadyExecuted.rowCount === 0 ||
|
||||||
|
file === '001_init.sql';
|
||||||
|
|
||||||
|
if (!shouldRun) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = path.join(migrationsDir, file);
|
||||||
|
const sql = await fs.readFile(fullPath, 'utf8');
|
||||||
|
|
||||||
|
console.log(`Running database migration: ${file}`);
|
||||||
|
|
||||||
|
await pool.query('BEGIN');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(sql);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO schema_migrations(filename)
|
||||||
|
VALUES ($1)
|
||||||
|
ON CONFLICT (filename) DO UPDATE
|
||||||
|
SET executed_at = now()
|
||||||
|
`,
|
||||||
|
[file],
|
||||||
|
);
|
||||||
|
|
||||||
|
await pool.query('COMMIT');
|
||||||
|
console.log(`✓ Migration completed: ${file}`);
|
||||||
|
} catch (err) {
|
||||||
|
await pool.query('ROLLBACK');
|
||||||
|
console.error(`✗ Migration failed: ${file}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedInitialAdmin() {
|
||||||
|
const adminEmail = process.env.MAILADMIN_ADMIN_EMAIL;
|
||||||
|
const adminPassword = process.env.MAILADMIN_ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
if (!adminEmail || !adminPassword) {
|
||||||
|
console.warn(
|
||||||
|
'MAILADMIN_ADMIN_EMAIL or MAILADMIN_ADMIN_PASSWORD is not set. Skipping initial admin creation.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await pool.query(
|
||||||
|
`SELECT id FROM admin_users WHERE email = $1`,
|
||||||
|
[adminEmail.toLowerCase()],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rowCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bcryptjs is already used by the auth route in the MVP.
|
||||||
|
* Dynamic import avoids requiring it before DB startup.
|
||||||
|
*/
|
||||||
|
const bcrypt = await import('bcryptjs');
|
||||||
|
const passwordHash = await bcrypt.hash(adminPassword, 12);
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO admin_users (
|
||||||
|
email,
|
||||||
|
password_hash,
|
||||||
|
role,
|
||||||
|
allowed_domains,
|
||||||
|
active
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, 'super_admin', '{}', true)
|
||||||
|
`,
|
||||||
|
[adminEmail.toLowerCase(), passwordHash],
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✓ Initial super admin created: ${adminEmail}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function initDb() {
|
export async function initDb() {
|
||||||
const maxAttempts = 30;
|
const maxAttempts = 30;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
await pool.query('SELECT 1');
|
await pool.query('SELECT 1');
|
||||||
console.log(`✓ PostgreSQL connected`);
|
console.log('✓ PostgreSQL connected');
|
||||||
|
|
||||||
await pool.query(`
|
await runMigrations();
|
||||||
CREATE TABLE IF NOT EXISTS nodes (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name TEXT UNIQUE NOT NULL,
|
|
||||||
hostname TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await pool.query(`
|
const hasAdminUsers = await tableExists('admin_users');
|
||||||
CREATE TABLE IF NOT EXISTS domains (
|
if (hasAdminUsers) {
|
||||||
id SERIAL PRIMARY KEY,
|
await seedInitialAdmin();
|
||||||
domain TEXT UNIQUE NOT NULL,
|
}
|
||||||
node_name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
first_seen_at TIMESTAMPTZ DEFAULT now(),
|
|
||||||
last_seen_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS mailboxes (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
email_address TEXT UNIQUE NOT NULL,
|
|
||||||
domain TEXT NOT NULL,
|
|
||||||
node_name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
used_bytes BIGINT DEFAULT 0,
|
|
||||||
last_usage_scan_at TIMESTAMPTZ,
|
|
||||||
first_seen_at TIMESTAMPTZ DEFAULT now(),
|
|
||||||
last_seen_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_users (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
email TEXT UNIQUE NOT NULL,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
role TEXT NOT NULL DEFAULT 'super_admin',
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
actor_email TEXT,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
target TEXT,
|
|
||||||
details JSONB,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`PostgreSQL not ready yet (${attempt}/${maxAttempts}): ${err.message}`
|
`PostgreSQL not ready yet (${attempt}/${maxAttempts}): ${err.message}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (attempt === maxAttempts) {
|
if (attempt === maxAttempts) {
|
||||||
|
|||||||
@@ -20,12 +20,70 @@ function ensureDomain(req: any, domain: string): void {
|
|||||||
if (!canAccessDomain(req.user, domain)) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
if (!canAccessDomain(req.user, domain)) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshQuotaForMailbox(emailAddress: string): Promise<void> {
|
||||||
|
const quota = await dms.getMailboxQuota(emailAddress);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE mailboxes
|
||||||
|
SET used_bytes=$2,
|
||||||
|
quota_bytes=$3,
|
||||||
|
quota_percent=$4,
|
||||||
|
message_count=$5,
|
||||||
|
message_limit=$6,
|
||||||
|
usage_scanned_at=now(),
|
||||||
|
updated_at=now()
|
||||||
|
WHERE email_address=$1`,
|
||||||
|
[
|
||||||
|
emailAddress,
|
||||||
|
quota.usedBytes,
|
||||||
|
quota.quotaBytes,
|
||||||
|
quota.quotaPercent,
|
||||||
|
quota.messageCount,
|
||||||
|
quota.messageLimit,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshQuotaForDomain(req: any, domain: string): Promise<number> {
|
||||||
|
ensureDomain(req, domain);
|
||||||
|
|
||||||
|
const rows = (await pool.query(
|
||||||
|
`SELECT email_address, domain
|
||||||
|
FROM mailboxes
|
||||||
|
WHERE node_name=$1 AND domain=$2 AND status='active'
|
||||||
|
ORDER BY email_address`,
|
||||||
|
[config.nodeName, domain],
|
||||||
|
)).rows;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!canAccessDomain(req.user!, row.domain)) continue;
|
||||||
|
try {
|
||||||
|
await refreshQuotaForMailbox(row.email_address);
|
||||||
|
count++;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Could not refresh quota for ${row.email_address}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
mailboxesRouter.get('/', async (req, res) => {
|
mailboxesRouter.get('/', async (req, res) => {
|
||||||
const domain = String(req.query.domain ?? '').toLowerCase();
|
const domain = String(req.query.domain ?? '').toLowerCase();
|
||||||
if (domain) ensureDomain(req, domain);
|
const refreshQuota = String(req.query.refreshQuota ?? '').toLowerCase() === 'true';
|
||||||
|
|
||||||
|
if (domain) {
|
||||||
|
ensureDomain(req, domain);
|
||||||
|
if (refreshQuota) {
|
||||||
|
const count = await refreshQuotaForDomain(req, domain);
|
||||||
|
await audit(req.user!.email, 'mailbox.quota_refresh', 'domain', domain, { count }, req.ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const params: unknown[] = [config.nodeName];
|
const params: unknown[] = [config.nodeName];
|
||||||
let where = 'WHERE node_name=$1';
|
let where = 'WHERE node_name=$1';
|
||||||
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(
|
||||||
`SELECT * FROM mailboxes ${where} ORDER BY domain, local_part`,
|
`SELECT * FROM mailboxes ${where} ORDER BY domain, local_part`,
|
||||||
params,
|
params,
|
||||||
@@ -40,6 +98,7 @@ mailboxesRouter.post('/', async (req, res) => {
|
|||||||
ensureDomain(req, domain);
|
ensureDomain(req, domain);
|
||||||
await dms.addMailbox(email, body.password);
|
await dms.addMailbox(email, body.password);
|
||||||
await sync.syncFromDms();
|
await sync.syncFromDms();
|
||||||
|
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);
|
||||||
res.status(201).json({ email, domain, local_part: localPartFromEmail(email) });
|
res.status(201).json({ email, domain, local_part: localPartFromEmail(email) });
|
||||||
});
|
});
|
||||||
@@ -64,24 +123,6 @@ mailboxesRouter.post('/:email/password', async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
mailboxesRouter.post('/usage/rescan', async (req, res) => {
|
|
||||||
const domain = String(req.body?.domain ?? '').toLowerCase();
|
|
||||||
if (domain) ensureDomain(req, domain);
|
|
||||||
const params: unknown[] = [config.nodeName];
|
|
||||||
let where = `WHERE node_name=$1 AND status='active'`;
|
|
||||||
if (domain) { params.push(domain); where += ` AND domain=$${params.length}`; }
|
|
||||||
const rows = (await pool.query(`SELECT email_address, domain FROM mailboxes ${where}`, params)).rows;
|
|
||||||
let count = 0;
|
|
||||||
for (const row of rows) {
|
|
||||||
if (!canAccessDomain(req.user!, row.domain)) continue;
|
|
||||||
const bytes = await dms.getMailboxUsageBytes(row.email_address);
|
|
||||||
await pool.query(`UPDATE mailboxes SET used_bytes=$2, usage_scanned_at=now(), updated_at=now() WHERE email_address=$1`, [row.email_address, bytes]);
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
await audit(req.user!.email, 'mailbox.usage_rescan', 'domain', domain || '*', { count }, req.ip);
|
|
||||||
res.json({ count });
|
|
||||||
});
|
|
||||||
|
|
||||||
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));
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { stat } from 'node:fs/promises';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { run } from '../utils/shell.js';
|
import { run } from '../utils/shell.js';
|
||||||
import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js';
|
import { domainFromEmail, localPartFromEmail, normalizeEmail } from '../utils/email.js';
|
||||||
@@ -11,6 +9,14 @@ export interface DmsAccount {
|
|||||||
domain: string;
|
domain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DmsQuota {
|
||||||
|
usedBytes: number;
|
||||||
|
quotaBytes: number | null;
|
||||||
|
quotaPercent: number | null;
|
||||||
|
messageCount: number | null;
|
||||||
|
messageLimit: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
function parseAccounts(output: string): DmsAccount[] {
|
function parseAccounts(output: string): DmsAccount[] {
|
||||||
const accounts: DmsAccount[] = [];
|
const accounts: DmsAccount[] = [];
|
||||||
for (const line of output.split('\n')) {
|
for (const line of output.split('\n')) {
|
||||||
@@ -22,6 +28,60 @@ function parseAccounts(output: string): DmsAccount[] {
|
|||||||
return [...new Map(accounts.map((a) => [a.email, a])).values()].sort((a, b) => a.email.localeCompare(b.email));
|
return [...new Map(accounts.map((a) => [a.email, a])).values()].sort((a, b) => a.email.localeCompare(b.email));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseQuotaNumber(value: string | undefined): number | null {
|
||||||
|
if (!value || value === '-' || value.trim() === '') return null;
|
||||||
|
const parsed = Number.parseInt(value.trim(), 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDoveadmQuota(output: string): DmsQuota {
|
||||||
|
let usedStorageKb: number | null = null;
|
||||||
|
let storageLimitKb: number | null = null;
|
||||||
|
let storagePercent: number | null = null;
|
||||||
|
let messageCount: number | null = null;
|
||||||
|
let messageLimit: number | null = null;
|
||||||
|
|
||||||
|
for (const line of output.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.toLowerCase().startsWith('quota name')) continue;
|
||||||
|
|
||||||
|
const parts = trimmed.split(/\s+/);
|
||||||
|
const typeIndex = parts.findIndex((p) => p === 'STORAGE' || p === 'MESSAGE');
|
||||||
|
if (typeIndex < 0) continue;
|
||||||
|
|
||||||
|
const type = parts[typeIndex];
|
||||||
|
const value = parseQuotaNumber(parts[typeIndex + 1]);
|
||||||
|
const limit = parseQuotaNumber(parts[typeIndex + 2]);
|
||||||
|
const percent = parseQuotaNumber(parts[typeIndex + 3]);
|
||||||
|
|
||||||
|
if (type === 'STORAGE') {
|
||||||
|
usedStorageKb = value;
|
||||||
|
storageLimitKb = limit;
|
||||||
|
storagePercent = percent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'MESSAGE') {
|
||||||
|
messageCount = value;
|
||||||
|
messageLimit = limit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedBytes = Math.max(0, usedStorageKb ?? 0) * 1024;
|
||||||
|
const quotaBytes = storageLimitKb && storageLimitKb > 0 ? storageLimitKb * 1024 : null;
|
||||||
|
|
||||||
|
// Dovecot's % column is integer-rounded. For the UI we calculate a more useful
|
||||||
|
// value ourselves, so small accounts do not always show exactly 0%.
|
||||||
|
const calculatedPercent = quotaBytes && quotaBytes > 0 ? (usedBytes / quotaBytes) * 100 : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
usedBytes,
|
||||||
|
quotaBytes,
|
||||||
|
quotaPercent: calculatedPercent ?? storagePercent,
|
||||||
|
messageCount,
|
||||||
|
messageLimit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class DmsService {
|
export class DmsService {
|
||||||
async listAccounts(): Promise<DmsAccount[]> {
|
async listAccounts(): Promise<DmsAccount[]> {
|
||||||
const { stdout } = await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'list']);
|
const { stdout } = await run('docker', ['exec', config.dmsContainer, 'setup', 'email', 'list']);
|
||||||
@@ -58,26 +118,13 @@ export class DmsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMailboxUsageBytes(email: string): Promise<number> {
|
async getMailboxQuota(email: string): Promise<DmsQuota> {
|
||||||
const domain = domainFromEmail(email);
|
const normalized = normalizeEmail(email);
|
||||||
const local = localPartFromEmail(email);
|
const { stdout } = await run(
|
||||||
const candidates = [
|
'docker',
|
||||||
join(config.mailDataPath, domain, local),
|
['exec', config.dmsContainer, 'doveadm', 'quota', 'get', '-u', normalized],
|
||||||
join(config.mailDataPath, domain, `${local}/`),
|
60000,
|
||||||
join(config.mailDataPath, domain, `${local}/Maildir`),
|
);
|
||||||
join(config.mailDataPath, email),
|
return parseDoveadmQuota(stdout);
|
||||||
];
|
|
||||||
|
|
||||||
for (const p of candidates) {
|
|
||||||
try {
|
|
||||||
await stat(p);
|
|
||||||
const { stdout } = await run('du', ['-sb', p], 60000);
|
|
||||||
const value = parseInt(stdout.trim().split(/\s+/)[0] ?? '0', 10);
|
|
||||||
return Number.isFinite(value) ? value : 0;
|
|
||||||
} catch {
|
|
||||||
// try next path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,28 @@ function bytes(n) {
|
|||||||
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[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])); }
|
function esc(s) { return String(s ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); }
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -29,11 +51,15 @@ async function init() {
|
|||||||
async function loadDomains(resync = false) {
|
async function loadDomains(resync = false) {
|
||||||
state.domains = await api(`/api/domains${resync ? '?resync=true' : ''}`);
|
state.domains = await api(`/api/domains${resync ? '?resync=true' : ''}`);
|
||||||
if (!state.selectedDomain && state.domains.length) state.selectedDomain = state.domains[0].domain;
|
if (!state.selectedDomain && state.domains.length) state.selectedDomain = state.domains[0].domain;
|
||||||
if (state.selectedDomain) await loadMailboxes();
|
if (state.selectedDomain) await loadMailboxes(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMailboxes() {
|
async function loadMailboxes(refreshQuota = false) {
|
||||||
state.mailboxes = await api(`/api/mailboxes?domain=${encodeURIComponent(state.selectedDomain)}`);
|
if (!state.selectedDomain) {
|
||||||
|
state.mailboxes = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.mailboxes = await api(`/api/mailboxes?domain=${encodeURIComponent(state.selectedDomain)}${refreshQuota ? '&refreshQuota=true' : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAudit() {
|
async function loadAudit() {
|
||||||
@@ -41,6 +67,22 @@ async function loadAudit() {
|
|||||||
renderAuditModal();
|
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() {
|
function render() {
|
||||||
if (!state.user) return renderLogin();
|
if (!state.user) return renderLogin();
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
@@ -70,8 +112,8 @@ function render() {
|
|||||||
</section>
|
</section>
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div style="display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:14px">
|
<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.</div></div>
|
<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><button class="secondary" id="usageBtn">Scan usage</button></div>
|
<div class="actions"><button id="newMailboxBtn">New mailbox</button></div>
|
||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th>Email</th><th>Status</th><th>Usage</th><th>Updated</th><th>Actions</th></tr></thead>
|
<thead><tr><th>Email</th><th>Status</th><th>Usage</th><th>Updated</th><th>Actions</th></tr></thead>
|
||||||
@@ -80,7 +122,7 @@ function render() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td><strong>${esc(m.email_address)}</strong><div class="muted">${esc(m.node_name)}</div></td>
|
<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><span class="pill">${esc(m.status)}</span></td>
|
||||||
<td>${bytes(m.used_bytes)}<div class="muted">${m.usage_scanned_at ? new Date(m.usage_scanned_at).toLocaleString() : 'not scanned'}</div></td>
|
<td>${renderUsage(m)}</td>
|
||||||
<td class="muted">${new Date(m.updated_at).toLocaleString()}</td>
|
<td class="muted">${new Date(m.updated_at).toLocaleString()}</td>
|
||||||
<td><div class="actions">
|
<td><div class="actions">
|
||||||
<button class="secondary" data-rules="${esc(m.email_address)}">Rules</button>
|
<button class="secondary" data-rules="${esc(m.email_address)}">Rules</button>
|
||||||
@@ -99,8 +141,7 @@ function 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('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('auditBtn').onclick = guard(loadAudit);
|
||||||
document.getElementById('newMailboxBtn').onclick = renderCreateMailboxModal;
|
document.getElementById('newMailboxBtn').onclick = renderCreateMailboxModal;
|
||||||
document.getElementById('usageBtn').onclick = guard(async () => { await api('/api/mailboxes/usage/rescan', { method:'POST', body: JSON.stringify({ domain: state.selectedDomain }) }); await loadMailboxes(); render(); });
|
document.querySelectorAll('[data-domain]').forEach(el => el.onclick = guard(async () => { state.selectedDomain = el.dataset.domain; await loadMailboxes(true); render(); }));
|
||||||
document.querySelectorAll('[data-domain]').forEach(el => el.onclick = guard(async () => { state.selectedDomain = el.dataset.domain; await loadMailboxes(); render(); }));
|
|
||||||
document.querySelectorAll('[data-delete]').forEach(el => el.onclick = () => renderDeleteModal(el.dataset.delete));
|
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-password]').forEach(el => el.onclick = () => renderPasswordModal(el.dataset.password));
|
||||||
document.querySelectorAll('[data-rules]').forEach(el => el.onclick = () => renderRulesModal(el.dataset.rules));
|
document.querySelectorAll('[data-rules]').forEach(el => el.onclick = () => renderRulesModal(el.dataset.rules));
|
||||||
@@ -127,13 +168,17 @@ function modal(html) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderCreateMailboxModal() {
|
function renderCreateMailboxModal() {
|
||||||
const d = modal(`<h2>New mailbox</h2><form id="createForm" class="form-grid"><label>Email<input name="email" type="email" value="@${esc(state.selectedDomain || '')}" required></label><label>Password<input name="password" type="password" minlength="8" required></label><div><button>Create</button></div></form>`);
|
const domain = state.selectedDomain || '';
|
||||||
|
const d = modal(`<h2>New mailbox</h2><form id="createForm" class="form-grid"><label>Email<input name="email" type="email" value="@${esc(domain)}" placeholder="@${esc(domain)}" required></label><label>Password<input name="password" type="password" value="" minlength="8" autocomplete="new-password" required></label><div><button>Create</button></div></form>`);
|
||||||
|
const input = d.querySelector('input[name="email"]');
|
||||||
|
input.focus();
|
||||||
|
input.setSelectionRange(0, 0);
|
||||||
d.querySelector('#createForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api('/api/mailboxes', { method:'POST', body: JSON.stringify({ email:f.get('email'), password:f.get('password') }) }); d.remove(); await loadDomains(false); render(); });
|
d.querySelector('#createForm').onsubmit = guard(async e => { e.preventDefault(); const f = new FormData(e.target); await api('/api/mailboxes', { method:'POST', body: JSON.stringify({ email:f.get('email'), password:f.get('password') }) }); d.remove(); await loadDomains(false); render(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDeleteModal(email) {
|
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>`);
|
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(); render(); });
|
d.querySelector('#confirmDelete').onclick = guard(async () => { await api(`/api/mailboxes/${encodeURIComponent(email)}`, { method:'DELETE' }); d.remove(); await loadMailboxes(true); render(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPasswordModal(email) {
|
function renderPasswordModal(email) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
:root { --bg:#f6f7fb; --card:#fff; --line:#e5e7eb; --text:#111827; --muted:#6b7280; --accent:#2563eb; --danger:#dc2626; }
|
:root { --bg:#f6f7fb; --card:#fff; --line:#e5e7eb; --text:#111827; --muted:#6b7280; --accent:#2563eb; --danger:#dc2626; --ok:#059669; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, sans-serif; background: var(--bg); color: var(--text); }
|
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, input, textarea, select { font: inherit; }
|
||||||
@@ -32,4 +32,8 @@ textarea { min-height: 110px; }
|
|||||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(15,23,42,.35); display:grid; place-items:center; padding: 20px; z-index: 10; }
|
.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; }
|
.modal { width:min(720px, 100%); max-height: 90vh; overflow:auto; }
|
||||||
.form-grid { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
|
.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; } }
|
@media (max-width: 900px) { .grid { grid-template-columns:1fr; } .form-grid { grid-template-columns:1fr; } .header { padding:14px; } .container { padding:14px; } }
|
||||||
Reference in New Issue
Block a user