← All templates
    Backend Logic
    Free download

    Internal Admin Dashboard Template for Lovable

    Role-gated metrics and an append-only event log, where the admin check lives in the database rather than in a hidden menu item.

    Stack: React + Vite, TypeScript, Tailwind CSS, shadcn/ui, Supabase

    What is in the download

    • A build prompt that requires a window on every metric and distinguishes zero from unknown
    • A schema with a separate user_roles table and an events log
    • RLS where a member sees only their own events and an admin sees all of them
    • No insert policy on user_roles at all, so nobody can self-promote from the client
    • An aggregate function deliberately left non-definer, so it respects the caller's policies
    • Seven verification queries, run once as a member and once as an admin

    What it is not

    • Not a pre-built app you clone and run. There is no repository and no node_modules.
    • Not a design file or a theme. Lovable generates the interface from the prompt.
    • Not a substitute for reading the SQL. You are running it against your own database.

    Setup order

    Running these out of order is the usual reason a template appears broken. The schema comes before the prompt, because the prompt references the table names it creates.

    1. Run schema.sql in the Supabase SQL editor

      Creates the role type, both tables and the policies.

    2. Grant yourself admin from the SQL editor

      The commented statement at the end of schema.sql. Deliberately not an endpoint, because a self-service role grant is a privilege escalation.

    3. Run verify.sql as a member first

      The member results prove the policies. Running only as an admin proves nothing.

    4. Paste prompt.md into Lovable

    Things that catch people out

    • Keeping roles in their own table rather than a column on profiles is what allows the read policy on user_roles to differ from the write policy. A role column on a user-editable profile row is self-service privilege escalation.
    • event_counts_by_day() is intentionally not SECURITY DEFINER. If you add that keyword to make a chart work for non-admins, you have just exposed the whole event log. Verification query 5 catches it.
    • There is no update or delete policy on events, because an audit log that can be rewritten is not one.

    prompt.md

    Download

    The build prompt

    # Context
    Build an internal dashboard that answers what is happening right now, for a team operating a
    product rather than browsing analytics. The reader is an operator on a shift, so the page
    must be legible in five seconds and must never show a number without a time window.
    
    ## Core Features (Priority Order)
    1. Role-gated access: only users with the admin role may open the dashboard at all
    2. Four headline metrics with an explicit window on each, such as last 24 hours
    3. One time-series chart with a selectable range, defaulting to 7 days
    4. A recent-events table, newest first, paginated
    5. An empty state that distinguishes "no data yet" from "query failed"
    
    ## Visual Style
    - Information density over whitespace, but one clear hierarchy: metrics, chart, table
    - shadcn/ui: Card for metric tiles, Table for events, Tabs or Select for the range picker,
      Skeleton for loading, Badge for event severity
    - Numbers right-aligned and tabular, so columns scan vertically
    
    ## Technical Requirements
    - Breakpoints sm 640px, md 768px, lg 1024px; the table scrolls inside its own container
      rather than making the page scroll sideways
    - Every metric states its window in the tile, not in a tooltip
    - Loading skeletons sized to the final content, so nothing jumps
    - Distinguish zero from unknown: render a dash for a failed query, never 0
    - Dark mode via Tailwind's dark: prefix
    
    ## Implementation Strategy
    1. The admin role check and the route guard, before any query
    2. One metric tile end to end against the schema in schema.sql
    3. The remaining tiles, then the events table
    4. The chart last, because it depends on the aggregate function being settled
    
    ## Safe-Guard Instructions
    - A dashboard is not an implementation of access control. Enforce the admin check in the
      data layer as well as the UI: the policies in schema.sql do this.
    - Never expose raw event payloads to a non-admin.
    - Do not add a policy that lets a user read all events "for testing".
    - Aggregate reads must respect the same policies as row reads.
    
    ## Growth Features (Required for Distribution)
    - **Product-led Growth:** expose a narrow, read-only version of one metric to ordinary users
      in their own account view, so the value of the data is visible without granting admin.

    schema.sql

    Download

    Roles, events and policies

    -- Internal dashboard schema for Lovable + Supabase
    -- Reviewed row-level security. Read the comments before running this.
    --
    -- The point of this file: role checks live in the database, not only in the UI. A generated
    -- dashboard that hides a menu item is not access control.
    
    create type public.app_role as enum ('admin', 'member');
    
    create table public.user_roles (
      user_id uuid not null references auth.users (id) on delete cascade,
      role public.app_role not null default 'member',
      created_at timestamptz not null default now(),
      primary key (user_id, role)
    );
    
    create table public.events (
      id bigserial primary key,
      user_id uuid references auth.users (id) on delete set null,
      name text not null,
      severity text not null default 'info' check (severity in ('info', 'warn', 'error')),
      payload jsonb not null default '{}'::jsonb,
      created_at timestamptz not null default now()
    );
    
    create index events_created_idx on public.events (created_at desc);
    create index events_name_created_idx on public.events (name, created_at desc);
    create index events_user_idx on public.events (user_id);
    
    -- SECURITY DEFINER so a policy on user_roles can call it without recursing.
    create or replace function public.has_role(_role public.app_role)
    returns boolean
    language sql
    security definer
    set search_path = public
    stable
    as $$
      select exists (
        select 1 from public.user_roles
        where user_id = auth.uid() and role = _role
      );
    $$;
    
    alter table public.user_roles enable row level security;
    alter table public.events enable row level security;
    
    -- Roles: you can see your own. Only an admin sees everyone's.
    -- Nobody can grant themselves a role from the client: there is no insert policy at all.
    create policy "roles: read own" on public.user_roles
      for select to authenticated using (user_id = auth.uid());
    create policy "roles: admin reads all" on public.user_roles
      for select to authenticated using (public.has_role('admin'));
    
    -- Events: an ordinary user sees only their own rows. Admins see everything.
    create policy "events: read own" on public.events
      for select to authenticated using (user_id = auth.uid());
    create policy "events: admin reads all" on public.events
      for select to authenticated using (public.has_role('admin'));
    
    -- Writes come from your application, attributed to the acting user.
    create policy "events: insert own" on public.events
      for insert to authenticated with check (user_id = auth.uid());
    
    -- No update or delete policy: an event log that can be edited is not a log.
    
    -- Aggregates respect the same policies because they read the same rows. This function is
    -- deliberately NOT security definer, so a non-admin calling it sees only their own events.
    create or replace function public.event_counts_by_day(_days int default 7)
    returns table (day date, total bigint)
    language sql
    stable
    set search_path = public
    as $$
      select created_at::date as day, count(*) as total
      from public.events
      where created_at >= (now() - make_interval(days => greatest(_days, 1)))
      group by 1
      order by 1;
    $$;
    
    grant execute on function public.event_counts_by_day(int) to authenticated;
    
    -- Grant yourself admin once, from the SQL editor, after signing up.
    -- Replace the email, then run it. Do not expose this as an endpoint.
    -- insert into public.user_roles (user_id, role)
    -- select id, 'admin' from auth.users where email = 'you@example.com'
    -- on conflict do nothing;

    verify.sql

    Download

    Policy verification queries

    -- Verification queries. Run these as an ordinary member first, then as an admin.
    -- The member results are the ones that prove the policies work.
    
    -- As a MEMBER (no admin role):
    
    -- 1. Should return only your own events, never the whole table.
    select id, name, user_id from public.events order by created_at desc limit 20;
    
    -- 2. Should return only your own role row.
    select * from public.user_roles;
    
    -- 3. Should FAIL. There is no insert policy on user_roles, so nobody self-promotes.
    insert into public.user_roles (user_id, role) values (auth.uid(), 'admin');
    
    -- 4. Should FAIL. No update policy on events.
    update public.events set name = 'tampered' where id = (select min(id) from public.events);
    
    -- 5. Should count ONLY your own events. If this matches the true table size, the
    --    aggregate function was made SECURITY DEFINER by mistake.
    select * from public.event_counts_by_day(30);
    
    -- As an ADMIN:
    
    -- 6. Should now return other users' rows too.
    select count(*) from public.events;
    
    -- 7. Confirm RLS is on for both tables.
    select tablename, rowsecurity from pg_tables
    where schemaname = 'public' and tablename in ('user_roles', 'events');

    Next

    If you want a prompt rather than a whole starting point, the prompt library has 101 of them across six categories. If you are watching what this costs to build, how Lovable credits work explains why a vague prompt against a large project is expensive twice over.