User Management
Understanding Supasheet's user and account system
Overview
Supasheet provides a complete user management system on top of Supabase Auth. Sign-up creates a profile row automatically, profile updates flow between auth.users and supasheet.users, and admin operations (create / invite / update / delete) are wired through dedicated Edge Functions that call the Supabase Admin API.
The supasheet.users Table
Every user in Supasheet has a profile row in supasheet.users. This is the canonical user table — always reference it instead of auth.users from your application tables.
Columns (defined in supabase/migrations/20250523000814_users.sql):
| Column | Purpose |
|---|---|
id (uuid) | Same value as auth.users.id |
name (text) | Display name |
email (text, unique) | Synced from Supabase Auth |
picture_url (text) | Avatar URL |
public_data (jsonb) | Free-form user metadata you can extend |
created_at / updated_at (timestamptz) | Audit timestamps |
created_by / updated_by (uuid) | Audit user references |
supasheet.users.id is identical to auth.users.id. You can compare it directly to auth.uid() in policies.
Automatic Profile Creation
Two database triggers keep supasheet.users in sync with Supabase Auth:
on_auth_user_created— fires afterINSERTonauth.usersand creates the matchingsupasheet.usersrow.namecomes fromraw_user_meta_data->>'name'(populated by OAuth providers) when present, falling back to the email local-part, or an empty string if there's no email either.picture_urlcomes fromraw_user_meta_data->>'avatar_url'when present — there's no fallback for it, it's simply leftnullotherwise.on_auth_user_updated— propagates email changes fromauth.userstosupasheet.users.
The trigger function protect_user_fields() blocks direct updates to id and email, ensuring those fields stay authoritative on the auth side.
Referencing Users in Your Tables
Always foreign-key to supasheet.users(id):
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT extensions.uuid_generate_v4(),
title TEXT NOT NULL,
user_id UUID REFERENCES supasheet.users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT now()
);In RLS policies compare user_id against auth.uid() directly:
create policy tasks_select on tasks
for select to authenticated
using (user_id = auth.uid());The foreign key above can point cross-schema with no issue — supasheet.users is fine to reference from public, store, or any other schema. What doesn't work cross-schema is PostgREST embedding/joining: if you want to pull user columns (name, email, …) into tasks' default list view via query.join, or use them as a fields.lookups target, you first need a same-named replica view (<your-schema>.users, with (security_invoker = true)) in your own schema. See Cross-Schema Joins for the exact pattern — every bundled example schema does this.
Account UI
Each signed-in user sees /account in the sidebar, with the following pages:
| Page | Route | Purpose |
|---|---|---|
| Profile | /account/profile | Edit name, avatar, and public_data |
| Security | /account/security | Change password, enrol / unenrol MFA factors |
| Identities | /account/identities | Link or unlink OAuth providers |
| Roles & Permissions | /account/roles-permissions | View the roles and permissions assigned to the current user |
The user dropdown in the header also exposes notifications and theme switching.
Admin User Management
Users with the right permissions on supasheet.users see the Core → Users section. Unlike most tables, these permission values aren't something you add yourself — they ship pre-seeded in supabase/migrations/20250523000822_roles.sql, granted by default to the x-admin role only:
| Permission | Capability |
|---|---|
supasheet.users:select | List and view users (/core/users) |
supasheet.users:insert | Create new users directly (/core/users/new) |
supasheet.users:update | Edit users (/core/users/$userId/edit) |
supasheet.users:delete | Delete users (/core/users/$userId/danger) |
supasheet.users:invite | Send a magic invite link to a new email (/core/users/invite) |
supasheet.users:ban | Ban / unban a user (/core/users/$userId/security) |
supasheet.users:generate_link | Generate a password recovery or email confirmation link (/core/users/$userId/security) |
Note this is a separate check from the RLS policies on the table itself: supasheet.users only has SELECT/UPDATE policies scoped to auth.uid() = id (a user reading/updating their own row) — there's no RLS path for inserting or deleting other users' rows at all. Every admin capability above works only because it's routed through a service-role Edge Function, not a direct authenticated-role table write.
Admin operations are not run as the signed-in user — they go through Deno Edge Functions in supabase/functions/:
admin-create-user— Create a user with email + passwordadmin-invite-user— Send a magic invite linkadmin-list-users/admin-get-user— Listing and detailadmin-update-user— Edit profile / emailadmin-delete-user— Tear down user account and auth rowadmin-generate-link— Generate password recovery / email confirmation links
These functions use the service role key and run server-side, so the publishable key alone cannot escalate privileges.
Assigning Roles and Permissions
Two more Core sections round out user administration — unlike the Edge-Function-backed pages above, these are ordinary permission-gated tables over supasheet.user_roles and supasheet.role_permissions, so they read/write directly as the signed-in x-admin user (no Edge Function involved):
| Permission | Capability |
|---|---|
supasheet.user_roles:select | List which roles are assigned to which users (/core/user_roles) |
supasheet.user_roles:insert | Assign a role to a user (/core/user_roles/new) |
supasheet.user_roles:delete | Revoke a role from a user |
supasheet.role_permissions:select | List which permissions each role grants (/core/role_permissions) |
supasheet.role_permissions:insert | Grant a permission to a role (/core/role_permissions/new) |
supasheet.role_permissions:delete | Revoke a permission from a role |
These six are also pre-seeded to x-admin only in 20250523000822_roles.sql, alongside the supasheet.users:* permissions above. In practice this means an x-admin never has to write the INSERT INTO supasheet.user_roles / role_permissions statements shown in Authorization by hand — the same operations are available as a UI over these two Core sections. Reach for raw SQL when seeding roles/permissions from a migration; use the UI for day-to-day changes.
Profile Pictures
Profile pictures live in the uploads storage bucket, under the auth/<uid>/ path — there is no dedicated avatar bucket. A storage policy scoped to that path lets a user read and write only their own auth/<uid>/... folder, bypassing the normal schema.table:action permission check that governs the rest of uploads. See Storage for the full bucket/permission model.
Custom User Data
The public_data JSONB column is yours to use. Common patterns:
-- Update preferences
UPDATE supasheet.users
SET public_data = public_data || jsonb_build_object('theme', 'dark', 'locale', 'en-GB')
WHERE id = auth.uid();It is also a great place to attach domain-specific user attributes (e.g. employee ID, department) without altering the schema.
Next Steps
- Authorization — Three roles, permissions, and RLS patterns
- Notifications — Deliver notifications to specific users or roles
- Audit Logs — Track every change made by every user