Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions server/domain-provider-routes.js
Original file line number Diff line number Diff line change
@@ -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 };
});
}
7 changes: 6 additions & 1 deletion server/entity-routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
2 changes: 1 addition & 1 deletion server/function-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
70 changes: 70 additions & 0 deletions server/functions/adminAssignDomainProvider.js
Original file line number Diff line number Diff line change
@@ -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 });
}
2 changes: 2 additions & 0 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down
34 changes: 33 additions & 1 deletion server/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);

Expand Down
6 changes: 6 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -216,6 +217,11 @@ const AuthenticatedApp = () => {
<Route path="/docker-engine" element={<DockerEngine />} />
<Route path="/admin-account-deletions" element={<AdminAccountDeletionRequests />} />
</Route>

{/* Domain Provider Routes */}
<Route element={<RoleProtectedRoute allowedRoles={['domain_provider', 'admin']} />}>
<Route path="/domain-provider-dashboard" element={<DomainProviderDashboard />} />
</Route>
</Route>
</Route>

Expand Down
12 changes: 12 additions & 0 deletions src/api/rootminsterClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
19 changes: 18 additions & 1 deletion src/components/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -120,6 +121,16 @@ function ProductSidebar({ user, mobile, onClose }) {
</div>
</>
)}

{isDomainProvider && (
<>
<div className="my-4 border-t border-sidebar-border" />
<p className="mb-2 px-3 text-[10px] font-semibold uppercase tracking-[.14em] text-muted-foreground/70">Domain Provider</p>
<div className="space-y-1">
<NavItem item={{ to: '/domain-provider-dashboard', icon: Globe2, label: 'My Donated Domains' }} active={active('/domain-provider-dashboard')} onNavigate={onClose} />
</div>
</>
)}
</div>

</aside>
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading