Add admin identity module: better-auth, per-app permissions, invitations #12
Reference in New Issue
Block a user
Delete Branch "feature/admin-auth-module"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Two changes to the admin module, both made now because it is not deployed yet and neither is free later. Permissions were "an app", with a `role` column reserved for a future fine-grained model. Reviewing whether that reservation was enough found three problems: - Every row was written with role = 'admin', hardcoded, and the column defaulted to it. On a `tickets` row that reads as "tickets administrator" when it only ever meant "has access", and once real roles existed there would have been no way to tell an old plain grant from a deliberate one. - The role never left the database. /admin/me, the user list, the user detail and both write endpoints all spoke apps: AppName[]. Adding roles would have been a breaking change to /admin/me - and after the cutover that endpoint has two more consumers, turning a local edit into a coordinated deploy of three apps. - The key (user_id, app) allowed one role per app, i.e. a tier rather than a set of capabilities. Choosing later means an ALTER on a live table. So: the key is now (user_id, app, role), the role is `access`, and APP_ROLES in admin.schema.ts is the contract - a role not listed there is rejected with 400 rather than written. permissions: [{app, role}] is on the wire alongside the derived apps: AppName[], which is kept because the three frontends only ever ask "may I show this app?". Both write endpoints accept either shape, and the invitation column (now `permissions`) is parsed leniently: invitations live seven days, so a deploy that changes the shape has in-flight rows in the old one. requireAppAccess(app, role?) takes an optional role; nothing passes one yet. countActiveAdminsForUpdate now counts DISTINCT users rather than rows. With several roles per app, counting rows would make a single admin holding two roles look like two admins and defeat the last-admin guard at exactly the moment it matters. Separately, passkey registration now fills `name` from the authenticator's AAGUID via registration.afterVerification and better-auth's own getAuthenticatorName, yielding "1Password", "iCloud Keychain", "Windows Hello". Without it the column stayed NULL and the account page could only label every passkey "Passkey" - useless when someone has to remove the one on the device they just lost. A client-supplied name still wins; an unknown AAGUID still leaves it blank. 148 unit tests (up from 131, including the new admin.schema.test.ts) and 41 integration tests pass. The integration suite applies sql/admin/001_init.sql, so the new key is exercised rather than trusted. No production migration is needed - the module is not deployed. An existing dev database needs three statements: set role = 'access', drop and re-add the primary key, rename invitations.apps to permissions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.