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.
What you're building
A property management portal with two user roles sharing the same Supabase tables but seeing different data. Landlord admins can create properties, add tenants, log payments, and triage maintenance requests across their entire portfolio. Tenants log in to a self-service portal to view their lease details, check payment history, and submit new maintenance requests with photo attachments. Row-level security policies enforce the data separation without any application-level filtering tricks.
Final result
A live multi-tenant portal where the admin views a full property portfolio and tenants access a scoped dashboard showing only their own data — all at the same Lovable URL.
Tech stack
Prerequisites
- 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
Build steps
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.
1-- Run in Supabase SQL Editor2create table public.profiles (3 id uuid primary key references auth.users(id) on delete cascade,4 role text not null check (role in ('landlord', 'tenant')),5 full_name text6);78create table public.properties (9 id uuid primary key default gen_random_uuid(),10 landlord_id uuid not null references public.profiles(id),11 address text not null,12 unit_count integer not null default 1,13 created_at timestamptz not null default now()14);1516create table public.tenants (17 id uuid primary key default gen_random_uuid(),18 user_id uuid references public.profiles(id),19 property_id uuid not null references public.properties(id) on delete cascade,20 landlord_id uuid not null references public.profiles(id),21 full_name text not null,22 email text not null,23 rent_amount numeric not null,24 lease_start date not null,25 lease_end date not null26);2728create table public.payments (29 id uuid primary key default gen_random_uuid(),30 tenant_id uuid not null references public.tenants(id) on delete cascade,31 amount numeric not null,32 due_date date not null,33 paid_at timestamptz,34 status text not null default 'pending' check (status in ('pending','paid','overdue'))35);3637create table public.maintenance_requests (38 id uuid primary key default gen_random_uuid(),39 tenant_id uuid not null references public.tenants(id),40 property_id uuid not null references public.properties(id),41 title text not null,42 description text,43 priority text not null default 'medium' check (priority in ('low','medium','high','urgent')),44 status text not null default 'open' check (status in ('open','in-progress','resolved')),45 photo_url text,46 created_at timestamptz not null default now()47);4849alter table public.properties enable row level security;50alter table public.tenants enable row level security;51alter table public.payments enable row level security;52alter table public.maintenance_requests enable row level security;53alter table public.profiles enable row level security;5455-- Landlord sees all their properties56create policy "landlord_properties" on public.properties for all to authenticated57 using (landlord_id = auth.uid()) with check (landlord_id = auth.uid());5859-- Tenants see their own record; landlords see all their tenants60create policy "tenant_self" on public.tenants for select to authenticated61 using (user_id = auth.uid() or landlord_id = auth.uid());62create policy "landlord_manage_tenants" on public.tenants for insert to authenticated63 with check (landlord_id = auth.uid());64create policy "landlord_update_tenants" on public.tenants for update to authenticated65 using (landlord_id = auth.uid());6667-- Payments: tenant sees own, landlord sees all theirs68create policy "payment_access" on public.payments for select to authenticated69 using (tenant_id in (select id from public.tenants where user_id = auth.uid() or landlord_id = auth.uid()));70create policy "landlord_manage_payments" on public.payments for all to authenticated71 using (tenant_id in (select id from public.tenants where landlord_id = auth.uid()))72 with check (tenant_id in (select id from public.tenants where landlord_id = auth.uid()));7374-- Maintenance: tenant sees/creates own; landlord sees all75create policy "maintenance_access" on public.maintenance_requests for select to authenticated76 using (tenant_id in (select id from public.tenants where user_id = auth.uid() or landlord_id = auth.uid()));77create policy "tenant_create_maintenance" on public.maintenance_requests for insert to authenticated78 with check (tenant_id in (select id from public.tenants where user_id = auth.uid()));79create policy "landlord_update_maintenance" on public.maintenance_requests for update to authenticated80 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.
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.
1// Lovable prompt — paste into chat2// Build a property management system with Supabase and two user roles: landlord and tenant.3// On login, read the user's role from the profiles table.4// Landlord routes (/admin/*):5// /admin/properties — DataTable: address, unit_count, occupancy, actions (View, Add Tenant)6// /admin/tenants — DataTable: name, property, rent_amount, lease_end, payment status Badge7// /admin/maintenance — DataTable: tenant, property, title, priority Badge, status Badge, actions8// /admin/payments — Calendar view showing due dates, Badge for paid/overdue/pending9// Tenant routes (/portal):10// Single dashboard page: Card with lease details, payment history list, maintenance requests list11// Button to open a Dialog for new maintenance request (title, description, priority Select, photo upload)12// Use shadcn/ui throughout. Protect landlord routes — redirect tenant users to /portal.13// 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.
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.
1// src/components/MaintenanceDialog.tsx2import { useState } from 'react'3import { useForm } from 'react-hook-form'4import { zodResolver } from '@hookform/resolvers/zod'5import { z } from 'zod'6import { supabase } from '@/lib/supabase'7import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'8import { Button } from '@/components/ui/button'9import { Input } from '@/components/ui/input'10import { Textarea } from '@/components/ui/textarea'11import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'12import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'13import { toast } from 'sonner'1415const schema = z.object({16 title: z.string().min(5).max(120),17 description: z.string().max(800).optional(),18 priority: z.enum(['low', 'medium', 'high', 'urgent'])19})20type FormValues = z.infer<typeof schema>2122type Props = { tenantId: string; propertyId: string; open: boolean; onClose: () => void; onSaved: () => void }2324export function MaintenanceDialog({ tenantId, propertyId, open, onClose, onSaved }: Props) {25 const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { priority: 'medium' } })26 const [file, setFile] = useState<File | null>(null)27 const [uploading, setUploading] = useState(false)2829 async function onSubmit(values: FormValues) {30 setUploading(true)31 let photoUrl: string | null = null32 if (file) {33 const path = `maintenance/${Date.now()}_${file.name}`34 const { error: upErr } = await supabase.storage.from('maintenance-photos').upload(path, file)35 if (upErr) { toast.error('Photo upload failed'); setUploading(false); return }36 const { data } = supabase.storage.from('maintenance-photos').getPublicUrl(path)37 photoUrl = data.publicUrl38 }39 const { error } = await supabase.from('maintenance_requests').insert({40 ...values, tenant_id: tenantId, property_id: propertyId, photo_url: photoUrl41 })42 setUploading(false)43 if (error) { toast.error('Failed to submit request'); return }44 toast.success('Maintenance request submitted')45 form.reset(); setFile(null); onSaved(); onClose()46 }4748 return (49 <Dialog open={open} onOpenChange={onClose}>50 <DialogContent className="max-w-md">51 <DialogHeader><DialogTitle>New Maintenance Request</DialogTitle></DialogHeader>52 <Form {...form}>53 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">54 <FormField control={form.control} name="title" render={({ field }) => (55 <FormItem><FormLabel>Issue</FormLabel><FormControl><Input placeholder="Leaking tap in kitchen" {...field} /></FormControl><FormMessage /></FormItem>56 )} />57 <FormField control={form.control} name="description" render={({ field }) => (58 <FormItem><FormLabel>Details</FormLabel><FormControl><Textarea placeholder="Describe the issue" {...field} /></FormControl><FormMessage /></FormItem>59 )} />60 <FormField control={form.control} name="priority" render={({ field }) => (61 <FormItem><FormLabel>Priority</FormLabel>62 <Select onValueChange={field.onChange} defaultValue={field.value}>63 <FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>64 <SelectContent>65 <SelectItem value="low">Low</SelectItem>66 <SelectItem value="medium">Medium</SelectItem>67 <SelectItem value="high">High</SelectItem>68 <SelectItem value="urgent">Urgent</SelectItem>69 </SelectContent>70 </Select>71 <FormMessage /></FormItem>72 )} />73 <div className="space-y-1">74 <label className="text-sm font-medium">Photo (optional)</label>75 <Input type="file" accept="image/*" onChange={e => setFile(e.target.files?.[0] ?? null)} />76 </div>77 <DialogFooter>78 <Button variant="ghost" type="button" onClick={onClose}>Cancel</Button>79 <Button type="submit" disabled={uploading}>{uploading ? 'Submitting...' : 'Submit'}</Button>80 </DialogFooter>81 </form>82 </Form>83 </DialogContent>84 </Dialog>85 )86}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.
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.
1// src/components/PaymentsCalendar.tsx2import { useEffect, useState } from 'react'3import { Calendar } from '@/components/ui/calendar'4import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'5import { Badge } from '@/components/ui/badge'6import { Card, CardContent } from '@/components/ui/card'7import { supabase } from '@/lib/supabase'8import { format, isPast, isSameDay } from 'date-fns'910type Payment = { id: string; due_date: string; amount: number; status: string; tenants: { full_name: string } }1112export function PaymentsCalendar() {13 const [payments, setPayments] = useState<Payment[]>([])14 const [selected, setSelected] = useState<Date | undefined>()15 const [sheetOpen, setSheetOpen] = useState(false)1617 useEffect(() => {18 supabase.from('payments').select('*, tenants(full_name)').then(({ data }) => setPayments(data ?? []))19 }, [])2021 const dueDates = payments.map(p => new Date(p.due_date))2223 function handleSelect(date: Date | undefined) {24 setSelected(date)25 if (date) setSheetOpen(true)26 }2728 const dayPayments = selected29 ? payments.filter(p => isSameDay(new Date(p.due_date), selected))30 : []3132 function statusVariant(p: Payment): 'default' | 'destructive' | 'outline' {33 if (p.status === 'paid') return 'outline'34 if (isPast(new Date(p.due_date)) && p.status !== 'paid') return 'destructive'35 return 'default'36 }3738 function statusLabel(p: Payment) {39 if (p.status === 'paid') return 'Paid'40 if (isPast(new Date(p.due_date))) return 'Overdue'41 return 'Pending'42 }4344 return (45 <>46 <Calendar47 mode="single"48 selected={selected}49 onSelect={handleSelect}50 modifiers={{ hasDue: dueDates }}51 modifiersClassNames={{ hasDue: 'font-bold underline text-primary' }}52 />53 <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>54 <SheetContent>55 <SheetHeader><SheetTitle>Payments due {selected ? format(selected, 'MMM d, yyyy') : ''}</SheetTitle></SheetHeader>56 <div className="space-y-3 mt-4">57 {dayPayments.length === 0 && <p className="text-muted-foreground text-sm">No payments due this day.</p>}58 {dayPayments.map(p => (59 <Card key={p.id}><CardContent className="pt-4 flex justify-between items-center">60 <div><p className="font-medium">{p.tenants?.full_name}</p><p className="text-sm text-muted-foreground">${p.amount}/mo</p></div>61 <Badge variant={statusVariant(p)}>{statusLabel(p)}</Badge>62 </CardContent></Card>63 ))}64 </div>65 </SheetContent>66 </Sheet>67 </>68 )69}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.
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.
1// src/pages/TenantPortal.tsx2import { useEffect, useState } from 'react'3import { supabase } from '@/lib/supabase'4import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'5import { Badge } from '@/components/ui/badge'6import { Button } from '@/components/ui/button'7import { MaintenanceDialog } from '@/components/MaintenanceDialog'8import { format } from 'date-fns'910export function TenantPortal() {11 const [tenant, setTenant] = useState<any>(null)12 const [payments, setPayments] = useState<any[]>([])13 const [requests, setRequests] = useState<any[]>([])14 const [dialogOpen, setDialogOpen] = useState(false)1516 async function load() {17 const { data: { user } } = await supabase.auth.getUser()18 if (!user) return19 const { data: t } = await supabase.from('tenants').select('*, properties(address)').eq('user_id', user.id).single()20 setTenant(t)21 if (t) {22 const [p, r] = await Promise.all([23 supabase.from('payments').select('*').eq('tenant_id', t.id).order('due_date', { ascending: false }),24 supabase.from('maintenance_requests').select('*').eq('tenant_id', t.id).order('created_at', { ascending: false })25 ])26 setPayments(p.data ?? [])27 setRequests(r.data ?? [])28 }29 }3031 useEffect(() => { load() }, [])3233 if (!tenant) return <p className="p-6 text-muted-foreground">Loading your portal...</p>3435 return (36 <div className="p-6 space-y-6 max-w-2xl">37 <Card>38 <CardHeader><CardTitle>Your Lease</CardTitle></CardHeader>39 <CardContent className="space-y-1">40 <p><span className="font-medium">Property:</span> {tenant.properties?.address}</p>41 <p><span className="font-medium">Rent:</span> ${tenant.rent_amount}/mo</p>42 <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>43 </CardContent>44 </Card>45 <Card>46 <CardHeader><CardTitle>Payment History</CardTitle></CardHeader>47 <CardContent className="space-y-2">48 {payments.slice(0, 6).map(p => (49 <div key={p.id} className="flex justify-between text-sm">50 <span>{format(new Date(p.due_date), 'MMM yyyy')}</span>51 <Badge variant={p.status === 'paid' ? 'outline' : p.status === 'overdue' ? 'destructive' : 'default'}>{p.status}</Badge>52 </div>53 ))}54 </CardContent>55 </Card>56 <Card>57 <CardHeader className="flex flex-row items-center justify-between">58 <CardTitle>Maintenance Requests</CardTitle>59 <Button size="sm" onClick={() => setDialogOpen(true)}>New Request</Button>60 </CardHeader>61 <CardContent className="space-y-2">62 {requests.map(r => (63 <div key={r.id} className="flex justify-between text-sm">64 <span>{r.title}</span>65 <Badge variant="outline">{r.status}</Badge>66 </div>67 ))}68 </CardContent>69 </Card>70 {tenant && <MaintenanceDialog tenantId={tenant.id} propertyId={tenant.properties?.id} open={dialogOpen} onClose={() => setDialogOpen(false)} onSaved={load} />}71 </div>72 )73}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
1import { useState } from 'react'2import { useForm } from 'react-hook-form'3import { zodResolver } from '@hookform/resolvers/zod'4import { z } from 'zod'5import { supabase } from '@/lib/supabase'6import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'7import { Button } from '@/components/ui/button'8import { Input } from '@/components/ui/input'9import { Textarea } from '@/components/ui/textarea'10import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'11import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'12import { toast } from 'sonner'1314const schema = z.object({15 title: z.string().min(5).max(120),16 description: z.string().max(800).optional(),17 priority: z.enum(['low', 'medium', 'high', 'urgent'])18})19type FormValues = z.infer<typeof schema>20type Props = { tenantId: string; propertyId: string; open: boolean; onClose: () => void; onSaved: () => void }2122export function MaintenanceDialog({ tenantId, propertyId, open, onClose, onSaved }: Props) {23 const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { priority: 'medium' } })24 const [file, setFile] = useState<File | null>(null)25 const [uploading, setUploading] = useState(false)2627 async function onSubmit(values: FormValues) {28 setUploading(true)29 let photoUrl: string | null = null30 if (file) {31 const path = `maintenance/${Date.now()}.${file.name.split('.').pop()}`32 const { error: upErr } = await supabase.storage.from('maintenance-photos').upload(path, file, { contentType: file.type })33 if (upErr) toast.error('Photo upload failed')34 else photoUrl = supabase.storage.from('maintenance-photos').getPublicUrl(path).data.publicUrl35 }36 const { error } = await supabase.from('maintenance_requests').insert({37 ...values, tenant_id: tenantId, property_id: propertyId, photo_url: photoUrl38 })39 setUploading(false)40 if (error) { toast.error('Failed to submit request'); return }41 toast.success('Maintenance request submitted')42 form.reset(); setFile(null); onSaved(); onClose()43 }4445 return (46 <Dialog open={open} onOpenChange={onClose}>47 <DialogContent className="max-w-md">48 <DialogHeader><DialogTitle>New Maintenance Request</DialogTitle></DialogHeader>49 <Form {...form}>50 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">51 <FormField control={form.control} name="title" render={({ field }) => (52 <FormItem><FormLabel>Issue summary</FormLabel><FormControl><Input placeholder="e.g. Leaking kitchen tap" {...field} /></FormControl><FormMessage /></FormItem>53 )} />54 <FormField control={form.control} name="description" render={({ field }) => (55 <FormItem><FormLabel>Details (optional)</FormLabel><FormControl><Textarea placeholder="When did it start? Which room?" {...field} /></FormControl><FormMessage /></FormItem>56 )} />57 <FormField control={form.control} name="priority" render={({ field }) => (58 <FormItem><FormLabel>Priority</FormLabel>59 <Select onValueChange={field.onChange} defaultValue={field.value}>60 <FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>61 <SelectContent>62 {['low','medium','high','urgent'].map(v => <SelectItem key={v} value={v}>{v.charAt(0).toUpperCase()+v.slice(1)}</SelectItem>)}63 </SelectContent>64 </Select>65 <FormMessage /></FormItem>66 )} />67 <div className="space-y-1">68 <label className="text-sm font-medium">Photo (optional)</label>69 <Input type="file" accept="image/*" onChange={e => setFile(e.target.files?.[0] ?? null)} />70 </div>71 <DialogFooter>72 <Button variant="ghost" type="button" onClick={onClose}>Cancel</Button>73 <Button type="submit" disabled={uploading}>{uploading ? 'Submitting...' : 'Submit Request'}</Button>74 </DialogFooter>75 </form>76 </Form>77 </DialogContent>78 </Dialog>79 )80}Customization ideas
Automated rent reminders
Schedule a Supabase Edge Function with pg_cron to run 3 days before each payment due_date and send an email reminder to the tenant using Resend, pulling their email from the tenants table.
Contractor assignment
Add a contractors table and an assignee_id column on maintenance_requests. The admin assigns a contractor from a Select dropdown; the contractor gets a scoped view of only their assigned requests.
Digital lease signing
Add a signed_at column and a signature_data text column. Embed a simple canvas drawing component in a Dialog for tenants to sign their lease digitally — store the base64 signature string in Supabase.
Occupancy rate dashboard
Add a computed column or database view that calculates occupancy per property (active tenants / unit_count) and display it as a Progress bar on each property card in the admin view.
Maintenance priority notifications
When a maintenance request is inserted with priority 'urgent', trigger a Supabase Edge Function that sends an SMS via Twilio to the landlord's phone number stored in their profile.
Document storage per tenant
Create a documents table with a Supabase Storage bucket. Tenants upload their rental agreements and ID documents, landlords can view them — each with separate RLS policies on the same bucket.
Common pitfalls
Pitfall: Applying only one RLS policy for both landlords and tenants
How to avoid: 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.
Pitfall: Uploading photos directly to a private Storage bucket
How to avoid: Use a public bucket for maintenance photos and save the permanent public URL via supabase.storage.from('bucket').getPublicUrl(path).data.publicUrl.
Pitfall: Not creating the profiles row automatically on signup
How to avoid: 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.
Pitfall: Hardcoding landlord_id in the client component
How to avoid: Always derive landlord_id from the authenticated user context: const { data: { user } } = await supabase.auth.getUser() then use user.id.
Pitfall: Fetching all payments without pagination
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a property management system with React and Supabase. I have a tenants table with columns: id, user_id, property_id, landlord_id, full_name, rent_amount, lease_start, lease_end. I need RLS policies so that: (1) tenants can only SELECT their own row where user_id = auth.uid(), (2) landlords can SELECT all tenants where landlord_id = auth.uid(), (3) only landlords can INSERT new tenants. Write the complete SQL for all three policies.
Add an occupancy dashboard card to my property management admin page. For each property, show: address, total unit_count, number of active tenants (where lease_end > today), occupancy percentage as a Progress bar, and average rent amount. Fetch this with a single Supabase query using a LEFT JOIN on tenants. Use shadcn Card and Progress components.
In my Lovable project, add a pg_cron scheduled Edge Function that runs every day at 9am UTC. It should query the payments table for rows where due_date = yesterday and status = 'pending', update their status to 'overdue', then for each one fetch the tenant's email from the tenants table and send a reminder email using the Resend API. Store the RESEND_API_KEY in Lovable's Cloud tab Secrets.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation