Skip to main content
RapidDev - Software Development Agency

How to Build a Admin Panel with Lovable

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'll build

  • Supabase roles table with RLS policies that enforce permission boundaries
  • Role-based UI rendering hook that hides controls based on the current user's role
  • DataTable with multi-select checkboxes and bulk action toolbar (delete, export, status change)
  • Audit log table with automatic writes via Postgres trigger on every destructive action
  • User management section for inviting, deactivating, and role-assigning team members
  • Global search across multiple resource types using a Supabase text search query
  • Confirmation Dialog for all destructive actions with typed confirmation input
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate13 min read2–3 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

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

LovableFrontend
SupabaseDatabase, Auth & RLS
TanStack Table v8Admin DataTable
shadcn/uiUI Components
Tailwind CSSStyling
date-fnsTimestamp formatting

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

1

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.

prompt.txt
1Set up the admin panel security infrastructure in Supabase.
2
31. Add a role column to the existing profiles table: role text NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'manager', 'viewer'))
4
52. 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')
8
93. 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)
10
114. Apply example RLS policies to a 'resources' table:
12 - Admin: full SELECT, INSERT, UPDATE, DELETE
13 - Manager: SELECT all, UPDATE own rows only, no DELETE
14 - Viewer: SELECT only, no INSERT, UPDATE, DELETE

Pro 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.

2

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.

src/components/admin/AdminDataTable.tsx
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'
12
13type Resource = { id: string; name: string; status: string; created_at: string }
14
15export function AdminDataTable() {
16 const { role } = useRole()
17 const qc = useQueryClient()
18 const [selected, setSelected] = useState<string[]>([])
19
20 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 error
25 return data
26 },
27 })
28
29 const deleteMutation = useMutation({
30 mutationFn: async (ids: string[]) => {
31 const { error } = await supabase.from('resources').delete().in('id', ids)
32 if (error) throw error
33 },
34 onSuccess: () => { qc.invalidateQueries({ queryKey: ['resources'] }); setSelected([]); toast.success('Deleted') },
35 })
36
37 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 ]
51
52 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" /> Delete
59 </Button>
60 <Button size="sm" variant="outline"><Download className="mr-1 h-3 w-3" /> Export</Button>
61 </div>
62 )}
63 <DataTable
64 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.

3

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.

src/hooks/useRole.ts
1import { useEffect, useState } from 'react'
2import { supabase } from '@/integrations/supabase/client'
3
4type Role = 'admin' | 'manager' | 'viewer'
5
6export function useRole() {
7 const [role, setRole] = useState<Role>('viewer')
8 const [isLoading, setIsLoading] = useState(true)
9
10 useEffect(() => {
11 async function fetchRole() {
12 const { data: { session } } = await supabase.auth.getSession()
13 if (!session) { setIsLoading(false); return }
14
15 const { data } = await supabase
16 .from('profiles')
17 .select('role')
18 .eq('id', session.user.id)
19 .single()
20
21 if (data?.role) setRole(data.role as Role)
22 setIsLoading(false)
23 }
24 fetchRole()
25 }, [])
26
27 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.

4

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.

prompt.txt
1Build two admin sections:
2
31. UserManagement component at src/components/admin/UserManagement.tsx:
4- List all profiles with: name, email, role (Select: admin/manager/viewer), status (active/inactive), last_seen
5- Invite User Button: opens a Dialog with email Input and role Select, calls Supabase's admin.inviteUserByEmail() via an Edge Function
6- Deactivate Button per row (admin only): sets a deactivated_at timestamp on the profile
7- 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'
8
92. AuditLogViewer component at src/components/admin/AuditLogViewer.tsx:
10- Fetch latest 100 audit_log rows ordered by created_at desc
11- 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 JSON
13- Add a filter Select for action type and a date range picker

Pro 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.

5

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.

prompt.txt
1Add two finishing features to the admin panel:
2
31. Global search:
4- Add a Command component (shadcn/ui) to the admin header, triggered by Cmd+K
5- 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 Badge
9- Clicking a result navigates to that resource's detail page
10
112. Confirmation Dialog for destructive actions:
12- Build a ConfirmDestructiveDialog component: accepts title, description, confirmText (the string the user must type), and onConfirm
13- 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 exactly
15- Wrap all bulk delete calls and deactivate calls in this Dialog

Expected 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

src/hooks/useRole.ts
1import { useEffect, useState } from 'react'
2import { supabase } from '@/integrations/supabase/client'
3
4export type Role = 'admin' | 'manager' | 'viewer'
5
6let cachedRole: Role | null = null
7
8export function useRole() {
9 const [role, setRole] = useState<Role>(cachedRole ?? 'viewer')
10 const [isLoading, setIsLoading] = useState(cachedRole === null)
11
12 useEffect(() => {
13 if (cachedRole !== null) return
14
15 async function fetchRole() {
16 const { data: { session } } = await supabase.auth.getSession()
17 if (!session) { setIsLoading(false); return }
18
19 const { data } = await supabase
20 .from('profiles')
21 .select('role')
22 .eq('id', session.user.id)
23 .single()
24
25 const r = (data?.role as Role) ?? 'viewer'
26 cachedRole = r
27 setRole(r)
28 setIsLoading(false)
29 }
30
31 fetchRole()
32 }, [])
33
34 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.

ChatGPT Prompt

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.

Lovable Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.