# How to Build a Property Management System with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro plan (for Dev Mode), Supabase Free tier
- Last updated: April 2026

## TL;DR

Build a full landlord portal in Lovable that manages properties, tenants, rent payments, and maintenance requests. Admins see all data across every property. Tenants log in and see only their own lease and requests — enforced by separate RLS policies on shared tables. File uploads, Calendar for due dates, and a maintenance request Dialog make it production-ready.

## Before you start

- Lovable Pro account (Dev Mode needed for editing Edge Function files)
- Supabase project with Auth enabled
- Basic understanding of Supabase RLS policies
- Familiarity with Lovable's Cloud tab and Secrets management
- Two test user accounts in Supabase Auth — one admin, one tenant

## Step-by-step guide

### 1. Design the schema with role-based RLS

This schema stores all data in shared tables. A profiles table marks each user as landlord or tenant. RLS policies check the role and user_id columns to enforce data isolation automatically — no application-level filtering needed.

```
-- Run in Supabase SQL Editor
create table public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  role text not null check (role in ('landlord', 'tenant')),
  full_name text
);

create table public.properties (
  id uuid primary key default gen_random_uuid(),
  landlord_id uuid not null references public.profiles(id),
  address text not null,
  unit_count integer not null default 1,
  created_at timestamptz not null default now()
);

create table public.tenants (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references public.profiles(id),
  property_id uuid not null references public.properties(id) on delete cascade,
  landlord_id uuid not null references public.profiles(id),
  full_name text not null,
  email text not null,
  rent_amount numeric not null,
  lease_start date not null,
  lease_end date not null
);

create table public.payments (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references public.tenants(id) on delete cascade,
  amount numeric not null,
  due_date date not null,
  paid_at timestamptz,
  status text not null default 'pending' check (status in ('pending','paid','overdue'))
);

create table public.maintenance_requests (
  id uuid primary key default gen_random_uuid(),
  tenant_id uuid not null references public.tenants(id),
  property_id uuid not null references public.properties(id),
  title text not null,
  description text,
  priority text not null default 'medium' check (priority in ('low','medium','high','urgent')),
  status text not null default 'open' check (status in ('open','in-progress','resolved')),
  photo_url text,
  created_at timestamptz not null default now()
);

alter table public.properties enable row level security;
alter table public.tenants enable row level security;
alter table public.payments enable row level security;
alter table public.maintenance_requests enable row level security;
alter table public.profiles enable row level security;

-- Landlord sees all their properties
create policy "landlord_properties" on public.properties for all to authenticated
  using (landlord_id = auth.uid()) with check (landlord_id = auth.uid());

-- Tenants see their own record; landlords see all their tenants
create policy "tenant_self" on public.tenants for select to authenticated
  using (user_id = auth.uid() or landlord_id = auth.uid());
create policy "landlord_manage_tenants" on public.tenants for insert to authenticated
  with check (landlord_id = auth.uid());
create policy "landlord_update_tenants" on public.tenants for update to authenticated
  using (landlord_id = auth.uid());

-- Payments: tenant sees own, landlord sees all theirs
create policy "payment_access" on public.payments for select to authenticated
  using (tenant_id in (select id from public.tenants where user_id = auth.uid() or landlord_id = auth.uid()));
create policy "landlord_manage_payments" on public.payments for all to authenticated
  using (tenant_id in (select id from public.tenants where landlord_id = auth.uid()))
  with check (tenant_id in (select id from public.tenants where landlord_id = auth.uid()));

-- Maintenance: tenant sees/creates own; landlord sees all
create policy "maintenance_access" on public.maintenance_requests for select to authenticated
  using (tenant_id in (select id from public.tenants where user_id = auth.uid() or landlord_id = auth.uid()));
create policy "tenant_create_maintenance" on public.maintenance_requests for insert to authenticated
  with check (tenant_id in (select id from public.tenants where user_id = auth.uid()));
create policy "landlord_update_maintenance" on public.maintenance_requests for update to authenticated
  using (property_id in (select id from public.properties where landlord_id = auth.uid()));
```

> Pro tip: Create a Supabase database function get_my_role() that returns auth.jwt()->'user_metadata'->>'role' — then your app components can call it once on login and cache the role in React context to avoid repeated round-trips.

**Expected result:** All five tables appear in Supabase Table Editor with RLS enabled. You can verify the policies in the Policies tab of each table.

### 2. Scaffold the dual-role app with Lovable

Connect Supabase in Lovable's Cloud tab, then use the prompt below to generate the landlord dashboard and tenant portal with role-based routing.

```
// Lovable prompt — paste into chat
// Build a property management system with Supabase and two user roles: landlord and tenant.
// On login, read the user's role from the profiles table.
// Landlord routes (/admin/*):
//   /admin/properties — DataTable: address, unit_count, occupancy, actions (View, Add Tenant)
//   /admin/tenants — DataTable: name, property, rent_amount, lease_end, payment status Badge
//   /admin/maintenance — DataTable: tenant, property, title, priority Badge, status Badge, actions
//   /admin/payments — Calendar view showing due dates, Badge for paid/overdue/pending
// Tenant routes (/portal):
//   Single dashboard page: Card with lease details, payment history list, maintenance requests list
//   Button to open a Dialog for new maintenance request (title, description, priority Select, photo upload)
// Use shadcn/ui throughout. Protect landlord routes — redirect tenant users to /portal.
// Use Supabase Auth for login/signup.
```

> Pro tip: After Lovable generates the routes, review each data fetch and confirm it uses auth.uid() implicitly through RLS — do not add extra .eq('landlord_id', user.id) filters in your queries. Let RLS do the work.

**Expected result:** Lovable generates admin and portal route groups, a role-detection hook, and DataTable components for each entity. Preview shows the login form.

### 3. Build the maintenance request Dialog with file upload

The maintenance request Dialog lets tenants describe the issue, set priority, and optionally upload a photo. The photo goes to a Supabase Storage bucket and the public URL is saved in the photo_url column.

```
// src/components/MaintenanceDialog.tsx
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { supabase } from '@/lib/supabase'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { toast } from 'sonner'

const schema = z.object({
  title: z.string().min(5).max(120),
  description: z.string().max(800).optional(),
  priority: z.enum(['low', 'medium', 'high', 'urgent'])
})
type FormValues = z.infer<typeof schema>

type Props = { tenantId: string; propertyId: string; open: boolean; onClose: () => void; onSaved: () => void }

export function MaintenanceDialog({ tenantId, propertyId, open, onClose, onSaved }: Props) {
  const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { priority: 'medium' } })
  const [file, setFile] = useState<File | null>(null)
  const [uploading, setUploading] = useState(false)

  async function onSubmit(values: FormValues) {
    setUploading(true)
    let photoUrl: string | null = null
    if (file) {
      const path = `maintenance/${Date.now()}_${file.name}`
      const { error: upErr } = await supabase.storage.from('maintenance-photos').upload(path, file)
      if (upErr) { toast.error('Photo upload failed'); setUploading(false); return }
      const { data } = supabase.storage.from('maintenance-photos').getPublicUrl(path)
      photoUrl = data.publicUrl
    }
    const { error } = await supabase.from('maintenance_requests').insert({
      ...values, tenant_id: tenantId, property_id: propertyId, photo_url: photoUrl
    })
    setUploading(false)
    if (error) { toast.error('Failed to submit request'); return }
    toast.success('Maintenance request submitted')
    form.reset(); setFile(null); onSaved(); onClose()
  }

  return (
    <Dialog open={open} onOpenChange={onClose}>
      <DialogContent className="max-w-md">
        <DialogHeader><DialogTitle>New Maintenance Request</DialogTitle></DialogHeader>
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
            <FormField control={form.control} name="title" render={({ field }) => (
              <FormItem><FormLabel>Issue</FormLabel><FormControl><Input placeholder="Leaking tap in kitchen" {...field} /></FormControl><FormMessage /></FormItem>
            )} />
            <FormField control={form.control} name="description" render={({ field }) => (
              <FormItem><FormLabel>Details</FormLabel><FormControl><Textarea placeholder="Describe the issue" {...field} /></FormControl><FormMessage /></FormItem>
            )} />
            <FormField control={form.control} name="priority" render={({ field }) => (
              <FormItem><FormLabel>Priority</FormLabel>
                <Select onValueChange={field.onChange} defaultValue={field.value}>
                  <FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
                  <SelectContent>
                    <SelectItem value="low">Low</SelectItem>
                    <SelectItem value="medium">Medium</SelectItem>
                    <SelectItem value="high">High</SelectItem>
                    <SelectItem value="urgent">Urgent</SelectItem>
                  </SelectContent>
                </Select>
                <FormMessage /></FormItem>
            )} />
            <div className="space-y-1">
              <label className="text-sm font-medium">Photo (optional)</label>
              <Input type="file" accept="image/*" onChange={e => setFile(e.target.files?.[0] ?? null)} />
            </div>
            <DialogFooter>
              <Button variant="ghost" type="button" onClick={onClose}>Cancel</Button>
              <Button type="submit" disabled={uploading}>{uploading ? 'Submitting...' : 'Submit'}</Button>
            </DialogFooter>
          </form>
        </Form>
      </DialogContent>
    </Dialog>
  )
}
```

> Pro tip: Create the maintenance-photos Storage bucket in Supabase Dashboard → Storage → New Bucket. Set it to public so getPublicUrl() works without signed URLs. Add an RLS policy so only authenticated users can upload.

**Expected result:** The Dialog renders with all fields. Submitting without a photo creates the request row immediately. Submitting with a photo uploads to Storage first, then saves the URL — the full flow takes 2-3 seconds.

### 4. Add the payments Calendar with overdue detection

The admin payments view shows a shadcn Calendar with due dates marked. Clicking a date reveals a Sheet with that month's payment list. A status Badge auto-calculates overdue from the due_date vs today.

```
// src/components/PaymentsCalendar.tsx
import { useEffect, useState } from 'react'
import { Calendar } from '@/components/ui/calendar'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { supabase } from '@/lib/supabase'
import { format, isPast, isSameDay } from 'date-fns'

type Payment = { id: string; due_date: string; amount: number; status: string; tenants: { full_name: string } }

export function PaymentsCalendar() {
  const [payments, setPayments] = useState<Payment[]>([])
  const [selected, setSelected] = useState<Date | undefined>()
  const [sheetOpen, setSheetOpen] = useState(false)

  useEffect(() => {
    supabase.from('payments').select('*, tenants(full_name)').then(({ data }) => setPayments(data ?? []))
  }, [])

  const dueDates = payments.map(p => new Date(p.due_date))

  function handleSelect(date: Date | undefined) {
    setSelected(date)
    if (date) setSheetOpen(true)
  }

  const dayPayments = selected
    ? payments.filter(p => isSameDay(new Date(p.due_date), selected))
    : []

  function statusVariant(p: Payment): 'default' | 'destructive' | 'outline' {
    if (p.status === 'paid') return 'outline'
    if (isPast(new Date(p.due_date)) && p.status !== 'paid') return 'destructive'
    return 'default'
  }

  function statusLabel(p: Payment) {
    if (p.status === 'paid') return 'Paid'
    if (isPast(new Date(p.due_date))) return 'Overdue'
    return 'Pending'
  }

  return (
    <>
      <Calendar
        mode="single"
        selected={selected}
        onSelect={handleSelect}
        modifiers={{ hasDue: dueDates }}
        modifiersClassNames={{ hasDue: 'font-bold underline text-primary' }}
      />
      <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
        <SheetContent>
          <SheetHeader><SheetTitle>Payments due {selected ? format(selected, 'MMM d, yyyy') : ''}</SheetTitle></SheetHeader>
          <div className="space-y-3 mt-4">
            {dayPayments.length === 0 && <p className="text-muted-foreground text-sm">No payments due this day.</p>}
            {dayPayments.map(p => (
              <Card key={p.id}><CardContent className="pt-4 flex justify-between items-center">
                <div><p className="font-medium">{p.tenants?.full_name}</p><p className="text-sm text-muted-foreground">${p.amount}/mo</p></div>
                <Badge variant={statusVariant(p)}>{statusLabel(p)}</Badge>
              </CardContent></Card>
            ))}
          </div>
        </SheetContent>
      </Sheet>
    </>
  )
}
```

> Pro tip: Run a daily Supabase Edge Function with pg_cron to update payment rows from pending to overdue where due_date < now() and paid_at is null. This keeps status accurate without relying on client-side logic.

**Expected result:** The calendar renders with underlined dates that have payments due. Clicking a date opens a Sheet listing the tenants with their payment amount and a colored Badge showing paid, pending, or overdue.

### 5. Build the tenant self-service portal

The tenant portal is a single page that shows lease details, payment history, and maintenance requests — all scoped automatically by RLS using the logged-in user's ID. No extra filtering code is needed.

```
// src/pages/TenantPortal.tsx
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { MaintenanceDialog } from '@/components/MaintenanceDialog'
import { format } from 'date-fns'

export function TenantPortal() {
  const [tenant, setTenant] = useState<any>(null)
  const [payments, setPayments] = useState<any[]>([])
  const [requests, setRequests] = useState<any[]>([])
  const [dialogOpen, setDialogOpen] = useState(false)

  async function load() {
    const { data: { user } } = await supabase.auth.getUser()
    if (!user) return
    const { data: t } = await supabase.from('tenants').select('*, properties(address)').eq('user_id', user.id).single()
    setTenant(t)
    if (t) {
      const [p, r] = await Promise.all([
        supabase.from('payments').select('*').eq('tenant_id', t.id).order('due_date', { ascending: false }),
        supabase.from('maintenance_requests').select('*').eq('tenant_id', t.id).order('created_at', { ascending: false })
      ])
      setPayments(p.data ?? [])
      setRequests(r.data ?? [])
    }
  }

  useEffect(() => { load() }, [])

  if (!tenant) return <p className="p-6 text-muted-foreground">Loading your portal...</p>

  return (
    <div className="p-6 space-y-6 max-w-2xl">
      <Card>
        <CardHeader><CardTitle>Your Lease</CardTitle></CardHeader>
        <CardContent className="space-y-1">
          <p><span className="font-medium">Property:</span> {tenant.properties?.address}</p>
          <p><span className="font-medium">Rent:</span> ${tenant.rent_amount}/mo</p>
          <p><span className="font-medium">Lease:</span> {format(new Date(tenant.lease_start), 'MMM d, yyyy')} – {format(new Date(tenant.lease_end), 'MMM d, yyyy')}</p>
        </CardContent>
      </Card>
      <Card>
        <CardHeader><CardTitle>Payment History</CardTitle></CardHeader>
        <CardContent className="space-y-2">
          {payments.slice(0, 6).map(p => (
            <div key={p.id} className="flex justify-between text-sm">
              <span>{format(new Date(p.due_date), 'MMM yyyy')}</span>
              <Badge variant={p.status === 'paid' ? 'outline' : p.status === 'overdue' ? 'destructive' : 'default'}>{p.status}</Badge>
            </div>
          ))}
        </CardContent>
      </Card>
      <Card>
        <CardHeader className="flex flex-row items-center justify-between">
          <CardTitle>Maintenance Requests</CardTitle>
          <Button size="sm" onClick={() => setDialogOpen(true)}>New Request</Button>
        </CardHeader>
        <CardContent className="space-y-2">
          {requests.map(r => (
            <div key={r.id} className="flex justify-between text-sm">
              <span>{r.title}</span>
              <Badge variant="outline">{r.status}</Badge>
            </div>
          ))}
        </CardContent>
      </Card>
      {tenant && <MaintenanceDialog tenantId={tenant.id} propertyId={tenant.properties?.id} open={dialogOpen} onClose={() => setDialogOpen(false)} onSaved={load} />}
    </div>
  )
}
```

> Pro tip: On first login, check if a profiles row exists for the user and create it if not using upsert. This handles the case where an admin creates a tenant account manually in Supabase Auth.

**Expected result:** A tenant user logging in sees only their own property address, lease dates, rent amount, payment history, and maintenance requests. No other tenants' data appears — enforced entirely by RLS.

## Complete code example

File: `src/components/MaintenanceDialog.tsx`

```typescript
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { supabase } from '@/lib/supabase'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { toast } from 'sonner'

const schema = z.object({
  title: z.string().min(5).max(120),
  description: z.string().max(800).optional(),
  priority: z.enum(['low', 'medium', 'high', 'urgent'])
})
type FormValues = z.infer<typeof schema>
type Props = { tenantId: string; propertyId: string; open: boolean; onClose: () => void; onSaved: () => void }

export function MaintenanceDialog({ tenantId, propertyId, open, onClose, onSaved }: Props) {
  const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { priority: 'medium' } })
  const [file, setFile] = useState<File | null>(null)
  const [uploading, setUploading] = useState(false)

  async function onSubmit(values: FormValues) {
    setUploading(true)
    let photoUrl: string | null = null
    if (file) {
      const path = `maintenance/${Date.now()}.${file.name.split('.').pop()}`
      const { error: upErr } = await supabase.storage.from('maintenance-photos').upload(path, file, { contentType: file.type })
      if (upErr) toast.error('Photo upload failed')
      else photoUrl = supabase.storage.from('maintenance-photos').getPublicUrl(path).data.publicUrl
    }
    const { error } = await supabase.from('maintenance_requests').insert({
      ...values, tenant_id: tenantId, property_id: propertyId, photo_url: photoUrl
    })
    setUploading(false)
    if (error) { toast.error('Failed to submit request'); return }
    toast.success('Maintenance request submitted')
    form.reset(); setFile(null); onSaved(); onClose()
  }

  return (
    <Dialog open={open} onOpenChange={onClose}>
      <DialogContent className="max-w-md">
        <DialogHeader><DialogTitle>New Maintenance Request</DialogTitle></DialogHeader>
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
            <FormField control={form.control} name="title" render={({ field }) => (
              <FormItem><FormLabel>Issue summary</FormLabel><FormControl><Input placeholder="e.g. Leaking kitchen tap" {...field} /></FormControl><FormMessage /></FormItem>
            )} />
            <FormField control={form.control} name="description" render={({ field }) => (
              <FormItem><FormLabel>Details (optional)</FormLabel><FormControl><Textarea placeholder="When did it start? Which room?" {...field} /></FormControl><FormMessage /></FormItem>
            )} />
            <FormField control={form.control} name="priority" render={({ field }) => (
              <FormItem><FormLabel>Priority</FormLabel>
                <Select onValueChange={field.onChange} defaultValue={field.value}>
                  <FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
                  <SelectContent>
                    {['low','medium','high','urgent'].map(v => <SelectItem key={v} value={v}>{v.charAt(0).toUpperCase()+v.slice(1)}</SelectItem>)}
                  </SelectContent>
                </Select>
                <FormMessage /></FormItem>
            )} />
            <div className="space-y-1">
              <label className="text-sm font-medium">Photo (optional)</label>
              <Input type="file" accept="image/*" onChange={e => setFile(e.target.files?.[0] ?? null)} />
            </div>
            <DialogFooter>
              <Button variant="ghost" type="button" onClick={onClose}>Cancel</Button>
              <Button type="submit" disabled={uploading}>{uploading ? 'Submitting...' : 'Submit Request'}</Button>
            </DialogFooter>
          </form>
        </Form>
      </DialogContent>
    </Dialog>
  )
}
```

## Common mistakes

- **Applying only one RLS policy for both landlords and tenants** — A single policy using OR (user_id = auth.uid() OR landlord_id = auth.uid()) works for SELECT, but UPDATE and DELETE need separate policies or a tenant could modify another tenant's records. Fix: Write separate named policies for each operation (SELECT, INSERT, UPDATE, DELETE) and each role. Explicit policies are easier to audit and debug than combined OR conditions.
- **Uploading photos directly to a private Storage bucket** — Private buckets require signed URLs which expire. If you save the signed URL in photo_url, it becomes invalid after the expiry period. Fix: Use a public bucket for maintenance photos and save the permanent public URL via supabase.storage.from('bucket').getPublicUrl(path).data.publicUrl.
- **Not creating the profiles row automatically on signup** — If you create Supabase Auth users without a corresponding profiles row, the role check fails and RLS blocks all queries. Fix: Add a Supabase Database Webhook on the auth.users table that calls an Edge Function to insert a default profiles row on INSERT, or use Supabase's built-in Auth Hooks.
- **Hardcoding landlord_id in the client component** — If multiple landlords use the system, hardcoding an ID means all data routes to one landlord account. Fix: Always derive landlord_id from the authenticated user context: const { data: { user } } = await supabase.auth.getUser() then use user.id.
- **Fetching all payments without pagination** — A landlord with many properties and years of payment history will hit Supabase's default 1,000 row limit and see incomplete data without any error. Fix: Add .range(0, 49) to payment queries and implement Load More pagination in the DataTable.

## Best practices

- Design RLS policies to be your only access control layer — never add .eq('landlord_id', userId) filters in components as a substitute for proper policies.
- Use a profiles table linked to auth.users rather than storing role metadata in the JWT — it is easier to update roles without forcing re-login.
- Create Storage buckets with folder-based paths (maintenance/[tenant_id]/[filename]) so you can write granular bucket policies scoped to each tenant's folder.
- Add database indexes on foreign key columns (tenant_id, property_id, landlord_id) to keep queries fast as the dataset grows.
- Use Supabase's built-in Auth email templates for lease invitations so tenants receive a properly branded signup link with their role pre-set.
- Validate file size and type on the client before uploading to Storage to prevent large files and non-image uploads from hitting the bucket.
- Keep the tenant portal as a single page with lazy-loaded sections rather than multiple routes — tenants need simple, focused UX.
- Test both roles in separate incognito windows simultaneously to verify RLS isolation visually before going live.

## Frequently asked questions

### How do I give a new tenant access to their portal?

Create a Supabase Auth user for the tenant via Authentication → Users → Invite User. Then create their tenants table row with the new user's auth ID in the user_id column. The RLS policy automatically grants them access to only their data on next login.

### Can multiple landlords use the same app?

Yes. Each landlord has their own profiles row with role 'landlord'. The RLS policies on properties and tenants use landlord_id = auth.uid(), so each landlord sees only their own portfolio. No additional application filtering is needed.

### How do I handle tenants who move to a new property?

Update the tenant's property_id to the new property, update lease_start and lease_end dates, and create a new payment schedule. The old maintenance requests remain linked to the previous property_id for historical records.

### Why does the tenant portal show no data even after I linked their user_id?

The most common cause is that the user is logged in but the profiles row was not created. Check your Supabase Authentication → Users table and the public.profiles table — there must be a matching row. Also verify the RLS select policy is correct.

### How do I deploy this so both landlords and tenants can use it?

Click the Publish icon in Lovable to get a production URL. Share it with both landlords and tenants — the app detects the logged-in user's role from the profiles table and routes them to either /admin or /portal automatically.

### Can I add a mobile app for tenants?

The Lovable app is a responsive React web app that works well on mobile browsers. For a native app, you would export the code from Lovable Dev Mode and wrap it in Capacitor or React Native with the same Supabase backend.

### Can RapidDev help set up the full multi-role auth and RLS configuration?

Yes. Setting up dual-role RLS correctly is one of the more complex parts of Lovable projects. RapidDev offers hands-on setup sessions for Supabase auth and RLS — details at rapiddev.io.

### How do I track which maintenance requests have photos attached?

Filter the maintenance_requests table by photo_url IS NOT NULL in your admin DataTable. Add a paperclip icon column that shows when photo_url is present, linking to the image URL in Supabase Storage.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/property-management-system
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/property-management-system
