From 85b608e3d42b4ddb27848b595d0809b40d3af25a Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Mon, 27 Apr 2026 16:57:08 -0500 Subject: [PATCH] Domain Admin --- .../002_admin_users_self_service.sql | 26 ++ backend/src/middleware/auth.ts | 12 + backend/src/routes/admins.ts | 158 +++++++ backend/src/routes/audit.ts | 42 +- backend/src/routes/auth.ts | 28 ++ backend/src/server.ts | 4 +- frontend/src/App.jsx | 146 +++--- frontend/src/components/AdminUsersModal.jsx | 439 ++++++++++++++++++ .../src/components/ChangeMyPasswordModal.jsx | 97 ++++ frontend/src/services/api.js | 12 + 10 files changed, 903 insertions(+), 61 deletions(-) create mode 100644 backend/migrations/002_admin_users_self_service.sql create mode 100644 backend/src/routes/admins.ts create mode 100644 frontend/src/components/AdminUsersModal.jsx create mode 100644 frontend/src/components/ChangeMyPasswordModal.jsx diff --git a/backend/migrations/002_admin_users_self_service.sql b/backend/migrations/002_admin_users_self_service.sql new file mode 100644 index 0000000..9dc2772 --- /dev/null +++ b/backend/migrations/002_admin_users_self_service.sql @@ -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); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index b0d3367..4ab11e2 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -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 { return user.role === 'super_admin' || user.allowed_domains.includes(domain.toLowerCase()); } diff --git a/backend/src/routes/admins.ts b/backend/src/routes/admins.ts new file mode 100644 index 0000000..3454be5 --- /dev/null +++ b/backend/src/routes/admins.ts @@ -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 = {}; + 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 }); +}); diff --git a/backend/src/routes/audit.ts b/backend/src/routes/audit.ts index b10cf4b..4a6a253 100644 --- a/backend/src/routes/audit.ts +++ b/backend/src/routes/audit.ts @@ -5,9 +5,45 @@ import { requireAuth } from '../middleware/auth.js'; export const auditRouter = Router(); 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( - `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); }); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index be78a0b..05d205a 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { pool } from '../db.js'; import { config } from '../config.js'; import { requireAuth, signUser } from '../middleware/auth.js'; +import { audit } from '../services/audit.js'; export const authRouter = Router(); @@ -38,3 +39,30 @@ authRouter.post('/logout', (_req, res) => { authRouter.get('/me', requireAuth, (req, res) => { 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 }); +}); diff --git a/backend/src/server.ts b/backend/src/server.ts index a5f9e52..7ae8fec 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -9,6 +9,7 @@ import { authRouter } from './routes/auth.js'; import { domainsRouter } from './routes/domains.js'; import { mailboxesRouter } from './routes/mailboxes.js'; import { auditRouter } from './routes/audit.js'; +import { adminsRouter } from './routes/admins.js'; import { SyncService } from './services/sync.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -41,6 +42,7 @@ app.use('/api/auth', authRouter); app.use('/api/domains', domainsRouter); app.use('/api/mailboxes', mailboxesRouter); app.use('/api/audit', auditRouter); +app.use('/api/admins', adminsRouter); app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => { const status = err.status ?? err.statusCode ?? 500; @@ -93,4 +95,4 @@ try { app.listen(config.port, () => { console.log(`mailadmin listening on ${config.port} for ${config.nodeName}`); -}); \ No newline at end of file +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8e6472a..453e16d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { FiRefreshCw, FiList, FiLogOut, FiSettings, FiKey, FiTrash2, FiPlus, FiInbox, + FiUsers, FiUser, } from 'react-icons/fi'; import Login from './components/Login'; @@ -12,6 +13,8 @@ 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 { authAPI, domainsAPI, mailboxesAPI } from './services/api'; @@ -23,19 +26,25 @@ function App() { const [selectedDomain, setSelectedDomain] = useState(null); const [mailboxes, setMailboxes] = useState([]); - const [busyMessage, setBusyMessage] = useState(''); // global blocking spinner + const [busyMessage, setBusyMessage] = useState(''); const [toast, setToast] = useState(null); - const [settingsTarget, setSettingsTarget] = useState(null); // { email, tab } + 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 showToast = useCallback((message, type = 'success') => { 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 ---- const loadDomains = useCallback(async (resync = false) => { const list = await domainsAPI.list(resync); @@ -63,13 +72,13 @@ function App() { })(); }, []); - // After login (or on first authenticated render): load domains. useEffect(() => { if (!user) return; (async () => { setBusyMessage('Loading domains...'); 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; setSelectedDomain(first); if (first) { @@ -85,7 +94,6 @@ function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [user]); - // Switch domain -> reload mailboxes (with quota refresh, like the original). const selectDomain = async (domain) => { if (domain === selectedDomain) return; setSelectedDomain(domain); @@ -104,7 +112,6 @@ function App() { try { await domainsAPI.resync(); const list = await loadDomains(false); - // If selected domain disappeared, fall back to first. if (selectedDomain && !list.find((d) => d.domain === selectedDomain)) { const first = list[0]?.domain || null; setSelectedDomain(first); @@ -169,22 +176,34 @@ function App() {
{/* Header */}
-
-
+
+

MailAdmin

-

+

{user.email} · {user.role}

-
+
+ - + {isSuperAdmin && ( + <> + + + + )} - ); - })} -
- )} - + {domains.length === 0 ? ( +

No domains available.

+ ) : ( +
+ {domains.map((d) => { + const active = d.domain === selectedDomain; + return ( + + ); + })} +
+ )} + + )} {/* Mailboxes */}
@@ -293,7 +316,6 @@ function App() { {new Date(m.updated_at).toLocaleString()} - {/* Actions: single row, no wrapping. Punkt 3 deiner Liste. */}
); diff --git a/frontend/src/components/AdminUsersModal.jsx b/frontend/src/components/AdminUsersModal.jsx new file mode 100644 index 0000000..21319db --- /dev/null +++ b/frontend/src/components/AdminUsersModal.jsx @@ -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 ( + +
+ {loading && } + + {view === 'list' && !loading && ( + { setEditTarget(null); setView('form'); }} + onEdit={(a) => { setEditTarget(a); setView('form'); }} + onDelete={(email) => setDeleteTarget(email)} + /> + )} + + {view === 'form' && !loading && ( + { setView('list'); setEditTarget(null); }} + onSaved={async () => { + await reload(); + setView('list'); + setEditTarget(null); + }} + onToast={onToast} + /> + )} +
+ + setDeleteTarget(null)} + /> +
+ ); +}; + +// ============================================================ +// Sub: AdminList +// ============================================================ +const AdminList = ({ admins, currentUserEmail, onCreate, onEdit, onDelete }) => { + return ( +
+
+ +
+ + {admins.length === 0 ? ( +

No admins yet.

+ ) : ( +
+ + + + + + + + + + + + {admins.map((a) => { + const isMe = a.email === currentUserEmail; + return ( + + + + + + + + ); + })} + +
EmailRoleDomainsStatusActions
+
+ {a.role === 'super_admin' + ? + : } + {a.email} + {isMe && you} +
+
+ {a.role === 'super_admin' + ? super_admin + : domain_admin} + + {a.role === 'super_admin' ? ( + all domains + ) : ( +
+ {(a.allowed_domains || []).map((d) => ( + {d} + ))} +
+ )} +
+ + {a.active ? 'active' : 'disabled'} + + +
+ + +
+
+
+ )} +
+ ); +}; + +// ============================================================ +// 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 ( +
+ + +
+ + setEmail(e.target.value)} + className="input-field" + disabled={isEdit} + placeholder="admin@example.com" + /> + {isEdit && ( +

Email cannot be changed after creation.

+ )} +
+ +
+ +
+ setPassword(e.target.value)} + className={`input-field ${passwordTooShort ? 'border-red-500' : ''}`} + placeholder="At least 8 characters" + /> + setConfirmPw(e.target.value)} + className={`input-field ${passwordMismatch ? 'border-red-500' : ''}`} + placeholder="Confirm" + /> +
+ {passwordTooShort &&

Minimum 8 characters.

} + {!passwordTooShort && passwordMismatch && ( +

Passwords do not match.

+ )} +
+ +
+ +
+ + +
+ {isSelf && ( +

You can't change your own role.

+ )} +
+ + {role === 'domain_admin' && ( +
+ + {sortedDomainNames.length === 0 ? ( +

No domains in the system yet.

+ ) : ( +
+ {sortedDomainNames.map((d) => ( + + ))} +
+ )} +
+ )} + + {isEdit && !isSelf && ( +
+
+

Account active

+

+ Disabled accounts cannot log in. +

+
+ +
+ )} + + {error &&

{error}

} + +
+ + +
+
+ ); +}; + +export default AdminUsersModal; diff --git a/frontend/src/components/ChangeMyPasswordModal.jsx b/frontend/src/components/ChangeMyPasswordModal.jsx new file mode 100644 index 0000000..67da9c0 --- /dev/null +++ b/frontend/src/components/ChangeMyPasswordModal.jsx @@ -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 ( + +
+
+ + setCurrentPw(e.target.value)} + className="input-field" + autoFocus + /> +
+ +
+ + setNewPw(e.target.value)} + className={`input-field ${tooShort ? 'border-red-500 focus:ring-red-500' : ''}`} + minLength={8} + /> + {tooShort &&

Minimum 8 characters.

} +
+ +
+ + 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 &&

Passwords do not match.

} + {!mismatch && confirmPw.length > 0 && newPw === confirmPw && newPw.length >= 8 && ( +

Passwords match.

+ )} +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +}; + +export default ChangeMyPasswordModal; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index e0f96a7..9b08d7c 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -26,6 +26,8 @@ export const authAPI = { 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 = { @@ -69,3 +71,13 @@ export const mailboxesAPI = { 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, +};