Starting with the smallest useful permission system
I finally added the admin role and user role system. This was one of those features that felt simple at first, then immediately touched almost every corner of the app: sign-up, navigation, database rules, API routes, settings screens, and even empty states.
The first version is intentionally small. I did not want a giant permissions engine with 40 toggles and nested teams before the product even needs it. The app only needed two clear roles right now:
- Admin: can manage users, change roles, access admin screens, and see account-level settings.
- User: can use the core product features, but cannot manage people or access admin-only areas.
That sounds basic, but basic is good here. A simple permission system is easier to test, easier to explain, and much harder to accidentally break. I wanted something I could build on later without painting myself into a corner today.
The first decision: roles live in the database
I started by deciding where the source of truth should live. The answer was the database. Not the frontend. Not some hardcoded email list. Not a temporary config file.
Each user now has a role stored directly on their user profile record. That keeps the logic easy to follow. When someone signs in, the app checks who they are, loads their profile, and reads the role from there.
The shape is plain:
| Field | Purpose |
|---|---|
| id | The user profile ID |
| auth_user_id | The ID from the auth provider |
| The user’s email address | |
| role | Either admin or user |
| created_at | When the profile was created |
| updated_at | When the profile was last changed |
I considered making a separate roles table, then a user_roles join table, then a permissions table. That design would make sense for larger teams, custom roles, billing tiers, or workspace-level access. But for this build, it was too much too early.
The rule I used was simple: if I cannot name the third role yet, I probably should not build the system for it yet.
Adding the default user role
The next step was making sure every new person gets a safe default. New accounts become regular users automatically. Nobody should become an admin because a field was missing or because the app guessed wrong.
So the role field gets a default value of user. That sounds tiny, but it removes a whole class of weird bugs. If a profile gets created during sign-up, it has a role. If a migration backfills old users, they get a role. If an API route reads the profile, it does not have to deal with a blank permission state.
I also added a basic constraint so the database only accepts known role values. The app should not allow random strings like owner, superadmin, or banana to sneak into the role column. Future me will be grateful for that.
Small database constraints save hours of debugging later. They are not glamorous, but they quietly keep the app honest.
Backfilling existing accounts
This app already had users before roles existed, so I needed a clean backfill. I ran a migration that set every existing profile to user. Then I manually promoted my own account to admin.
This was one of the spots where I slowed down. Role migrations can get messy fast if you guess too much. I did not want the script deciding admins based on email domains, sign-up dates, or anything clever. Clever is where permissions bugs hide.
The safer move was:
- Add the role column.
- Set every existing user to the standard user role.
- Pick known admin accounts manually.
- Verify the values in the database before wiring them into the app.
Not fancy. Very effective.
Creating one helper instead of sprinkling checks everywhere
Once the role existed, the temptation was to start writing checks directly inside components. Something like: if the current user’s role is admin, show this button. If not, hide it.
That works for one button. It becomes painful after ten screens.
So I added a small permission helper. Nothing huge. Just a single place where the app can ask simple questions:
- Is this user an admin?
- Can this user access the admin area?
- Can this user manage other users?
- Can this user change roles?
For now, all of those admin-level permissions map to the admin role. Later, if I add a manager role or workspace owner role, I can update the helper without hunting through every page.
This was one of the better decisions in the build. It kept the frontend readable and gave the backend one shared language for access checks.
Protecting routes on the server
The UI checks are nice, but they are not security. Hiding a button only hides the button. A curious user can still try to call an admin route directly if the server does not stop them.
So every admin action now checks the role on the server before doing anything sensitive. The flow is simple:
- Confirm there is a signed-in user.
- Load that user’s profile from the database.
- Read the role.
- Block the request if the role is not admin.
- Run the admin action only after the check passes.
This is where I caught my first real bug. I had protected the admin page itself, but one API endpoint behind the page still accepted requests without checking the role. The UI made it look safe. The server was not safe yet.
That mistake changed my approach. Now I treat the page guard as a convenience and the server check as the actual gate. The page guard improves the experience. The server guard protects the app.
Adding an admin area without overbuilding it
After the backend checks were in place, I added the first admin screen. It is not trying to be a full control center yet. It shows users, their emails, their current roles, and basic account info.
The first admin tools are:
- View all users.
- See each user’s current role.
- Promote a user to admin.
- Demote an admin back to user.
- Prevent accidental self-demotion.
That last one matters. I almost skipped it, then realized I could remove my own admin access with one bad click. That would be annoying in development and much worse in production.
So the app now blocks an admin from demoting themselves. If I need to transfer ownership later, I will build a proper flow for it. For now, no self-lockouts.
The role change flow
I wanted role changes to feel boring. Click, confirm, update, refresh. No mystery. No hidden side effects.
The flow looks like this:
- An admin opens the users page.
- The app loads the list of profiles.
- The admin chooses a new role for a user.
- The app asks for confirmation before saving.
- The server checks that the current user is still an admin.
- The server updates the target user’s role.
- The UI refreshes and shows the new role.
I added the confirmation step because role changes are not casual edits. A mistaken admin promotion can expose sensitive areas of the product. A mistaken demotion can block someone from doing their job. One extra click is a fair trade.
I also made sure the update action returns a clear message. If it works, the admin sees that the role changed. If it fails, the message says why. During testing, this helped a lot because permission bugs can otherwise feel like the app is just ignoring you.
Showing and hiding navigation items
Once the admin page existed, I added role-aware navigation. Admins see an Admin link. Regular users do not.
This is mostly about keeping the interface clean. Users should not see links that lead to locked pages. It makes the product feel unfinished, even when the security is working correctly.
The nav reads the current profile, checks the role, and renders the right menu items. Simple. But I still kept the server-side route protection in place, because navigation can never be trusted as the only layer.
One small win here: the app now feels more tailored without adding any heavy personalization system. Admins get the tools they need. Everyone else gets a cleaner product.
Handling loading states and missing profiles
This part took longer than expected. Permissions sound like a yes-or-no problem, but the app also has a few temporary states:
- The user is signed in, but the profile is still loading.
- The auth session exists, but the profile row has not been created yet.
- The profile exists, but the role is missing because of old data.
- The user is signed out.
I had one early version where the nav flashed the admin link for a split second while the profile loaded. Bad look. The fix was to treat unknown permission state as not allowed until the app has real data.
Default closed. That became the rule.
If the app does not know whether someone is an admin yet, it should not show admin tools. Once the profile loads, then it can render the right UI.
Testing the system with real accounts
I tested this with more than one account because testing admin logic with only an admin account is a trap. Everything works when you have full access.
I used three test states:
| Test account | Expected result |
|---|---|
| Signed-out visitor | Cannot access app-only or admin-only pages |
| Regular user | Can access normal product screens, cannot access admin screens |
| Admin user | Can access normal screens and admin tools |
The regular user test caught another bug. The user could not see the Admin link, which was good, but typing the admin route manually showed a half-loaded page before redirecting away. It was not exposing data, but it felt sloppy.
I fixed that by making the protected admin layout check access before rendering the page content. Now unauthorized users get blocked before the admin UI appears at all.
What broke during the build
A few things broke, and most of them were predictable after the fact.
- Old profiles had no role. The migration fixed this, but I had to make sure local data and production data both matched the new shape.
- The frontend trusted itself too much. I had UI checks before every server action was protected. That got fixed quickly.
- The admin page loaded before access was confirmed. The route guard needed to run earlier.
- Role labels were inconsistent. One part of the app used lowercase values, another displayed title case. I separated stored values from display labels.
- I almost allowed self-demotion. That would have been a silly way to lose access.
None of these were huge disasters, but together they reminded me that permissions are a system, not a single field. The database, server, UI, and user experience all have to agree.
Keeping role values boring and predictable
I used lowercase role values in the database: admin and user. Then the UI turns those into friendly labels like Admin and User.
That keeps the stored data stable. The database does not care how I want labels to look in the interface. If I later rename User to Member in the product, the role value can stay the same.
I also avoided using booleans like is_admin. A boolean works for two states, but it gets awkward once more roles arrive. A role column gives me more room without much extra complexity.
Adding permission constants
Another small cleanup was creating shared constants for the role names. Instead of typing admin and user by hand all over the codebase, the app imports the same role values wherever it needs them.
This prevents annoying typo bugs. A permission check should not fail because one file says admins while another says admin. Shared constants make that harder to mess up.
I did the same thing for permission helper names. The goal is not to make the code look fancy. The goal is to make the next feature easier to add without guessing how the last one was wired.
The current admin feature set
Here is what is working now:
- New users receive the standard user role by default.
- Existing users were backfilled safely.
- Admin users can access the admin area.
- Regular users are blocked from admin routes.
- Admin-only server actions check permissions before running.
- The navigation changes based on role.
- Admins can promote and demote other users.
- Admins cannot demote themselves.
- Missing or loading role data defaults to no admin access.
That is enough for the current product stage. It gives the app a real access layer without turning the codebase into a policy maze.
What I am leaving for later
I made a short list of things I am not building yet. This helped keep the scope under control.
- Custom roles with editable permissions.
- Team-based roles.
- Workspace ownership transfer.
- Audit logs for every role change.
- Temporary admin access.
- Invite-based role assignment.
Some of these will probably matter later. Audit logs, especially. For now, the product needed a dependable admin and user split. Shipping that cleanly is better than half-building a giant access system nobody uses yet.
A few lessons from this pass
The biggest lesson: permission systems should start strict. It is easier to open access later than to explain why someone saw something they should not have seen.
The second lesson: UI checks are not enough. They make the product feel right, but the server has to make the final call. Every admin action needs its own guard.
The third lesson: simple role systems can still have edge cases. Loading states, missing profiles, old records, bad redirects, and self-demotion all showed up in this build. None of them were hard to fix, but they only appeared once I tested like a real user instead of clicking around as myself.
I also learned that boring naming pays off. A role called admin means exactly what it says. A role called power_user would have raised more questions than it answered.
The part that feels good now
The app has crossed a real line with this feature. Before, every signed-in user lived in the same permission bucket. Now there is structure. Admins can manage the product from inside the product, and regular users stay focused on the main experience.
It also unlocks the next set of features. User management, billing controls, content moderation, internal tools, and account settings all need some kind of admin layer. Now that foundation is in place.
The best part is that the implementation stayed understandable. One role field. One default. One helper. Server-side guards. Clean UI checks. A small admin screen. That is the kind of system I like shipping because I can still explain it the next morning.
Next up, I will probably add an activity trail for role changes. Not a huge event system. Just enough to know who changed what and when. Permissions are live now, and the next step is making admin actions easier to review when the team grows.