From d46429cddae0e096dff089c8a539fd45bc2067d1 Mon Sep 17 00:00:00 2001 From: "rootminster-bot[bot]" Date: Fri, 23 Oct 2026 14:11:27 +0000 Subject: [PATCH] feat: domain provider role - Add 'domain_provider' to users role CHECK constraint with migration for existing databases - New adminAssignDomainProvider function: admin-only, assigns or removes a domain provider by linking provider_user_id/email on Domain entity; enforces that the target user already has the domain_provider role before assignment - New /api/domain-provider/stats and /api/domain-provider/domains routes: returns per-domain and total stats (active subdomains, pending requests, DNS records, new requests in 7 days) scoped to the authenticated provider; admins can query any provider via ?user_id= - entity-routes: domain providers see record_count and last_synced for their assigned domains (zone_id, nameservers, notes still redacted) - rootminsterClient: domainProvider.stats() and domainProvider.domains() API - DomainProviderDashboard page: stat cards, per-domain breakdown, refresh - AdminUsers: domain_provider badge (emerald), 'Domain Provider' action button for user-role accounts, demote path, Domain Providers count in stats bar - AdminDomains: 'Assign/Provider' button per domain opens a modal that lists domain_provider users and shows/removes the current assignment - App.jsx: /domain-provider-dashboard route protected by domain_provider|admin - Layout.jsx: 'Domain Provider' sidebar section and command palette entry Validated: eslint clean, 309/309 tests pass, production build emits 0 source maps --- server/domain-provider-routes.js | 109 ++++++++++++ server/entity-routes.js | 7 +- server/function-runner.js | 2 +- server/functions/adminAssignDomainProvider.js | 70 ++++++++ server/index.js | 2 + server/schema.sql | 34 +++- src/App.jsx | 6 + src/api/rootminsterClient.js | 12 ++ src/components/Layout.jsx | 19 ++- src/pages/AdminDomains.jsx | 103 +++++++++++- src/pages/AdminUsers.jsx | 44 ++++- src/pages/DomainProviderDashboard.jsx | 156 ++++++++++++++++++ 12 files changed, 556 insertions(+), 8 deletions(-) create mode 100644 server/domain-provider-routes.js create mode 100644 server/functions/adminAssignDomainProvider.js create mode 100644 src/pages/DomainProviderDashboard.jsx diff --git a/server/domain-provider-routes.js b/server/domain-provider-routes.js new file mode 100644 index 0000000..ccfc75d --- /dev/null +++ b/server/domain-provider-routes.js @@ -0,0 +1,109 @@ +import { authenticateRequest } from './auth.js'; +import { pool } from './database.js'; +import { store } from './store.js'; + +/** + * Dedicated routes for domain provider users. + * A domain provider is a user with role='domain_provider' who has been assigned + * one or more donated domains by an admin (Domain.provider_user_id = user.id). + */ +export async function registerDomainProviderRoutes(app) { + /** + * GET /api/domain-provider/stats + * Returns stats for all domains assigned to the authenticated domain_provider. + * Accessible only to users with role='domain_provider' or 'admin'. + */ + app.get('/api/domain-provider/stats', async (request, reply) => { + const user = await authenticateRequest(request); + if (!user) return reply.code(401).send({ error: 'Unauthorized' }); + if (user.role !== 'domain_provider' && user.role !== 'admin') { + return reply.code(403).send({ error: 'Forbidden' }); + } + + // Admins may supply ?user_id= to look up any provider; providers see only their own + const targetUserId = user.role === 'admin' && request.query?.user_id + ? request.query.user_id + : user.id; + + // Find all domains assigned to this provider + const domains = await store.filter('Domain', { provider_user_id: targetUserId }); + + if (!domains.length) { + return { data: { domains: [], totals: { active_subdomains: 0, pending_requests: 0, dns_records: 0, new_requests_7d: 0 } } }; + } + + const domainNames = domains.map((d) => d.name); + + // Per-domain stats from entity_records + const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + const statsResult = await pool.query( + `SELECT + data->>'domain' AS domain_name, + count(*) FILTER (WHERE entity_type = 'SubdomainRequest' AND data->>'status' = 'pending') AS pending_requests, + count(*) FILTER (WHERE entity_type = 'SubdomainRequest' AND data->>'status' = 'approved') AS approved_subdomains, + count(*) FILTER (WHERE entity_type = 'SubdomainRequest' AND created_at >= $2) AS new_requests_7d, + count(*) FILTER (WHERE entity_type = 'DnsRecord' AND (data->>'managed')::boolean = true) AS dns_records + FROM entity_records + WHERE (entity_type IN ('SubdomainRequest', 'DnsRecord')) + AND data->>'domain' = ANY($1::text[]) + GROUP BY data->>'domain'`, + [domainNames, weekAgo], + ); + + const statsByDomain = Object.fromEntries(statsResult.rows.map((row) => [row.domain_name, row])); + + const domainStats = domains.map((domain) => { + const s = statsByDomain[domain.name] || {}; + return { + id: domain.id, + name: domain.name, + status: domain.status, + allow_new_requests: domain.allow_new_requests, + record_count: domain.record_count ?? null, + last_synced: domain.last_synced ?? null, + provider_assigned_at: domain.provider_assigned_at ?? null, + stats: { + pending_requests: parseInt(s.pending_requests || 0, 10), + approved_subdomains: parseInt(s.approved_subdomains || 0, 10), + new_requests_7d: parseInt(s.new_requests_7d || 0, 10), + dns_records: parseInt(s.dns_records || 0, 10), + }, + }; + }); + + const totals = domainStats.reduce( + (acc, d) => ({ + active_subdomains: acc.active_subdomains + d.stats.approved_subdomains, + pending_requests: acc.pending_requests + d.stats.pending_requests, + dns_records: acc.dns_records + d.stats.dns_records, + new_requests_7d: acc.new_requests_7d + d.stats.new_requests_7d, + }), + { active_subdomains: 0, pending_requests: 0, dns_records: 0, new_requests_7d: 0 }, + ); + + return { data: { domains: domainStats, totals } }; + }); + + /** + * GET /api/domain-provider/domains + * Lists domains assigned to the authenticated domain_provider (no stats, fast). + * Accessible to domain_provider and admin. + */ + app.get('/api/domain-provider/domains', async (request, reply) => { + const user = await authenticateRequest(request); + if (!user) return reply.code(401).send({ error: 'Unauthorized' }); + if (user.role !== 'domain_provider' && user.role !== 'admin') { + return reply.code(403).send({ error: 'Forbidden' }); + } + const targetUserId = user.role === 'admin' && request.query?.user_id + ? request.query.user_id + : user.id; + const domains = await store.filter('Domain', { provider_user_id: targetUserId }); + // Strip sensitive infra fields for non-admin providers + const safe = domains.map(({ zone_id: _, nameservers: __, reserved_names: ___, notes: ____, created_by: _____, ...rest }) => + user.role === 'admin' ? { zone_id: _, nameservers: __, reserved_names: ___, notes: ____, created_by: _____, ...rest } : rest, + ); + return { data: safe }; + }); +} diff --git a/server/entity-routes.js b/server/entity-routes.js index 0870f66..487f0ec 100644 --- a/server/entity-routes.js +++ b/server/entity-routes.js @@ -53,7 +53,12 @@ function redact(user, entity, record) { if (entity === 'User') return publicUser(record); const copy = { ...record }; if (entity === 'Domain' && !isElevated(user)) { - for (const key of ['zone_id', 'nameservers', 'reserved_names', 'notes', 'last_synced', 'record_count', 'created_by']) delete copy[key]; + // Domain providers can see stats for their own assigned domain; strip sensitive infra fields only + const isDomainProvider = user?.role === 'domain_provider' && (record.provider_user_id === user?.id || record.provider_user_email === user?.email); + const sensitiveFields = isDomainProvider + ? ['zone_id', 'nameservers', 'reserved_names', 'notes', 'created_by'] + : ['zone_id', 'nameservers', 'reserved_names', 'notes', 'last_synced', 'record_count', 'created_by']; + for (const key of sensitiveFields) delete copy[key]; } if (hidesStaffFields(user, entity)) { for (const key of Object.keys(copy)) if (isStaffOnlyRequestField(key)) delete copy[key]; diff --git a/server/function-runner.js b/server/function-runner.js index 0d0c9a4..324f372 100644 --- a/server/function-runner.js +++ b/server/function-runner.js @@ -6,7 +6,7 @@ import { getModuleConfig } from './module-settings.js'; import { captureServerMessage } from './glitchtip.js'; const FUNCTION_NAMES = new Set([ - 'adminDirectCfOp', 'adminListUsers', 'adminMigrateDomains', 'analyticsManager', + 'adminAssignDomainProvider', 'adminDirectCfOp', 'adminListUsers', 'adminMigrateDomains', 'analyticsManager', 'appealRequest', 'approveRequest', 'checkAvailability', 'checkDomainHealth', 'cleanupPendingDonations', 'cleanupSuspendedRecords', 'createDonationSession', 'createNestedSubdomain', 'deviceAuth', 'getCloudflareZones', 'getQueueStatus', diff --git a/server/functions/adminAssignDomainProvider.js b/server/functions/adminAssignDomainProvider.js new file mode 100644 index 0000000..568db5d --- /dev/null +++ b/server/functions/adminAssignDomainProvider.js @@ -0,0 +1,70 @@ +import { createPlatformClientFromRequest } from '../lib/platform-client.js'; + +/** + * Assigns or removes a domain provider for a given domain. + * Admin-only. Body shape: + * { domain_id: string, user_id: string | null } + * + * Setting user_id to null unassigns the current provider. + * The target user must have role='domain_provider'. + */ +export default async function (req) { + const platform = createPlatformClientFromRequest(req); + const actor = await platform.auth.me(); + if (!actor) return Response.json({ error: 'Unauthorized' }, { status: 401 }); + if (actor.role !== 'admin') return Response.json({ error: 'Forbidden' }, { status: 403 }); + + const body = req.method === 'POST' ? await req.json() : {}; + const { domain_id, user_id } = body; + if (!domain_id) return Response.json({ error: 'domain_id is required' }, { status: 400 }); + + const domain = await platform.asServiceRole.entities.Domain.get(domain_id); + if (!domain) return Response.json({ error: 'Domain not found' }, { status: 404 }); + + // Assigning a provider: validate the target user exists and has the domain_provider role + if (user_id) { + const targetUser = await platform.asServiceRole.entities.User.get(user_id); + if (!targetUser) return Response.json({ error: 'User not found' }, { status: 404 }); + if (targetUser.role !== 'domain_provider') { + return Response.json( + { error: `User must have role 'domain_provider' before being assigned a domain. Current role: ${targetUser.role}` }, + { status: 422 }, + ); + } + + const updated = await platform.asServiceRole.entities.Domain.update(domain_id, { + provider_user_id: targetUser.id, + provider_user_email: targetUser.email, + provider_assigned_at: new Date().toISOString(), + }); + + await platform.asServiceRole.entities.AuditLog.create({ + actor_email: actor.email, + actor_role: actor.role, + action: 'domain_provider_assigned', + entity_type: 'Domain', + entity_id: domain_id, + description: `Domain provider for ${domain.name} assigned to ${targetUser.email}`, + }); + + return Response.json({ success: true, domain: updated }); + } + + // Unassigning: clear provider fields + const updated = await platform.asServiceRole.entities.Domain.update(domain_id, { + provider_user_id: null, + provider_user_email: null, + provider_assigned_at: null, + }); + + await platform.asServiceRole.entities.AuditLog.create({ + actor_email: actor.email, + actor_role: actor.role, + action: 'domain_provider_unassigned', + entity_type: 'Domain', + entity_id: domain_id, + description: `Domain provider for ${domain.name} removed`, + }); + + return Response.json({ success: true, domain: updated }); +} diff --git a/server/index.js b/server/index.js index 8233465..36a0c55 100644 --- a/server/index.js +++ b/server/index.js @@ -31,6 +31,7 @@ import { registerImpersonationRoutes } from './impersonation-routes.js'; import { registerDockerManagerRoutes } from './docker-manager.js'; import { registerObserverRoutes } from './observer.js'; import { registerZammadRoutes } from './zammad.js'; +import { registerDomainProviderRoutes } from './domain-provider-routes.js'; assertProductionConfiguration(); @@ -151,6 +152,7 @@ await registerModuleSettingsRoutes(app); await registerDockerManagerRoutes(app); await registerObserverRoutes(app); await registerZammadRoutes(app); +await registerDomainProviderRoutes(app); await registerDesignRoutes(app); await registerBackupRoutes(app); await registerTermsRoutes(app); diff --git a/server/schema.sql b/server/schema.sql index 94f5bfd..20f8a42 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS users ( email citext NOT NULL UNIQUE, password_hash text, full_name text, - role text NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'staff', 'admin')), + role text NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'staff', 'admin', 'domain_provider')), status text NOT NULL DEFAULT 'active' CHECK (status IN ('pending', 'active', 'disabled')), email_verified_at timestamptz, tos_accepted_at timestamptz, @@ -33,6 +33,38 @@ BEGIN END IF; END $$; +-- Migration: extend role CHECK constraint to include 'domain_provider' +DO $$ +BEGIN + -- Only needed when the constraint still has the old value set (fresh installs get it from CREATE TABLE above) + IF EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_schema = 'public' AND table_name = 'users' AND constraint_type = 'CHECK' + AND constraint_name LIKE '%role%' + ) THEN + -- Drop any check constraint on role (name varies by Postgres version) and re-add the expanded one + EXECUTE ( + SELECT 'ALTER TABLE users DROP CONSTRAINT ' || quote_ident(constraint_name) + FROM information_schema.table_constraints + WHERE table_schema = 'public' AND table_name = 'users' AND constraint_type = 'CHECK' + AND constraint_name LIKE '%role%' + LIMIT 1 + ); + END IF; + -- Add (or re-add) the constraint with all valid roles + IF NOT EXISTS ( + SELECT 1 FROM information_schema.check_constraints + WHERE constraint_schema = 'public' AND check_clause LIKE '%domain_provider%' + AND constraint_name IN ( + SELECT constraint_name FROM information_schema.table_constraints + WHERE table_name = 'users' AND constraint_type = 'CHECK' + ) + ) THEN + ALTER TABLE users ADD CONSTRAINT users_role_check + CHECK (role IN ('user', 'staff', 'admin', 'domain_provider')); + END IF; +END $$; + CREATE INDEX IF NOT EXISTS users_role_idx ON users(role); CREATE INDEX IF NOT EXISTS users_status_idx ON users(status); diff --git a/src/App.jsx b/src/App.jsx index 1359c7c..cdbfe95 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -110,6 +110,7 @@ const AdminAccountDeletionRequests = lazy(() => import('@/pages/AdminAccountDele const AdminReports = lazy(() => import('@/pages/AdminReports')); const AdminObserverFindings = lazy(() => import('@/pages/AdminObserverFindings')); const DockerEngine = lazy(() => import('@/pages/DockerEngine')); +const DomainProviderDashboard = lazy(() => import('@/pages/DomainProviderDashboard')); const AuthenticatedApp = () => { return ( @@ -216,6 +217,11 @@ const AuthenticatedApp = () => { } /> } /> + + {/* Domain Provider Routes */} + }> + } /> + diff --git a/src/api/rootminsterClient.js b/src/api/rootminsterClient.js index a38bbb6..13cc307 100644 --- a/src/api/rootminsterClient.js +++ b/src/api/rootminsterClient.js @@ -276,6 +276,18 @@ export const rootminster = { window.location.href = `/login?return_to=${encodeURIComponent(returnTo)}`; }, }, + domainProvider: { + async stats(userId) { + const params = userId ? `?user_id=${encodeURIComponent(userId)}` : ''; + const result = await request(`/api/domain-provider/stats${params}`); + return result.data; + }, + async domains(userId) { + const params = userId ? `?user_id=${encodeURIComponent(userId)}` : ''; + const result = await request(`/api/domain-provider/domains${params}`); + return result.data; + }, + }, }; export default rootminster; diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 72adb22..22f866e 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -81,6 +81,7 @@ function ProductSidebar({ user, mobile, onClose }) { const location = useLocation(); const isAdmin = user?.role === 'admin'; const isStaff = user?.role === 'staff'; + const isDomainProvider = user?.role === 'domain_provider'; const privileged = isAdmin || isStaff; const active = (to) => location.pathname === to || (to !== '/user-dashboard' && location.pathname.startsWith(to)); const adminItems = adminNav.filter(item => !item.adminOnly || isAdmin); @@ -120,6 +121,16 @@ function ProductSidebar({ user, mobile, onClose }) { )} + + {isDomainProvider && ( + <> +
+

Domain Provider

+
+ +
+ + )}
@@ -146,6 +157,7 @@ export default function Layout() { const isAdmin = user?.role === 'admin'; const isStaff = user?.role === 'staff'; const isPrivileged = isAdmin || isStaff; + const isDomainProvider = user?.role === 'domain_provider'; const requestedReturnTo = new URLSearchParams(location.search).get('return_to'); const mcpReturnTo = requestedReturnTo === '/oauth/authorize' || requestedReturnTo?.startsWith('/oauth/authorize?') ? requestedReturnTo @@ -236,7 +248,12 @@ export default function Layout() { const initials = ((user?.full_name || user?.email || 'U')[0] || 'U').toUpperCase(); const fullName = user?.full_name || 'User'; const isAdminPath = location.pathname.startsWith('/admin-') || location.pathname === '/docker-engine'; - const cmdItems = [...userNav, ...resourceNav, ...(isPrivileged ? adminNav.filter(i => !i.adminOnly || isAdmin) : [])]; + const cmdItems = [ + ...userNav, + ...resourceNav, + ...(isPrivileged ? adminNav.filter(i => !i.adminOnly || isAdmin) : []), + ...(isDomainProvider ? [{ to: '/domain-provider-dashboard', icon: Globe2, label: 'My Donated Domains' }] : []), + ]; const stopImpersonation = async () => { try { await rootminster.impersonation.stop(); diff --git a/src/pages/AdminDomains.jsx b/src/pages/AdminDomains.jsx index 2c71312..b6c2f57 100644 --- a/src/pages/AdminDomains.jsx +++ b/src/pages/AdminDomains.jsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Plus, RefreshCw, Trash2, AlertTriangle, Globe, Database, CheckCircle2, XCircle, ShieldCheck, Lock } from 'lucide-react'; +import { Plus, RefreshCw, Trash2, AlertTriangle, Globe, Database, CheckCircle2, XCircle, ShieldCheck, Lock, UserCheck } from 'lucide-react'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; import { toast } from 'sonner'; import { format } from 'date-fns'; @@ -44,6 +44,10 @@ export default function AdminDomains() { const [newDomain, setNewDomain] = useState({ zone_id: '', name: '' }); const [loading, setLoading] = useState(true); const [confirmDelete, setConfirmDelete] = useState(null); + const [assignProviderDomain, setAssignProviderDomain] = useState(null); + const [providerUsers, setProviderUsers] = useState([]); + const [loadingProviders, setLoadingProviders] = useState(false); + const [assigningProvider, setAssigningProvider] = useState(false); const load = async () => { const [doms, logs] = await Promise.all([ @@ -98,6 +102,38 @@ export default function AdminDomains() { load(); }; + const openAssignProvider = async (domain) => { + setAssignProviderDomain(domain); + setLoadingProviders(true); + try { + const res = await rootminster.functions.invoke('adminListUsers', {}); + const all = res.data?.users || []; + setProviderUsers(all.filter(u => u.role === 'domain_provider')); + } catch { + toast.error('Failed to load domain provider users'); + } finally { + setLoadingProviders(false); + } + }; + + const assignProvider = async (userId) => { + if (!assignProviderDomain) return; + setAssigningProvider(true); + try { + await rootminster.functions.invoke('adminAssignDomainProvider', { + domain_id: assignProviderDomain.id, + user_id: userId, + }); + toast.success(userId ? 'Provider assigned' : 'Provider removed'); + setAssignProviderDomain(null); + load(); + } catch (err) { + toast.error(err?.response?.data?.error || 'Failed to assign provider'); + } finally { + setAssigningProvider(false); + } + }; + const domainColumns = [ { key: 'name', label: t('adminDomains.colDomain'), render: v => ( @@ -112,6 +148,10 @@ export default function AdminDomains() { { key: 'last_synced', label: t('adminDomains.colLastSync'), render: v => {v ? format(new Date(v), 'MMM d, HH:mm') : t('adminDomains.never')} }, { key: 'id', label: '', render: (_, row) => (
+
+ + {/* Assign Domain Provider modal */} + !open && setAssignProviderDomain(null)}> + + + + + Assign Domain Provider — {assignProviderDomain?.name} + + +
+ {assignProviderDomain?.provider_user_email && ( +
+

Current provider

+

{assignProviderDomain.provider_user_email}

+ +
+ )} + {loadingProviders ? ( +
+
+
+ ) : providerUsers.length === 0 ? ( +

+ No users have the Domain Provider role yet. Go to Admin → Users and grant the role first. +

+ ) : ( +
+ +
+ {providerUsers.map(u => ( + + ))} +
+
+ )} + +
+ +
); } diff --git a/src/pages/AdminUsers.jsx b/src/pages/AdminUsers.jsx index 5c920b1..684c6cf 100644 --- a/src/pages/AdminUsers.jsx +++ b/src/pages/AdminUsers.jsx @@ -5,7 +5,7 @@ import UserDetailModal from '@/components/UserDetailModal'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; -import { Shield, Ban, Unlock, Heart, GitBranch, KeyRound, AlertTriangle, Users, UserCheck, Crown, Key, Trash2, Loader2 } from 'lucide-react'; +import { Shield, Ban, Unlock, Heart, GitBranch, KeyRound, AlertTriangle, Users, UserCheck, Crown, Key, Trash2, Loader2, Globe2 } from 'lucide-react'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; import { toast } from 'sonner'; import { format } from 'date-fns'; @@ -112,6 +112,18 @@ export default function AdminUsers() { }); }; + const makeDomainProvider = async (user) => { + confirm( + t('adminUsers.makeDomainProvider', 'Make Domain Provider'), + t('adminUsers.makeDomainProviderDesc', { email: user.email, defaultValue: `Grant {{email}} the Domain Provider role? An admin can then assign one or more donated domains to this user.` }), + async () => { + await updateUser(user.id, { role: 'domain_provider' }); + toast.success(t('adminUsers.domainProviderGranted', 'Domain Provider role granted')); + await load(); + }, + ); + }; + const toggleLegacyDonor = async (user) => { const newVal = !user.legacy_donor; confirm( @@ -235,9 +247,13 @@ export default function AdminUsers() { - {v === 'admin' ? t('adminUsers.roleAdmin') : v === 'staff' ? t('adminUsers.roleStaff') : t('adminUsers.roleUser')} + {v === 'admin' ? t('adminUsers.roleAdmin') : + v === 'staff' ? t('adminUsers.roleStaff') : + v === 'domain_provider' ? t('adminUsers.roleDomainProvider', 'Domain Provider') : + t('adminUsers.roleUser')} )}, { key: 'email', label: t('adminUsers.colSubdomains'), render: v => {getOwnedCount(v)} }, @@ -255,6 +271,22 @@ export default function AdminUsers() { { key: 'id', label: t('adminUsers.colActions'), render: (_, row) => (
{row.role === 'user' && ( + <> + + + + + )} + {row.role === 'domain_provider' && ( <> + )} {row.role === 'staff' && ( @@ -301,6 +337,7 @@ export default function AdminUsers() { const admins = users.filter(u => u.role === 'admin').length; const staff = users.filter(u => u.role === 'staff').length; + const providers = users.filter(u => u.role === 'domain_provider').length; const unlocked = users.filter(u => u.ns_unlocked).length; return ( @@ -362,11 +399,12 @@ export default function AdminUsers() {
-
+
{[ { label: t('adminUsers.labelUsers'), value: users.length, icon: Users }, { label: t('adminUsers.labelStaff'), value: staff, icon: UserCheck }, { label: t('adminUsers.labelAdmins'), value: admins, icon: Crown }, + { label: t('adminUsers.labelDomainProviders', 'Domain Providers'), value: providers, icon: Globe2 }, ...(donationsEnabled ? [{ label: t('adminUsers.labelNsUnlocked'), value: unlocked, icon: Key }] : []), ].map((item, index) => (
0 ? 'border-l border-border' : ''} px-4 py-3.5`}> diff --git a/src/pages/DomainProviderDashboard.jsx b/src/pages/DomainProviderDashboard.jsx new file mode 100644 index 0000000..30cc971 --- /dev/null +++ b/src/pages/DomainProviderDashboard.jsx @@ -0,0 +1,156 @@ +import { useState, useEffect } from 'react'; +import { rootminster } from '@/api/rootminsterClient'; +import { Globe2, BarChart3, GitPullRequest, Server, TrendingUp, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { format } from 'date-fns'; +import { useTranslation } from 'react-i18next'; + +function StatCard({ icon: Icon, label, value, color = 'text-primary' }) { + return ( +
+
+ + {label} +
+

{value}

+
+ ); +} + +function DomainCard({ domain }) { + const { t } = useTranslation(); + const s = domain.stats; + return ( +
+
+
+ + {domain.name} + + {domain.status} + +
+ {domain.last_synced && ( + + {t('domainProvider.lastSynced', 'Synced')} {format(new Date(domain.last_synced), 'MMM d, HH:mm')} + + )} +
+ +
+ {[ + { label: t('domainProvider.activeSubdomains', 'Active Subdomains'), value: s.approved_subdomains, color: 'text-emerald-400' }, + { label: t('domainProvider.pendingRequests', 'Pending Requests'), value: s.pending_requests, color: 'text-amber-400' }, + { label: t('domainProvider.dnsRecords', 'DNS Records'), value: domain.record_count ?? s.dns_records, color: 'text-primary' }, + { label: t('domainProvider.new7d', 'New (7 days)'), value: s.new_requests_7d, color: 'text-blue-400' }, + ].map(({ label, value, color }) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {domain.provider_assigned_at && ( +
+ + {t('domainProvider.assignedAt', 'Provider since')} {format(new Date(domain.provider_assigned_at), 'MMM d, yyyy')} + +
+ )} +
+ ); +} + +export default function DomainProviderDashboard() { + const { t } = useTranslation(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const load = async (isRefresh = false) => { + if (isRefresh) setRefreshing(true); + try { + const stats = await rootminster.domainProvider.stats(); + setData(stats); + } catch { + toast.error(t('domainProvider.loadError', 'Failed to load domain stats')); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { load(); }, []); + + const totals = data?.totals ?? { active_subdomains: 0, pending_requests: 0, dns_records: 0, new_requests_7d: 0 }; + + return ( +
+ {/* Header */} +
+
+

+ {t('domainProvider.eyebrow', 'Domain Provider')} +

+

+ {t('domainProvider.title', 'Your Donated Domains')} +

+

+ {t('domainProvider.subtitle', 'Live stats for the domains you have contributed to Open Domains.')} +

+
+ +
+ + {loading ? ( +
+
+
+ ) : ( + <> + {/* Totals */} +
+ + + + +
+ + {/* Per-domain breakdown */} + {data?.domains?.length > 0 ? ( +
+

+ + {t('domainProvider.breakdownTitle', 'Domain breakdown')} +

+ {data.domains.map((domain) => ( + + ))} +
+ ) : ( +
+ +

+ {t('domainProvider.noDomains', 'No domains have been assigned to you yet. Contact an admin to get started.')} +

+
+ )} + + )} +
+ ); +}