create-crm-starter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/index.js +2341 -0
- package/package.json +51 -0
- package/template/base/_dot_gitignore +11 -0
- package/template/base/eslint.config.mjs +7 -0
- package/template/base/next.config.ts +17 -0
- package/template/base/package.json +35 -0
- package/template/base/postcss.config.mjs +7 -0
- package/template/base/scripts/setup.mjs +146 -0
- package/template/base/src/app/globals.css +68 -0
- package/template/base/src/app/layout.tsx +19 -0
- package/template/base/src/app/page.tsx +37 -0
- package/template/base/src/components/ui/badge.tsx +29 -0
- package/template/base/src/components/ui/button.tsx +49 -0
- package/template/base/src/components/ui/card.tsx +54 -0
- package/template/base/src/components/ui/input.tsx +19 -0
- package/template/base/src/components/ui/label.tsx +19 -0
- package/template/base/src/components/ui/separator.tsx +25 -0
- package/template/base/src/components/ui/skeleton.tsx +7 -0
- package/template/base/src/lib/utils.ts +17 -0
- package/template/base/tsconfig.json +21 -0
- package/template/extras/auth-better-auth/src/app/(auth)/layout.tsx +7 -0
- package/template/extras/auth-better-auth/src/app/(auth)/sign-in/page.tsx +65 -0
- package/template/extras/auth-better-auth/src/app/(auth)/sign-up/page.tsx +70 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/admin/page.tsx +68 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/dashboard/page.tsx +42 -0
- package/template/extras/auth-better-auth/src/app/(dashboard)/layout.tsx +41 -0
- package/template/extras/auth-better-auth/src/app/api/auth/[...all]/route.ts +4 -0
- package/template/extras/auth-better-auth/src/components/sign-out-button.tsx +19 -0
- package/template/extras/auth-better-auth/src/lib/auth-client.ts +7 -0
- package/template/extras/auth-better-auth/src/lib/auth-server.ts +51 -0
- package/template/extras/auth-better-auth/src/lib/auth.ts +76 -0
- package/template/extras/auth-better-auth/src/middleware.ts +25 -0
- package/template/extras/auth-clerk/src/app/(auth)/layout.tsx +7 -0
- package/template/extras/auth-clerk/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +5 -0
- package/template/extras/auth-clerk/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +5 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/admin/page.tsx +37 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/dashboard/page.tsx +42 -0
- package/template/extras/auth-clerk/src/app/(dashboard)/layout.tsx +57 -0
- package/template/extras/auth-clerk/src/lib/auth.ts +55 -0
- package/template/extras/auth-clerk/src/middleware.ts +17 -0
- package/template/extras/calendar-dispatch/_shared/src/app/(dashboard)/calendar/page.tsx +39 -0
- package/template/extras/calendar-dispatch/drizzle/src/app/(dashboard)/calendar/page.tsx +21 -0
- package/template/extras/calendar-dispatch/drizzle/src/components/calendar/calendar-board.tsx +195 -0
- package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/actions.ts +35 -0
- package/template/extras/calendar-dispatch/drizzle/src/lib/calendar/data.ts +74 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/[id]/page.tsx +48 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/new/page.tsx +15 -0
- package/template/extras/checklists/_shared/src/app/(dashboard)/checklists/page.tsx +83 -0
- package/template/extras/checklists/_shared/src/components/jobs/job-checklists-section.tsx +18 -0
- package/template/extras/checklists/_shared/src/lib/checklists/data.ts +17 -0
- package/template/extras/checklists/_shared/src/lib/checklists/sample-data.ts +56 -0
- package/template/extras/checklists/_shared/src/lib/checklists/types.ts +47 -0
- package/template/extras/checklists/drizzle/src/app/(dashboard)/checklists/new/page.tsx +10 -0
- package/template/extras/checklists/drizzle/src/components/checklists/new-template-form.tsx +158 -0
- package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-client.tsx +202 -0
- package/template/extras/checklists/drizzle/src/components/jobs/job-checklists-section.tsx +24 -0
- package/template/extras/checklists/drizzle/src/db/schema/checklists.ts +52 -0
- package/template/extras/checklists/drizzle/src/lib/checklists/actions.ts +112 -0
- package/template/extras/checklists/drizzle/src/lib/checklists/data.ts +80 -0
- package/template/extras/comms-email/src/app/(dashboard)/email/page.tsx +32 -0
- package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/new/page.tsx +35 -0
- package/template/extras/comms-sms/_shared/src/app/(dashboard)/sms/page.tsx +55 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/[customerId]/page.tsx +102 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/new/page.tsx +120 -0
- package/template/extras/comms-sms/drizzle/src/app/(dashboard)/sms/page.tsx +70 -0
- package/template/extras/comms-sms/drizzle/src/app/api/twilio/sms/route.ts +69 -0
- package/template/extras/comms-sms/drizzle/src/db/schema/sms-messages.ts +27 -0
- package/template/extras/comms-sms/drizzle/src/lib/sms/actions.ts +107 -0
- package/template/extras/comms-sms/drizzle/src/lib/sms/data.ts +111 -0
- package/template/extras/customer-portal/_shared/src/app/portal/[token]/layout.tsx +51 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/appointments/page.tsx +78 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/estimates/page.tsx +94 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/invoices/page.tsx +71 -0
- package/template/extras/customer-portal/drizzle/src/app/portal/[token]/page.tsx +118 -0
- package/template/extras/customer-portal/drizzle/src/components/portal/estimate-approval.tsx +141 -0
- package/template/extras/customer-portal/drizzle/src/components/portal/signature-pad.tsx +126 -0
- package/template/extras/customer-portal/drizzle/src/lib/portal/actions.ts +107 -0
- package/template/extras/customer-portal/drizzle/src/lib/portal/data.ts +158 -0
- package/template/extras/customers/_fragments/convex.txt +28 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/[id]/page.tsx +81 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/new/page.tsx +16 -0
- package/template/extras/customers/_shared/src/app/(dashboard)/customers/page.tsx +73 -0
- package/template/extras/customers/_shared/src/lib/customers/data.ts +15 -0
- package/template/extras/customers/_shared/src/lib/customers/sample-data.ts +67 -0
- package/template/extras/customers/_shared/src/lib/customers/types.ts +31 -0
- package/template/extras/customers/convex/convex/customers.ts +52 -0
- package/template/extras/customers/convex/src/lib/customers/data.ts +64 -0
- package/template/extras/customers/drizzle/src/app/(dashboard)/customers/new/page.tsx +82 -0
- package/template/extras/customers/drizzle/src/db/schema/customers.ts +34 -0
- package/template/extras/customers/drizzle/src/lib/customers/actions.ts +67 -0
- package/template/extras/customers/drizzle/src/lib/customers/data.ts +34 -0
- package/template/extras/db-convex/convex/_generated/README.md +13 -0
- package/template/extras/db-convex/convex/_generated/api.d.ts +8 -0
- package/template/extras/db-convex/convex/_generated/api.js +12 -0
- package/template/extras/db-convex/convex/_generated/dataModel.d.ts +9 -0
- package/template/extras/db-convex/convex/_generated/server.d.ts +18 -0
- package/template/extras/db-convex/convex/_generated/server.js +12 -0
- package/template/extras/db-convex/convex/auth.config.ts +17 -0
- package/template/extras/db-convex/convex/schema.ts +28 -0
- package/template/extras/db-convex/src/app/layout.tsx +20 -0
- package/template/extras/db-convex/src/components/providers.tsx +28 -0
- package/template/extras/db-convex/src/lib/convex.ts +6 -0
- package/template/extras/db-drizzle-pg/docker-compose.yml +21 -0
- package/template/extras/db-drizzle-pg/drizzle.config.ts +24 -0
- package/template/extras/db-drizzle-pg/src/app/layout.tsx +20 -0
- package/template/extras/db-drizzle-pg/src/components/providers.tsx +16 -0
- package/template/extras/db-drizzle-pg/src/db/client.ts +14 -0
- package/template/extras/db-drizzle-pg/src/db/schema/auth.ts +62 -0
- package/template/extras/db-drizzle-pg/src/db/schema/index.ts +3 -0
- package/template/extras/emergency-dispatch/src/app/(dashboard)/emergency/page.tsx +43 -0
- package/template/extras/equipment-tracking/src/app/(dashboard)/equipment/page.tsx +61 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/[id]/page.tsx +123 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/new/page.tsx +22 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/estimates/page.tsx +102 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/[id]/page.tsx +168 -0
- package/template/extras/estimates-invoices/_shared/src/app/(dashboard)/invoices/page.tsx +100 -0
- package/template/extras/estimates-invoices/_shared/src/components/estimates/convert-to-job-button.tsx +14 -0
- package/template/extras/estimates-invoices/_shared/src/components/invoices/pay-invoice-button.tsx +15 -0
- package/template/extras/estimates-invoices/_shared/src/components/invoices/send-invoice-email-button.tsx +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/data.ts +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/sample-data.ts +74 -0
- package/template/extras/estimates-invoices/_shared/src/lib/estimates/types.ts +60 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/data.ts +14 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/sample-data.ts +83 -0
- package/template/extras/estimates-invoices/_shared/src/lib/invoices/types.ts +78 -0
- package/template/extras/estimates-invoices/drizzle/src/app/(dashboard)/estimates/new/page.tsx +18 -0
- package/template/extras/estimates-invoices/drizzle/src/app/api/stripe/webhook/route.ts +87 -0
- package/template/extras/estimates-invoices/drizzle/src/app/i/[token]/page.tsx +148 -0
- package/template/extras/estimates-invoices/drizzle/src/components/estimates/convert-to-job-button.tsx +18 -0
- package/template/extras/estimates-invoices/drizzle/src/components/estimates/new-estimate-form.tsx +261 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/pay-invoice-button.tsx +19 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/public-pay-button.tsx +20 -0
- package/template/extras/estimates-invoices/drizzle/src/components/invoices/send-invoice-email-button.tsx +37 -0
- package/template/extras/estimates-invoices/drizzle/src/components/jobs/generate-invoice-button.tsx +23 -0
- package/template/extras/estimates-invoices/drizzle/src/db/schema/estimates.ts +41 -0
- package/template/extras/estimates-invoices/drizzle/src/db/schema/invoices.ts +59 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/estimates/actions.ts +110 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/estimates/data.ts +57 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/actions.ts +199 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/data.ts +99 -0
- package/template/extras/estimates-invoices/drizzle/src/lib/invoices/email-actions.ts +102 -0
- package/template/extras/inspection-checklists/src/app/(dashboard)/inspections/page.tsx +60 -0
- package/template/extras/jobs/_fragments/convex.txt +21 -0
- package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/[id]/page.tsx +102 -0
- package/template/extras/jobs/_shared/src/app/(dashboard)/jobs/page.tsx +72 -0
- package/template/extras/jobs/_shared/src/components/jobs/advance-status-button.tsx +21 -0
- package/template/extras/jobs/_shared/src/components/jobs/generate-invoice-button.tsx +15 -0
- package/template/extras/jobs/_shared/src/components/jobs/photo-gallery.tsx +17 -0
- package/template/extras/jobs/_shared/src/components/jobs/photos-section.tsx +18 -0
- package/template/extras/jobs/_shared/src/lib/jobs/data.ts +14 -0
- package/template/extras/jobs/_shared/src/lib/jobs/sample-data.ts +50 -0
- package/template/extras/jobs/_shared/src/lib/jobs/types.ts +62 -0
- package/template/extras/jobs/convex/convex/jobs.ts +46 -0
- package/template/extras/jobs/convex/src/lib/jobs/data.ts +65 -0
- package/template/extras/jobs/drizzle/src/app/(dashboard)/jobs/new/page.tsx +18 -0
- package/template/extras/jobs/drizzle/src/components/jobs/advance-status-button.tsx +34 -0
- package/template/extras/jobs/drizzle/src/components/jobs/new-job-form.tsx +275 -0
- package/template/extras/jobs/drizzle/src/components/jobs/photo-gallery.tsx +130 -0
- package/template/extras/jobs/drizzle/src/components/jobs/photos-section.tsx +7 -0
- package/template/extras/jobs/drizzle/src/db/schema/job-attachments.ts +26 -0
- package/template/extras/jobs/drizzle/src/db/schema/jobs.ts +29 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/actions.ts +71 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/data.ts +48 -0
- package/template/extras/jobs/drizzle/src/lib/jobs/photos-actions.ts +121 -0
- package/template/extras/jobs/drizzle/src/lib/r2.ts +45 -0
- package/template/extras/landing-page/_shared/src/app/book/page.tsx +43 -0
- package/template/extras/landing-page/_shared/src/app/book/thanks/page.tsx +31 -0
- package/template/extras/landing-page/_shared/src/app/page.tsx +81 -0
- package/template/extras/landing-page/drizzle/src/app/book/page.tsx +105 -0
- package/template/extras/landing-page/drizzle/src/lib/booking/actions.ts +97 -0
- package/template/extras/maintenance-plans/src/app/(dashboard)/maintenance-plans/page.tsx +72 -0
- package/template/extras/mobile/mobile/README.md +67 -0
- package/template/extras/mobile/mobile/_dot_env.example +5 -0
- package/template/extras/mobile/mobile/_dot_gitignore +26 -0
- package/template/extras/mobile/mobile/app/(app)/_layout.tsx +37 -0
- package/template/extras/mobile/mobile/app/(app)/estimate.tsx +135 -0
- package/template/extras/mobile/mobile/app/(app)/inbox/[customerId].tsx +103 -0
- package/template/extras/mobile/mobile/app/(app)/inbox/index.tsx +70 -0
- package/template/extras/mobile/mobile/app/(app)/index.tsx +111 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id]/checklist.tsx +99 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id]/invoice.tsx +143 -0
- package/template/extras/mobile/mobile/app/(app)/job/[id].tsx +259 -0
- package/template/extras/mobile/mobile/app/_layout.tsx +14 -0
- package/template/extras/mobile/mobile/app/index.tsx +23 -0
- package/template/extras/mobile/mobile/app/sign-in.tsx +101 -0
- package/template/extras/mobile/mobile/app.brand.ts +14 -0
- package/template/extras/mobile/mobile/app.config.ts +40 -0
- package/template/extras/mobile/mobile/app.features.ts +11 -0
- package/template/extras/mobile/mobile/components/SignaturePad.tsx +60 -0
- package/template/extras/mobile/mobile/eas.json +22 -0
- package/template/extras/mobile/mobile/lib/api.ts +253 -0
- package/template/extras/mobile/mobile/lib/auth.ts +51 -0
- package/template/extras/mobile/mobile/lib/format.ts +23 -0
- package/template/extras/mobile/mobile/lib/push.ts +24 -0
- package/template/extras/mobile/mobile/lib/theme.ts +16 -0
- package/template/extras/mobile/mobile/package.json +34 -0
- package/template/extras/mobile/mobile/tsconfig.json +11 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/customers/[id]/route.ts +18 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/devices/route.ts +40 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/attachments/route.ts +59 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/item/route.ts +34 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/checklists/route.ts +15 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/route.ts +35 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/status/route.ts +28 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-in/route.ts +35 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/clock-out/route.ts +27 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/[id]/time/route.ts +36 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/jobs/route.ts +30 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/me/route.ts +26 -0
- package/template/extras/mobile-api/drizzle/src/app/api/mobile/v1/uploads/sign/route.ts +46 -0
- package/template/extras/mobile-api/drizzle/src/db/schema/push-tokens.ts +21 -0
- package/template/extras/mobile-api/drizzle/src/db/schema/time-entries.ts +23 -0
- package/template/extras/mobile-api/drizzle/src/lib/mobile/cors.ts +30 -0
- package/template/extras/mobile-api/drizzle/src/lib/mobile/guard.ts +49 -0
- package/template/extras/mobile-api/drizzle/src/lib/push/send.ts +56 -0
- package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/estimates/route.ts +52 -0
- package/template/extras/mobile-api/estimates/src/app/api/mobile/v1/price-book/route.ts +14 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/payments/route.ts +32 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/invoices/[id]/route.ts +28 -0
- package/template/extras/mobile-api/invoices/src/app/api/mobile/v1/jobs/[id]/invoice/route.ts +82 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/send/route.ts +32 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/[customerId]/route.ts +15 -0
- package/template/extras/mobile-api/sms/src/app/api/mobile/v1/sms/threads/route.ts +14 -0
- package/template/extras/payments-stripe/src/app/(dashboard)/payments/page.tsx +56 -0
- package/template/extras/payments-stripe/src/app/api/stripe/webhook/route.ts +56 -0
- package/template/extras/payments-stripe/src/components/payments/demo-checkout-button.tsx +18 -0
- package/template/extras/payments-stripe/src/lib/payments/actions.ts +63 -0
- package/template/extras/payments-stripe/src/lib/stripe.ts +25 -0
- package/template/extras/permit-tracking/src/app/(dashboard)/permits/page.tsx +56 -0
- package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/new/page.tsx +20 -0
- package/template/extras/price-book/_shared/src/app/(dashboard)/price-book/page.tsx +79 -0
- package/template/extras/price-book/_shared/src/components/price-book/item-picker.tsx +145 -0
- package/template/extras/price-book/_shared/src/lib/price-book/actions.ts +14 -0
- package/template/extras/price-book/_shared/src/lib/price-book/data.ts +28 -0
- package/template/extras/price-book/_shared/src/lib/price-book/sample-data.ts +62 -0
- package/template/extras/price-book/_shared/src/lib/price-book/types.ts +35 -0
- package/template/extras/price-book/drizzle/src/app/(dashboard)/price-book/new/page.tsx +18 -0
- package/template/extras/price-book/drizzle/src/components/price-book/new-item-form.tsx +254 -0
- package/template/extras/price-book/drizzle/src/db/schema/price-book.ts +33 -0
- package/template/extras/price-book/drizzle/src/lib/price-book/actions.ts +72 -0
- package/template/extras/price-book/drizzle/src/lib/price-book/data.ts +81 -0
- package/template/extras/reporting/src/app/(dashboard)/reports/page.tsx +66 -0
- package/template/extras/reviews/src/app/(dashboard)/reviews/page.tsx +58 -0
- package/template/extras/seed/_shared/scripts/seed.ts +35 -0
- package/template/extras/seed/drizzle/scripts/seed.ts +314 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/[id]/page.tsx +114 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/new/page.tsx +18 -0
- package/template/extras/service-plans/_shared/src/app/(dashboard)/service-plans/page.tsx +92 -0
- package/template/extras/service-plans/_shared/src/components/service-plans/subscribe-customer-section.tsx +17 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/data.ts +14 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/sample-data.ts +83 -0
- package/template/extras/service-plans/_shared/src/lib/service-plans/types.ts +57 -0
- package/template/extras/service-plans/drizzle/src/app/(dashboard)/service-plans/new/page.tsx +10 -0
- package/template/extras/service-plans/drizzle/src/app/api/stripe/webhook/route.ts +143 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/new-plan-form.tsx +126 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-form.tsx +88 -0
- package/template/extras/service-plans/drizzle/src/components/service-plans/subscribe-customer-section.tsx +12 -0
- package/template/extras/service-plans/drizzle/src/db/schema/service-plans.ts +46 -0
- package/template/extras/service-plans/drizzle/src/lib/service-plans/actions.ts +196 -0
- package/template/extras/service-plans/drizzle/src/lib/service-plans/data.ts +124 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { Button } from '@/components/ui/button';
|
|
7
|
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
|
8
|
+
import { Input } from '@/components/ui/input';
|
|
9
|
+
import { Label } from '@/components/ui/label';
|
|
10
|
+
import { signIn } from '@/lib/auth-client';
|
|
11
|
+
|
|
12
|
+
export default function SignInPage() {
|
|
13
|
+
const router = useRouter();
|
|
14
|
+
const [email, setEmail] = useState('');
|
|
15
|
+
const [password, setPassword] = useState('');
|
|
16
|
+
const [error, setError] = useState<string | null>(null);
|
|
17
|
+
const [loading, setLoading] = useState(false);
|
|
18
|
+
|
|
19
|
+
async function onSubmit(e: React.FormEvent) {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
setError(null);
|
|
22
|
+
setLoading(true);
|
|
23
|
+
const result = await signIn.email({ email, password });
|
|
24
|
+
setLoading(false);
|
|
25
|
+
if (result.error) {
|
|
26
|
+
setError(result.error.message ?? 'Sign-in failed');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
router.push('/dashboard');
|
|
30
|
+
router.refresh();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Card>
|
|
35
|
+
<CardHeader>
|
|
36
|
+
<CardTitle>Sign in</CardTitle>
|
|
37
|
+
<CardDescription>Welcome back. Enter your credentials.</CardDescription>
|
|
38
|
+
</CardHeader>
|
|
39
|
+
<form onSubmit={onSubmit}>
|
|
40
|
+
<CardContent className="space-y-4">
|
|
41
|
+
<div className="space-y-2">
|
|
42
|
+
<Label htmlFor="email">Email</Label>
|
|
43
|
+
<Input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" />
|
|
44
|
+
</div>
|
|
45
|
+
<div className="space-y-2">
|
|
46
|
+
<Label htmlFor="password">Password</Label>
|
|
47
|
+
<Input id="password" type="password" required value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
|
|
48
|
+
</div>
|
|
49
|
+
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
50
|
+
</CardContent>
|
|
51
|
+
<CardFooter className="flex flex-col gap-3">
|
|
52
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
53
|
+
{loading ? 'Signing in...' : 'Sign in'}
|
|
54
|
+
</Button>
|
|
55
|
+
<p className="text-muted-foreground text-center text-sm">
|
|
56
|
+
No account?{' '}
|
|
57
|
+
<Link href="/sign-up" className="text-brand underline">
|
|
58
|
+
Sign up
|
|
59
|
+
</Link>
|
|
60
|
+
</p>
|
|
61
|
+
</CardFooter>
|
|
62
|
+
</form>
|
|
63
|
+
</Card>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { Button } from '@/components/ui/button';
|
|
7
|
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
|
8
|
+
import { Input } from '@/components/ui/input';
|
|
9
|
+
import { Label } from '@/components/ui/label';
|
|
10
|
+
import { signUp } from '@/lib/auth-client';
|
|
11
|
+
|
|
12
|
+
export default function SignUpPage() {
|
|
13
|
+
const router = useRouter();
|
|
14
|
+
const [name, setName] = useState('');
|
|
15
|
+
const [email, setEmail] = useState('');
|
|
16
|
+
const [password, setPassword] = useState('');
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const [loading, setLoading] = useState(false);
|
|
19
|
+
|
|
20
|
+
async function onSubmit(e: React.FormEvent) {
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
setError(null);
|
|
23
|
+
setLoading(true);
|
|
24
|
+
const result = await signUp.email({ email, password, name });
|
|
25
|
+
setLoading(false);
|
|
26
|
+
if (result.error) {
|
|
27
|
+
setError(result.error.message ?? 'Sign-up failed');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
router.push('/dashboard');
|
|
31
|
+
router.refresh();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Card>
|
|
36
|
+
<CardHeader>
|
|
37
|
+
<CardTitle>Create your account</CardTitle>
|
|
38
|
+
<CardDescription>Get started with your CRM.</CardDescription>
|
|
39
|
+
</CardHeader>
|
|
40
|
+
<form onSubmit={onSubmit}>
|
|
41
|
+
<CardContent className="space-y-4">
|
|
42
|
+
<div className="space-y-2">
|
|
43
|
+
<Label htmlFor="name">Name</Label>
|
|
44
|
+
<Input id="name" required value={name} onChange={(e) => setName(e.target.value)} autoComplete="name" />
|
|
45
|
+
</div>
|
|
46
|
+
<div className="space-y-2">
|
|
47
|
+
<Label htmlFor="email">Email</Label>
|
|
48
|
+
<Input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" />
|
|
49
|
+
</div>
|
|
50
|
+
<div className="space-y-2">
|
|
51
|
+
<Label htmlFor="password">Password (min 8 chars)</Label>
|
|
52
|
+
<Input id="password" type="password" required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
|
|
53
|
+
</div>
|
|
54
|
+
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
55
|
+
</CardContent>
|
|
56
|
+
<CardFooter className="flex flex-col gap-3">
|
|
57
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
58
|
+
{loading ? 'Creating account...' : 'Create account'}
|
|
59
|
+
</Button>
|
|
60
|
+
<p className="text-muted-foreground text-center text-sm">
|
|
61
|
+
Have an account?{' '}
|
|
62
|
+
<Link href="/sign-in" className="text-brand underline">
|
|
63
|
+
Sign in
|
|
64
|
+
</Link>
|
|
65
|
+
</p>
|
|
66
|
+
</CardFooter>
|
|
67
|
+
</form>
|
|
68
|
+
</Card>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { asc } from 'drizzle-orm';
|
|
2
|
+
import { db } from '@/db/client';
|
|
3
|
+
import { user as userTable } from '@/db/schema';
|
|
4
|
+
import { requireRole } from '@/lib/auth-server';
|
|
5
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
6
|
+
import { Badge } from '@/components/ui/badge';
|
|
7
|
+
import { formatDate } from '@/lib/utils';
|
|
8
|
+
|
|
9
|
+
export default async function AdminPage() {
|
|
10
|
+
// Gate: only admins reach this page (requireRole redirects others).
|
|
11
|
+
await requireRole('admin');
|
|
12
|
+
|
|
13
|
+
const members = await db
|
|
14
|
+
.select({
|
|
15
|
+
id: userTable.id,
|
|
16
|
+
name: userTable.name,
|
|
17
|
+
email: userTable.email,
|
|
18
|
+
role: userTable.role,
|
|
19
|
+
createdAt: userTable.createdAt,
|
|
20
|
+
})
|
|
21
|
+
.from(userTable)
|
|
22
|
+
.orderBy(asc(userTable.createdAt));
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="space-y-6">
|
|
26
|
+
<div>
|
|
27
|
+
<h1 className="text-3xl font-bold tracking-tight">Admin</h1>
|
|
28
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
29
|
+
Team members and their roles. {members.length} user{members.length === 1 ? '' : 's'}.
|
|
30
|
+
</p>
|
|
31
|
+
</div>
|
|
32
|
+
<Card>
|
|
33
|
+
<CardHeader>
|
|
34
|
+
<CardTitle>Team members</CardTitle>
|
|
35
|
+
<CardDescription>
|
|
36
|
+
Roles control access. The first user to sign up (or the owner email set at setup)
|
|
37
|
+
is the admin. To change a role today, update the user's <code>role</code> column in
|
|
38
|
+
your database; an in-app role editor is a good next addition.
|
|
39
|
+
</CardDescription>
|
|
40
|
+
</CardHeader>
|
|
41
|
+
<CardContent className="p-0">
|
|
42
|
+
<table className="w-full text-sm">
|
|
43
|
+
<thead className="text-muted-foreground bg-muted/50 text-xs uppercase">
|
|
44
|
+
<tr>
|
|
45
|
+
<th className="px-4 py-3 text-left font-medium">Name</th>
|
|
46
|
+
<th className="px-4 py-3 text-left font-medium">Email</th>
|
|
47
|
+
<th className="px-4 py-3 text-left font-medium">Role</th>
|
|
48
|
+
<th className="px-4 py-3 text-left font-medium">Joined</th>
|
|
49
|
+
</tr>
|
|
50
|
+
</thead>
|
|
51
|
+
<tbody className="divide-border divide-y">
|
|
52
|
+
{members.map((m) => (
|
|
53
|
+
<tr key={m.id}>
|
|
54
|
+
<td className="px-4 py-3 font-medium">{m.name}</td>
|
|
55
|
+
<td className="text-muted-foreground px-4 py-3">{m.email}</td>
|
|
56
|
+
<td className="px-4 py-3">
|
|
57
|
+
<Badge variant={m.role === 'admin' ? 'default' : 'secondary'}>{m.role}</Badge>
|
|
58
|
+
</td>
|
|
59
|
+
<td className="text-muted-foreground px-4 py-3">{formatDate(m.createdAt)}</td>
|
|
60
|
+
</tr>
|
|
61
|
+
))}
|
|
62
|
+
</tbody>
|
|
63
|
+
</table>
|
|
64
|
+
</CardContent>
|
|
65
|
+
</Card>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
import { requireUser } from '@/lib/auth-server';
|
|
3
|
+
|
|
4
|
+
export default async function DashboardPage() {
|
|
5
|
+
const user = await requireUser();
|
|
6
|
+
return (
|
|
7
|
+
<div className="space-y-6">
|
|
8
|
+
<div>
|
|
9
|
+
<h1 className="text-3xl font-bold tracking-tight">
|
|
10
|
+
Welcome back{user.name ? `, ${user.name}` : ''}.
|
|
11
|
+
</h1>
|
|
12
|
+
<p className="text-muted-foreground mt-1">
|
|
13
|
+
Your CRM dashboard. Add modules with{' '}
|
|
14
|
+
<code className="bg-muted rounded px-1.5 py-0.5">npx create-crm-starter add <module></code>.
|
|
15
|
+
</p>
|
|
16
|
+
</div>
|
|
17
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
18
|
+
<Card>
|
|
19
|
+
<CardHeader>
|
|
20
|
+
<CardDescription>Open jobs</CardDescription>
|
|
21
|
+
<CardTitle className="text-4xl">0</CardTitle>
|
|
22
|
+
</CardHeader>
|
|
23
|
+
<CardContent className="text-muted-foreground text-xs">No data yet.</CardContent>
|
|
24
|
+
</Card>
|
|
25
|
+
<Card>
|
|
26
|
+
<CardHeader>
|
|
27
|
+
<CardDescription>Revenue this month</CardDescription>
|
|
28
|
+
<CardTitle className="text-4xl">$0</CardTitle>
|
|
29
|
+
</CardHeader>
|
|
30
|
+
<CardContent className="text-muted-foreground text-xs">No data yet.</CardContent>
|
|
31
|
+
</Card>
|
|
32
|
+
<Card>
|
|
33
|
+
<CardHeader>
|
|
34
|
+
<CardDescription>AR outstanding</CardDescription>
|
|
35
|
+
<CardTitle className="text-4xl">$0</CardTitle>
|
|
36
|
+
</CardHeader>
|
|
37
|
+
<CardContent className="text-muted-foreground text-xs">No data yet.</CardContent>
|
|
38
|
+
</Card>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { requireUser } from '@/lib/auth-server';
|
|
3
|
+
import { SignOutButton } from '@/components/sign-out-button';
|
|
4
|
+
|
|
5
|
+
export default async function DashboardLayout({
|
|
6
|
+
children,
|
|
7
|
+
}: {
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
}) {
|
|
10
|
+
const user = await requireUser();
|
|
11
|
+
return (
|
|
12
|
+
<div className="grid min-h-screen grid-cols-[14rem_1fr]">
|
|
13
|
+
<aside className="border-border bg-muted/30 border-r p-4">
|
|
14
|
+
<Link href="/dashboard" className="mb-6 block text-lg font-semibold">
|
|
15
|
+
CRM
|
|
16
|
+
</Link>
|
|
17
|
+
<nav className="flex flex-col gap-1 text-sm">
|
|
18
|
+
<Link href="/dashboard" className="hover:bg-accent rounded-md px-3 py-2">Dashboard</Link>
|
|
19
|
+
<Link href="/customers" className="hover:bg-accent rounded-md px-3 py-2">Customers</Link>
|
|
20
|
+
<Link href="/jobs" className="hover:bg-accent rounded-md px-3 py-2">Jobs</Link>
|
|
21
|
+
<Link href="/calendar" className="hover:bg-accent rounded-md px-3 py-2">Calendar</Link>
|
|
22
|
+
<Link href="/estimates" className="hover:bg-accent rounded-md px-3 py-2">Estimates</Link>
|
|
23
|
+
<Link href="/invoices" className="hover:bg-accent rounded-md px-3 py-2">Invoices</Link>
|
|
24
|
+
<Link href="/price-book" className="hover:bg-accent rounded-md px-3 py-2">Price Book</Link>
|
|
25
|
+
{user.role === 'admin' && (
|
|
26
|
+
<Link href="/admin" className="hover:bg-accent rounded-md px-3 py-2">Admin</Link>
|
|
27
|
+
)}
|
|
28
|
+
</nav>
|
|
29
|
+
</aside>
|
|
30
|
+
<div className="flex flex-col">
|
|
31
|
+
<header className="border-border flex h-14 items-center justify-between border-b px-6">
|
|
32
|
+
<div className="text-muted-foreground text-sm">
|
|
33
|
+
Signed in as <span className="font-medium text-foreground">{user.email}</span> ({user.role})
|
|
34
|
+
</div>
|
|
35
|
+
<SignOutButton />
|
|
36
|
+
</header>
|
|
37
|
+
<main className="flex-1 p-6">{children}</main>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRouter } from 'next/navigation';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { signOut } from '@/lib/auth-client';
|
|
6
|
+
|
|
7
|
+
export function SignOutButton() {
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
async function handle() {
|
|
10
|
+
await signOut();
|
|
11
|
+
router.push('/');
|
|
12
|
+
router.refresh();
|
|
13
|
+
}
|
|
14
|
+
return (
|
|
15
|
+
<Button variant="outline" size="sm" onClick={handle}>
|
|
16
|
+
Sign out
|
|
17
|
+
</Button>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { headers } from 'next/headers';
|
|
2
|
+
import { redirect } from 'next/navigation';
|
|
3
|
+
import { auth } from './auth';
|
|
4
|
+
|
|
5
|
+
export type AppRole = 'admin' | 'dispatcher' | 'technician' | 'csr' | 'sales' | 'accountant';
|
|
6
|
+
|
|
7
|
+
export interface AppUser {
|
|
8
|
+
id: string;
|
|
9
|
+
email: string;
|
|
10
|
+
name: string | null;
|
|
11
|
+
role: AppRole;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeRole(role: string | null | undefined): AppRole {
|
|
15
|
+
switch (role) {
|
|
16
|
+
case 'admin':
|
|
17
|
+
case 'dispatcher':
|
|
18
|
+
case 'technician':
|
|
19
|
+
case 'csr':
|
|
20
|
+
case 'sales':
|
|
21
|
+
case 'accountant':
|
|
22
|
+
return role;
|
|
23
|
+
default:
|
|
24
|
+
return 'csr';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getCurrentUser(): Promise<AppUser | null> {
|
|
29
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
30
|
+
if (!session?.user) return null;
|
|
31
|
+
const u = session.user as { id: string; email: string; name: string | null; role?: string | null };
|
|
32
|
+
return {
|
|
33
|
+
id: u.id,
|
|
34
|
+
email: u.email,
|
|
35
|
+
name: u.name,
|
|
36
|
+
role: normalizeRole(u.role),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function requireUser(): Promise<AppUser> {
|
|
41
|
+
const u = await getCurrentUser();
|
|
42
|
+
if (!u) redirect('/sign-in');
|
|
43
|
+
return u;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function requireRole(role: AppRole | AppRole[]): Promise<AppUser> {
|
|
47
|
+
const u = await requireUser();
|
|
48
|
+
const allowed = Array.isArray(role) ? role : [role];
|
|
49
|
+
if (u.role === 'admin' || allowed.includes(u.role)) return u;
|
|
50
|
+
redirect('/dashboard');
|
|
51
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { betterAuth } from 'better-auth';
|
|
2
|
+
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
3
|
+
import { nextCookies } from 'better-auth/next-js';
|
|
4
|
+
import { sql } from 'drizzle-orm';
|
|
5
|
+
import { db } from '@/db/client';
|
|
6
|
+
import * as schema from '@/db/schema';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Decides the role a newly-created user should get. The business owner must
|
|
10
|
+
* land as 'admin' on first sign-up, otherwise they can't reach admin UI.
|
|
11
|
+
* Two triggers, in order:
|
|
12
|
+
* 1. Their email matches ADMIN_BOOTSTRAP_EMAIL (set at scaffold time from
|
|
13
|
+
* the owner email you entered).
|
|
14
|
+
* 2. They're the very first user in the database (robust fallback).
|
|
15
|
+
* Everyone else defaults to 'csr'.
|
|
16
|
+
*/
|
|
17
|
+
async function resolveSignupRole(email: string): Promise<'admin' | 'csr'> {
|
|
18
|
+
const bootstrapEmail = process.env.ADMIN_BOOTSTRAP_EMAIL?.trim().toLowerCase();
|
|
19
|
+
if (bootstrapEmail && email.trim().toLowerCase() === bootstrapEmail) {
|
|
20
|
+
return 'admin';
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const [{ count }] = await db
|
|
24
|
+
.select({ count: sql<number>`count(*)::int` })
|
|
25
|
+
.from(schema.user);
|
|
26
|
+
if (count === 0) return 'admin';
|
|
27
|
+
} catch {
|
|
28
|
+
/* table may not exist yet during setup — fall through to csr */
|
|
29
|
+
}
|
|
30
|
+
return 'csr';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const auth = betterAuth({
|
|
34
|
+
database: drizzleAdapter(db, {
|
|
35
|
+
provider: 'pg',
|
|
36
|
+
schema: {
|
|
37
|
+
user: schema.user,
|
|
38
|
+
session: schema.session,
|
|
39
|
+
account: schema.account,
|
|
40
|
+
verification: schema.verification,
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
emailAndPassword: {
|
|
44
|
+
enabled: true,
|
|
45
|
+
autoSignIn: true,
|
|
46
|
+
},
|
|
47
|
+
user: {
|
|
48
|
+
additionalFields: {
|
|
49
|
+
role: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
defaultValue: 'csr',
|
|
52
|
+
required: false,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
databaseHooks: {
|
|
57
|
+
user: {
|
|
58
|
+
create: {
|
|
59
|
+
before: async (user) => {
|
|
60
|
+
const role = await resolveSignupRole(user.email);
|
|
61
|
+
return { data: { ...user, role } };
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
session: {
|
|
67
|
+
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
|
68
|
+
updateAge: 60 * 60 * 24, // 1 day
|
|
69
|
+
},
|
|
70
|
+
// The mobile-api installer injects `trustedOrigins` + bearer()/expo()
|
|
71
|
+
// plugins here when you scaffold the field-tech iPhone app. nextCookies()
|
|
72
|
+
// must remain LAST in the plugins array.
|
|
73
|
+
plugins: [nextCookies()], // <crm-starter:auth-plugins>
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export type Session = typeof auth.$Infer.Session;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { getSessionCookie } from 'better-auth/cookies';
|
|
3
|
+
|
|
4
|
+
const PROTECTED = [/^\/dashboard(\/.*)?$/, /^\/admin(\/.*)?$/, /^\/customers(\/.*)?$/, /^\/jobs(\/.*)?$/, /^\/calendar(\/.*)?$/, /^\/estimates(\/.*)?$/, /^\/invoices(\/.*)?$/];
|
|
5
|
+
|
|
6
|
+
export async function middleware(req: NextRequest) {
|
|
7
|
+
const { pathname } = req.nextUrl;
|
|
8
|
+
const needsAuth = PROTECTED.some((re) => re.test(pathname));
|
|
9
|
+
if (!needsAuth) return NextResponse.next();
|
|
10
|
+
|
|
11
|
+
const session = getSessionCookie(req);
|
|
12
|
+
if (!session) {
|
|
13
|
+
const url = req.nextUrl.clone();
|
|
14
|
+
url.pathname = '/sign-in';
|
|
15
|
+
url.searchParams.set('redirect', pathname);
|
|
16
|
+
return NextResponse.redirect(url);
|
|
17
|
+
}
|
|
18
|
+
return NextResponse.next();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const config = {
|
|
22
|
+
matcher: [
|
|
23
|
+
'/((?!_next|api/auth|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|webmanifest)).*)',
|
|
24
|
+
],
|
|
25
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { requireRole } from '@/lib/auth';
|
|
2
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
3
|
+
|
|
4
|
+
export default async function AdminPage() {
|
|
5
|
+
// Gate: only admins reach this page (requireRole redirects others).
|
|
6
|
+
const user = await requireRole('admin');
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<div className="space-y-6">
|
|
10
|
+
<div>
|
|
11
|
+
<h1 className="text-3xl font-bold tracking-tight">Admin</h1>
|
|
12
|
+
<p className="text-muted-foreground mt-1 text-sm">Signed in as {user.email}</p>
|
|
13
|
+
</div>
|
|
14
|
+
<Card>
|
|
15
|
+
<CardHeader>
|
|
16
|
+
<CardTitle>Team & roles</CardTitle>
|
|
17
|
+
<CardDescription>
|
|
18
|
+
On the Clerk stack, user roles live in each user's{' '}
|
|
19
|
+
<code>publicMetadata.role</code>. Manage team members in the Clerk
|
|
20
|
+
dashboard, and set <code>{'{ "role": "admin" }'}</code> (or dispatcher,
|
|
21
|
+
technician, csr, sales, accountant) in their public metadata.
|
|
22
|
+
</CardDescription>
|
|
23
|
+
</CardHeader>
|
|
24
|
+
<CardContent className="text-muted-foreground space-y-2 text-sm">
|
|
25
|
+
<p>
|
|
26
|
+
To make yourself admin: open the Clerk dashboard → Users → your user →
|
|
27
|
+
Metadata → Public → add <code>{'{ "role": "admin" }'}</code>.
|
|
28
|
+
</p>
|
|
29
|
+
<p>
|
|
30
|
+
A full in-app team manager (listing users + editing roles via the Clerk
|
|
31
|
+
API) is a good next addition.
|
|
32
|
+
</p>
|
|
33
|
+
</CardContent>
|
|
34
|
+
</Card>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
2
|
+
import { requireUser } from '@/lib/auth';
|
|
3
|
+
|
|
4
|
+
export default async function DashboardPage() {
|
|
5
|
+
const user = await requireUser();
|
|
6
|
+
return (
|
|
7
|
+
<div className="space-y-6">
|
|
8
|
+
<div>
|
|
9
|
+
<h1 className="text-3xl font-bold tracking-tight">
|
|
10
|
+
Welcome back{user.firstName ? `, ${user.firstName}` : ''}.
|
|
11
|
+
</h1>
|
|
12
|
+
<p className="text-muted-foreground mt-1">
|
|
13
|
+
Your CRM dashboard. Add modules with{' '}
|
|
14
|
+
<code className="bg-muted rounded px-1.5 py-0.5">npx create-crm-starter add <module></code>.
|
|
15
|
+
</p>
|
|
16
|
+
</div>
|
|
17
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
18
|
+
<Card>
|
|
19
|
+
<CardHeader>
|
|
20
|
+
<CardDescription>Open jobs</CardDescription>
|
|
21
|
+
<CardTitle className="text-4xl">0</CardTitle>
|
|
22
|
+
</CardHeader>
|
|
23
|
+
<CardContent className="text-muted-foreground text-xs">No data yet.</CardContent>
|
|
24
|
+
</Card>
|
|
25
|
+
<Card>
|
|
26
|
+
<CardHeader>
|
|
27
|
+
<CardDescription>Revenue this month</CardDescription>
|
|
28
|
+
<CardTitle className="text-4xl">$0</CardTitle>
|
|
29
|
+
</CardHeader>
|
|
30
|
+
<CardContent className="text-muted-foreground text-xs">No data yet.</CardContent>
|
|
31
|
+
</Card>
|
|
32
|
+
<Card>
|
|
33
|
+
<CardHeader>
|
|
34
|
+
<CardDescription>AR outstanding</CardDescription>
|
|
35
|
+
<CardTitle className="text-4xl">$0</CardTitle>
|
|
36
|
+
</CardHeader>
|
|
37
|
+
<CardContent className="text-muted-foreground text-xs">No data yet.</CardContent>
|
|
38
|
+
</Card>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { UserButton } from '@clerk/nextjs';
|
|
3
|
+
import { requireUser } from '@/lib/auth';
|
|
4
|
+
|
|
5
|
+
export default async function DashboardLayout({
|
|
6
|
+
children,
|
|
7
|
+
}: {
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
}) {
|
|
10
|
+
const user = await requireUser();
|
|
11
|
+
return (
|
|
12
|
+
<div className="grid min-h-screen grid-cols-[14rem_1fr]">
|
|
13
|
+
<aside className="border-border bg-muted/30 border-r p-4">
|
|
14
|
+
<Link href="/dashboard" className="mb-6 block text-lg font-semibold">
|
|
15
|
+
CRM
|
|
16
|
+
</Link>
|
|
17
|
+
<nav className="flex flex-col gap-1 text-sm">
|
|
18
|
+
<Link href="/dashboard" className="hover:bg-accent rounded-md px-3 py-2">
|
|
19
|
+
Dashboard
|
|
20
|
+
</Link>
|
|
21
|
+
<Link href="/customers" className="hover:bg-accent rounded-md px-3 py-2">
|
|
22
|
+
Customers
|
|
23
|
+
</Link>
|
|
24
|
+
<Link href="/jobs" className="hover:bg-accent rounded-md px-3 py-2">
|
|
25
|
+
Jobs
|
|
26
|
+
</Link>
|
|
27
|
+
<Link href="/calendar" className="hover:bg-accent rounded-md px-3 py-2">
|
|
28
|
+
Calendar
|
|
29
|
+
</Link>
|
|
30
|
+
<Link href="/estimates" className="hover:bg-accent rounded-md px-3 py-2">
|
|
31
|
+
Estimates
|
|
32
|
+
</Link>
|
|
33
|
+
<Link href="/invoices" className="hover:bg-accent rounded-md px-3 py-2">
|
|
34
|
+
Invoices
|
|
35
|
+
</Link>
|
|
36
|
+
<Link href="/price-book" className="hover:bg-accent rounded-md px-3 py-2">
|
|
37
|
+
Price Book
|
|
38
|
+
</Link>
|
|
39
|
+
{user.role === 'admin' && (
|
|
40
|
+
<Link href="/admin" className="hover:bg-accent rounded-md px-3 py-2">
|
|
41
|
+
Admin
|
|
42
|
+
</Link>
|
|
43
|
+
)}
|
|
44
|
+
</nav>
|
|
45
|
+
</aside>
|
|
46
|
+
<div className="flex flex-col">
|
|
47
|
+
<header className="border-border flex h-14 items-center justify-between border-b px-6">
|
|
48
|
+
<div className="text-muted-foreground text-sm">
|
|
49
|
+
Signed in as <span className="font-medium text-foreground">{user.email}</span> ({user.role})
|
|
50
|
+
</div>
|
|
51
|
+
<UserButton afterSignOutUrl="/" />
|
|
52
|
+
</header>
|
|
53
|
+
<main className="flex-1 p-6">{children}</main>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|