Build a back-office admin panel in Lovable with role-based UI rendering, a bulk-action DataTable, and a Supabase audit log. Admins get full CRUD controls, managers see a read-only view, and every destructive action is logged automatically with user identity and timestamp.
What you're building
An admin panel built in Lovable gives your team controlled access to back-office operations. The foundation is a roles table in Supabase with three levels: admin, manager, and viewer. Row-Level Security policies on every resource table enforce these roles at the database level — not just in the UI — so a viewer cannot read sensitive columns even if they call the Supabase API directly.
The DataTable renders action controls conditionally based on the current user's role. Admins see a Select All checkbox, bulk delete, bulk status change, and CSV export. Managers see single-row edit and export only. Viewers see read-only rows. This conditional rendering happens via a useRole hook that fetches the current user's role once on mount and memoizes it.
Every destructive action — delete, status change, role assignment — writes a row to the audit_log table. A Postgres trigger fires on DELETE events automatically. For UPDATE and INSERT operations, the React mutation functions explicitly insert an audit entry alongside the main operation in a Supabase transaction-equivalent using a single RPC call.
Final result
A production-ready admin panel with role-enforced UI, bulk operations, global search, and a complete audit trail — all running on your Supabase backend.
Tech stack
Prerequisites
- Lovable Pro account
- Supabase project with at least one resource table to manage (users, orders, content, etc.)
- VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in Cloud tab → Secrets
- A profiles table with a role column (admin, manager, viewer) linked to auth.users
- Basic familiarity with Lovable's Cloud tab and RLS policy syntax
Build steps
Set up roles, RLS policies, and the audit log table
Ask Lovable to create the roles infrastructure and audit log table with the Postgres trigger that fires on DELETE. This is the security foundation everything else builds on.
1Set up the admin panel security infrastructure in Supabase.231. Add a role column to the existing profiles table: role text NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'manager', 'viewer'))452. Create an audit_log table:6 id, user_id (references auth.users), action (text: 'create'|'update'|'delete'|'role_change'), resource_type (text), resource_id (text), old_value (jsonb), new_value (jsonb), ip_address (text), created_at (timestamptz default now())7 Enable RLS: only admins can SELECT audit_log rows (user's role = 'admin')893. Create a Postgres trigger: after DELETE on any managed resource table, insert a row into audit_log with action='delete', resource_type = TG_TABLE_NAME, resource_id = OLD.id::text, old_value = row_to_json(OLD)10114. Apply example RLS policies to a 'resources' table:12 - Admin: full SELECT, INSERT, UPDATE, DELETE13 - Manager: SELECT all, UPDATE own rows only, no DELETE14 - Viewer: SELECT only, no INSERT, UPDATE, DELETEPro tip: Test your RLS policies by creating three test accounts with different roles in Supabase Auth, logging in as each in a private browser window, and verifying that the DataTable shows the correct action controls for each role.
Expected result: Lovable creates the audit_log table, the trigger function, and the RLS policies. The SQL editor shows all policies applied to the resources table.
Build the role-aware DataTable with bulk actions
Create the main DataTable that reads the current user's role and renders action controls conditionally. Admins get checkboxes and a bulk action toolbar; managers get single-row actions; viewers get read-only rows.
1import { useState } from 'react'2import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'3import { supabase } from '@/integrations/supabase/client'4import { DataTable } from '@/components/ui/data-table'5import { Button } from '@/components/ui/button'6import { Checkbox } from '@/components/ui/checkbox'7import { Badge } from '@/components/ui/badge'8import { Trash2, Download, RefreshCw } from 'lucide-react'9import { useRole } from '@/hooks/useRole'10import { toast } from 'sonner'11import type { ColumnDef } from '@tanstack/react-table'1213type Resource = { id: string; name: string; status: string; created_at: string }1415export function AdminDataTable() {16 const { role } = useRole()17 const qc = useQueryClient()18 const [selected, setSelected] = useState<string[]>([])1920 const { data: rows = [] } = useQuery<Resource[]>({21 queryKey: ['resources'],22 queryFn: async () => {23 const { data, error } = await supabase.from('resources').select('*').order('created_at', { ascending: false })24 if (error) throw error25 return data26 },27 })2829 const deleteMutation = useMutation({30 mutationFn: async (ids: string[]) => {31 const { error } = await supabase.from('resources').delete().in('id', ids)32 if (error) throw error33 },34 onSuccess: () => { qc.invalidateQueries({ queryKey: ['resources'] }); setSelected([]); toast.success('Deleted') },35 })3637 const columns: ColumnDef<Resource>[] = [38 ...(role === 'admin' ? [{39 id: 'select',40 header: ({ table }: { table: { getIsAllRowsSelected: () => boolean; toggleAllRowsSelected: (v: boolean) => void } }) => (41 <Checkbox checked={table.getIsAllRowsSelected()} onCheckedChange={(v) => table.toggleAllRowsSelected(!!v)} />42 ),43 cell: ({ row }: { row: { original: Resource; getIsSelected: () => boolean; toggleSelected: (v: boolean) => void } }) => (44 <Checkbox checked={row.getIsSelected()} onCheckedChange={(v) => row.toggleSelected(!!v)} />45 ),46 }] : []),47 { accessorKey: 'name', header: 'Name' },48 { accessorKey: 'status', header: 'Status', cell: ({ getValue }) => <Badge variant="outline">{getValue() as string}</Badge> },49 { accessorKey: 'created_at', header: 'Created', cell: ({ getValue }) => new Date(getValue() as string).toLocaleDateString() },50 ]5152 return (53 <div className="space-y-3">54 {role === 'admin' && selected.length > 0 && (55 <div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">56 <span className="text-sm">{selected.length} selected</span>57 <Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(selected)}>58 <Trash2 className="mr-1 h-3 w-3" /> Delete59 </Button>60 <Button size="sm" variant="outline"><Download className="mr-1 h-3 w-3" /> Export</Button>61 </div>62 )}63 <DataTable64 columns={columns}65 data={rows}66 onRowSelectionChange={(sel) => setSelected(Object.keys(sel).filter((k) => sel[k]))}67 />68 </div>69 )70}Pro tip: For the CSV export, build the CSV string from the selected rows client-side using Array.join() and create a Blob download link. This avoids needing an Edge Function for a simple export.
Expected result: Admins see checkboxes and a bulk action toolbar when rows are selected. Managers see rows without checkboxes. Viewers see a read-only table with no action buttons.
Build the useRole hook
Create a hook that fetches the current user's role from the profiles table once on mount, memoizes it, and exposes permission helpers like isAdmin and canEdit.
1import { useEffect, useState } from 'react'2import { supabase } from '@/integrations/supabase/client'34type Role = 'admin' | 'manager' | 'viewer'56export function useRole() {7 const [role, setRole] = useState<Role>('viewer')8 const [isLoading, setIsLoading] = useState(true)910 useEffect(() => {11 async function fetchRole() {12 const { data: { session } } = await supabase.auth.getSession()13 if (!session) { setIsLoading(false); return }1415 const { data } = await supabase16 .from('profiles')17 .select('role')18 .eq('id', session.user.id)19 .single()2021 if (data?.role) setRole(data.role as Role)22 setIsLoading(false)23 }24 fetchRole()25 }, [])2627 return {28 role,29 isLoading,30 isAdmin: role === 'admin',31 isManager: role === 'admin' || role === 'manager',32 canEdit: role === 'admin' || role === 'manager',33 canDelete: role === 'admin',34 }35}Expected result: Any component can call useRole() to get the current user's role and permission flags without re-fetching the database.
Add user management and the audit log viewer
Build a Users tab where admins can invite new team members, deactivate accounts, and change roles. Below it, add an Audit Log tab showing the latest admin actions.
1Build two admin sections:231. UserManagement component at src/components/admin/UserManagement.tsx:4- List all profiles with: name, email, role (Select: admin/manager/viewer), status (active/inactive), last_seen5- Invite User Button: opens a Dialog with email Input and role Select, calls Supabase's admin.inviteUserByEmail() via an Edge Function6- Deactivate Button per row (admin only): sets a deactivated_at timestamp on the profile7- Role change Select: on change, calls supabase.from('profiles').update({ role: newRole }).eq('id', userId), then inserts an audit_log row with action='role_change'892. AuditLogViewer component at src/components/admin/AuditLogViewer.tsx:10- Fetch latest 100 audit_log rows ordered by created_at desc11- Table columns: Action (Badge by type), Resource Type, Resource ID (truncated), User (email from join), Timestamp (relative)12- Click any row to open a Sheet showing old_value and new_value as formatted JSON13- Add a filter Select for action type and a date range pickerPro tip: Invite user via email requires Supabase's service role key which must stay server-side. Route this through a Supabase Edge Function that receives the email and role, calls the Admin API with the service role key from Deno.env, and returns the result.
Expected result: The Users tab shows all team members with inline role selectors. Changing a role adds an entry to the Audit Log tab. Clicking an audit log entry shows the before/after values in a Sheet.
Add global search and confirmation dialogs
Implement a global search bar in the admin header that queries multiple resource types. Add a confirmation Dialog with typed text for all destructive actions.
1Add two finishing features to the admin panel:231. Global search:4- Add a Command component (shadcn/ui) to the admin header, triggered by Cmd+K5- On input change, debounce by 300ms then query Supabase:6 supabase.from('resources').select('id, name').ilike('name', '%query%').limit(5)7 supabase.from('profiles').select('id, email').ilike('email', '%query%').limit(5)8- Show results grouped by type (Resources, Users) with a type Badge9- Clicking a result navigates to that resource's detail page10112. Confirmation Dialog for destructive actions:12- Build a ConfirmDestructiveDialog component: accepts title, description, confirmText (the string the user must type), and onConfirm13- Render a Dialog with a Textarea where the user must type the exact confirmText (e.g. 'delete 5 records')14- The Confirm Button is disabled until the typed value matches confirmText exactly15- Wrap all bulk delete calls and deactivate calls in this DialogExpected result: Pressing Cmd+K opens a search panel with real-time results. Attempting to delete selected rows opens the confirmation Dialog requiring typed confirmation before proceeding.
Complete code
1import { useEffect, useState } from 'react'2import { supabase } from '@/integrations/supabase/client'34export type Role = 'admin' | 'manager' | 'viewer'56let cachedRole: Role | null = null78export function useRole() {9 const [role, setRole] = useState<Role>(cachedRole ?? 'viewer')10 const [isLoading, setIsLoading] = useState(cachedRole === null)1112 useEffect(() => {13 if (cachedRole !== null) return1415 async function fetchRole() {16 const { data: { session } } = await supabase.auth.getSession()17 if (!session) { setIsLoading(false); return }1819 const { data } = await supabase20 .from('profiles')21 .select('role')22 .eq('id', session.user.id)23 .single()2425 const r = (data?.role as Role) ?? 'viewer'26 cachedRole = r27 setRole(r)28 setIsLoading(false)29 }3031 fetchRole()32 }, [])3334 return {35 role,36 isLoading,37 isAdmin: role === 'admin',38 isManager: role === 'admin' || role === 'manager',39 canEdit: role === 'admin' || role === 'manager',40 canDelete: role === 'admin',41 }42}Customization ideas
IP-based access restriction
Store allowed IP ranges in an admin_settings table. The invite Edge Function checks the request IP against the allowlist and rejects logins from unrecognized locations.
Soft delete with recovery
Add a deleted_at column to resource tables instead of hard-deleting rows. The DataTable's default filter hides soft-deleted rows. Admins can open a Recycle Bin tab to restore or permanently delete them.
Bulk import via CSV
Add an Import CSV button to the DataTable toolbar. A Dialog accepts a CSV file, previews the first five rows in a table, and on confirm inserts all rows in a batch Supabase insert call.
Column visibility toggle
Add a Columns button using TanStack Table's column visibility API. Admins can show/hide columns and save their column preferences to the dashboard_configs table.
Rate limiting for admin actions
Track consecutive failed operations in the audit_log. If more than 10 destructive actions fire within 60 seconds from the same user, show a cooldown notice and temporarily disable the bulk delete button.
Common pitfalls
Pitfall: Relying only on UI role checks without RLS policies
How to avoid: Always enforce roles at the Supabase RLS level. UI role checks are for UX only — database policies are the actual security boundary.
Pitfall: Inserting audit log rows from the client
How to avoid: Use Postgres triggers for DELETE events and server-side RPC functions for UPDATE/INSERT audit entries so logs are written by the database, not the browser.
Pitfall: Hardcoding role strings in multiple component files
How to avoid: Define Role as a TypeScript type and export permission helper booleans (isAdmin, canEdit) from the useRole hook so changes happen in one place.
Pitfall: Not confirming bulk delete before executing
How to avoid: Always gate bulk delete (and all destructive actions) behind the ConfirmDestructiveDialog that requires typed confirmation.
Best practices
- Enforce role-based access at the Supabase RLS level, not just in React — client-side checks are UX, database policies are security.
- Write audit log entries server-side via triggers and RPC functions — never trust the client to self-report its own actions.
- Use a module-level cache for the useRole hook result to avoid re-querying the profiles table on every component that calls the hook.
- Add a UNIQUE constraint on profiles(id) and ensure every auth.users signup creates a profile row via a Supabase Auth hook.
- Gate all admin routes with both a React router guard and RLS policies so direct URL navigation and API calls are both blocked.
- Log the user's IP address in the audit_log by forwarding it from the Edge Function request headers when actions go through server-side calls.
- Use TanStack Table's row selection API to manage bulk selection state — it handles edge cases like select-all with filtered rows correctly.
- Test your audit trigger by deleting a row directly in the Supabase Table Editor and verifying the audit_log entry appears.
AI prompts to try
Copy these prompts to build this project faster.
I'm building an admin panel with role-based access control in React and Supabase. I have three roles: admin, manager, and viewer stored in a profiles table. Help me write a useRole hook that fetches the current user's role from Supabase on mount, memoizes the result to avoid re-fetching, and returns derived permission booleans: isAdmin, isManager, canEdit, and canDelete.
Add a settings section to the admin panel with three tabs: General (app name, logo upload to Supabase Storage, timezone select), Security (session timeout slider, 2FA enforcement toggle, IP allowlist textarea), and Notifications (email digest frequency select, alert threshold inputs). Save all settings to an app_settings table in Supabase with a single row per organization.
In Lovable, build a resource detail page that opens when an admin clicks a row in the DataTable. The page should show a two-column layout: left side has the editable resource form using react-hook-form and zod validation, right side shows the full audit log for that specific resource_id filtered from the audit_log table. Show old_value → new_value diffs for each UPDATE entry.
Frequently asked questions
How do I create the first admin user if everyone starts as a viewer?
In the Supabase Table Editor, find your own row in the profiles table and manually set the role column to 'admin'. After that, you can use the admin panel's User Management section to assign roles to other users.
Can managers invite new users?
That depends on your requirements. In the default setup, only admins can call the invite Edge Function. To allow managers, update the Edge Function to check for role 'admin' OR 'manager' in the JWT claim before calling the Supabase Admin API.
How do I add a new manageable resource type to the panel?
Create a new tab in the admin layout. Add an AdminDataTable instance pointing to your new Supabase table. Apply the same RLS policy pattern (admin/manager/viewer permissions) to the new table. The useRole hook works across all resource types without changes.
Is the audit log queryable for compliance reports?
Yes. The audit_log table stores action, resource_type, resource_id, old_value (JSONB), and new_value (JSONB) for every tracked operation. You can run SQL queries against it in the Supabase SQL editor or export it via the Audit Log Viewer's CSV export button.
How do I prevent a manager from escalating their own role to admin?
The RLS UPDATE policy on profiles should be: users can only update their own profile for non-role columns. Role changes must go through an RPC function that checks the caller is an admin before applying the update. The manager's useRole hook cannot call this function successfully.
The bulk delete trigger fires but the audit log shows no entry. Why?
The Postgres trigger needs to be attached to the specific table being deleted from. Run SHOW TRIGGERS; in the SQL editor to verify the trigger is attached. Also check that the audit_log table insert inside the trigger function does not throw an error — wrap it in an EXCEPTION block for debugging.
Can RapidDev help me extend this panel with custom resource management sections?
Yes. RapidDev can help you add new resource types, custom RLS policy patterns, and advanced audit reporting to this admin panel for your specific back-office workflow.
Does this work for multi-tenant SaaS where each organization has its own admins?
Yes. Add an org_id column to profiles and all resource tables. Update the useRole hook to read both the user's role AND org_id. RLS policies check both: the role for permission level and org_id for data isolation.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation