From 4bd5eb9fda0a6a099a5df219f01c9e24abee97fc Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:44:22 +0000 Subject: [PATCH 01/14] Work in progress --- bun.lockb | Bin 285029 -> 289483 bytes package.json | 1 + 2 files changed, 1 insertion(+) diff --git a/bun.lockb b/bun.lockb index a01dbdb3c79b2e94c758f1aa14a4634d2e433547..e27f20207c329043bd475f72ee1f00feeb1ed8a6 100755 GIT binary patch delta 47247 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 OZz{U=J7=5QZT=6%(zuoY diff --git a/package.json b/package.json index 9919e5c..fc9df6f 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@supabase/supabase-js": "^2.103.3", "@tailwindcss/vite": "^4.2.1", "@tanstack/react-query": "^5.83.0", "@tanstack/react-router": "^1.168.0", From 2911e241cd7e4dd1c767a6bb3554937df2369c80 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:44:26 +0000 Subject: [PATCH 02/14] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- .env | 5 + src/integrations/supabase/auth-middleware.ts | 77 +++++++++ src/integrations/supabase/client.server.ts | 37 +++++ src/integrations/supabase/client.ts | 36 +++++ src/integrations/supabase/types.ts | 155 +++++++++++++++++++ supabase/config.toml | 1 + 6 files changed, 311 insertions(+) create mode 100644 .env create mode 100644 src/integrations/supabase/auth-middleware.ts create mode 100644 src/integrations/supabase/client.server.ts create mode 100644 src/integrations/supabase/client.ts create mode 100644 src/integrations/supabase/types.ts create mode 100644 supabase/config.toml diff --git a/.env b/.env new file mode 100644 index 0000000..ed5fb40 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl2b2FlbGN1bHR1YXRzdmtoZGl1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzYzNzIwMTYsImV4cCI6MjA5MTk0ODAxNn0.dXU0K_7fE1uih0kzbxTeVhfsbjG1V9CEl17DIPL_tfo" +SUPABASE_URL="https://yvoaelcultuatsvkhdiu.supabase.co" +VITE_SUPABASE_PROJECT_ID="yvoaelcultuatsvkhdiu" +VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inl2b2FlbGN1bHR1YXRzdmtoZGl1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzYzNzIwMTYsImV4cCI6MjA5MTk0ODAxNn0.dXU0K_7fE1uih0kzbxTeVhfsbjG1V9CEl17DIPL_tfo" +VITE_SUPABASE_URL="https://yvoaelcultuatsvkhdiu.supabase.co" diff --git a/src/integrations/supabase/auth-middleware.ts b/src/integrations/supabase/auth-middleware.ts new file mode 100644 index 0000000..4e8f69d --- /dev/null +++ b/src/integrations/supabase/auth-middleware.ts @@ -0,0 +1,77 @@ +// This file is automatically generated. Do not edit it directly. +import { createMiddleware } from '@tanstack/react-start' +import { getRequest } from '@tanstack/react-start/server' +import { createClient } from '@supabase/supabase-js' +import type { Database } from './types' + + + +export const requireSupabaseAuth = createMiddleware({ type: 'function' }).server( + async ({ next }) => { + + const SUPABASE_URL = process.env.SUPABASE_URL; + const SUPABASE_PUBLISHABLE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY; + + if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) { + throw new Response( + 'Missing Supabase environment variables. Ensure SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are set.', + { status: 500 } + ); + } + + const request = getRequest(); + + if (!request?.headers) { + throw new Response('Unauthorized: No request headers available', { status: 401 }); + } + + const authHeader = request.headers.get('authorization'); + + if (!authHeader) { + throw new Response('Unauthorized: No authorization header provided', { status: 401 }); + } + + if (!authHeader.startsWith('Bearer ')) { + throw new Response('Unauthorized: Only Bearer tokens are supported', { status: 401 }); + } + + const token = authHeader.replace('Bearer ', ''); + if (!token) { + throw new Response('Unauthorized: No token provided', { status: 401 }); + } + + const supabase = createClient( + SUPABASE_URL!, + SUPABASE_PUBLISHABLE_KEY!, + { + global: { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + auth: { + storage: undefined, + persistSession: false, + autoRefreshToken: false, + }, + } + ); + + const { data, error } = await supabase.auth.getClaims(token); + if (error || !data?.claims) { + throw new Response('Unauthorized: Invalid token', { status: 401 }); + } + + if (!data.claims.sub) { + throw new Response('Unauthorized: No user ID found in token', { status: 401 }); + } + + return next({ + context: { + supabase, + userId: data.claims.sub, + claims: data.claims, + }, + }) + } +) diff --git a/src/integrations/supabase/client.server.ts b/src/integrations/supabase/client.server.ts new file mode 100644 index 0000000..bfe7546 --- /dev/null +++ b/src/integrations/supabase/client.server.ts @@ -0,0 +1,37 @@ +// This file is automatically generated. Do not edit it directly. +// Server-side Supabase client with service role key - bypasses RLS. +// Use this for admin operations in server functions and server routes only. +// For user-authenticated queries (with RLS), use the auth middleware instead. +import { createClient } from '@supabase/supabase-js'; +import type { Database } from './types'; + +function createSupabaseAdminClient() { + const SUPABASE_URL = process.env.SUPABASE_URL; + const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { + throw new Error( + 'Missing Supabase server environment variables. Ensure SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are set.' + ); + } + + return createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { + storage: undefined, + persistSession: false, + autoRefreshToken: false, + } + }); +} + +let _supabaseAdmin: ReturnType | undefined; + +// Server-side Supabase client with service role - bypasses RLS +// SECURITY: Only use this for trusted server-side operations, never expose to client code +// Import like: import { supabaseAdmin } from "@/integrations/supabase/client.server"; +export const supabaseAdmin = new Proxy({} as ReturnType, { + get(_, prop, receiver) { + if (!_supabaseAdmin) _supabaseAdmin = createSupabaseAdminClient(); + return Reflect.get(_supabaseAdmin, prop, receiver); + }, +}); diff --git a/src/integrations/supabase/client.ts b/src/integrations/supabase/client.ts new file mode 100644 index 0000000..c81d68f --- /dev/null +++ b/src/integrations/supabase/client.ts @@ -0,0 +1,36 @@ +// This file is automatically generated. Do not edit it directly. +import { createClient } from '@supabase/supabase-js'; +import type { Database } from './types'; + +function createSupabaseClient() { + // Use import.meta.env for client-side (Vite build-time replacement) + // Fall back to process.env for SSR (server-side rendering) + const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL; + const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_PUBLISHABLE_KEY; + + if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) { + throw new Error( + 'Missing Supabase environment variables. Ensure SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY (or VITE_ prefixed versions) are set in your .env file.' + ); + } + + return createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, { + auth: { + storage: typeof window !== 'undefined' ? localStorage : undefined, + persistSession: true, + autoRefreshToken: true, + } + }); +} + +let _supabase: ReturnType | undefined; + +// Import the supabase client like this: +// import { supabase } from "@/integrations/supabase/client"; +export const supabase = new Proxy({} as ReturnType, { + get(_, prop, receiver) { + if (!_supabase) _supabase = createSupabaseClient(); + return Reflect.get(_supabase, prop, receiver); + }, +}); + diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts new file mode 100644 index 0000000..ba7e5bb --- /dev/null +++ b/src/integrations/supabase/types.ts @@ -0,0 +1,155 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "14.5" + } + public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: {}, + }, +} as const diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..45f3c5a --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1 @@ +project_id = "yvoaelcultuatsvkhdiu" \ No newline at end of file From f1b1cc8f6ddd707c80590a4e2dc9049e7cb4d7fe Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:44:32 +0000 Subject: [PATCH 03/14] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routeTree.gen.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index d204c26..dceedff 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -57,3 +57,12 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} From dc787df0aafa6ecd33997deb741e82dea3059e07 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:45:54 +0000 Subject: [PATCH 04/14] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 474 +++++++++++++++++- ...1_467073c0-09ae-4595-8850-8072a75114c1.sql | 366 ++++++++++++++ 2 files changed, 836 insertions(+), 4 deletions(-) create mode 100644 supabase/migrations/20260416224551_467073c0-09ae-4595-8850-8072a75114c1.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index ba7e5bb..00f29fb 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -14,16 +14,470 @@ export type Database = { } public: { Tables: { - [_ in never]: never + cases: { + Row: { + assigned_attorney_id: string | null + case_number: string + client_id: string + closed_at: string | null + created_at: string + created_by: string | null + default_hourly_rate: number | null + description: string | null + id: string + opened_at: string + practice_area: string | null + status: Database["public"]["Enums"]["case_status"] + title: string + updated_at: string + } + Insert: { + assigned_attorney_id?: string | null + case_number: string + client_id: string + closed_at?: string | null + created_at?: string + created_by?: string | null + default_hourly_rate?: number | null + description?: string | null + id?: string + opened_at?: string + practice_area?: string | null + status?: Database["public"]["Enums"]["case_status"] + title: string + updated_at?: string + } + Update: { + assigned_attorney_id?: string | null + case_number?: string + client_id?: string + closed_at?: string | null + created_at?: string + created_by?: string | null + default_hourly_rate?: number | null + description?: string | null + id?: string + opened_at?: string + practice_area?: string | null + status?: Database["public"]["Enums"]["case_status"] + title?: string + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "cases_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] + }, + ] + } + clients: { + Row: { + address_line1: string | null + address_line2: string | null + board_members: Json + city: string | null + client_type: Database["public"]["Enums"]["client_type"] + created_at: string + created_by: string | null + id: string + management_company: string | null + name: string + notes: string | null + num_units: number | null + postal_code: string | null + primary_contact_email: string | null + primary_contact_name: string | null + primary_contact_phone: string | null + state: string | null + updated_at: string + } + Insert: { + address_line1?: string | null + address_line2?: string | null + board_members?: Json + city?: string | null + client_type?: Database["public"]["Enums"]["client_type"] + created_at?: string + created_by?: string | null + id?: string + management_company?: string | null + name: string + notes?: string | null + num_units?: number | null + postal_code?: string | null + primary_contact_email?: string | null + primary_contact_name?: string | null + primary_contact_phone?: string | null + state?: string | null + updated_at?: string + } + Update: { + address_line1?: string | null + address_line2?: string | null + board_members?: Json + city?: string | null + client_type?: Database["public"]["Enums"]["client_type"] + created_at?: string + created_by?: string | null + id?: string + management_company?: string | null + name?: string + notes?: string | null + num_units?: number | null + postal_code?: string | null + primary_contact_email?: string | null + primary_contact_name?: string | null + primary_contact_phone?: string | null + state?: string | null + updated_at?: string + } + Relationships: [] + } + documents: { + Row: { + case_id: string + created_at: string + description: string | null + id: string + mime_type: string | null + name: string + size_bytes: number | null + storage_path: string + uploaded_by: string | null + } + Insert: { + case_id: string + created_at?: string + description?: string | null + id?: string + mime_type?: string | null + name: string + size_bytes?: number | null + storage_path: string + uploaded_by?: string | null + } + Update: { + case_id?: string + created_at?: string + description?: string | null + id?: string + mime_type?: string | null + name?: string + size_bytes?: number | null + storage_path?: string + uploaded_by?: string | null + } + Relationships: [ + { + foreignKeyName: "documents_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, + ] + } + expenses: { + Row: { + amount: number + billable: boolean + case_id: string + created_at: string + description: string + expense_date: string + id: string + invoice_id: string | null + receipt_storage_path: string | null + updated_at: string + user_id: string + } + Insert: { + amount: number + billable?: boolean + case_id: string + created_at?: string + description: string + expense_date?: string + id?: string + invoice_id?: string | null + receipt_storage_path?: string | null + updated_at?: string + user_id: string + } + Update: { + amount?: number + billable?: boolean + case_id?: string + created_at?: string + description?: string + expense_date?: string + id?: string + invoice_id?: string | null + receipt_storage_path?: string | null + updated_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "expenses_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, + { + foreignKeyName: "expenses_invoice_fk" + columns: ["invoice_id"] + isOneToOne: false + referencedRelation: "invoices" + referencedColumns: ["id"] + }, + ] + } + invoices: { + Row: { + amount_paid: number + case_id: string | null + client_id: string + created_at: string + created_by: string | null + due_date: string | null + id: string + invoice_number: string + issue_date: string + notes: string | null + paid_at: string | null + status: Database["public"]["Enums"]["invoice_status"] + subtotal: number + tax: number + total: number + updated_at: string + } + Insert: { + amount_paid?: number + case_id?: string | null + client_id: string + created_at?: string + created_by?: string | null + due_date?: string | null + id?: string + invoice_number: string + issue_date?: string + notes?: string | null + paid_at?: string | null + status?: Database["public"]["Enums"]["invoice_status"] + subtotal?: number + tax?: number + total?: number + updated_at?: string + } + Update: { + amount_paid?: number + case_id?: string | null + client_id?: string + created_at?: string + created_by?: string | null + due_date?: string | null + id?: string + invoice_number?: string + issue_date?: string + notes?: string | null + paid_at?: string | null + status?: Database["public"]["Enums"]["invoice_status"] + subtotal?: number + tax?: number + total?: number + updated_at?: string + } + Relationships: [ + { + foreignKeyName: "invoices_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, + { + foreignKeyName: "invoices_client_id_fkey" + columns: ["client_id"] + isOneToOne: false + referencedRelation: "clients" + referencedColumns: ["id"] + }, + ] + } + profiles: { + Row: { + created_at: string + email: string + full_name: string + id: string + updated_at: string + } + Insert: { + created_at?: string + email?: string + full_name?: string + id: string + updated_at?: string + } + Update: { + created_at?: string + email?: string + full_name?: string + id?: string + updated_at?: string + } + Relationships: [] + } + status_updates: { + Row: { + body: string + case_id: string + created_at: string + created_by: string | null + id: string + report_storage_path: string | null + title: string + } + Insert: { + body: string + case_id: string + created_at?: string + created_by?: string | null + id?: string + report_storage_path?: string | null + title: string + } + Update: { + body?: string + case_id?: string + created_at?: string + created_by?: string | null + id?: string + report_storage_path?: string | null + title?: string + } + Relationships: [ + { + foreignKeyName: "status_updates_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, + ] + } + time_entries: { + Row: { + billable: boolean + case_id: string + created_at: string + description: string + hourly_rate: number + hours: number + id: string + invoice_id: string | null + updated_at: string + user_id: string + work_date: string + } + Insert: { + billable?: boolean + case_id: string + created_at?: string + description: string + hourly_rate?: number + hours: number + id?: string + invoice_id?: string | null + updated_at?: string + user_id: string + work_date?: string + } + Update: { + billable?: boolean + case_id?: string + created_at?: string + description?: string + hourly_rate?: number + hours?: number + id?: string + invoice_id?: string | null + updated_at?: string + user_id?: string + work_date?: string + } + Relationships: [ + { + foreignKeyName: "time_entries_case_id_fkey" + columns: ["case_id"] + isOneToOne: false + referencedRelation: "cases" + referencedColumns: ["id"] + }, + { + foreignKeyName: "time_entries_invoice_fk" + columns: ["invoice_id"] + isOneToOne: false + referencedRelation: "invoices" + referencedColumns: ["id"] + }, + ] + } + user_roles: { + Row: { + created_at: string + id: string + role: Database["public"]["Enums"]["app_role"] + user_id: string + } + Insert: { + created_at?: string + id?: string + role: Database["public"]["Enums"]["app_role"] + user_id: string + } + Update: { + created_at?: string + id?: string + role?: Database["public"]["Enums"]["app_role"] + user_id?: string + } + Relationships: [] + } } Views: { [_ in never]: never } Functions: { - [_ in never]: never + can_access_case: { + Args: { _case_id: string; _user_id: string } + Returns: boolean + } + has_role: { + Args: { + _role: Database["public"]["Enums"]["app_role"] + _user_id: string + } + Returns: boolean + } + is_admin: { Args: { _user_id: string }; Returns: boolean } } Enums: { - [_ in never]: never + app_role: "admin" | "attorney" | "staff" + case_status: + | "intake" + | "active" + | "on_hold" + | "closed_won" + | "closed_lost" + | "closed" + client_type: "hoa" | "individual" | "business" + invoice_status: "draft" | "sent" | "paid" | "overdue" | "void" } CompositeTypes: { [_ in never]: never @@ -150,6 +604,18 @@ export type CompositeTypes< export const Constants = { public: { - Enums: {}, + Enums: { + app_role: ["admin", "attorney", "staff"], + case_status: [ + "intake", + "active", + "on_hold", + "closed_won", + "closed_lost", + "closed", + ], + client_type: ["hoa", "individual", "business"], + invoice_status: ["draft", "sent", "paid", "overdue", "void"], + }, }, } as const diff --git a/supabase/migrations/20260416224551_467073c0-09ae-4595-8850-8072a75114c1.sql b/supabase/migrations/20260416224551_467073c0-09ae-4595-8850-8072a75114c1.sql new file mode 100644 index 0000000..4605ead --- /dev/null +++ b/supabase/migrations/20260416224551_467073c0-09ae-4595-8850-8072a75114c1.sql @@ -0,0 +1,366 @@ +-- ========================================= +-- ENUMS +-- ========================================= +CREATE TYPE public.app_role AS ENUM ('admin', 'attorney', 'staff'); +CREATE TYPE public.client_type AS ENUM ('hoa', 'individual', 'business'); +CREATE TYPE public.case_status AS ENUM ('intake', 'active', 'on_hold', 'closed_won', 'closed_lost', 'closed'); +CREATE TYPE public.invoice_status AS ENUM ('draft', 'sent', 'paid', 'overdue', 'void'); + +-- ========================================= +-- UPDATED-AT TRIGGER FN +-- ========================================= +CREATE OR REPLACE FUNCTION public.tg_set_updated_at() +RETURNS TRIGGER LANGUAGE plpgsql SET search_path = public AS $$ +BEGIN NEW.updated_at = now(); RETURN NEW; END; +$$; + +-- ========================================= +-- PROFILES (display info for auth users) +-- ========================================= +CREATE TABLE public.profiles ( + id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + full_name TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; + +CREATE TRIGGER profiles_updated_at BEFORE UPDATE ON public.profiles +FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); + +-- Auto-create profile on signup +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ +BEGIN + INSERT INTO public.profiles (id, email, full_name) + VALUES (NEW.id, NEW.email, COALESCE(NEW.raw_user_meta_data->>'full_name', '')); + -- First-ever user becomes admin + IF (SELECT count(*) FROM public.user_roles) = 0 THEN + INSERT INTO public.user_roles (user_id, role) VALUES (NEW.id, 'admin'); + ELSE + INSERT INTO public.user_roles (user_id, role) VALUES (NEW.id, 'staff'); + END IF; + RETURN NEW; +END; +$$; + +-- ========================================= +-- USER ROLES +-- ========================================= +CREATE TABLE public.user_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role public.app_role NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, role) +); +ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY; + +CREATE OR REPLACE FUNCTION public.has_role(_user_id UUID, _role public.app_role) +RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = _user_id AND role = _role) +$$; + +CREATE OR REPLACE FUNCTION public.is_admin(_user_id UUID) +RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT EXISTS (SELECT 1 FROM public.user_roles WHERE user_id = _user_id AND role = 'admin') +$$; + +-- Now create the trigger (after user_roles exists) +CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users +FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); + +-- ========================================= +-- CLIENTS +-- ========================================= +CREATE TABLE public.clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_type public.client_type NOT NULL DEFAULT 'hoa', + name TEXT NOT NULL, + primary_contact_name TEXT, + primary_contact_email TEXT, + primary_contact_phone TEXT, + address_line1 TEXT, + address_line2 TEXT, + city TEXT, + state TEXT, + postal_code TEXT, + notes TEXT, + -- HOA-specific + management_company TEXT, + num_units INTEGER, + board_members JSONB NOT NULL DEFAULT '[]'::jsonb, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.clients ENABLE ROW LEVEL SECURITY; +CREATE TRIGGER clients_updated_at BEFORE UPDATE ON public.clients +FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); + +-- ========================================= +-- CASES +-- ========================================= +CREATE TABLE public.cases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id UUID NOT NULL REFERENCES public.clients(id) ON DELETE RESTRICT, + case_number TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + description TEXT, + practice_area TEXT, + status public.case_status NOT NULL DEFAULT 'intake', + assigned_attorney_id UUID REFERENCES auth.users(id), + opened_at DATE NOT NULL DEFAULT CURRENT_DATE, + closed_at DATE, + default_hourly_rate NUMERIC(10,2), + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.cases ENABLE ROW LEVEL SECURITY; +CREATE TRIGGER cases_updated_at BEFORE UPDATE ON public.cases +FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); +CREATE INDEX idx_cases_client ON public.cases(client_id); +CREATE INDEX idx_cases_attorney ON public.cases(assigned_attorney_id); + +-- Helper: can current user access this case? +CREATE OR REPLACE FUNCTION public.can_access_case(_case_id UUID, _user_id UUID) +RETURNS BOOLEAN LANGUAGE SQL STABLE SECURITY DEFINER SET search_path = public AS $$ + SELECT + public.has_role(_user_id, 'admin') + OR EXISTS ( + SELECT 1 FROM public.cases c + WHERE c.id = _case_id + AND (c.assigned_attorney_id = _user_id OR c.created_by = _user_id) + ) +$$; + +-- ========================================= +-- DOCUMENTS +-- ========================================= +CREATE TABLE public.documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + case_id UUID NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE, + name TEXT NOT NULL, + storage_path TEXT NOT NULL, + mime_type TEXT, + size_bytes BIGINT, + description TEXT, + uploaded_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY; +CREATE INDEX idx_documents_case ON public.documents(case_id); + +-- ========================================= +-- STATUS UPDATES (case log entries) +-- ========================================= +CREATE TABLE public.status_updates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + case_id UUID NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE, + title TEXT NOT NULL, + body TEXT NOT NULL, + report_storage_path TEXT, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.status_updates ENABLE ROW LEVEL SECURITY; +CREATE INDEX idx_status_updates_case ON public.status_updates(case_id); + +-- ========================================= +-- TIME ENTRIES +-- ========================================= +CREATE TABLE public.time_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + case_id UUID NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id), + work_date DATE NOT NULL DEFAULT CURRENT_DATE, + hours NUMERIC(6,2) NOT NULL CHECK (hours > 0), + hourly_rate NUMERIC(10,2) NOT NULL DEFAULT 0, + description TEXT NOT NULL, + billable BOOLEAN NOT NULL DEFAULT true, + invoice_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.time_entries ENABLE ROW LEVEL SECURITY; +CREATE TRIGGER time_entries_updated_at BEFORE UPDATE ON public.time_entries +FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); +CREATE INDEX idx_time_case ON public.time_entries(case_id); +CREATE INDEX idx_time_user ON public.time_entries(user_id); +CREATE INDEX idx_time_invoice ON public.time_entries(invoice_id); + +-- ========================================= +-- EXPENSES +-- ========================================= +CREATE TABLE public.expenses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + case_id UUID NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id), + expense_date DATE NOT NULL DEFAULT CURRENT_DATE, + description TEXT NOT NULL, + amount NUMERIC(10,2) NOT NULL CHECK (amount >= 0), + billable BOOLEAN NOT NULL DEFAULT true, + receipt_storage_path TEXT, + invoice_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.expenses ENABLE ROW LEVEL SECURITY; +CREATE TRIGGER expenses_updated_at BEFORE UPDATE ON public.expenses +FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); +CREATE INDEX idx_expenses_case ON public.expenses(case_id); +CREATE INDEX idx_expenses_invoice ON public.expenses(invoice_id); + +-- ========================================= +-- INVOICES +-- ========================================= +CREATE TABLE public.invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + invoice_number TEXT NOT NULL UNIQUE, + client_id UUID NOT NULL REFERENCES public.clients(id) ON DELETE RESTRICT, + case_id UUID REFERENCES public.cases(id) ON DELETE SET NULL, + status public.invoice_status NOT NULL DEFAULT 'draft', + issue_date DATE NOT NULL DEFAULT CURRENT_DATE, + due_date DATE, + subtotal NUMERIC(12,2) NOT NULL DEFAULT 0, + tax NUMERIC(12,2) NOT NULL DEFAULT 0, + total NUMERIC(12,2) NOT NULL DEFAULT 0, + amount_paid NUMERIC(12,2) NOT NULL DEFAULT 0, + notes TEXT, + paid_at TIMESTAMPTZ, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +ALTER TABLE public.invoices ENABLE ROW LEVEL SECURITY; +CREATE TRIGGER invoices_updated_at BEFORE UPDATE ON public.invoices +FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at(); +CREATE INDEX idx_invoices_client ON public.invoices(client_id); +CREATE INDEX idx_invoices_case ON public.invoices(case_id); + +ALTER TABLE public.time_entries + ADD CONSTRAINT time_entries_invoice_fk FOREIGN KEY (invoice_id) REFERENCES public.invoices(id) ON DELETE SET NULL; +ALTER TABLE public.expenses + ADD CONSTRAINT expenses_invoice_fk FOREIGN KEY (invoice_id) REFERENCES public.invoices(id) ON DELETE SET NULL; + +-- ========================================= +-- RLS POLICIES +-- ========================================= + +-- profiles: any authenticated user can read names; users can update their own +CREATE POLICY "profiles_select_auth" ON public.profiles FOR SELECT TO authenticated USING (true); +CREATE POLICY "profiles_update_own" ON public.profiles FOR UPDATE TO authenticated USING (auth.uid() = id); + +-- user_roles: users can see their own roles; admins see all; only admins manage +CREATE POLICY "user_roles_select_self" ON public.user_roles FOR SELECT TO authenticated + USING (user_id = auth.uid() OR public.is_admin(auth.uid())); +CREATE POLICY "user_roles_admin_insert" ON public.user_roles FOR INSERT TO authenticated + WITH CHECK (public.is_admin(auth.uid())); +CREATE POLICY "user_roles_admin_update" ON public.user_roles FOR UPDATE TO authenticated + USING (public.is_admin(auth.uid())); +CREATE POLICY "user_roles_admin_delete" ON public.user_roles FOR DELETE TO authenticated + USING (public.is_admin(auth.uid())); + +-- clients: any authenticated firm user can view clients (firm-wide). +CREATE POLICY "clients_select_auth" ON public.clients FOR SELECT TO authenticated USING (true); +CREATE POLICY "clients_insert_auth" ON public.clients FOR INSERT TO authenticated + WITH CHECK (auth.uid() IS NOT NULL); +CREATE POLICY "clients_update_auth" ON public.clients FOR UPDATE TO authenticated + USING (public.is_admin(auth.uid()) OR created_by = auth.uid()); +CREATE POLICY "clients_delete_admin" ON public.clients FOR DELETE TO authenticated + USING (public.is_admin(auth.uid())); + +-- cases: only admin or assigned attorney/creator can see +CREATE POLICY "cases_select_assigned" ON public.cases FOR SELECT TO authenticated + USING (public.is_admin(auth.uid()) OR assigned_attorney_id = auth.uid() OR created_by = auth.uid()); +CREATE POLICY "cases_insert_auth" ON public.cases FOR INSERT TO authenticated + WITH CHECK (auth.uid() IS NOT NULL); +CREATE POLICY "cases_update_assigned" ON public.cases FOR UPDATE TO authenticated + USING (public.is_admin(auth.uid()) OR assigned_attorney_id = auth.uid() OR created_by = auth.uid()); +CREATE POLICY "cases_delete_admin" ON public.cases FOR DELETE TO authenticated + USING (public.is_admin(auth.uid())); + +-- documents +CREATE POLICY "documents_select_case" ON public.documents FOR SELECT TO authenticated + USING (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "documents_insert_case" ON public.documents FOR INSERT TO authenticated + WITH CHECK (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "documents_delete_case" ON public.documents FOR DELETE TO authenticated + USING (public.can_access_case(case_id, auth.uid())); + +-- status updates +CREATE POLICY "status_select_case" ON public.status_updates FOR SELECT TO authenticated + USING (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "status_insert_case" ON public.status_updates FOR INSERT TO authenticated + WITH CHECK (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "status_update_owner" ON public.status_updates FOR UPDATE TO authenticated + USING (public.is_admin(auth.uid()) OR created_by = auth.uid()); +CREATE POLICY "status_delete_owner" ON public.status_updates FOR DELETE TO authenticated + USING (public.is_admin(auth.uid()) OR created_by = auth.uid()); + +-- time entries +CREATE POLICY "time_select_case" ON public.time_entries FOR SELECT TO authenticated + USING (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "time_insert_case" ON public.time_entries FOR INSERT TO authenticated + WITH CHECK (public.can_access_case(case_id, auth.uid()) AND user_id = auth.uid()); +CREATE POLICY "time_update_owner" ON public.time_entries FOR UPDATE TO authenticated + USING (public.is_admin(auth.uid()) OR user_id = auth.uid()); +CREATE POLICY "time_delete_owner" ON public.time_entries FOR DELETE TO authenticated + USING (public.is_admin(auth.uid()) OR user_id = auth.uid()); + +-- expenses +CREATE POLICY "exp_select_case" ON public.expenses FOR SELECT TO authenticated + USING (public.can_access_case(case_id, auth.uid())); +CREATE POLICY "exp_insert_case" ON public.expenses FOR INSERT TO authenticated + WITH CHECK (public.can_access_case(case_id, auth.uid()) AND user_id = auth.uid()); +CREATE POLICY "exp_update_owner" ON public.expenses FOR UPDATE TO authenticated + USING (public.is_admin(auth.uid()) OR user_id = auth.uid()); +CREATE POLICY "exp_delete_owner" ON public.expenses FOR DELETE TO authenticated + USING (public.is_admin(auth.uid()) OR user_id = auth.uid()); + +-- invoices +CREATE POLICY "inv_select" ON public.invoices FOR SELECT TO authenticated + USING ( + public.is_admin(auth.uid()) + OR created_by = auth.uid() + OR (case_id IS NOT NULL AND public.can_access_case(case_id, auth.uid())) + ); +CREATE POLICY "inv_insert" ON public.invoices FOR INSERT TO authenticated + WITH CHECK (auth.uid() IS NOT NULL); +CREATE POLICY "inv_update" ON public.invoices FOR UPDATE TO authenticated + USING ( + public.is_admin(auth.uid()) + OR created_by = auth.uid() + OR (case_id IS NOT NULL AND public.can_access_case(case_id, auth.uid())) + ); +CREATE POLICY "inv_delete_admin" ON public.invoices FOR DELETE TO authenticated + USING (public.is_admin(auth.uid())); + +-- ========================================= +-- STORAGE BUCKETS +-- ========================================= +INSERT INTO storage.buckets (id, name, public) VALUES ('case-documents', 'case-documents', false) + ON CONFLICT (id) DO NOTHING; +INSERT INTO storage.buckets (id, name, public) VALUES ('case-receipts', 'case-receipts', false) + ON CONFLICT (id) DO NOTHING; +INSERT INTO storage.buckets (id, name, public) VALUES ('case-reports', 'case-reports', false) + ON CONFLICT (id) DO NOTHING; + +-- Storage policies: file path must start with case-id/ that the user can access +CREATE POLICY "case_docs_select" ON storage.objects FOR SELECT TO authenticated + USING ( + bucket_id IN ('case-documents','case-receipts','case-reports') + AND public.can_access_case(((storage.foldername(name))[1])::uuid, auth.uid()) + ); +CREATE POLICY "case_docs_insert" ON storage.objects FOR INSERT TO authenticated + WITH CHECK ( + bucket_id IN ('case-documents','case-receipts','case-reports') + AND public.can_access_case(((storage.foldername(name))[1])::uuid, auth.uid()) + ); +CREATE POLICY "case_docs_delete" ON storage.objects FOR DELETE TO authenticated + USING ( + bucket_id IN ('case-documents','case-receipts','case-reports') + AND public.can_access_case(((storage.foldername(name))[1])::uuid, auth.uid()) + ); From 111fbb90a11ac25ac60cf48972d38d2fa7929428 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:46:00 +0000 Subject: [PATCH 05/14] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routeTree.gen.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index dceedff..d204c26 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -57,12 +57,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } -} From 6c5b8536d089a9ba395ee3d5050af3f56eabf9e5 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:46:50 +0000 Subject: [PATCH 06/14] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/lib/auth.tsx | 88 +++++++++++++++++++++++ src/lib/format.ts | 46 ++++++++++++ src/styles.css | 179 +++++++++++++++++++++++----------------------- 3 files changed, 224 insertions(+), 89 deletions(-) create mode 100644 src/lib/auth.tsx create mode 100644 src/lib/format.ts diff --git a/src/lib/auth.tsx b/src/lib/auth.tsx new file mode 100644 index 0000000..b49428c --- /dev/null +++ b/src/lib/auth.tsx @@ -0,0 +1,88 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import type { Session, User } from "@supabase/supabase-js"; +import { supabase } from "@/integrations/supabase/client"; + +export type AppRole = "admin" | "attorney" | "staff"; + +interface AuthState { + session: Session | null; + user: User | null; + roles: AppRole[]; + loading: boolean; + isAdmin: boolean; + signIn: (email: string, password: string) => Promise<{ error: string | null }>; + signOut: () => Promise; + refreshRoles: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [session, setSession] = useState(null); + const [user, setUser] = useState(null); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + + const loadRoles = async (uid: string | undefined) => { + if (!uid) { + setRoles([]); + return; + } + const { data } = await supabase.from("user_roles").select("role").eq("user_id", uid); + setRoles((data ?? []).map((r) => r.role as AppRole)); + }; + + useEffect(() => { + const { data: sub } = supabase.auth.onAuthStateChange((_event, sess) => { + setSession(sess); + setUser(sess?.user ?? null); + // Defer DB call to avoid deadlock + setTimeout(() => { + loadRoles(sess?.user?.id); + }, 0); + }); + + supabase.auth.getSession().then(({ data: { session: sess } }) => { + setSession(sess); + setUser(sess?.user ?? null); + loadRoles(sess?.user?.id).finally(() => setLoading(false)); + }); + + return () => sub.subscription.unsubscribe(); + }, []); + + const signIn = async (email: string, password: string) => { + const { error } = await supabase.auth.signInWithPassword({ email, password }); + return { error: error?.message ?? null }; + }; + + const signOut = async () => { + await supabase.auth.signOut(); + setRoles([]); + }; + + const refreshRoles = async () => loadRoles(user?.id); + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within AuthProvider"); + return ctx; +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..055eb44 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,46 @@ +export function formatCurrency(amount: number | string | null | undefined) { + const n = typeof amount === "string" ? parseFloat(amount) : (amount ?? 0); + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n || 0); +} + +export function formatDate(value: string | Date | null | undefined) { + if (!value) return "—"; + const d = typeof value === "string" ? new Date(value) : value; + return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); +} + +export function formatDateTime(value: string | Date | null | undefined) { + if (!value) return "—"; + const d = typeof value === "string" ? new Date(value) : value; + return d.toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export function statusBadgeClass(status: string) { + switch (status) { + case "active": + case "sent": + return "bg-primary/10 text-primary border-primary/20"; + case "intake": + case "draft": + return "bg-muted text-muted-foreground border-border"; + case "on_hold": + case "overdue": + return "bg-warning/15 text-warning-foreground border-warning/40"; + case "closed_won": + case "paid": + return "bg-success/15 text-success border-success/30"; + case "closed_lost": + case "void": + return "bg-destructive/10 text-destructive border-destructive/30"; + case "closed": + return "bg-secondary text-secondary-foreground border-border"; + default: + return "bg-muted text-muted-foreground border-border"; + } +} diff --git a/src/styles.css b/src/styles.css index a9806b4..cd7f01d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4,28 +4,11 @@ @custom-variant dark (&:is(.dark *)); -/* - * Design system definition. - * - * The @theme inline block maps CSS custom properties to Tailwind utility - * classes (e.g. --color-primary -> bg-primary, text-primary). - * - * The :root and .dark blocks define the actual color values using oklch. - * All colors MUST use oklch format. - * - * To add a new semantic color: - * 1. Add the variable to :root (light value) and .dark (dark value) - * 2. Register it in @theme inline as --color-: var(--) - */ - @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); - --radius-2xl: calc(var(--radius) + 8px); - --radius-3xl: calc(var(--radius) + 12px); - --radius-4xl: calc(var(--radius) + 16px); --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -42,15 +25,13 @@ --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); - --color-ring-offset-background: var(--background); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); @@ -59,86 +40,106 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --font-serif: "Source Serif 4", "Source Serif Pro", Georgia, serif; + --font-sans: "Inter", system-ui, -apple-system, sans-serif; } :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.129 0.042 264.695); + --radius: 0.5rem; + + /* Surfaces — warm off-white paper */ + --background: oklch(0.985 0.005 85); + --foreground: oklch(0.18 0.025 250); + --card: oklch(1 0 0); - --card-foreground: oklch(0.129 0.042 264.695); + --card-foreground: oklch(0.18 0.025 250); + --popover: oklch(1 0 0); - --popover-foreground: oklch(0.129 0.042 264.695); - --primary: oklch(0.208 0.042 265.755); - --primary-foreground: oklch(0.984 0.003 247.858); - --secondary: oklch(0.968 0.007 247.896); - --secondary-foreground: oklch(0.208 0.042 265.755); - --muted: oklch(0.968 0.007 247.896); - --muted-foreground: oklch(0.554 0.046 257.417); - --accent: oklch(0.968 0.007 247.896); - --accent-foreground: oklch(0.208 0.042 265.755); - --destructive: oklch(0.577 0.245 27.325); - --destructive-foreground: oklch(0.984 0.003 247.858); - --border: oklch(0.929 0.013 255.508); - --input: oklch(0.929 0.013 255.508); - --ring: oklch(0.704 0.04 256.788); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.984 0.003 247.858); - --sidebar-foreground: oklch(0.129 0.042 264.695); - --sidebar-primary: oklch(0.208 0.042 265.755); - --sidebar-primary-foreground: oklch(0.984 0.003 247.858); - --sidebar-accent: oklch(0.968 0.007 247.896); - --sidebar-accent-foreground: oklch(0.208 0.042 265.755); - --sidebar-border: oklch(0.929 0.013 255.508); - --sidebar-ring: oklch(0.704 0.04 256.788); + --popover-foreground: oklch(0.18 0.025 250); + + /* Brand: deep navy ink */ + --primary: oklch(0.27 0.06 255); + --primary-foreground: oklch(0.98 0.005 85); + + --secondary: oklch(0.94 0.012 250); + --secondary-foreground: oklch(0.27 0.06 255); + + --muted: oklch(0.95 0.008 250); + --muted-foreground: oklch(0.5 0.025 250); + + --accent: oklch(0.92 0.04 80); /* subtle gold/parchment accent */ + --accent-foreground: oklch(0.27 0.06 255); + + --destructive: oklch(0.55 0.2 27); + --destructive-foreground: oklch(0.98 0.005 85); + + --success: oklch(0.55 0.13 155); + --success-foreground: oklch(0.98 0.005 85); + + --warning: oklch(0.7 0.14 75); + --warning-foreground: oklch(0.18 0.025 250); + + --border: oklch(0.9 0.012 250); + --input: oklch(0.9 0.012 250); + --ring: oklch(0.45 0.08 255); + + --sidebar: oklch(0.22 0.04 255); + --sidebar-foreground: oklch(0.92 0.012 250); + --sidebar-primary: oklch(0.62 0.15 70); + --sidebar-primary-foreground: oklch(0.18 0.025 250); + --sidebar-accent: oklch(0.28 0.05 255); + --sidebar-accent-foreground: oklch(0.96 0.008 85); + --sidebar-border: oklch(0.3 0.04 255); + --sidebar-ring: oklch(0.55 0.1 255); + + --chart-1: oklch(0.62 0.15 70); + --chart-2: oklch(0.45 0.1 255); + --chart-3: oklch(0.55 0.13 155); + --chart-4: oklch(0.55 0.2 27); + --chart-5: oklch(0.5 0.025 250); } .dark { - --background: oklch(0.129 0.042 264.695); - --foreground: oklch(0.984 0.003 247.858); - --card: oklch(0.208 0.042 265.755); - --card-foreground: oklch(0.984 0.003 247.858); - --popover: oklch(0.208 0.042 265.755); - --popover-foreground: oklch(0.984 0.003 247.858); - --primary: oklch(0.929 0.013 255.508); - --primary-foreground: oklch(0.208 0.042 265.755); - --secondary: oklch(0.279 0.041 260.031); - --secondary-foreground: oklch(0.984 0.003 247.858); - --muted: oklch(0.279 0.041 260.031); - --muted-foreground: oklch(0.704 0.04 256.788); - --accent: oklch(0.279 0.041 260.031); - --accent-foreground: oklch(0.984 0.003 247.858); - --destructive: oklch(0.704 0.191 22.216); - --destructive-foreground: oklch(0.984 0.003 247.858); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.551 0.027 264.364); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.208 0.042 265.755); - --sidebar-foreground: oklch(0.984 0.003 247.858); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.984 0.003 247.858); - --sidebar-accent: oklch(0.279 0.041 260.031); - --sidebar-accent-foreground: oklch(0.984 0.003 247.858); + --background: oklch(0.16 0.025 250); + --foreground: oklch(0.96 0.008 85); + --card: oklch(0.22 0.03 255); + --card-foreground: oklch(0.96 0.008 85); + --popover: oklch(0.22 0.03 255); + --popover-foreground: oklch(0.96 0.008 85); + --primary: oklch(0.85 0.04 80); + --primary-foreground: oklch(0.18 0.025 250); + --secondary: oklch(0.28 0.04 255); + --secondary-foreground: oklch(0.96 0.008 85); + --muted: oklch(0.28 0.04 255); + --muted-foreground: oklch(0.72 0.02 250); + --accent: oklch(0.32 0.05 255); + --accent-foreground: oklch(0.96 0.008 85); + --destructive: oklch(0.65 0.18 27); + --destructive-foreground: oklch(0.98 0.005 85); + --success: oklch(0.65 0.13 155); + --success-foreground: oklch(0.18 0.025 250); + --warning: oklch(0.78 0.14 75); + --warning-foreground: oklch(0.18 0.025 250); + --border: oklch(1 0 0 / 12%); + --input: oklch(1 0 0 / 14%); + --ring: oklch(0.62 0.1 255); + --sidebar: oklch(0.18 0.03 255); + --sidebar-foreground: oklch(0.92 0.012 250); + --sidebar-primary: oklch(0.62 0.15 70); + --sidebar-primary-foreground: oklch(0.18 0.025 250); + --sidebar-accent: oklch(0.24 0.04 255); + --sidebar-accent-foreground: oklch(0.96 0.008 85); --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.551 0.027 264.364); + --sidebar-ring: oklch(0.55 0.1 255); } @layer base { - * { - border-color: var(--color-border); - } - + * { border-color: var(--color-border); } body { background-color: var(--color-background); color: var(--color-foreground); + font-family: var(--font-sans); + font-feature-settings: "cv11", "ss01"; } + h1, h2, h3, h4 { font-family: var(--font-serif); letter-spacing: -0.01em; } } From 411137b170acceb803d4325873daa7df586e9f60 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:47:22 +0000 Subject: [PATCH 07/14] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/app-shell.tsx | 160 ++++++++++++++++++++++++++++ src/components/protected-layout.tsx | 29 +++++ src/routes/__root.tsx | 34 +++--- 3 files changed, 204 insertions(+), 19 deletions(-) create mode 100644 src/components/app-shell.tsx create mode 100644 src/components/protected-layout.tsx diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx new file mode 100644 index 0000000..d119f0c --- /dev/null +++ b/src/components/app-shell.tsx @@ -0,0 +1,160 @@ +import { Link, useLocation, useNavigate } from "@tanstack/react-router"; +import { useAuth } from "@/lib/auth"; +import { Button } from "@/components/ui/button"; +import { + Briefcase, + Users, + FileText, + Receipt, + ShieldCheck, + LogOut, + Scale, + LayoutDashboard, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; + +interface NavItem { + to: string; + label: string; + icon: typeof Briefcase; + adminOnly?: boolean; +} + +const NAV: NavItem[] = [ + { to: "/", label: "Dashboard", icon: LayoutDashboard }, + { to: "/clients", label: "Clients", icon: Users }, + { to: "/cases", label: "Cases", icon: Briefcase }, + { to: "/invoices", label: "Invoices", icon: Receipt }, + { to: "/admin/users", label: "Users", icon: ShieldCheck, adminOnly: true }, +]; + +export function AppShell({ children }: { children: ReactNode }) { + const { user, signOut, isAdmin, roles } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + + const handleSignOut = async () => { + await signOut(); + navigate({ to: "/login" }); + }; + + return ( +
+ {/* Sidebar */} + + + {/* Mobile top bar */} +
+ + + Counsel + + +
+ +
+
+ {NAV.filter((n) => !n.adminOnly || isAdmin).map((item) => { + const active = + item.to === "/" + ? location.pathname === "/" + : location.pathname.startsWith(item.to); + return ( + + {item.label} + + ); + })} +
+ {children} +
+
+ ); +} + +export function PageHeader({ + title, + description, + actions, +}: { + title: string; + description?: string; + actions?: ReactNode; +}) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} + +export function PageContainer({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/src/components/protected-layout.tsx b/src/components/protected-layout.tsx new file mode 100644 index 0000000..96b2bc6 --- /dev/null +++ b/src/components/protected-layout.tsx @@ -0,0 +1,29 @@ +import { useEffect, type ReactNode } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { useAuth } from "@/lib/auth"; +import { AppShell } from "@/components/app-shell"; +import { Loader2 } from "lucide-react"; + +export function ProtectedLayout({ children, adminOnly }: { children: ReactNode; adminOnly?: boolean }) { + const { loading, session, isAdmin } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + if (loading) return; + if (!session) { + navigate({ to: "/login" }); + } else if (adminOnly && !isAdmin) { + navigate({ to: "/" }); + } + }, [loading, session, isAdmin, adminOnly, navigate]); + + if (loading || !session || (adminOnly && !isAdmin)) { + return ( +
+ +
+ ); + } + + return {children}; +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index a23245a..ed8366d 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,15 +1,16 @@ -import { Outlet, Link, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router"; - +import { Outlet, createRootRoute, HeadContent, Scripts, Link } from "@tanstack/react-router"; +import { Toaster } from "@/components/ui/sonner"; +import { AuthProvider } from "@/lib/auth"; import appCss from "../styles.css?url"; function NotFoundComponent() { return (
-

404

+

404

Page not found

- The page you're looking for doesn't exist or has been moved. + The page you're looking for doesn't exist.

; + return ( + + + + + ); } From c1cd4f415b867a85789072796e6b333bde873182 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:47:56 +0000 Subject: [PATCH 08/14] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routeTree.gen.ts | 51 ++++++++++++++- src/routes/login.tsx | 122 ++++++++++++++++++++++++++++++++++++ src/routes/setup.tsx | 143 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 src/routes/login.tsx create mode 100644 src/routes/setup.tsx diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index d204c26..2c17859 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -9,8 +9,20 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as SetupRouteImport } from './routes/setup' +import { Route as LoginRouteImport } from './routes/login' import { Route as IndexRouteImport } from './routes/index' +const SetupRoute = SetupRouteImport.update({ + id: '/setup', + path: '/setup', + getParentRoute: () => rootRouteImport, +} as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -19,28 +31,50 @@ const IndexRoute = IndexRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/login': typeof LoginRoute + '/setup': typeof SetupRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/login': typeof LoginRoute + '/setup': typeof SetupRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/login': typeof LoginRoute + '/setup': typeof SetupRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' + fullPaths: '/' | '/login' | '/setup' fileRoutesByTo: FileRoutesByTo - to: '/' - id: '__root__' | '/' + to: '/' | '/login' | '/setup' + id: '__root__' | '/' | '/login' | '/setup' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + LoginRoute: typeof LoginRoute + SetupRoute: typeof SetupRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/setup': { + id: '/setup' + path: '/setup' + fullPath: '/setup' + preLoaderRoute: typeof SetupRouteImport + parentRoute: typeof rootRouteImport + } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -53,7 +87,18 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + LoginRoute: LoginRoute, + SetupRoute: SetupRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/src/routes/login.tsx b/src/routes/login.tsx new file mode 100644 index 0000000..98e6f15 --- /dev/null +++ b/src/routes/login.tsx @@ -0,0 +1,122 @@ +import { createFileRoute, useNavigate, Link } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { useAuth } from "@/lib/auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Scale, Loader2, ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; + +export const Route = createFileRoute("/login")({ + component: LoginPage, +}); + +function LoginPage() { + const { signIn, session, loading } = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!loading && session) navigate({ to: "/" }); + }, [loading, session, navigate]); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + const { error } = await signIn(email.trim(), password); + setSubmitting(false); + if (error) { + toast.error("Sign-in failed", { description: error }); + } else { + navigate({ to: "/" }); + } + }; + + return ( +
+ {/* Brand panel */} +
+
+
+ +
+
+
Counsel
+
+ Law Firm Suite +
+
+
+ +
+

+ Practice management, built for the discipline of law. +

+

+ Cases, clients, documents, time, and billing — secured behind authenticated access and + row-level data protection. Designed for HOA-focused firms and beyond. +

+
+ +
+ + Encrypted in transit · Role-based access · Audit-ready +
+
+ + {/* Form */} +
+ + +
+ + Counsel +
+ Sign in + Restricted access. Contact your administrator for credentials. +
+ +
+
+ + setEmail(e.target.value)} + placeholder="attorney@firm.com" + /> +
+
+ + setPassword(e.target.value)} + /> +
+ +

+ First time setting up the firm?{" "} + + Create the founding admin account + +

+
+
+
+
+
+ ); +} diff --git a/src/routes/setup.tsx b/src/routes/setup.tsx new file mode 100644 index 0000000..334a956 --- /dev/null +++ b/src/routes/setup.tsx @@ -0,0 +1,143 @@ +import { createFileRoute, useNavigate, Link } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Loader2, Scale } from "lucide-react"; +import { toast } from "sonner"; + +export const Route = createFileRoute("/setup")({ + component: SetupPage, +}); + +function SetupPage() { + const navigate = useNavigate(); + const { loading, session } = useAuth(); + const [checking, setChecking] = useState(true); + const [allowed, setAllowed] = useState(false); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + // If anyone exists, block access to /setup + (async () => { + const { count } = await supabase + .from("user_roles") + .select("*", { count: "exact", head: true }); + if ((count ?? 0) === 0) setAllowed(true); + setChecking(false); + })(); + }, []); + + useEffect(() => { + if (!loading && session && !allowed) navigate({ to: "/" }); + }, [loading, session, allowed, navigate]); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + const redirectUrl = `${window.location.origin}/`; + const { error } = await supabase.auth.signUp({ + email: email.trim(), + password, + options: { + emailRedirectTo: redirectUrl, + data: { full_name: name.trim() }, + }, + }); + setSubmitting(false); + if (error) { + toast.error("Could not create account", { description: error.message }); + return; + } + toast.success("Founding admin account created"); + navigate({ to: "/" }); + }; + + if (checking) { + return ( +
+ +
+ ); + } + + if (!allowed) { + return ( +
+ + + Setup unavailable + + Firm is already initialized. Use the standard sign-in page. + + + + + + +
+ ); + } + + return ( +
+ + +
+ + Counsel +
+ Create founding admin + + This is a one-time setup. The first account becomes the firm administrator and can invite + attorneys and staff afterward. + +
+ +
+
+ + setName(e.target.value)} /> +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +

+ Minimum 10 characters. Avoid commonly leaked passwords. +

+
+ +
+
+
+
+ ); +} From 09d4acd58ad1baa8661169add5fb19f775dbb456 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:49:09 +0000 Subject: [PATCH 09/14] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/clients/client-form-dialog.tsx | 362 ++++++++++++++++++ src/routeTree.gen.ts | 24 +- src/routes/clients.index.tsx | 132 +++++++ src/routes/index.tsx | 163 +++++++- 4 files changed, 659 insertions(+), 22 deletions(-) create mode 100644 src/components/clients/client-form-dialog.tsx create mode 100644 src/routes/clients.index.tsx diff --git a/src/components/clients/client-form-dialog.tsx b/src/components/clients/client-form-dialog.tsx new file mode 100644 index 0000000..674d437 --- /dev/null +++ b/src/components/clients/client-form-dialog.tsx @@ -0,0 +1,362 @@ +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Loader2, Plus, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +const boardMember = z.object({ + name: z.string().trim().min(1, "Required").max(120), + role: z.string().trim().max(60).optional().or(z.literal("")), + email: z.string().trim().email("Invalid email").max(255).optional().or(z.literal("")), + phone: z.string().trim().max(40).optional().or(z.literal("")), +}); + +const schema = z.object({ + client_type: z.enum(["hoa", "individual", "business"]), + name: z.string().trim().min(1, "Required").max(200), + primary_contact_name: z.string().trim().max(120).optional().or(z.literal("")), + primary_contact_email: z.string().trim().email("Invalid email").max(255).optional().or(z.literal("")), + primary_contact_phone: z.string().trim().max(40).optional().or(z.literal("")), + address_line1: z.string().trim().max(200).optional().or(z.literal("")), + city: z.string().trim().max(100).optional().or(z.literal("")), + state: z.string().trim().max(60).optional().or(z.literal("")), + postal_code: z.string().trim().max(20).optional().or(z.literal("")), + management_company: z.string().trim().max(200).optional().or(z.literal("")), + num_units: z.coerce.number().int().min(0).max(100000).optional().or(z.literal("").transform(() => undefined)), + board_members: z.array(boardMember), + notes: z.string().trim().max(5000).optional().or(z.literal("")), +}); + +type FormValues = z.infer; + +export function ClientFormDialog({ + open, + onOpenChange, + onSaved, + client, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + onSaved?: () => void; + client?: any; +}) { + const { user } = useAuth(); + const [submitting, setSubmitting] = useState(false); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + client_type: "hoa", + name: "", + primary_contact_name: "", + primary_contact_email: "", + primary_contact_phone: "", + address_line1: "", + city: "", + state: "", + postal_code: "", + management_company: "", + num_units: undefined as any, + board_members: [], + notes: "", + }, + }); + + useEffect(() => { + if (open) { + form.reset({ + client_type: client?.client_type ?? "hoa", + name: client?.name ?? "", + primary_contact_name: client?.primary_contact_name ?? "", + primary_contact_email: client?.primary_contact_email ?? "", + primary_contact_phone: client?.primary_contact_phone ?? "", + address_line1: client?.address_line1 ?? "", + city: client?.city ?? "", + state: client?.state ?? "", + postal_code: client?.postal_code ?? "", + management_company: client?.management_company ?? "", + num_units: client?.num_units ?? (undefined as any), + board_members: client?.board_members ?? [], + notes: client?.notes ?? "", + }); + } + }, [open, client, form]); + + const clientType = form.watch("client_type"); + const board = form.watch("board_members"); + + const addBoardMember = () => { + form.setValue("board_members", [...board, { name: "", role: "", email: "", phone: "" }]); + }; + const removeBoardMember = (idx: number) => { + form.setValue("board_members", board.filter((_, i) => i !== idx)); + }; + + const onSubmit = async (values: FormValues) => { + setSubmitting(true); + const payload: any = { + ...values, + num_units: values.num_units || null, + // strip empty strings to nulls + ...Object.fromEntries( + Object.entries(values).map(([k, v]) => [k, v === "" ? null : v]), + ), + }; + payload.board_members = values.board_members; + if (!client) payload.created_by = user?.id; + + const { error } = client + ? await supabase.from("clients").update(payload).eq("id", client.id) + : await supabase.from("clients").insert(payload); + + setSubmitting(false); + if (error) { + toast.error("Could not save client", { description: error.message }); + return; + } + toast.success(client ? "Client updated" : "Client created"); + onOpenChange(false); + onSaved?.(); + }; + + return ( + + + + {client ? "Edit client" : "New client"} + + HOA associations support board members and management companies. Other client types only show + relevant fields. + + + +
+ +
+ ( + + Type + + + + )} + /> + ( + + {clientType === "hoa" ? "Association name" : "Name"} + + + + )} + /> +
+ + {clientType === "hoa" && ( +
+ ( + + Management company + + + + )} + /> + ( + + # of units + + + + + + )} + /> +
+ )} + +
+ ( + + Primary contact + + + + )} + /> + ( + + Email + + + + )} + /> + ( + + Phone + + + + )} + /> +
+ + ( + + Address + + + + )} + /> +
+ ( + City + )} /> + ( + State + )} /> + ( + Zip + )} /> +
+ + {clientType === "hoa" && ( +
+
+ + +
+
+ {board.length === 0 && ( +

No board members yet.

+ )} + {board.map((_, idx) => ( +
+ { + const next = [...board]; + next[idx].name = e.target.value; + form.setValue("board_members", next); + }} + /> + { + const next = [...board]; + next[idx].role = e.target.value; + form.setValue("board_members", next); + }} + /> + { + const next = [...board]; + next[idx].email = e.target.value; + form.setValue("board_members", next); + }} + /> + { + const next = [...board]; + next[idx].phone = e.target.value; + form.setValue("board_members", next); + }} + /> + +
+ ))} +
+
+ )} + + ( + + Notes +