Tracking issue for Phase 4 of the roadmap.
Motivation
Opt-in self-registration shipped in PR #33 (Collectify:Auth:AllowRegistration), and every entity already has an OwnerId column (design hook from Phase 1), so row-level isolation is already working. The remaining multi-user work is the session / account / admin layer that turns "users can register" into "this install can actually be shared."
Today:
- Every authenticated user can register if the flag is on, but there's no admin role. The first-run setup user is no different from a self-registered user.
- No way to change a password without dropping into the DB.
- No way to deactivate / remove a stale user.
- All users share the install-wide TMDB / MusicBrainz / IGDB credentials. There's no way to BYOK.
Scope
Admin role + admin pages
Self-service account
Per-user metadata provider keys (BYOK)
Out of scope (file separately if needed)
- OAuth providers (Google / GitHub sign-in).
- 2FA / WebAuthn.
- Email verification — requires SMTP infra. Registration today trusts whatever username comes in.
- Password reset via email — same SMTP dependency.
- Audit logging of admin actions (
user X locked user Y at …).
- Bulk OwnerId transfer on user delete (we just refuse the delete for now).
Verification
Implementation notes
- Identity already supports the lockout fields (
LockoutEnabled / LockoutEnd) — we just need to wire admin endpoints to toggle them. Don't roll our own user-state column.
- An
AdminOnly AuthorizationPolicy + .RequireAuthorization("AdminOnly") on the admin endpoint group is the right shape.
- Backfill: existing single-user install gets
IsAdmin = true on the existing row at migration time so they don't lock themselves out of their own install.
- For BYOK, the cleanest plumbing is a per-request scoped wrapper around
IOptions<MetadataLookupOptions> that overlays the current user's settings on top. The providers don't need to know about the multi-user shape; they keep reading from IOptions<MetadataLookupOptions>.Value.
Phasing
The three subsections (admin pages / self-service / BYOK) are independent enough that they can land as separate PRs if the scope feels too big to ship in one slice. Suggested order:
- Admin role + admin pages (the table-stakes piece).
- Self-service change-password (small, ships fast).
- BYOK (largest of the three; touches every metadata provider's options plumbing).
Tracking issue for Phase 4 of the roadmap.
Motivation
Opt-in self-registration shipped in PR #33 (
Collectify:Auth:AllowRegistration), and every entity already has anOwnerIdcolumn (design hook from Phase 1), so row-level isolation is already working. The remaining multi-user work is the session / account / admin layer that turns "users can register" into "this install can actually be shared."Today:
Scope
Admin role + admin pages
AppUser.IsAdmin: bool(defaultfalse). The user created by the first-run/setupflow is automatically marked admin./api/auth/meexposesisAdminso the SPA can gate the admin link in the nav./api/admin/...guarded by anAdminOnlypolicy:GET /api/admin/users— list users{ id, userName, isAdmin, isLocked, createdAt }.POST /api/admin/users/{id}/lock+/unlock— toggleLockoutEnabled/LockoutEndso the user can't sign in.POST /api/admin/users/{id}/role— promote / demote (refuse demoting the last admin so the install can't lock itself out).DELETE /api/admin/users/{id}— refuse if they own any rows; force the admin to transfer or hard-delete content first (no orphan rows, no surprise data loss)./admin/userspage (client). Simple list + action buttons; toast on each outcome.Self-service account
POST /api/auth/change-password— requires the current password, re-signs in on success./accountpage (client) — current username (read-only), change-password form. Per-user provider keys (below) live here too.Per-user metadata provider keys (BYOK)
UserSettingstable (or columns onAppUser):TmdbApiKey,MusicBrainzUserAgent,IgdbClientId,IgdbClientSecret. GiantBomb columns can be included for completeness even though the API stays Cloudflare-gated.GET /api/me/settings+PUT /api/me/settings.MetadataLookupOptions. The providers' existingIsConfiguredshort-circuit still applies (e.g. no key →configured: falsein the lookup response)./account: collapsed-by-default "Provider keys" section with one field per provider; help-text linking to where each key comes from.Out of scope (file separately if needed)
user X locked user Y at …).Verification
/admin/usersis reachable, list shows just that user, marked admin, with no destructive actions enabled on the self row./accountsettings. Without changing the install-wide config, a fresh TMDB search uses the user-supplied key (verify by setting the install-wide key to a known-bad value and confirming the lookup still works for that user).Implementation notes
LockoutEnabled/LockoutEnd) — we just need to wire admin endpoints to toggle them. Don't roll our own user-state column.AdminOnlyAuthorizationPolicy+.RequireAuthorization("AdminOnly")on the admin endpoint group is the right shape.IsAdmin = trueon the existing row at migration time so they don't lock themselves out of their own install.IOptions<MetadataLookupOptions>that overlays the current user's settings on top. The providers don't need to know about the multi-user shape; they keep reading fromIOptions<MetadataLookupOptions>.Value.Phasing
The three subsections (admin pages / self-service / BYOK) are independent enough that they can land as separate PRs if the scope feels too big to ship in one slice. Suggested order: