A legal access platform connecting citizens with verified legal professionals. DIA provides secure, confidential, and accessible legal support - specialising in empowering women and vulnerable communities.
- Project Overview
- Tech Stack
- Architecture
- Features - Phase 1
- Route Map
- Roles & Permissions
- Database Schema
- File Storage
- Authentication Flows
- Project Structure
- Environment Variables
- Getting Started
- Supabase Setup
- AWS S3 Setup
- Development
- Security Model
- Phase 2+ Roadmap
DIA (Digital Inclusive Aid) is a multi-role legal platform built with Next.js 16 (App Router). Phase 1 covers the complete authentication, authorisation, role management, and lawyer approval workflow.
The platform has four user roles:
| Role | Description |
|---|---|
| Admin | Single superuser. Manages moderators, reviews lawyer applications, views all users. Created manually - cannot self-register. |
| Moderator | Created by Admin. Reviews and actions lawyer applications. Cannot create admins or change platform settings. |
| Lawyer | Self-registers via email/password only. Completes a professional profile and uploads credentials for approval before gaining dashboard access. |
| Civilian | Self-registers via email/password or Google OAuth. Instant access - no approval required. |
| Layer | Technology | Version |
|---|---|---|
| Framework | Next.js App Router | 16.2.12 |
| Runtime | React | 19.2.4 |
| Language | TypeScript | 5.9.3 |
| Styling | TailwindCSS v4 | 4.3.3 |
| UI Components | shadcn/ui (base-nova style, @base-ui/react) | 4.15.0 |
| Animation | Framer Motion | 12.x |
| Forms | React Hook Form | 7.83.0 |
| Validation | Zod v4 | 4.4.3 |
| Auth + Database | Supabase (Auth + PostgreSQL + RLS) | 2.110.8 |
| SSR Auth | @supabase/ssr | 0.12.3 |
| File Storage | AWS S3 (V3 SDK) | 3.1107.0 |
| Package Manager | pnpm | 10.x |
┌─────────────────────────────────────────────────────┐
│ Next.js 16 App │
│ │
│ proxy.ts ──► RBAC route guard (all requests) │
│ │
│ app/ │
│ ├── (public)/ ← unauthenticated pages │
│ ├── (civilian)/ ← civilian dashboard │
│ ├── (lawyer)/ ← lawyer onboarding + dashboard │
│ ├── (moderator)/ ← moderator review panel │
│ └── (admin)/ ← admin management panel │
│ │
│ features/ ← Server Actions + queries │
│ lib/ ← Supabase clients, storage, etc │
│ components/ ← UI components │
│ types/ ← TypeScript type definitions │
└─────────────────────────────────────────────────────┘
│ │
▼ ▼
Supabase (Auth + AWS S3 (private
PostgreSQL + RLS) document storage)
Key architectural decisions:
proxy.ts(Next.js 16 replacement formiddleware.ts) intercepts every request, validates the session viasupabase.auth.getUser(), reads the authoritative role from theprofilestable, and redirects accordingly. The JWT alone is never trusted for routing.- Server Actions handle all mutations - no API routes needed for data writes.
- The service-role Supabase client is used in exactly three places:
auth.admin.createUser()(create moderator), S3 file operations (private bucket), and activity logging (no guaranteed session context). Everything else uses the user's session client with RLS. - Documents are stored in a private AWS S3 bucket. Pre-signed GET URLs (1-hour expiry) are generated server-side - never exposed directly.
| Page | URL | Description |
|---|---|---|
| Home | / |
Landing page with hero, stats, feature highlights, and CTAs |
| About | /about |
Organisation mission and values |
| Legal Services | /legal |
Practice areas overview - family law, criminal, civil rights, immigration |
| Resources | /resources |
Legal guides and resource cards (content coming in future phases) |
| Login | /login |
Email + password for all roles. Google OAuth for civilians |
| Sign Up | /signup |
Single page with an inline role toggle. Civilian form (email/password or Google) and lawyer form (email/password only) on one page. ?role=civilian / ?role=lawyer preselects a tab |
| Forgot Password | /forgot-password |
Sends Supabase password reset email |
| Reset Password | /reset-password |
Sets new password after clicking email link |
| Verify Email | /verify-email |
Informational screen shown after lawyer signup |
| Feature | URL | Description |
|---|---|---|
| Dashboard | /dashboard |
Welcome screen. Phase 2 will add lawyer discovery and consultation booking |
| Feature | URL | Description |
|---|---|---|
| Onboarding (profile) | /onboarding |
Step 1 of lawyer onboarding: the detailed professional profile (full name, phone, gender, DOB, location, bar council number + state bar council, experience, qualification, practice areas, languages, optional firm, bio) |
| Onboarding (documents) | /onboarding?step=documents |
Step 2: upload 4 required documents to private AWS S3 (bar certificate, government ID, enrollment certificate, profile photo). On submit, the application is set pending and a notification email is sent (stubbed in lib/email.ts) |
| Pending Status | /lawyer/status/pending |
Shown after application submission. Explains the review timeline |
| Hold Status | /lawyer/status/hold |
Shown when reviewer places application on hold. Displays reviewer notes |
| Rejected Status | /lawyer/status/rejected |
Shown when application is rejected. Displays reason and contact info |
| Lawyer Dashboard | /lawyer/dashboard |
Available to approved lawyers only. Shows status badge, bar council number, practice areas |
| Feature | URL | Description |
|---|---|---|
| Dashboard | /moderator/dashboard |
Live count of pending applications + recently reviewed list with timestamps |
| Applications List | /moderator/lawyers |
Paginated table of all pending applications with status badges |
| Lawyer Review | /moderator/lawyers/[id] |
Full profile details, all 4 uploaded documents with signed S3 URLs, approve / hold / reject panel with optional notes |
| Feature | URL | Description |
|---|---|---|
| Dashboard | /admin/dashboard |
4 live stat cards: total lawyers, pending applications, approved lawyers, moderator count |
| All Applications | /admin/lawyers |
Table of all lawyer applications across all statuses (pending, approved, hold, rejected) |
| Lawyer Detail | /admin/lawyers/[id] |
Same review panel as moderator, accessible by admin |
| Moderators List | /admin/moderators |
All moderator accounts with active/inactive status |
| Create Moderator | /admin/moderators/create |
Creates a new moderator account (bypasses email confirmation) |
| All Users | /admin/users |
Complete user table with role, join date, and active/inactive status |
All significant user actions are recorded to the activity_logs table:
| Event | Triggered by |
|---|---|
lawyer_registered |
Lawyer completes signup |
profile_updated |
Lawyer saves professional profile |
document_uploaded |
Each individual document upload |
application_approved |
Moderator or admin approves |
application_hold |
Moderator or admin places on hold |
application_rejected |
Moderator or admin rejects |
moderator_created |
Admin creates a moderator account |
user_login |
Successful login (all roles) + civilian signup |
password_reset |
Password reset completed |
app/not-found.tsx- Custom 404 page matching the site designapp/error.tsx- Global error boundary with retry and home navigation. Shows error message in development only
PUBLIC (unauthenticated)
GET / → Landing page
GET /about → About page
GET /legal → Legal services overview
GET /resources → Legal resources
GET /login → Login (all roles)
GET /signup → Signup (inline civilian/lawyer toggle; ?role= preselects)
GET /forgot-password → Forgot password
GET /reset-password → Reset password (token in URL)
GET /verify-email → Email verification screen
GET /auth/callback → OAuth + email verification handler
CIVILIAN (role = civilian)
GET /dashboard → Civilian dashboard
ONBOARDING (role = civilian or lawyer, not yet complete)
GET /onboarding → Civilian: name + purpose. Lawyer: profile step
GET /onboarding?step=documents → Lawyer: document upload step
LAWYER (role = lawyer)
GET /lawyer/status/pending → Application pending screen
GET /lawyer/status/hold → Application on hold screen
GET /lawyer/status/rejected → Application rejected screen
GET /lawyer/dashboard → Lawyer dashboard (approved only)
MODERATOR (role = moderator)
GET /moderator/dashboard → Moderator dashboard
GET /moderator/lawyers → Pending applications list
GET /moderator/lawyers/[id] → Review a lawyer
ADMIN (role = admin)
GET /admin/dashboard → Admin dashboard
GET /admin/lawyers → All lawyer applications
GET /admin/lawyers/[id] → Lawyer detail + review
GET /admin/moderators → Moderators list
GET /admin/moderators/create → Create moderator
GET /admin/users → All users
Every HTTP request passes through proxy.ts (Next.js 16's replacement for middleware.ts). The proxy:
- Validates the session with
supabase.auth.getUser()- never trusts the JWT alone - Reads the authoritative
roleandis_activefrom theprofilestable - Applies redirect rules:
| Actor | Accessing | Result |
|---|---|---|
| Unauthenticated | Any protected route | → /login |
| Any role | Own public/auth page | → Role dashboard |
| Civilian | /lawyer/*, /admin/*, /moderator/* |
→ /dashboard |
| Lawyer (not approved) | /lawyer/dashboard |
→ Status screen |
| Lawyer (approved) | Status / profile / docs pages | → /lawyer/dashboard |
| Moderator | /admin/* |
→ /moderator/dashboard |
| Admin | /lawyer/*, /dashboard |
→ /admin/dashboard |
| Inactive account | Anything | → /login |
Every Server Action re-validates the caller's role from the database before performing any mutation. Frontend role state is never trusted.
The complete schema is in supabase/schema.sql. Apply it in Supabase Dashboard → SQL Editor.
| Table | Purpose |
|---|---|
profiles |
One row per auth user. Stores email, role, full_name, phone, avatar_url, is_active, email_verified |
lawyer_profiles |
Extended professional data for lawyers. All 16 profile fields + application workflow state |
lawyer_documents |
One row per uploaded document. Stores S3 object key in file_url. Unique constraint on (lawyer_id, document_type) |
moderator_profiles |
Created when admin adds a moderator. Tracks created_by |
admin_profiles |
Exists for the single admin account |
activity_logs |
Append-only audit trail. Never updated or deleted |
| Enum | Values |
|---|---|
user_role |
admin, moderator, lawyer, civilian |
application_status |
pending, approved, hold, rejected |
gender_type |
male, female, other, prefer_not_to_say |
document_type |
bar_certificate, government_id, enrollment_certificate, profile_photo, other |
handle_new_user- fires on everyauth.usersinsert. Creates theprofilesrow automatically, reading the role fromraw_user_meta_data(set duringsupabase.auth.signUp()). Defaults tocivilianfor OAuth signups with no role hint.update_timestamp- keepsupdated_atcurrent onprofilesandlawyer_profiles.
All six tables have RLS enabled. Summary:
- Users read/update their own
profilesrow - Admins and moderators read all
profiles,lawyer_profiles, andlawyer_documents - Admins and moderators can update
lawyer_profiles(for review decisions) - Admins have full access to
moderator_profiles - Any authenticated user can insert to
activity_logs - Only admins can read
activity_logs
Documents are stored in a private AWS S3 bucket. No document is ever publicly accessible.
- Lawyer selects files in
/lawyer/documents uploadLawyerDocumentsServer Action validates each file (type + size)- Files are uploaded to S3 via
lib/storage.ts→uploadLawyerDocument() - The S3 object key (not a URL) is stored in
lawyer_documents.file_url - When a reviewer opens the review page,
getSignedUrl()generates a temporary pre-signed GET URL (1-hour expiry) for each document - The signed URL is rendered as a "View" link - it expires and is never stored
- Allowed types:
application/pdf,image/png,image/jpeg - Maximum size: 10 MB per file
- Validated both client-side (Zod schema) and server-side (before S3 upload)
{lawyerUserId}/{documentType}-{timestamp}.{ext}
e.g. a1b2c3d4/bar_certificate-1718000000000.pdf
/signup/civilian
→ supabase.auth.signUp({ data: { role: 'civilian' } })
→ DB trigger creates profiles row with role='civilian'
→ if auto-confirmed → /dashboard
→ else → /verify-email → /auth/callback → /dashboard
/login → OAuthButton → supabase.auth.signInWithOAuth({ provider: 'google' })
→ /auth/callback → exchangeCodeForSession()
→ reads role from profiles → /dashboard
/signup/lawyer
→ supabase.auth.signUp({ data: { role: 'lawyer' }, emailRedirectTo: '/auth/callback' })
→ email verification sent → /verify-email
→ lawyer clicks email link → /auth/callback
→ no lawyer_profile yet → /onboarding
→ completes profile → /onboarding?step=documents
→ uploads 4 documents → /lawyer/status/pending (notification email sent - stubbed)
→ admin/moderator reviews → approved / hold / rejected
→ if approved → /lawyer/dashboard
/login → supabase.auth.signInWithPassword()
→ reads role from profiles
→ admin → /admin/dashboard
→ moderator → /moderator/dashboard
/forgot-password
→ supabase.auth.resetPasswordForEmail(email, { redirectTo: '/reset-password' })
→ user clicks email link → /reset-password (Supabase sets session)
→ supabase.auth.updateUser({ password: newPassword })
→ → /login
d:\Projects\dia\
├── app/
│ ├── (public)/ # Unauthenticated pages
│ │ ├── layout.tsx # Nav + footer for public site
│ │ ├── page.tsx # Home / landing page
│ │ ├── about/page.tsx
│ │ ├── legal/page.tsx
│ │ ├── resources/page.tsx
│ │ ├── login/
│ │ ├── signup/
│ │ ├── forgot-password/
│ │ ├── reset-password/
│ │ └── verify-email/
│ ├── (civilian)/
│ │ ├── layout.tsx # Guards: role = civilian
│ │ └── dashboard/page.tsx
│ ├── (lawyer)/
│ │ ├── layout.tsx # Guards: role = lawyer
│ │ └── lawyer/
│ │ ├── dashboard/page.tsx
│ │ ├── profile/page.tsx + LawyerProfileForm.tsx
│ │ ├── documents/page.tsx + DocumentUploadForm.tsx
│ │ └── status/{pending,hold,rejected}/page.tsx
│ ├── (moderator)/
│ │ ├── layout.tsx # Guards: role = moderator
│ │ └── moderator/
│ │ ├── dashboard/page.tsx
│ │ └── lawyers/{page.tsx,[id]/page.tsx}
│ ├── (admin)/
│ │ ├── layout.tsx # Guards: role = admin
│ │ └── admin/
│ │ ├── dashboard/page.tsx
│ │ ├── lawyers/{page.tsx,[id]/page.tsx}
│ │ ├── moderators/{page.tsx,create/}
│ │ └── users/page.tsx
│ ├── auth/callback/route.ts # OAuth + email verification handler
│ ├── not-found.tsx # Custom 404
│ ├── error.tsx # Global error boundary
│ ├── layout.tsx # Root layout (fonts, Toaster)
│ └── globals.css
│
├── components/
│ ├── auth/
│ │ ├── SubmitButton.tsx # Spinner-aware submit button
│ │ ├── FieldError.tsx # Per-field validation errors
│ │ ├── FormAlert.tsx # Top-level error/success banners
│ │ └── OAuthButton.tsx # Google sign-in (civilian only)
│ ├── shared/
│ │ ├── DashboardShell.tsx # Top nav + layout wrapper for all dashboards
│ │ ├── LogoutButton.tsx
│ │ ├── PageHeader.tsx
│ │ └── ReviewPanel.tsx # Approve/hold/reject panel (admin + moderator)
│ └── ui/ # shadcn/ui generated components
│
├── features/
│ ├── auth/
│ │ ├── actions.ts # login, signup, logout, forgotPassword, resetPassword, signInWithGoogle
│ │ └── schemas.ts # Zod schemas for all auth forms
│ ├── lawyer/
│ │ ├── actions.ts # saveLawyerProfile, uploadLawyerDocuments
│ │ ├── queries.ts # getLawyerProfile, getLawyerDocuments, getLawyerProfileById
│ │ └── schemas.ts # lawyerProfileSchema, documentUploadSchema
│ ├── admin/
│ │ ├── actions.ts # createModerator
│ │ ├── queries.ts # getAllUsers, getAllLawyerApplications, getAdminDashboardStats, getAllModerators
│ │ └── schemas.ts # createModeratorSchema, reviewApplicationSchema
│ └── moderator/
│ ├── actions.ts # reviewApplication (shared with admin)
│ ├── queries.ts # getPendingApplications, getRecentlyReviewed
│ └── schemas.ts # re-exports from admin/schemas
│
├── hooks/
│ ├── useUser.ts # Client hook: reactive current user + role
│ └── useSupabase.ts # Client hook: stable browser Supabase instance
│
├── lib/
│ ├── supabase/
│ │ ├── client.ts # Browser client (anon key, for Client Components)
│ │ ├── server.ts # Server client (async cookies API, for Server Components)
│ │ └── admin.ts # Service-role client (bypasses RLS, server-only)
│ ├── auth/
│ │ └── session.ts # getServerSession(), getLawyerApplicationStatus()
│ ├── storage.ts # uploadLawyerDocument, getSignedUrl, deleteLawyerDocument (AWS S3)
│ ├── activity-log.ts # logActivity() - writes to activity_logs
│ └── utils.ts # cn(), formatDistanceToNow()
│
├── types/
│ ├── auth.ts # UserRole, ApplicationStatus, UserSession, LawyerStatus
│ ├── database.ts # Row types mirroring all Supabase tables
│ └── forms.ts # Re-exports Zod-inferred form types
│
├── supabase/
│ └── schema.sql # Complete PostgreSQL schema (run in Supabase SQL Editor)
│
├── proxy.ts # RBAC route guard (Next.js 16 replacement for middleware.ts)
├── next.config.ts
├── tsconfig.json
├── package.json
└── .env.local # Environment variables (see below)
Create .env.local in the project root:
# Supabase - Authentication and Database
# Found in: Supabase Dashboard → Settings → API
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
# Service role key - SERVER SIDE ONLY. Never expose to the browser.
# Found in: Supabase Dashboard → Settings → API → service_role key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
# Public URL of this deployment
# Used for email verification redirects, password reset links, and OAuth callback
# Local dev: http://localhost:3000
# Production: https://your-domain.com
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# AWS S3 - Document Storage (private bucket)
# The IAM user needs: s3:PutObject, s3:GetObject, s3:DeleteObject on this bucket
AWS_REGION=us-east-1
AWS_S3_BUCKET=your-bucket-name
AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-keypnpm installCopy the template above into .env.local and fill in your values.
See Supabase Setup below.
See AWS S3 Setup below.
pnpm devOpen http://localhost:3000.
Open Supabase Dashboard → SQL Editor → New Query, paste the full contents of supabase/schema.sql, and run it.
This creates all tables, enums, indexes, triggers, functions, and RLS policies.
- Go to Authentication → Providers → Google
- Enable it and add your Google OAuth Client ID and Secret
- Add
https://your-project-ref.supabase.co/auth/v1/callbackto your Google OAuth Authorized redirect URIs
The admin cannot self-register. Create it manually:
Step 1 - Create the auth user:
- Go to Authentication → Users → Add User
- Enter the admin email and a strong password
- Tick Auto Confirm User
Step 2 - Update their role (the trigger defaults to civilian):
UPDATE public.profiles
SET role = 'admin'
WHERE email = 'admin@yourdomain.com';Step 3 - Insert the admin_profiles row:
INSERT INTO public.admin_profiles (user_id)
SELECT id FROM public.profiles
WHERE email = 'admin@yourdomain.com';Go to Authentication → Email Templates to customise the verification and password reset emails with your branding.
- Open AWS S3 → Create bucket
- Choose a name (set it as
AWS_S3_BUCKETin.env.local) - Block all public access: ON - the bucket must be private
- Enable server-side encryption (AES-256 recommended)
- Open AWS IAM → Users → Create user
- Attach a custom inline policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::your-bucket-name/*"
}
]
}- Generate an access key and add the values to
.env.local
pnpm buildpnpm tsc --noEmitpnpm lint- Server Components by default. Client Components (
'use client') are only used when browser APIs or React hooks are needed. - Server Actions for all mutations. No API routes for data writes.
- Zod everywhere. Every form field is validated on the server via a Zod schema before any database write.
- Role validated server-side on every action.
getServerSession()is called at the top of every Server Action that touches protected data. - Never trust JWT claims for role. The
profiles.rolecolumn is always read from the database. - Async APIs only.
cookies(),headers(),params, andsearchParamsare allawait-ed (Next.js 16 breaking change). proxy.tsnotmiddleware.ts. Next.js 16 renamed middleware to proxy. The named export isproxy, notmiddleware.
| Concern | Approach |
|---|---|
| Role spoofing | Role read from profiles table on every request and every Server Action - never from JWT claims |
| Unauthenticated access | proxy.ts redirects all non-public routes to /login |
| Cross-role access | proxy.ts + per-action getServerSession() checks prevent any role from accessing another role's routes or data |
| Inactive accounts | is_active = false → forced to /login in proxy and blocked in all Server Actions |
| Document exposure | Documents stored in a private S3 bucket. Only pre-signed URLs (1-hour expiry) are served - raw keys are never sent to clients |
| RLS | Row-Level Security enabled on all 6 Supabase tables. The anon key and session key both respect RLS |
| Service-role key | Used only in 3 places: supabase.auth.admin.createUser(), S3 file operations, and activity logging. Never imported in Client Components or exposed to the browser |
| CSRF | Handled automatically by Next.js Server Actions (Origin header check) |
| Input validation | All form inputs validated with Zod on the server before any database operation |
The following features are out of scope for Phase 1 and not implemented:
- Lawyer search and discovery
- Consultation booking and appointments
- In-app messaging
- Video calls
- Payments
- Document generation / AI assistance
- Evidence vault
- Knowledge base
- Case tracking
- Notifications (email/push)
- Analytics dashboards
- Client reviews and ratings
- Safe Campus module
Private project - Digital Inclusive Aid. All rights reserved.
The public marketing pages were intentionally written with honest, verifiable claims instead of invented metrics. As the platform grows and you have real data, drop the confirmed values here and wire them into the pages listed below.
Rule of thumb: never publish a number we can't back up. An empty or honest claim builds more trust than a fabricated statistic.
Currently uses honest, non-numeric claims (e.g. "Every lawyer is Bar Council verified", "Free to request help", "Confidential by design"). Replace with real figures once available:
| Placeholder claim | Real value (fill in) | Source of truth |
|---|---|---|
| Support requests handled | TBD |
count from activity_logs / cases table |
| Verified legal professionals | TBD |
count of lawyer_profiles where application_status = 'approved' |
| Average time to first response | TBD |
measured once live |
| Resolution / success rate | TBD |
measured once live |
The invented "Anonymized Platform User" quote and fake case IDs were removed. When you have real, consented testimonials:
- Store the quote, first name (or "Anonymous, City"), and case type.
- Only publish with explicit written consent; anonymise anything identifying.
The fake "Adv. Sarah Jenkins / Bar #SJ-89244" profile was replaced with a clearly-labeled illustrative example. If you want a real featured lawyer, get consent and swap in real details.
Article counts, "updated X days ago" timestamps, and the non-functional search were removed. When real guides exist:
- List actual published guides with real titles and dates.
- Wire the search box to a real query (or keep it hidden until then).
Copy currently assumes an India context (Bar Council of India verification, Indian practice areas), consistent with the lawyer_profiles schema (bar council number, state bar council). Update if the target jurisdiction differs.