v0.6.1: added better auth admin plugin#3639
Conversation
waleedlatif1
commented
Mar 17, 2026
- fix(mothership): fix tool call scheduling (fix(mothership): fix tool call scheduling #3635)
- feat(auth): migrate to better-auth admin plugin with unified Admin tab (feat(auth): migrate to better-auth admin plugin with unified Admin tab #3612)
* Fix mothership tool scheduling * Fix
#3612) * feat(auth): migrate to better-auth admin plugin * feat(settings): add unified Admin tab with user management Consolidate superuser features into a single Admin settings tab: - Super admin mode toggle (moved from General) - Workflow import (moved from Debug) - User management via better-auth admin (list, set role, ban/unban) Replace Debug tab with Admin tab gated by requiresAdminRole. Add React Query hooks for admin user operations. * fix(db): backfill existing super users to admin role in migration Add UPDATE statement to promote is_super_user=true rows to role='admin' before dropping the is_super_user column, preventing silent demotion. * fix(admin): resolve type errors in admin tab - Fix cn import path to @/lib/core/utils/cn - Use valid Badge variants (blue/gray/red/green instead of secondary/destructive) - Type setRole param as 'user' | 'admin' union * improvement(auth): remove /api/user/super-user route, use session role Include user.role in customSession so it's available client-side. Replace all useSuperUserStatus() calls with session.user.role === 'admin'. Delete the now-redundant /api/user/super-user endpoint. * chore(auth): remove redundant role override in customSession The admin plugin already includes role on the user object. No need to manually spread it in customSession. * improvement(queries): clean up admin-users hooks per React Query best practices - Remove unsafe unknown/Record casting, use better-auth typed response - Add placeholderData: keepPreviousData for paginated variable-key query - Remove nullable types where defaults are always applied * fix(admin): address review feedback on admin tab - Fix superUserModeEnabled default to false (matches sidebar behavior) - Reset banReason when switching ban target to prevent state bleed - Guard admin section render with session role check for direct URL access * fix(settings): align superUserModeEnabled default to false everywhere Three places defaulted to true while admin tab and sidebar used false. Align all to false so new admins see consistent behavior. * fix(admin): fix stale pendingUserId, add isPending guard and error feedback - Only read mutation.variables when mutation isPending (prevents stale ID) - Add isPending guard to super user mode toggle (prevents concurrent mutations) - Show inline error message when setRole/ban/unban mutations fail * fix(admin): concurrent pending users Set, session loading guard, domain blocking - Replace pendingUserId scalar with pendingUserIds Set (useMemo) so concurrent mutations across different users each disable their own row correctly - Add sessionLoading guard to admin section redirect to prevent flash on direct /settings/admin navigation before session resolves - Add BLOCKED_SIGNUP_DOMAINS env var and before-hook for email domain denylist, parsed once at module init as a Set for O(1) per-request lookups - Add trailing newline to migration file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(admin): close OAuth domain bypass, fix stale errors, deduplicate icon - Add databaseHooks.user.create.before to enforce BLOCKED_SIGNUP_DOMAINS at the model level, covering all signup vectors (email, OAuth, social) not just /sign-up paths - Call .reset() on each mutation before firing to clear stale error state from previous operations - Change Admin nav icon from ShieldCheck to Lock to avoid duplicate with Access Control tab Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
You have used all Bugbot PR reviews included in your free trial for your GitHub account on this workspace. To continue using Bugbot reviews, enable Bugbot for your team in the Cursor dashboard. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR migrates the admin/super-user system from a custom Key changes:
Confidence Score: 4/5
Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[User navigates to /settings/admin] --> B{sessionLoading?}
B -- true --> C[effectiveSection = 'admin'\nAdmin skeleton renders ⚠️]
C --> D{Session resolves}
D -- role === 'admin' --> E[Admin panel loads fully]
D -- role !== 'admin' --> F[Redirect → effectiveSection = 'general']
B -- false --> G{role === 'admin'?}
G -- yes --> E
G -- no --> F
E --> H[Super-admin mode toggle\nuseGeneralSettings]
E --> I[Workflow import\nuseImportWorkflow]
E --> J[User management\nuseAdminUsers via client.admin.listUsers]
J --> K[Promote / Demote\nclient.admin.setRole]
J --> L[Ban / Unban\nclient.admin.banUser / unbanUser]
style C fill:#ffcccc,stroke:#ff0000
Last reviewed commit: 25a03f1 |
| const effectiveSection = | ||
| !isBillingEnabled && (section === 'subscription' || section === 'team') ? 'general' : section | ||
| !isBillingEnabled && (section === 'subscription' || section === 'team') | ||
| ? 'general' | ||
| : section === 'admin' && !sessionLoading && !isAdminRole | ||
| ? 'general' | ||
| : section |
There was a problem hiding this comment.
Admin section renders briefly for non-admin users
When sessionLoading is true and section === 'admin', the condition section === 'admin' && !sessionLoading && !isAdminRole evaluates to false (because !sessionLoading is false), so effectiveSection stays as 'admin' and <Admin /> is rendered — showing its skeleton to a potentially non-admin user until the session resolves.
This means a non-admin user who navigates directly to /settings/admin will briefly see the AdminSkeleton before being redirected to general. A safer pattern is to treat a loading session the same as a non-admin session for this section:
| const effectiveSection = | |
| !isBillingEnabled && (section === 'subscription' || section === 'team') ? 'general' : section | |
| !isBillingEnabled && (section === 'subscription' || section === 'team') | |
| ? 'general' | |
| : section === 'admin' && !sessionLoading && !isAdminRole | |
| ? 'general' | |
| : section | |
| const effectiveSection = | |
| !isBillingEnabled && (section === 'subscription' || section === 'team') | |
| ? 'general' | |
| : section === 'admin' && (sessionLoading || !isAdminRole) | |
| ? 'general' | |
| : section |
This ensures non-admin users never see any part of the Admin UI, even transiently. Admins will still see the General section skeleton briefly while the session loads, but that is less sensitive than leaking the Admin skeleton.
| const pendingUserIds = useMemo(() => { | ||
| const ids = new Set<string>() | ||
| if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId) | ||
| ids.add((setUserRole.variables as { userId: string }).userId) | ||
| if (banUser.isPending && (banUser.variables as { userId?: string })?.userId) | ||
| ids.add((banUser.variables as { userId: string }).userId) | ||
| if (unbanUser.isPending && (unbanUser.variables as { userId?: string })?.userId) | ||
| ids.add((unbanUser.variables as { userId: string }).userId) | ||
| return ids | ||
| }, [ | ||
| setUserRole.isPending, | ||
| setUserRole.variables, | ||
| banUser.isPending, | ||
| banUser.variables, | ||
| unbanUser.isPending, | ||
| unbanUser.variables, | ||
| ]) |
There was a problem hiding this comment.
Unnecessary type assertions on mutation
variables
TanStack Query v5 types variables as TVariables | undefined when a mutation is in-flight, but the hook already knows the shape. The explicit casts to { userId?: string } and { userId: string } bypass the inferred types and can silently hide future mismatches. Since setUserRole.isPending already guards the path, you can rely on the inferred types directly:
| const pendingUserIds = useMemo(() => { | |
| const ids = new Set<string>() | |
| if (setUserRole.isPending && (setUserRole.variables as { userId?: string })?.userId) | |
| ids.add((setUserRole.variables as { userId: string }).userId) | |
| if (banUser.isPending && (banUser.variables as { userId?: string })?.userId) | |
| ids.add((banUser.variables as { userId: string }).userId) | |
| if (unbanUser.isPending && (unbanUser.variables as { userId?: string })?.userId) | |
| ids.add((unbanUser.variables as { userId: string }).userId) | |
| return ids | |
| }, [ | |
| setUserRole.isPending, | |
| setUserRole.variables, | |
| banUser.isPending, | |
| banUser.variables, | |
| unbanUser.isPending, | |
| unbanUser.variables, | |
| ]) | |
| const pendingUserIds = useMemo(() => { | |
| const ids = new Set<string>() | |
| if (setUserRole.isPending && setUserRole.variables?.userId) | |
| ids.add(setUserRole.variables.userId) | |
| if (banUser.isPending && banUser.variables?.userId) | |
| ids.add(banUser.variables.userId) | |
| if (unbanUser.isPending && unbanUser.variables?.userId) | |
| ids.add(unbanUser.variables.userId) | |
| return ids | |
| }, [ | |
| setUserRole.isPending, | |
| setUserRole.variables, | |
| banUser.isPending, | |
| banUser.variables, | |
| unbanUser.isPending, | |
| unbanUser.variables, | |
| ]) |