Domain Admin
This commit is contained in:
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);
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -5,9 +5,45 @@ import { requireAuth } from '../middleware/auth.js';
|
|||||||
export const auditRouter = Router();
|
export const auditRouter = Router();
|
||||||
auditRouter.use(requireAuth);
|
auditRouter.use(requireAuth);
|
||||||
|
|
||||||
auditRouter.get('/', async (_req, res) => {
|
auditRouter.get('/', async (req, res) => {
|
||||||
|
const user = req.user!;
|
||||||
|
|
||||||
|
if (user.role === 'super_admin') {
|
||||||
|
// Super admin sees everything.
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 200`,
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain admin: filter to entries that touch one of their domains.
|
||||||
|
// We fetch a slightly larger window because filtering happens after the LIMIT
|
||||||
|
// would skip relevant rows. 1000 should be plenty for a single domain.
|
||||||
|
const allowed = (user.allowed_domains ?? []).map((d) => d.toLowerCase());
|
||||||
|
if (allowed.length === 0) {
|
||||||
|
res.json([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 200`,
|
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 1000`,
|
||||||
);
|
);
|
||||||
res.json(result.rows);
|
|
||||||
|
const visible = result.rows
|
||||||
|
.filter((row) => {
|
||||||
|
const t = String(row.target_type ?? '').toLowerCase();
|
||||||
|
const id = String(row.target_id ?? '').toLowerCase();
|
||||||
|
if (t === 'domain') return allowed.includes(id);
|
||||||
|
if (t === 'mailbox') {
|
||||||
|
const at = id.lastIndexOf('@');
|
||||||
|
if (at < 0) return false;
|
||||||
|
return allowed.includes(id.slice(at + 1));
|
||||||
|
}
|
||||||
|
// 'node' and unknown types: globally scoped, hidden from domain admins.
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.slice(0, 200);
|
||||||
|
|
||||||
|
res.json(visible);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 { 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 +42,7 @@ 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((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;
|
||||||
@@ -93,4 +95,4 @@ try {
|
|||||||
|
|
||||||
app.listen(config.port, () => {
|
app.listen(config.port, () => {
|
||||||
console.log(`mailadmin listening on ${config.port} for ${config.nodeName}`);
|
console.log(`mailadmin listening on ${config.port} for ${config.nodeName}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
|
FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox,
|
||||||
|
FiUsers, FiUser,
|
||||||
} from 'react-icons/fi';
|
} from 'react-icons/fi';
|
||||||
|
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
@@ -12,6 +13,8 @@ import NewMailboxModal from './components/NewMailboxModal';
|
|||||||
import PasswordResetModal from './components/PasswordResetModal';
|
import PasswordResetModal from './components/PasswordResetModal';
|
||||||
import ConfirmDialog from './components/ConfirmDialog';
|
import ConfirmDialog from './components/ConfirmDialog';
|
||||||
import AuditLogModal from './components/AuditLogModal';
|
import AuditLogModal from './components/AuditLogModal';
|
||||||
|
import AdminUsersModal from './components/AdminUsersModal';
|
||||||
|
import ChangeMyPasswordModal from './components/ChangeMyPasswordModal';
|
||||||
|
|
||||||
import { authAPI, domainsAPI, mailboxesAPI } from './services/api';
|
import { authAPI, domainsAPI, mailboxesAPI } from './services/api';
|
||||||
|
|
||||||
@@ -23,19 +26,25 @@ function App() {
|
|||||||
const [selectedDomain, setSelectedDomain] = useState(null);
|
const [selectedDomain, setSelectedDomain] = useState(null);
|
||||||
const [mailboxes, setMailboxes] = useState([]);
|
const [mailboxes, setMailboxes] = useState([]);
|
||||||
|
|
||||||
const [busyMessage, setBusyMessage] = useState(''); // global blocking spinner
|
const [busyMessage, setBusyMessage] = useState('');
|
||||||
const [toast, setToast] = useState(null);
|
const [toast, setToast] = useState(null);
|
||||||
|
|
||||||
const [settingsTarget, setSettingsTarget] = useState(null); // { email, tab }
|
const [settingsTarget, setSettingsTarget] = useState(null);
|
||||||
const [pwTarget, setPwTarget] = useState(null);
|
const [pwTarget, setPwTarget] = useState(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
const [showNew, setShowNew] = useState(false);
|
const [showNew, setShowNew] = useState(false);
|
||||||
const [showAudit, setShowAudit] = useState(false);
|
const [showAudit, setShowAudit] = useState(false);
|
||||||
|
const [showAdmins, setShowAdmins] = useState(false);
|
||||||
|
const [showChangePw, setShowChangePw] = useState(false);
|
||||||
|
|
||||||
const showToast = useCallback((message, type = 'success') => {
|
const showToast = useCallback((message, type = 'success') => {
|
||||||
setToast({ message, type });
|
setToast({ message, type });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isSuperAdmin = user?.role === 'super_admin';
|
||||||
|
// Hide left column entirely if a domain admin only has a single domain.
|
||||||
|
const hideDomainList = !isSuperAdmin && domains.length <= 1;
|
||||||
|
|
||||||
// ---- data loading helpers ----
|
// ---- data loading helpers ----
|
||||||
const loadDomains = useCallback(async (resync = false) => {
|
const loadDomains = useCallback(async (resync = false) => {
|
||||||
const list = await domainsAPI.list(resync);
|
const list = await domainsAPI.list(resync);
|
||||||
@@ -63,13 +72,13 @@ function App() {
|
|||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// After login (or on first authenticated render): load domains.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
(async () => {
|
(async () => {
|
||||||
setBusyMessage('Loading domains...');
|
setBusyMessage('Loading domains...');
|
||||||
try {
|
try {
|
||||||
const list = await loadDomains(true);
|
// Only super admin should trigger an upstream DMS resync on login.
|
||||||
|
const list = await loadDomains(isSuperAdmin);
|
||||||
const first = list[0]?.domain || null;
|
const first = list[0]?.domain || null;
|
||||||
setSelectedDomain(first);
|
setSelectedDomain(first);
|
||||||
if (first) {
|
if (first) {
|
||||||
@@ -85,7 +94,6 @@ function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
// Switch domain -> reload mailboxes (with quota refresh, like the original).
|
|
||||||
const selectDomain = async (domain) => {
|
const selectDomain = async (domain) => {
|
||||||
if (domain === selectedDomain) return;
|
if (domain === selectedDomain) return;
|
||||||
setSelectedDomain(domain);
|
setSelectedDomain(domain);
|
||||||
@@ -104,7 +112,6 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
await domainsAPI.resync();
|
await domainsAPI.resync();
|
||||||
const list = await loadDomains(false);
|
const list = await loadDomains(false);
|
||||||
// If selected domain disappeared, fall back to first.
|
|
||||||
if (selectedDomain && !list.find((d) => d.domain === selectedDomain)) {
|
if (selectedDomain && !list.find((d) => d.domain === selectedDomain)) {
|
||||||
const first = list[0]?.domain || null;
|
const first = list[0]?.domain || null;
|
||||||
setSelectedDomain(first);
|
setSelectedDomain(first);
|
||||||
@@ -169,22 +176,34 @@ function App() {
|
|||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
|
<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">
|
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-xl font-bold text-gray-900">MailAdmin</h1>
|
<h1 className="text-xl font-bold text-gray-900">MailAdmin</h1>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500 truncate">
|
||||||
{user.email} · <span className="font-medium">{user.role}</span>
|
{user.email} · <span className="font-medium">{user.role}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
||||||
<button onClick={() => setShowAudit(true)} className="btn-secondary">
|
<button onClick={() => setShowAudit(true)} className="btn-secondary">
|
||||||
<FiList className="w-4 h-4 mr-2" />
|
<FiList className="w-4 h-4 mr-2" />
|
||||||
Audit Log
|
Audit Log
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleResync} className="btn-secondary">
|
{isSuperAdmin && (
|
||||||
<FiRefreshCw className="w-4 h-4 mr-2" />
|
<>
|
||||||
DMS Resync
|
<button onClick={() => setShowAdmins(true)} className="btn-secondary">
|
||||||
</button>
|
<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">
|
<button onClick={handleLogout} className="btn-ghost">
|
||||||
<FiLogOut className="w-4 h-4 mr-2" />
|
<FiLogOut className="w-4 h-4 mr-2" />
|
||||||
Logout
|
Logout
|
||||||
@@ -195,46 +214,50 @@ function App() {
|
|||||||
|
|
||||||
{/* Main */}
|
{/* Main */}
|
||||||
<main className="max-w-7xl mx-auto px-6 py-6">
|
<main className="max-w-7xl mx-auto px-6 py-6">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6 items-start">
|
<div className={`grid grid-cols-1 ${hideDomainList ? '' : 'lg:grid-cols-[320px_1fr]'} gap-6 items-start`}>
|
||||||
{/* Domains */}
|
{/* Domains - hidden when single-domain user */}
|
||||||
<section className="card">
|
{!hideDomainList && (
|
||||||
<h2 className="text-base font-semibold text-gray-900">Domains on this node</h2>
|
<section className="card">
|
||||||
<p className="text-xs text-gray-500 mt-1 mb-4">
|
<h2 className="text-base font-semibold text-gray-900">Domains on this node</h2>
|
||||||
Domains are discovered dynamically from DMS accounts.
|
<p className="text-xs text-gray-500 mt-1 mb-4">
|
||||||
</p>
|
{isSuperAdmin
|
||||||
|
? 'Domains are discovered dynamically from DMS accounts.'
|
||||||
|
: 'Your assigned domains.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
{domains.length === 0 ? (
|
{domains.length === 0 ? (
|
||||||
<p className="text-sm text-gray-400">No domains found yet.</p>
|
<p className="text-sm text-gray-400">No domains available.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{domains.map((d) => {
|
{domains.map((d) => {
|
||||||
const active = d.domain === selectedDomain;
|
const active = d.domain === selectedDomain;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={d.domain}
|
key={d.domain}
|
||||||
onClick={() => selectDomain(d.domain)}
|
onClick={() => selectDomain(d.domain)}
|
||||||
className={`text-left p-3 rounded-lg border transition-colors ${
|
className={`text-left p-3 rounded-lg border transition-colors ${
|
||||||
active
|
active
|
||||||
? 'border-primary-500 bg-primary-50'
|
? 'border-primary-500 bg-primary-50'
|
||||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-gray-900">{d.domain}</div>
|
<div className="font-semibold text-gray-900">{d.domain}</div>
|
||||||
<div className="text-xs text-gray-500 mt-0.5">
|
<div className="text-xs text-gray-500 mt-0.5">
|
||||||
{d.active_mailboxes || 0} inboxes · {formatBytes(d.used_bytes)}
|
{d.active_mailboxes || 0} inboxes · {formatBytes(d.used_bytes)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
<span className="pill">{d.current_node || d.node_name}</span>
|
<span className="pill">{d.current_node || d.node_name}</span>
|
||||||
<span className={d.status === 'active' ? 'pill-success' : 'pill'}>
|
<span className={d.status === 'active' ? 'pill-success' : 'pill'}>
|
||||||
{d.status}
|
{d.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Mailboxes */}
|
{/* Mailboxes */}
|
||||||
<section className="card relative">
|
<section className="card relative">
|
||||||
@@ -293,7 +316,6 @@ function App() {
|
|||||||
<td className="py-3 pr-4 text-xs text-gray-500 whitespace-nowrap">
|
<td className="py-3 pr-4 text-xs text-gray-500 whitespace-nowrap">
|
||||||
{new Date(m.updated_at).toLocaleString()}
|
{new Date(m.updated_at).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
{/* Actions: single row, no wrapping. Punkt 3 deiner Liste. */}
|
|
||||||
<td className="py-3 pr-2">
|
<td className="py-3 pr-2">
|
||||||
<div className="flex flex-row flex-nowrap items-center gap-2">
|
<div className="flex flex-row flex-nowrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -371,11 +393,21 @@ function App() {
|
|||||||
onClose={() => setShowAudit(false)}
|
onClose={() => setShowAudit(false)}
|
||||||
onToast={showToast}
|
onToast={showToast}
|
||||||
/>
|
/>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<AdminUsersModal
|
||||||
|
open={showAdmins}
|
||||||
|
currentUser={user}
|
||||||
|
onClose={() => setShowAdmins(false)}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ChangeMyPasswordModal
|
||||||
|
open={showChangePw}
|
||||||
|
onClose={() => setShowChangePw(false)}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Global blocking overlay (Punkt 2 deiner Liste) */}
|
|
||||||
{busyMessage && <LoadingOverlay message={busyMessage} fullscreen />}
|
{busyMessage && <LoadingOverlay message={busyMessage} fullscreen />}
|
||||||
|
|
||||||
{/* Toast */}
|
|
||||||
{toast && <Toast {...toast} onClose={() => setToast(null)} />}
|
{toast && <Toast {...toast} onClose={() => setToast(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
439
frontend/src/components/AdminUsersModal.jsx
Normal file
439
frontend/src/components/AdminUsersModal.jsx
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
import React, { useEffect, useMemo, 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-pane modal:
|
||||||
|
* - 'list' shows all admins
|
||||||
|
* - 'form' shows create/edit form
|
||||||
|
*
|
||||||
|
* editTarget is null for "create new" or an existing admin for "edit".
|
||||||
|
*/
|
||||||
|
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 sortedDomainNames = useMemo(
|
||||||
|
() => [...domains].map((d) => d.domain).sort(),
|
||||||
|
[domains]
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// For new admins password is required.
|
||||||
|
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;
|
||||||
|
// Don't send forbidden fields when editing self.
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="input-field"
|
||||||
|
disabled={isEdit}
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
/>
|
||||||
|
{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"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className={`input-field ${passwordTooShort ? 'border-red-500' : ''}`}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPw}
|
||||||
|
onChange={(e) => setConfirmPw(e.target.value)}
|
||||||
|
className={`input-field ${passwordMismatch ? 'border-red-500' : ''}`}
|
||||||
|
placeholder="Confirm"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</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;
|
||||||
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;
|
||||||
@@ -26,6 +26,8 @@ export const authAPI = {
|
|||||||
login: async (email, password) =>
|
login: async (email, password) =>
|
||||||
(await api.post('/api/auth/login', { email, password })).data,
|
(await api.post('/api/auth/login', { email, password })).data,
|
||||||
logout: async () => (await api.post('/api/auth/logout')).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 = {
|
export const domainsAPI = {
|
||||||
@@ -69,3 +71,13 @@ export const mailboxesAPI = {
|
|||||||
export const auditAPI = {
|
export const auditAPI = {
|
||||||
list: async () => (await api.get('/api/audit')).data,
|
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,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user