Screen: Users
web/users.html. Lists the people in the organization, shows which of them can actually sign in,
invites new internal teammates, and lets an administrator change a person's role. Admin-only.
Roster and access are two different facts
A people row makes someone addressable — they can own work, appear in the spine, and be named in
audit history. It does not give them a login. Signing in additionally requires a Supabase Auth
identity linked through people.auth_user_id; without that link, a person who authenticates is
"authenticated but not a provisioned person" and every API returns 401. See
security posture.
The Sign-in access column reports which of the two a person has:
| State | Meaning |
|---|---|
Active | Invitation accepted. This person can sign in. |
Invited | An identity exists; the invitation has not been accepted yet. |
No access | On the roster, but cannot sign in. Use Send invite. |
Adding a teammate
Add a teammate takes a name, work email, role, and access type, creates the people row, and
mails an invitation. The invitation lands on auth-callback.html, which shows the set-a-password
form before forwarding to the workspace.
Access type is a deliberate choice, not a guess from the email domain:
- Employee — appears in staffing, availability, and account assignment (
is_employee = true). - External collaborator — can sign in and is audited, but is never offered as a delivery owner.
Someone already on the roster is not added twice; the form says so and points at the Send invite button on their row, which is also how a previously revoked login is restored.
Customers are not added here
Customer access is granted from Portal → the customer → Invite customer, which creates the
person, the client_memberships row, and the invitation together. The Users screen refuses the
client role for exactly this reason: a client-role person with no membership can sign in and see
nothing. See the customer portal.
Roles
admin, lead, user (the consultant value in person_role), or client. Role determines
what a person can see and do; project data stays scoped to their access regardless of role — a
higher role doesn't override row-level security, it changes which
rows the policies let through.
Guardrails
Only administrators reach this screen. The target role must be one of the recognized values, and
the last remaining administrator cannot be demoted — a role change here can never lock the org
out of its own admin surface. Invitations are rate-limited per admin, a failed invitation rolls
back the person row it created, and every invite, resend, and role change writes an audit row.
Password resets for an already-active person are self-serve on the sign-in page ("Forgot password" or a magic link), so this screen does not duplicate them.
Where the code lives
web/users.htmlweb/api/users.js— see the Admin, Environment, and Costs APIweb/api/_user_provisioning.js— validation, redirect safety, access-state rules