# How to Build an Admin Panel with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

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

## Before you start

- 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

## Step-by-step guide

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

```
Set up the admin panel security infrastructure in Supabase.

1. Add a role column to the existing profiles table: role text NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'manager', 'viewer'))

2. Create an audit_log table:
   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())
   Enable RLS: only admins can SELECT audit_log rows (user's role = 'admin')

3. 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)

4. Apply example RLS policies to a 'resources' table:
   - Admin: full SELECT, INSERT, UPDATE, DELETE
   - Manager: SELECT all, UPDATE own rows only, no DELETE
   - 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.

```
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { supabase } from '@/integrations/supabase/client'
import { DataTable } from '@/components/ui/data-table'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { Trash2, Download, RefreshCw } from 'lucide-react'
import { useRole } from '@/hooks/useRole'
import { toast } from 'sonner'
import type { ColumnDef } from '@tanstack/react-table'

type Resource = { id: string; name: string; status: string; created_at: string }

export function AdminDataTable() {
  const { role } = useRole()
  const qc = useQueryClient()
  const [selected, setSelected] = useState<string[]>([])

  const { data: rows = [] } = useQuery<Resource[]>({
    queryKey: ['resources'],
    queryFn: async () => {
      const { data, error } = await supabase.from('resources').select('*').order('created_at', { ascending: false })
      if (error) throw error
      return data
    },
  })

  const deleteMutation = useMutation({
    mutationFn: async (ids: string[]) => {
      const { error } = await supabase.from('resources').delete().in('id', ids)
      if (error) throw error
    },
    onSuccess: () => { qc.invalidateQueries({ queryKey: ['resources'] }); setSelected([]); toast.success('Deleted') },
  })

  const columns: ColumnDef<Resource>[] = [
    ...(role === 'admin' ? [{
      id: 'select',
      header: ({ table }: { table: { getIsAllRowsSelected: () => boolean; toggleAllRowsSelected: (v: boolean) => void } }) => (
        <Checkbox checked={table.getIsAllRowsSelected()} onCheckedChange={(v) => table.toggleAllRowsSelected(!!v)} />
      ),
      cell: ({ row }: { row: { original: Resource; getIsSelected: () => boolean; toggleSelected: (v: boolean) => void } }) => (
        <Checkbox checked={row.getIsSelected()} onCheckedChange={(v) => row.toggleSelected(!!v)} />
      ),
    }] : []),
    { accessorKey: 'name', header: 'Name' },
    { accessorKey: 'status', header: 'Status', cell: ({ getValue }) => <Badge variant="outline">{getValue() as string}</Badge> },
    { accessorKey: 'created_at', header: 'Created', cell: ({ getValue }) => new Date(getValue() as string).toLocaleDateString() },
  ]

  return (
    <div className="space-y-3">
      {role === 'admin' && selected.length > 0 && (
        <div className="flex items-center gap-2 rounded-lg border bg-muted/50 p-2">
          <span className="text-sm">{selected.length} selected</span>
          <Button size="sm" variant="destructive" onClick={() => deleteMutation.mutate(selected)}>
            <Trash2 className="mr-1 h-3 w-3" /> Delete
          </Button>
          <Button size="sm" variant="outline"><Download className="mr-1 h-3 w-3" /> Export</Button>
        </div>
      )}
      <DataTable
        columns={columns}
        data={rows}
        onRowSelectionChange={(sel) => setSelected(Object.keys(sel).filter((k) => sel[k]))}
      />
    </div>
  )
}
```

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

```
import { useEffect, useState } from 'react'
import { supabase } from '@/integrations/supabase/client'

type Role = 'admin' | 'manager' | 'viewer'

export function useRole() {
  const [role, setRole] = useState<Role>('viewer')
  const [isLoading, setIsLoading] = useState(true)

  useEffect(() => {
    async function fetchRole() {
      const { data: { session } } = await supabase.auth.getSession()
      if (!session) { setIsLoading(false); return }

      const { data } = await supabase
        .from('profiles')
        .select('role')
        .eq('id', session.user.id)
        .single()

      if (data?.role) setRole(data.role as Role)
      setIsLoading(false)
    }
    fetchRole()
  }, [])

  return {
    role,
    isLoading,
    isAdmin: role === 'admin',
    isManager: role === 'admin' || role === 'manager',
    canEdit: role === 'admin' || role === 'manager',
    canDelete: role === 'admin',
  }
}
```

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

```
Build two admin sections:

1. UserManagement component at src/components/admin/UserManagement.tsx:
- List all profiles with: name, email, role (Select: admin/manager/viewer), status (active/inactive), last_seen
- Invite User Button: opens a Dialog with email Input and role Select, calls Supabase's admin.inviteUserByEmail() via an Edge Function
- Deactivate Button per row (admin only): sets a deactivated_at timestamp on the profile
- 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'

2. AuditLogViewer component at src/components/admin/AuditLogViewer.tsx:
- Fetch latest 100 audit_log rows ordered by created_at desc
- Table columns: Action (Badge by type), Resource Type, Resource ID (truncated), User (email from join), Timestamp (relative)
- Click any row to open a Sheet showing old_value and new_value as formatted JSON
- 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.

```
Add two finishing features to the admin panel:

1. Global search:
- Add a Command component (shadcn/ui) to the admin header, triggered by Cmd+K
- On input change, debounce by 300ms then query Supabase:
  supabase.from('resources').select('id, name').ilike('name', '%query%').limit(5)
  supabase.from('profiles').select('id, email').ilike('email', '%query%').limit(5)
- Show results grouped by type (Resources, Users) with a type Badge
- Clicking a result navigates to that resource's detail page

2. Confirmation Dialog for destructive actions:
- Build a ConfirmDestructiveDialog component: accepts title, description, confirmText (the string the user must type), and onConfirm
- Render a Dialog with a Textarea where the user must type the exact confirmText (e.g. 'delete 5 records')
- The Confirm Button is disabled until the typed value matches confirmText exactly
- 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 example

File: `src/hooks/useRole.ts`

```typescript
import { useEffect, useState } from 'react'
import { supabase } from '@/integrations/supabase/client'

export type Role = 'admin' | 'manager' | 'viewer'

let cachedRole: Role | null = null

export function useRole() {
  const [role, setRole] = useState<Role>(cachedRole ?? 'viewer')
  const [isLoading, setIsLoading] = useState(cachedRole === null)

  useEffect(() => {
    if (cachedRole !== null) return

    async function fetchRole() {
      const { data: { session } } = await supabase.auth.getSession()
      if (!session) { setIsLoading(false); return }

      const { data } = await supabase
        .from('profiles')
        .select('role')
        .eq('id', session.user.id)
        .single()

      const r = (data?.role as Role) ?? 'viewer'
      cachedRole = r
      setRole(r)
      setIsLoading(false)
    }

    fetchRole()
  }, [])

  return {
    role,
    isLoading,
    isAdmin: role === 'admin',
    isManager: role === 'admin' || role === 'manager',
    canEdit: role === 'admin' || role === 'manager',
    canDelete: role === 'admin',
  }
}
```

## Common mistakes

- **Relying only on UI role checks without RLS policies** — React role checks can be bypassed by calling the Supabase API directly. A determined user can still read or delete rows if RLS policies are absent. Fix: Always enforce roles at the Supabase RLS level. UI role checks are for UX only — database policies are the actual security boundary.
- **Inserting audit log rows from the client** — Client-side audit inserts can be skipped, modified, or replayed. They are not trustworthy for compliance purposes. Fix: 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.
- **Hardcoding role strings in multiple component files** — When you rename a role or add a new one, you need to update every file that contains the string 'admin' or 'manager'. Fix: Define Role as a TypeScript type and export permission helper booleans (isAdmin, canEdit) from the useRole hook so changes happen in one place.
- **Not confirming bulk delete before executing** — A single misclick on the bulk delete button with 50 rows selected causes irreversible data loss if there is no confirmation step. Fix: 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.

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

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/admin-panel
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/admin-panel
