Starting with the thing admins actually need
I started this part of the build with a very plain goal: an admin should be able to open a Users tab, see who has access, invite someone new, and understand what is happening without guessing.
That sounds small, but user management has a way of getting messy fast. You need roles. You need pending invites. You need a way to resend an invite when someone misses the email. You need to stop old invites from hanging around forever. And you need to make sure nobody can invite themselves into a higher permission level than they should have.
So I built this in layers. First the table. Then the invite modal. Then the backend action that creates an invitation. Then the email handoff. Then the accept-invite path. Each step gave me something visible to test, which kept the momentum going.
The win here was not building a fancy admin screen. The win was making access management feel calm and predictable.
The first version of the Users tab
I began with the tab itself. No invite logic yet. Just a route inside the admin area and a clean page that could eventually hold the full user list.
The first pass had four pieces:
- A page heading that clearly says “Users”
- A short helper line explaining that admins can manage team access here
- An “Invite user” button in the top-right area
- A table with name, email, role, status, and actions
I like starting with the interface before wiring the data because it exposes the shape of the feature. When the table is sitting there empty, you immediately notice the missing states. What should admins see before anyone has been invited? What does a pending invite look like? Does a deactivated user stay in the table? Those questions are easier to answer when the screen exists, even as a fake shell.
For the table, I used a simple status model: active, pending, expired, and disabled. I almost added “invited” as a separate status, then caught myself. Pending already means invited but not accepted. One less status makes the UI easier to read and the database easier to query.
| Status | Meaning | Primary action |
|---|---|---|
| Active | The user has accepted access and can sign in | Change role or disable |
| Pending | An invite was sent but not accepted yet | Resend or cancel invite |
| Expired | The invite link is no longer valid | Send a new invite |
| Disabled | The user exists but cannot access the workspace | Reactivate or remove |
Designing the invite modal without overthinking it
The invite flow starts with a modal. I kept it small on purpose. One email field. One role selector. Two buttons: cancel and send invite.
I considered adding a custom message field, but I skipped it for now. That is the kind of feature that sounds nice and then adds more edge cases than expected. Does the message need moderation? Should it appear in the email template? Do we store it? Can it include links? Not today.
The modal does a few basic checks before it talks to the server:
- Email cannot be blank
- Email has to look like a real address
- Role must be selected
- The submit button locks while the request is running
- Errors show inside the modal, close to the form
That last one matters. My first version showed a toast when the invite failed. It worked, but it felt disconnected. If the email was already invited, the error should sit right near the email field. So I moved the error into the modal and kept the success message as a toast after the modal closes.
Small adjustment, much better feel.
The data model that kept things sane
I split users and invites instead of trying to cram everything into one table. That made the logic easier to reason about.
The users table represents people who have accepted access. The invites table represents invitations that may or may not turn into users. That separation helped with expired invites, canceled invites, and resends.
The invite record stores the email, role, token hash, inviter ID, workspace ID, status, expiration date, and timestamps. I did not store the raw invite token. The app creates the token, stores only a hashed version, and sends the raw token in the email link. When someone accepts, the app hashes the token from the URL and compares it to the stored hash.
That sounds like a lot, but the mental model is simple: the database should not contain the magic key that lets someone join the workspace. It should only contain a safe fingerprint of that key.
Invite creation flow
Once the modal felt good, I wired the “Send invite” button to a server action. The server action does the real checks. Client checks are helpful for fast feedback, but they are not protection.
The server checks looked like this:
- Confirm the current user is signed in
- Confirm the current user has admin permission for this workspace
- Normalize the invited email by trimming it and making it lowercase
- Check whether that email already belongs to an active user in the workspace
- Check whether there is already a pending invite for that email
- Create a new invite token and store its hash
- Save the invite record with an expiration date
- Send the invite email
- Return the new pending invite to the table
The duplicate checks saved me from a weird early bug. I invited the same test email three times, accepted the latest one, and then had two stale invite rows still sitting in the table. That made the admin screen look broken even though the account was fine. After that, I blocked duplicate pending invites and added a resend action instead.
Making pending invites visible
Pending invites belong in the Users tab. I almost put them in a separate “Invites” tab, but that felt like extra navigation for no real gain. Admins want one place to answer one question: who has access or has been asked to join?
So the table combines active users and pending invites into one list. The row layout stays consistent, but pending rows show a status badge and different actions.
For an active user, the actions are things like changing the role or disabling access. For a pending invite, the actions are resend and cancel. Expired invites show a “send new invite” action instead of resend because the old token is done.
This was one of those little UI decisions that made the whole feature feel more finished. The admin does not need to know how the backend stores users and invites. They just need a clear list of people and access states.
The accept invite page
The email link points to an accept-invite page with the token in the URL. This page has two jobs: validate the token and guide the invited person into the account setup flow.
If the token is valid and the person is not signed in, they see a clear message saying they have been invited to join the workspace. Then they can create an account or sign in with the same email address.
If they are already signed in with a different email, the page does not try to be clever. It tells them the invite was sent to another address and asks them to switch accounts. I tried an early version that let the current account claim the invite, but it felt risky. Invites are identity-based. If the invite says alex@example.com, then alex@example.com should be the account that accepts it.
After the invite is accepted, the server creates the membership record, marks the invite as accepted, and redirects the user into the workspace. The Users tab then shows them as active.
Resend and cancel actions
Resend was easy to add once invites had their own table. The action checks admin permission, finds the pending invite, creates a fresh token, updates the token hash and expiration date, then sends a new email.
I chose to replace the old token instead of keeping multiple valid tokens. It keeps the state cleaner. Only the newest invite link works. If someone clicks an older email, they get a friendly expired message and can ask the admin to resend.
Cancel is even simpler. It marks the invite as canceled and removes it from the default table view. I did not hard-delete it because audit trails are useful later, even if I am not building a full audit screen yet. Keeping the record also helps answer support questions like, “Did we invite this person last week?”
Role handling without turning it into a maze
I started with three roles: owner, admin, and member. Then I made one rule right away: owners cannot be invited from the normal invite modal.
That might change later, but for now owner transfer should be a separate flow with extra confirmation. The invite modal allows admin and member only. This avoids the scary version of the feature where an admin accidentally gives someone the highest level of control with one click.
I also added server-side role validation. The client can hide owner from the dropdown, but the server still needs to reject it if someone sends a manual request. That is the boring part of permissions work, but it is where the real safety lives.
Empty states and loading states
The first empty state was too plain. It said “No users found.” Accurate, but not helpful. I changed it to something more useful: “No team members yet. Invite your first user to start sharing access.” Then I put the invite button right there too.
Loading got the same treatment. Instead of letting the table pop in awkwardly, I added simple skeleton rows. Nothing fancy. Just enough to show that the page is working while the data loads.
Error states needed their own pass. If the user list fails to load, the page shows a short message and a retry button. If sending an invite fails, the modal keeps the entered email and role so the admin does not have to start over. That tiny detail feels respectful when something goes wrong.
Where I tripped up
The biggest mistake was treating email sending like it would always work. My first server action created the invite, sent the email, and returned success. Then I tested with a broken email API key. The invite was created, but no email went out. The admin saw success. Not great.
I changed the flow so failed email delivery marks the invite as email_failed and returns a clear error. Then the admin can retry. Depending on the product, you might still create the invite and show a warning, but I wanted the first version to be honest: if the email did not send, the invite did not fully happen.
The other bug was timezone-related. I set invites to expire in seven days, but the UI showed “expired” earlier than expected in one test account. The fix was to store expiration timestamps in UTC and only format them for display at the edge. Timezones are sneaky. I now try to keep the database boring and consistent.
The step-by-step build path
If I were rebuilding this from scratch, I would follow the same order again. It kept each task small enough to test without waiting for the whole feature to be done.
- Create the Users tab route inside the admin area.
- Build the table with mock active users and pending invites.
- Add the invite modal with email and role fields.
- Create the invites table with token hash, status, role, workspace ID, inviter ID, and expiration.
- Wire the send invite action with permission checks and duplicate checks.
- Send the invite email with a single-use token link.
- Build the accept invite page and validate the token server-side.
- Convert accepted invites into active workspace memberships.
- Add resend, cancel, expired, loading, empty, and error states.
- Test the awkward cases, especially duplicate emails and wrong signed-in accounts.
Security checks I kept in the first version
I did not want this feature to become a month-long permissions project, but there are a few checks I was not willing to skip.
- Only admins can invite users.
- The server validates the requested role.
- Invite tokens are stored as hashes, not raw strings.
- Invites expire after a set amount of time.
- Pending invites cannot be accepted by a different email address.
- Duplicate active users and duplicate pending invites are blocked.
- Resending an invite replaces the old token.
- Canceled and accepted invites cannot be reused.
This is one of those areas where simple rules beat clever code. Every action should answer the same questions: who is asking, what workspace are they acting in, are they allowed, and is the invite still valid?
The small details that made it feel real
The feature started feeling like a real admin tool when I added the little feedback loops.
After sending an invite, the modal closes and the new pending row appears immediately. After canceling, the row disappears without a full page reload. After resending, the row shows a fresh “sent just now” timestamp. These are not huge engineering moves, but they tell the admin that their action worked.
I also added plain labels. “Pending” is better than “Invite created.” “Expired” is better than “Token invalid.” “Resend invite” is better than “Regenerate token.” The backend can use technical words. The interface should speak like a person.
One detail I still want to improve is the activity history. Right now, the table gives the current state, but it does not show a full trail of who invited whom, who resent an invite, or when access changed. I left the data model open for that by keeping inviter IDs and timestamps. The full activity log can come later.
Final shape of the feature
By the end of this pass, the Admin Users tab had a working invite flow that covered the main path and the annoying edge cases. Admins can invite a user, choose a role, see the pending invite in the table, resend the email, cancel the invite, and watch the row become active after acceptance.
The best part is that the feature now has a clean spine. Users are users. Invites are invites. Tokens are temporary. Roles are checked on the server. The UI shows the state without exposing all the machinery underneath.
That is the kind of build I like. Start with the visible workflow, wire just enough backend to make it real, test the uncomfortable paths, then tighten the language and feedback until the whole thing feels obvious.