Security & User Management
User CRUD, role-based access control, admin resolution from ADMIN_EMAILS, feature gating, per-user service assignment, and dashboard profile management
Security & User Management
Arcturus-Prime implements a role-based access control system backed by Cloudflare Workers KV. User records are stored at the KV key data:user-roles. Admin access is determined by the ADMIN_EMAILS environment variable — this is the only way to grant admin privileges, preventing privilege escalation through the API.
Users (/admin/users)
The user management page at /admin/users provides full CRUD operations for user accounts. All operations go through the /api/auth/roles API endpoint.
User List
The main view displays a table of all users with columns for:
- Email — login identifier, with a source badge (
ENVorKV+ENV) for admin users resolved from environment - Display Name — shown in dashboard UI and content attribution
- Role — color-coded pill: admin (purple), member (cyan), demo (amber)
- Services / Features — compact tag summary of assigned services, features, profiles, sites, network affinity, and quick links
- Actions — Preview (eye icon opens
/dashboard?as=email), Edit, Delete
Stats bar above the table shows total users, admins, members, and demo accounts.
Ghost Admin Resolution
Admin users defined in ADMIN_EMAILS are automatically merged into the user list even if they have no KV record. The GET handler synthesizes a record with role: 'admin', displayName: 'Admin', and source: 'env'. If an admin user also has a KV record (after saving via the edit form), the source shows as kv+env — the KV fields are preserved but the role is forced to admin.
This means:
- New installs always show at least one admin user in the list
- Admin users can have services, profiles, and all other fields saved to KV
- The admin role itself cannot be removed via the UI — it’s always resolved from the env var
Add User
Click Add User to open the modal. The form has 5 collapsible sections:
1. Basic Info (always visible)
- Email (required, editable only on create)
- Display Name + Role in a 2-column row
- Notes (optional internal notes)
- Role select:
memberordemo(admin option only appears when editing env-sourced admins)
2. Dashboard Profiles (collapsible)
- Checkboxes loaded dynamically from
/api/dashboard/profiles - Select one or more profiles. Empty = role default
- Profiles are created/managed at
/admin/dashboard-profiles
3. Services & Features (collapsible, visible for member and admin roles)
- Services — checkboxes loaded dynamically from the service registry at
/api/dashboard/services, deduplicated bywidgetIdso “Plex (daniel)” and “Plex (bogie)” both appear as just “Plex” - Portal Features — checkboxes for
sites,editor,workbench,media
4. Sites (collapsible, visible for member and admin roles)
- Dynamic site entries with fields: Name, URL, Gitea Repo, GitHub Repo
- Add/remove entries with the + Add Site button
5. Network & Quick Links (collapsible)
- Network Affinity select: Milky Way, Andromeda, or none
- Quick link entries with fields: Label, URL, FA icon, Service ID
- Add/remove entries with the + Add Link button
Edit User
Click Edit on any row. The modal opens with all fields populated. Email is read-only during edit. For env-sourced admin users:
- The role select is locked to
adminand disabled - All other fields (display name, services, profiles, etc.) are fully editable
- Saving writes to KV, making the user
kv+envsource
Delete User
Click Delete on any row. A confirmation dialog appears. For env-sourced admin users, the delete button is disabled — you cannot remove an admin via the UI (remove them from ADMIN_EMAILS instead).
Preview Dashboard
Click the eye icon to open the user’s dashboard in a new tab at /dashboard?as=email. This shows exactly what that user sees, using their profile, services, and theme.
Roles
admin
Controlled exclusively by the ADMIN_EMAILS env var (comma-separated list of email addresses). Admins have full unrestricted access to every feature and endpoint. The API rejects any attempt to assign the admin role to a user not in ADMIN_EMAILS.
Admin capabilities:
- Access all admin pages and settings
- View and edit all content
- Manage users and assign roles
- Full service registry access
- All dashboard widgets visible
- GitHub sync, build tools, AI workbench
member
Standard user role for regular contributors and family members. Members see:
- Dashboard with assigned profile and widgets
- Services granted in their
servicesarray - Features enabled in their
featuresarray - Sites listed in their
sitesarray
Members cannot manage users, access admin-only pages, or modify infrastructure settings.
demo
Read-only tour experience for demonstration:
- Dashboard with a fixed demo profile
- No services, no features, no sites
- Useful for showing the platform to potential users
UserRecord Schema
Every user account is stored as a UserRecord in KV:
interface UserRecord {
email: string; // Login identifier (lowercase, trimmed)
role: 'admin' | 'member' | 'demo';
displayName: string; // Shown in UI
services?: string[]; // Widget IDs for granted services (e.g. ['plex', 'grafana-embed'])
features?: string[]; // Feature flags: 'sites', 'editor', 'workbench', 'media'
sites?: SiteEntry[]; // Managed site configs
dashboardProfiles?: string[]; // Profile IDs to enable
networkAffinity?: 'milky-way' | 'andromeda'; // Which network the user is on
quickLinks?: QuickLink[]; // Custom shortcut links on dashboard
notes?: string; // Internal admin notes
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
}
Services Array
The services array contains widget IDs (not service registry IDs). When the dashboard renders, only widgets matching the user’s services are shown. Widget IDs map to the widget registry at src/lib/widget-registry.ts. Examples: plex, rutorrent, audiobookshelf, tautulli, grafana-embed, speedtest, storage-overview, unraid-dashboard.
Dashboard Profiles
The dashboardProfiles array contains profile IDs. Profiles define which widgets appear, their order, and the theme colors. Profiles are stored separately at KV key data:dashboard-profiles and managed at /admin/dashboard-profiles.
Network Affinity
Sets which network the user belongs to for service URL resolution. milky-way = local Milky Way, andromeda = remote Andromeda.
API Endpoints
GET /api/auth/roles
Returns all users merged with ADMIN_EMAILS. Response:
{
"users": {
"[email protected]": {
"email": "[email protected]",
"role": "member",
"displayName": "User Name",
"services": ["plex", "rutorrent"],
"source": "kv"
},
"[email protected]": {
"email": "[email protected]",
"role": "admin",
"displayName": "Admin",
"source": "env"
}
}
}
POST /api/auth/roles
Create or update a user. Requires admin auth. Body: UserRecord fields. Returns { ok: true, user: UserRecord }.
Security: role: 'admin' is only accepted if the email is in ADMIN_EMAILS. All other emails get a 403 if they try to set admin role.
DELETE /api/auth/roles
Remove a user from KV. Body: { email: "[email protected]" }. Returns { ok: true }. Invalidates the roles cache.
Dashboard Profiles (/admin/dashboard-profiles)
The profile listing at /admin/dashboard-profiles manages shared dashboard layouts. Custom profiles are stored in KV at data:dashboard-profiles. Built-in profiles (admin, member, demo, homelab, creator, media) ship with the code and cannot be modified.
Profile Table
The listing shows all profiles (built-in first, then custom alphabetically) with columns:
- Label — display name
- ID — slug identifier
- Theme — color dot pair (primary + secondary)
- Description — what the profile is for
- Widgets — count of enabled widgets
- Layout —
Gridbadge (has positioned layout) orAuto(default auto-fill) - Type —
Built-in(locked) orCustom(editable) - Actions — Edit (opens grid builder) and Delete (with confirmation)
Grid Builder (/admin/dashboard-profiles/edit/[id])
Clicking Create or Edit opens a full-page grid builder at /admin/dashboard-profiles/edit/new or /admin/dashboard-profiles/edit/{id}.
The builder has three sections:
Header bar: Profile name, description, theme color pickers (primary + secondary with gradient preview), Save and Cancel buttons.
Sidebar palette (~220px): Widget checkboxes grouped by category (Infrastructure, Sites & Deploy, Media, Tools). Checking a widget adds it to the grid canvas at its default size. Unchecking removes it. Palette and grid stay in sync bidirectionally.
Grid canvas: A 12-column drag-and-drop grid powered by GridStack.js. Each widget card shows its icon, label, current size (e.g. “4x1”), and a remove button. Drag to reposition, use corner handles to resize. Widgets snap to the grid and auto-pack upward.
Default widget sizes when first added:
- 3x1: plex, rutorrent, audiobookshelf, tautulli
- 4x1: infra-status, docker-containers, vm-status, storage-overview, network-status, sites, deploy-status, cf-pages, editor, workbench, quick-launch, speedtest
- 6x2: grafana-embed
- 12x2: ai-chat
Save writes both layout[] (positioned grid data) and widgets[] (flat ID list for backward compatibility) to KV via the profiles API. Ctrl+S keyboard shortcut is supported.
Dashboard Rendering
When a user’s profile has layout data, their dashboard renders on a 12-column CSS Grid with widgets at their saved positions and sizes. Category section headings are omitted in positioned mode — widgets appear in a single flat grid.
Profiles without layout data (including all built-in profiles) render the original auto-fill grid grouped by category. On mobile, positioned layouts collapse to a single column.
DashboardProfile Schema
interface DashboardProfile {
id: string; // Slug identifier
label: string; // Display name
description: string; // Optional text
widgets: string[]; // Widget IDs (backward compat)
layout?: WidgetLayout[]; // Positioned grid data (optional)
builtIn: boolean; // true for built-in, false for custom
theme?: {
primary: string; // Hex color, e.g. "#06b6d4"
secondary: string; // Hex color, e.g. "#8b5cf6"
};
}
interface WidgetLayout {
id: string; // Widget ID from widget-registry
x: number; // Column position (0-11)
y: number; // Row position (0-based)
w: number; // Width in columns (1-12)
h: number; // Height in grid rows (1+)
}