Skip to main content
Admin Modules

Security & User Management

User CRUD, role-based access control, admin resolution from ADMIN_EMAILS, feature gating, per-user service assignment, and dashboard profile management

February 23, 2026 Updated February 25, 2026

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 (ENV or KV+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: member or demo (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 by widgetId so “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 admin and disabled
  • All other fields (display name, services, profiles, etc.) are fully editable
  • Saving writes to KV, making the user kv+env source

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 services array
  • Features enabled in their features array
  • Sites listed in their sites array

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
  • LayoutGrid badge (has positioned layout) or Auto (default auto-fill)
  • TypeBuilt-in (locked) or Custom (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+)
}
securityusersrolesrbacfeature-gatingsettingsdashboard-profiles