# How to Build a Privacy Tools with Lovable

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

## TL;DR

Build a GDPR and CCPA compliance toolkit in Lovable with consent record storage, a data export Edge Function, cascading user data deletion using the service_role key, and versioned privacy policies with acceptance tracking. Gives users full control over their data and gives you the audit trail regulators require.

## Before you start

- Lovable Pro account for Edge Function generation
- Supabase project with service_role key saved to Cloud tab → Secrets as SUPABASE_SERVICE_ROLE_KEY
- List of all tables in your app that contain user data (needed for the export and deletion functions)

## Step-by-step guide

### 1. Create the consent and privacy policy schema

Set up the tables that track privacy policy versions and user consent. The audit trail these tables create is what makes compliance demonstrable.

```
Build a privacy compliance toolkit. Create these Supabase tables:

- privacy_policies: id, version (text, e.g. '2024-01'), content (text, full policy markdown), summary_of_changes (text), effective_date (date), created_at, is_current (bool default false)

- consent_records: id, user_id (FK auth.users), policy_version (text), accepted_at (timestamptz), ip_address (text, for audit), user_agent (text, for audit), UNIQUE(user_id, policy_version)

- consent_preferences: id, user_id (FK auth.users UNIQUE), analytics_consent (bool default false), marketing_consent (bool default false), functional_consent (bool default true), last_updated_at

- deletion_requests: id, user_id, email (text, stored separately since user will be deleted), requested_at, status (pending|processing|completed|failed), completed_at, error_message

RLS:
- privacy_policies: public SELECT for is_current = true; admin INSERT/UPDATE
- consent_records: user_id = auth.uid() for SELECT/INSERT; service_role for DELETE
- consent_preferences: user_id = auth.uid() for SELECT/UPDATE
- deletion_requests: user_id = auth.uid() for INSERT and SELECT; service_role for updates

Create a view current_privacy_policy that returns the single row WHERE is_current = true.
```

> Pro tip: Ask Lovable to create a trigger on privacy_policies that automatically sets is_current = false on all other rows when a new row is inserted with is_current = true. This ensures only one policy is ever marked as current.

**Expected result:** All four tables are created with RLS. A starter privacy policy row can be inserted in the Supabase SQL editor. The app shows a basic Privacy Settings page in the navigation.

### 2. Build the consent banner and preference center

Create the consent banner that appears for users who haven't accepted the current policy, and the preference center where users can adjust granular consent settings.

```
Build two consent components:

1. Consent Banner at src/components/privacy/ConsentBanner.tsx:
   - Display at the bottom of the screen as a fixed sticky bar
   - Fetch the current privacy policy version from current_privacy_policy view
   - Check consent_records: if no row exists for current user + current version, show the banner
   - Banner content: 'We updated our privacy policy. Please review and accept to continue.' with a link to the full policy
   - Buttons: 'Accept All' and 'Customize'
   - 'Accept All' inserts into consent_records with current version and sets all consent_preferences to true
   - 'Customize' opens the preference center Dialog
   - Once accepted, hide the banner using local state (don't show again until next policy version)

2. Consent Preference Center Dialog at src/components/privacy/ConsentPreferences.tsx:
   - Three switches with labels and descriptions:
     - Functional (required, Switch disabled and always on): 'Required for the app to function'
     - Analytics (optional, Switch): 'Help us improve by tracking feature usage anonymously'
     - Marketing (optional, Switch): 'Receive product updates and promotional emails'
   - 'Save Preferences' Button: upserts consent_preferences and inserts/upserts consent_records for current policy version
   - Show last updated date below the switches
   - Link to privacy policy page
```

**Expected result:** A new user sees the consent banner at the bottom of the screen. Clicking 'Accept All' dismisses it and creates a consent_records row. Clicking 'Customize' opens the preference toggles.

### 3. Build the data export Edge Function

Create the Edge Function that compiles all of a user's data into a downloadable JSON archive. This fulfills GDPR Article 15 (right of access) and CCPA Section 1798.100.

```
// supabase/functions/export-user-data/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  const authHeader = req.headers.get('Authorization')
  if (!authHeader) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: corsHeaders })

  const userClient = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '', {
    global: { headers: { Authorization: authHeader } },
  })
  const serviceClient = createClient(Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '')

  const { data: { user }, error: authError } = await userClient.auth.getUser()
  if (authError || !user) return new Response(JSON.stringify({ error: 'Invalid session' }), { status: 401, headers: corsHeaders })

  const userId = user.id

  const [consentRecords, consentPreferences, deletionRequests] = await Promise.all([
    serviceClient.from('consent_records').select('*').eq('user_id', userId),
    serviceClient.from('consent_preferences').select('*').eq('user_id', userId).single(),
    serviceClient.from('deletion_requests').select('*').eq('user_id', userId),
  ])

  // Add queries for all other app-specific user tables here
  const exportData = {
    export_date: new Date().toISOString(),
    user: { id: userId, email: user.email, created_at: user.created_at },
    consent_records: consentRecords.data ?? [],
    consent_preferences: consentPreferences.data ?? null,
    deletion_requests: deletionRequests.data ?? [],
    // Add other tables here: profiles, posts, orders, etc.
  }

  return new Response(JSON.stringify(exportData, null, 2), {
    headers: { ...corsHeaders, 'Content-Type': 'application/json', 'Content-Disposition': `attachment; filename="data-export-${userId.slice(0, 8)}.json"` },
  })
})
```

**Expected result:** Calling the Edge Function with a valid auth token returns a JSON file download containing all user data. The response includes Content-Disposition header so the browser treats it as a file download.

### 4. Build the account deletion flow and privacy dashboard

Create the cascading deletion Edge Function and the privacy dashboard where users can view their data summary, request export, and permanently delete their account.

```
Build two features:

1. Account deletion Edge Function at supabase/functions/delete-user-data/index.ts:
   - Authenticate the user the same way as the export function
   - Insert a deletion_requests row with status='processing'
   - Using the service_role client, delete all user data in this order:
     a. Delete from all app-specific tables by user_id (consent_records, consent_preferences, all other user tables)
     b. Delete any Supabase Storage objects owned by the user: list all objects with user_id in name, delete them
     c. Call supabase.auth.admin.deleteUser(userId) to delete the auth user
   - Update deletion_requests row: status='completed', completed_at=now()
   - If any step fails, set status='failed' and error_message, do NOT proceed with auth deletion
   - Return { success: true } on completion

2. Privacy Dashboard page at src/pages/PrivacyDashboard.tsx:
   - Tabs: 'My Consent', 'My Data', 'Delete Account'
   - My Consent tab: show all consent_records as a timeline, consent_preferences switches (edit in place)
   - My Data tab: show counts per table (e.g. 'Profile: 1 record', 'Orders: 12 records'), 'Download My Data' Button that calls export-user-data Edge Function
   - Delete Account tab: danger zone with red styling. Steps: 'This is permanent and cannot be undone', a checkbox 'I understand this is permanent', a text Input requiring the user to type their email, 'Delete My Account' Button (destructive). On confirm: calls delete-user-data Edge Function and redirects to a farewell page.
```

**Expected result:** The privacy dashboard shows three tabs. Downloading data triggers a JSON file download. The delete account flow requires email confirmation before proceeding.

## Complete code example

File: `src/components/privacy/ConsentBanner.tsx`

```typescript
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { supabase } from '@/integrations/supabase/client'
import { useAuth } from '@/hooks/useAuth'

interface Policy { version: string; summary_of_changes: string }

export function ConsentBanner() {
  const { user } = useAuth()
  const [showBanner, setShowBanner] = useState(false)
  const [policy, setPolicy] = useState<Policy | null>(null)

  useEffect(() => {
    if (!user) return
    async function checkConsent() {
      const { data: currentPolicy } = await supabase.from('current_privacy_policy').select('version, summary_of_changes').single()
      if (!currentPolicy) return
      setPolicy(currentPolicy)
      const { data: existing } = await supabase.from('consent_records').select('id').eq('user_id', user!.id).eq('policy_version', currentPolicy.version).single()
      if (!existing) setShowBanner(true)
    }
    checkConsent()
  }, [user])

  async function acceptAll() {
    if (!user || !policy) return
    await Promise.all([
      supabase.from('consent_records').upsert({ user_id: user.id, policy_version: policy.version, accepted_at: new Date().toISOString() }, { onConflict: 'user_id,policy_version' }),
      supabase.from('consent_preferences').upsert({ user_id: user.id, analytics_consent: true, marketing_consent: true, functional_consent: true, last_updated_at: new Date().toISOString() }, { onConflict: 'user_id' }),
    ])
    setShowBanner(false)
  }

  if (!showBanner || !policy) return null

  return (
    <div className="fixed bottom-0 left-0 right-0 z-50 border-t bg-background p-4 shadow-lg">
      <div className="mx-auto flex max-w-4xl items-start gap-4 sm:items-center">
        <div className="flex-1 text-sm">
          <p className="font-medium">We updated our privacy policy (v{policy.version})</p>
          <p className="text-muted-foreground mt-0.5">{policy.summary_of_changes}</p>
        </div>
        <div className="flex shrink-0 gap-2">
          <Button variant="outline" size="sm" onClick={() => setShowBanner(false)}>Customize</Button>
          <Button size="sm" onClick={acceptAll}>Accept All</Button>
        </div>
      </div>
    </div>
  )
}
```

## Common mistakes

- **Using the anon key for the deletion Edge Function** — RLS policies prevent the anon key from deleting data in tables that belong to other users or in tables with service_role-only policies. The deletion function will silently fail to delete some data, leaving orphaned records. Fix: Use the service_role key only inside Edge Functions (never in the frontend) for operations that need to bypass RLS. The delete-user-data function authenticates the user first, then uses the service_role client for all deletions. The service_role key should never appear in frontend code or VITE_ variables.
- **Deleting the auth user before deleting their data** — If you call auth.admin.deleteUser() first, all subsequent queries using user_id as a filter may fail if there are FK constraints to auth.users. The delete-user-data function may error out halfway through, leaving orphaned data. Fix: Always delete application data (all user tables, storage objects) before deleting the auth user. The auth user deletion should be the last step. If any earlier step fails, do not proceed to auth deletion and set the deletion request status to 'failed' with the error details.
- **Not recording IP address and user agent with consent** — GDPR requires that you can prove consent was given. Just having a consent_records row with a timestamp is not sufficient evidence if challenged. Regulators may require more context. Fix: Record the IP address and user agent in consent_records. In the consent acceptance function, pass these values from the frontend (the client knows its own IP via navigator and User-Agent header). Store ip_address as text (not inet type to avoid parsing issues) and user_agent as text.
- **Allowing consent banner dismissal without acceptance** — Under GDPR, consent must be an affirmative action. If users can close the banner without clicking 'Accept' or 'Customize', the closure cannot be treated as consent. Fix: Remove the X close button from the consent banner. The only options should be 'Accept All' or 'Customize'. If users need to access the site to read the full policy before deciding, allow a 'Read Policy First' link that opens the policy in a sheet without dismissing the banner.

## Best practices

- Consent records should be immutable. Never update an existing consent_records row — always insert a new row when consent is updated. This preserves the full history of consent changes for audit purposes.
- Use a service_role Edge Function for both data export and deletion. These operations need to read and write across all tables, bypassing user-scoped RLS. Never expose the service_role key to the frontend.
- Test the deletion function in a staging environment before deploying. Create a test user, populate all tables with their data, run the deletion function, and verify that every table is empty for that user_id. Document which tables are included.
- Show users a data inventory in the privacy dashboard: a list of each data category and record count. This builds trust by showing transparency about what is stored, and fulfills the GDPR Article 15 right to know.
- Store the exact text of the privacy policy in the privacy_policies table, not just a link to an external URL. If you link to a URL and the content changes, your consent records no longer accurately reflect what users consented to.
- Implement a 30-day cooling-off period before processing deletion requests. Store the requested_at timestamp. Notify the user by email when the request is received and when it is processed. This gives users time to change their mind before data is permanently deleted.

## Frequently asked questions

### Does this toolkit make my app fully GDPR compliant?

This toolkit provides the technical infrastructure that GDPR requires: consent records, data access, data portability, and the right to erasure. However, full GDPR compliance also requires: a lawful basis for processing, a Data Protection Officer for certain organizations, data processing agreements with vendors, a cookie policy, a breach notification procedure, and potentially a Privacy Impact Assessment. This guide covers the technical implementation; consult a lawyer for legal compliance.

### What is CCPA and how is it different from GDPR?

CCPA (California Consumer Privacy Act) applies to businesses serving California residents. It grants similar rights to GDPR: access, deletion, and portability. Key differences: CCPA has a specific 'Do Not Sell My Personal Information' requirement (add a notice and opt-out mechanism), CCPA applies to businesses above certain revenue thresholds, and GDPR requires a legal basis for processing while CCPA uses an opt-out model. The consent and deletion infrastructure in this guide satisfies both frameworks' technical requirements.

### How do I handle the 'right to be forgotten' for data in backups?

Database backups are a known challenge for GDPR deletion requests. GDPR Article 17 allows a 'reasonable period' to process deletion requests (typically 30 days). For backups, document that backups are retained for a maximum period (e.g. 30 days) and that deleted user data will be removed from all backups within that window naturally. You don't need to manually remove specific users from every backup — just ensure old backups are deleted on schedule.

### Can I use this with anonymous (non-authenticated) users?

For anonymous users, you can't use user_id as the identifier. Instead, use a UUID stored in localStorage as a session ID. Store consent records with this session ID instead of user_id. When an anonymous user creates an account, migrate their session consent records to their new user_id. For deletion, anonymous sessions are lower-risk since no identifying information is tied to them — a cookie deletion or localStorage clear effectively 'deletes' them.

### How long should I retain consent records?

Consent records should be retained for the duration of the user's account plus a period after deletion to defend against legal claims. Common practice is 3-7 years after account deletion, depending on jurisdiction. Do not delete consent_records as part of the user deletion flow — archive them with user_id and email (captured before deletion) for your legal retention period, then delete them automatically via a data retention policy after that period.

### What happens to data in Supabase Storage when I delete a user?

Supabase Storage objects are not automatically deleted when a user's auth record is deleted. The delete-user-data Edge Function must explicitly list and delete all Storage objects associated with the user. Use supabase.storage.from('bucket').list(userId + '/') to find user-specific objects (assuming you store them in a user_id-prefixed path) and then call supabase.storage.from('bucket').remove(paths) to delete them.

### Do I need to show a consent banner for users who already accepted an older policy version?

Yes, if your new policy version contains material changes that affect user rights. Minor formatting updates may not require re-consent, but changes to data collection, third-party sharing, or retention periods do. The privacy_policies.summary_of_changes field helps you communicate what changed. Show the banner to any user whose consent_records do not include an entry for the new version number.

### Can RapidDev help with more complex privacy compliance features?

RapidDev builds production Lovable apps including compliance toolkits with cookie consent managers, automated data retention, DSAR (Data Subject Access Request) workflows, and privacy impact assessments. Reach out if your compliance requirements go beyond what this guide covers.

---

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