All playbooksNext.js + Flutter Product Monorepo

Add an admin page

Add a page to the admin app that reads data of all users, authorized on the server and by an RLS policy reserved to admins.

When to use it

Admins need to see or manage data beyond their own, for example the list of users with their role and billing state. The steps use a /users page as the example.

Rules to keep

  • The admin app is not a security boundary by itself: every page and Route Handler calls getCurrentAdmin() and stops without an admin. A check only in a layout is not enough, because layouts do not re-render on navigation and do not stop the page from rendering.
  • The role is read on the server from profiles through RLS, never from a cookie, a query parameter or a client flag.
  • Admins read other users' rows through RLS policies, with the anon key and the session. Do not use a service-role key in apps/admin: it bypasses RLS, so a mistake in a page would expose every row.
  • No policy may let users change profiles.role. Promote an admin with SQL from the Supabase dashboard or a migration.

Steps

  1. Add a migration in supabase/migrations/ (append-only) with a helper that tells whether the caller is an admin, and the policies the page needs. A policy on profiles cannot query profiles directly (Postgres reports an infinite recursion), so the helper is security definer:

    create function public.is_admin()
    returns boolean
    language sql
    stable
    security definer set search_path = ''
    as $$
      select exists (
        select 1 from public.profiles where id = (select auth.uid()) and role = 'admin'
      );
    $$;
    
    revoke execute on function public.is_admin() from public, anon;
    grant execute on function public.is_admin() to authenticated;
    
    create policy "admins read all profiles"
      on public.profiles for select to authenticated
      using ((select public.is_admin()));
    create policy "admins read all billing accounts"
      on public.billing_accounts for select to authenticated
      using ((select public.is_admin()));
    

    The helper only answers about the caller, so it is safe to call over the API. Add only select policies unless the page really changes data.

  2. In apps/admin/lib/current_admin.ts move the creation of the Supabase client into an exported createAdminSupabaseClient() and use it in getCurrentAdmin(), so pages query with the same per-request client.

  3. Create apps/admin/app/users/page.tsx as a Server Component. Call getCurrentAdmin() first and, without an admin, render the same "Access denied" as apps/admin/app/page.tsx before any query. Then read with createAdminSupabaseClient(), for example from('profiles').select('id, email, role, billing_accounts(status)'), and render only the fields the page shows.

  4. If the page changes data, use a Server Action or a Route Handler that calls getCurrentAdmin() again, and add an RLS policy for that operation that also uses public.is_admin().

  5. Apply the migration and check with two accounts: a member sees "Access denied" on /users, and a select on profiles with their session still returns only their own row; an admin sees every user.

  6. Describe the new policies in DATABASE.md and what admins can do in PRODUCT.md.

Done when

  • the verification commands pass;
  • the page and every action on it call getCurrentAdmin() on the server;
  • a member gets no extra rows from the API or the database, checked as in step 5;
  • DATABASE.md and PRODUCT.md describe the change.