From 8d4e362f42da53f91515f683daa1299a3cef8e34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 15:10:29 +0000 Subject: [PATCH] chore(ci): bump actions/checkout from 4 to 6 --- .env.example | 286 +- .github/workflows/ci.yml | 130 +- .github/workflows/lighthouse.yml | 130 +- .gitignore | 100 +- README.md | 1302 +- .../client-portal/support/new/page.tsx | 74 +- app/(protected)/clients/index.tsx | 142 +- app/(protected)/layout.tsx | 216 +- app/(protected)/projects/index.tsx | 196 +- app/(protected)/settings/layout.tsx | 170 +- app/(public)/api-docs/index.tsx | 212 +- app/(public)/blogs/index.tsx | 168 +- app/(public)/blogs/page.tsx | 22 +- app/(public)/careers/index.tsx | 220 +- app/(public)/careers/page.tsx | 22 +- app/(public)/changelog/index.tsx | 152 +- app/(public)/docs/index.tsx | 134 +- app/(public)/index.tsx | 50 +- app/(public)/integrations/index.tsx | 224 +- app/(public)/partners/index.tsx | 192 +- app/(public)/partners/page.tsx | 22 +- app/(public)/press/index.tsx | 190 +- app/(public)/press/page.tsx | 22 +- app/(public)/security/index.tsx | 286 +- app/(public)/status/index.tsx | 298 +- app/(public)/status/page.tsx | 156 +- app/admin/users/[id]/index.tsx | 284 +- app/api/billing/webhook/route.ts | 136 +- app/auth/sign-in-otp/index.tsx | 302 +- app/auth/sign-in/index.tsx | 524 +- app/auth/sign-up/index.tsx | 456 +- app/auth/verify-email/index.tsx | 184 +- app/invite/[token]/page.tsx | 364 +- app/sitemap.ts | 88 +- .../admin/billing/RefundInvoiceDialog.tsx | 506 +- .../admin/organizations/OrgDetailTabs.tsx | 430 +- .../organizations/OrganizationsTable.tsx | 734 +- components/admin/users/UsersTable.tsx | 686 +- components/admin/users/index.ts | 6 +- components/data-table/DataTable.tsx | 1138 +- components/data-table/index.ts | 14 +- .../ControlledFooterLinkSections/index.tsx | 720 +- .../media/ControlledLogoUpload/index.tsx | 454 +- components/homepage/StatsSection.tsx | 72 +- components/homepage/TestimonialsSection.tsx | 142 +- .../homepage/hero-preview/HeroNavbar.tsx | 136 +- components/homepage/index.ts | 12 +- components/layout/app/AppShell.tsx | 258 +- components/layout/app/AppSidebar.tsx | 650 +- components/layout/public/PublicHeader.tsx | 548 +- components/notifications/NotificationBell.tsx | 278 +- components/tasks/DeleteColumnDialog.tsx | 126 +- components/tasks/DeleteTaskDialog.tsx | 114 +- components/tasks/MoveToProjectDialog.tsx | 312 +- config/api-docs.ts | 126 +- config/blogs.ts | 112 +- config/careers.ts | 98 +- config/changelog.ts | 142 +- config/docs.ts | 108 +- config/features.ts | 138 +- config/footer.ts | 84 +- config/help.ts | 94 +- config/integrations.ts | 134 +- config/partners.ts | 74 +- config/press.ts | 50 +- config/security.ts | 106 +- config/stats.ts | 12 +- config/status.ts | 50 +- config/testimonials.ts | 46 +- constants/admin/navigation.ts | 86 +- db/auth-schema.ts | 222 +- db/schema.ts | 10 +- db/schemas/billing.ts | 436 +- db/schemas/platform.ts | 612 +- drizzle/meta/_journal.json | 362 +- eslint.config.mjs | 36 +- lib/analytics/events.ts | 42 +- middleware.ts | 334 +- package-lock.json | 55818 ++++++++-------- package.json | 272 +- postcss.config.mjs | 19 +- scripts/bundle-budget.mjs | 228 +- server/api/helpers.ts | 180 +- server/api/v1-mutations.ts | 398 +- server/auth/auth.ts | 442 +- server/email/send.ts | 2214 +- server/email/types.ts | 28 +- server/notifications/data.ts | 1000 +- server/security/ip-allowlist.ts | 130 +- server/subscription/plan-enforcement.ts | 374 +- server/task-comments.ts | 574 +- server/tasks.ts | 1476 +- tsconfig.json | 68 +- vercel.json | 50 +- 94 files changed, 40790 insertions(+), 40785 deletions(-) diff --git a/.env.example b/.env.example index 0caa484..3393212 100644 --- a/.env.example +++ b/.env.example @@ -1,143 +1,143 @@ -# ═══════════════════════════════════════════════════════════════════════════ -# ClientFlow - Environment Variables -# ═══════════════════════════════════════════════════════════════════════════ -# Copy this file to `.env` and fill in real values before running the app. -# See docs/DEPLOYMENT.md for a setup runbook. -# ─────────────────────────────────────────────────────────────────────────── - -# ─── Core app ────────────────────────────────────────────────────────────── -NEXT_PUBLIC_APP_URL=http://localhost:3000 -BETTER_AUTH_URL=http://localhost:3000 -BETTER_AUTH_SECRET=replace-with-a-secure-random-secret -BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION=false - -# ─── Database (Neon Postgres) ────────────────────────────────────────────── -NEON_DATABASE_URL=postgresql://:@/?sslmode=require - -# ─── Rate limiting (Upstash Redis) - REQUIRED ────────────────────────────── -# The proxy middleware (proxy.ts) enforces rate limits on /api/auth/* and /api/* -# via these credentials. The app will fail at request time if unset. -UPSTASH_REDIS_REST_URL= -UPSTASH_REDIS_REST_TOKEN= - -# ─── File storage (Cloudinary) ───────────────────────────────────────────── -CLOUDINARY_CLOUD_NAME= -CLOUDINARY_API_KEY= -CLOUDINARY_API_SECRET= - -# ─── Email delivery ──────────────────────────────────────────────────────── -# Pick ONE provider. If both EmailJS and Resend vars are set, EmailJS wins. -# -# Option A - EmailJS (browser-style templates) -EMAILJS_PUBLIC_KEY= -EMAILJS_PRIVATE_KEY= -EMAILJS_SERVICE_ID= -EMAILJS_TEMPLATE_ID= -EMAILJS_TRANSACTIONAL_TEMPLATE_ID= -# EMAILJS_TRANSACTIONAL_TEMPLATE_ID requires a template with variables: -# {{to_email}}, {{subject}}, {{html_content}} -# Set the template body type to HTML in the EmailJS dashboard. -# -# Option B - Resend (recommended for production) -RESEND_API_KEY= -EMAIL_FROM="ClientFlow " - -# Reply-to / support address shown in email footers -RESEND_REPLY_TO_EMAIL=support@yourdomain.com - -# Resend webhook signing secret (from Resend dashboard → Webhooks → endpoint). -# Protects /api/webhooks/resend against forged bounce / complaint events. -RESEND_WEBHOOK_SECRET=whsec_... - -# ─── OAuth (optional) ────────────────────────────────────────────────────── -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= - -# ─── Web push notifications ──────────────────────────────────────────────── -# Generate VAPID keys with: npx web-push generate-vapid-keys -NEXT_PUBLIC_VAPID_PUBLIC_KEY= -VAPID_PRIVATE_KEY= -VAPID_SUBJECT=mailto:admin@yourdomain.com - -# ─── Stripe billing ──────────────────────────────────────────────────────── -STRIPE_SECRET_KEY=sk_test_... -STRIPE_PUBLISHABLE_KEY=pk_test_... -STRIPE_WEBHOOK_SECRET=whsec_... -# Legacy fallback - only used if a plan has no stripe_monthly_price_id in DB. -# DB-driven plans (created via admin UI) populate these IDs automatically. -# Run `npm run stripe:backfill-plans` to retro-fit pre-seeded plans. -STRIPE_PRICE_ID_STARTER=price_... -STRIPE_PRICE_ID_PROFESSIONAL=price_... - -# ─── Seed overrides (dev / CI) ───────────────────────────────────────────── -SEED_ORGANIZATION_NAME=ClientFlow Seed Org -SEED_ORGANIZATION_SLUG=clientflow-seed-org -SEED_ADMIN_NAME=ClientFlow Admin -SEED_ADMIN_EMAIL=admin@clientflow.local -SEED_ADMIN_PASSWORD=Admin@123456 -SEED_USER_NAME=ClientFlow User -SEED_USER_EMAIL=user@clientflow.local -SEED_USER_PASSWORD=User@123456 - -# ─── Bot protection (Cloudflare Turnstile) ───────────────────────────────── -# When BOTH are set, /contact, /sign-up and /forgot-password require a valid -# Turnstile token. When unset, the widget hides and server-side checks soft-pass -# so local dev isn't blocked. Get keys from Cloudflare dashboard → Turnstile. -NEXT_PUBLIC_TURNSTILE_SITE_KEY= -TURNSTILE_SECRET_KEY= - -# ─── Product analytics (PostHog) ─────────────────────────────────────────── -# Public env vars - exposed to the browser. Loaded only after the user grants -# analytics consent (see lib/consent.ts and components/analytics/PostHogProvider). -# Get the key from PostHog → Project Settings → Project API Keys. -NEXT_PUBLIC_POSTHOG_KEY= -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com - -# ─── India GST (platform-side seller info) ───────────────────────────────── -# Two-digit GST state code for the platform's GST registration (e.g. "27" for -# Maharashtra, "29" for Karnataka). When set AND a customer org has a GSTIN -# on file AND the invoice currency is INR, every Stripe-paid invoice gets a -# GST breakdown (CGST/SGST for intra-state, IGST for inter-state) snapshotted -# onto the invoice row at `invoice.paid` time, alongside HSN/SAC code 998314 -# and the buyer's GSTIN. Leave blank for non-Indian platforms - the GST module -# soft-skips rather than failing when this is unset. -# -# Note: this only captures the *snapshot* on the invoice. Stripe invoices are -# still gross-priced; full Stripe Tax integration (which reverse-engineers the -# pricing into a tax-exclusive base + tax line) is the next step. -PLATFORM_GST_STATE_CODE= - -# ─── Async background jobs (Inngest) ─────────────────────────────────────── -# Inngest hosts a serverless queue we use to decouple email sends (and -# eventually other side effects like webhook dispatch) from request handlers. -# When INNGEST_EVENT_KEY is unset, the app falls back to synchronous email -# sends so local dev and prod-without-Inngest both work fine - the queue is -# strictly an opt-in production hardening. -# -# Operator setup: -# 1. Sign up at inngest.com (free tier covers low-volume launch traffic). -# 2. Create an "app" - copy the Event Key + Signing Key here. -# 3. Set the app's "Serve URL" to https://client-flow.in/api/inngest. -INNGEST_EVENT_KEY= -INNGEST_SIGNING_KEY= - -# ─── Cron jobs ───────────────────────────────────────────────────────────── -# Shared secret for cron-job.org → /api/cron/* endpoints. -# Generate: `openssl rand -hex 32` or `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` -# Also paste the SAME value (with "Bearer " prefix) into every cron-job.org -# entry's Authorization header. -CRON_SECRET=replace-with-a-random-hex-string - -# ─── Security (CSP rollback switch) ──────────────────────────────────────── -# Production sends Content-Security-Policy in *enforcing* mode (the browser -# blocks resources outside the directive list in next.config.ts). If a real -# user-impacting block lands, set CSP_REPORT_ONLY=1 in Vercel env and redeploy -# - the header key flips to Content-Security-Policy-Report-Only and nothing -# is blocked, while violations still surface in the browser console for -# diagnosis. Unset (or set to anything other than "1") to restore enforcing. -CSP_REPORT_ONLY= - -# ─── Testing (E2E) ───────────────────────────────────────────────────────── -PLAYWRIGHT_BASE_URL=http://localhost:3000 -TEST_USER_EMAIL=playwright@test.clientflow.dev -TEST_USER_PASSWORD=Playwright123! +# ═══════════════════════════════════════════════════════════════════════════ +# ClientFlow - Environment Variables +# ═══════════════════════════════════════════════════════════════════════════ +# Copy this file to `.env` and fill in real values before running the app. +# See docs/DEPLOYMENT.md for a setup runbook. +# ─────────────────────────────────────────────────────────────────────────── + +# ─── Core app ────────────────────────────────────────────────────────────── +NEXT_PUBLIC_APP_URL=http://localhost:3000 +BETTER_AUTH_URL=http://localhost:3000 +BETTER_AUTH_SECRET=replace-with-a-secure-random-secret +BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION=false + +# ─── Database (Neon Postgres) ────────────────────────────────────────────── +NEON_DATABASE_URL=postgresql://:@/?sslmode=require + +# ─── Rate limiting (Upstash Redis) - REQUIRED ────────────────────────────── +# The proxy middleware (proxy.ts) enforces rate limits on /api/auth/* and /api/* +# via these credentials. The app will fail at request time if unset. +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= + +# ─── File storage (Cloudinary) ───────────────────────────────────────────── +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= + +# ─── Email delivery ──────────────────────────────────────────────────────── +# Pick ONE provider. If both EmailJS and Resend vars are set, EmailJS wins. +# +# Option A - EmailJS (browser-style templates) +EMAILJS_PUBLIC_KEY= +EMAILJS_PRIVATE_KEY= +EMAILJS_SERVICE_ID= +EMAILJS_TEMPLATE_ID= +EMAILJS_TRANSACTIONAL_TEMPLATE_ID= +# EMAILJS_TRANSACTIONAL_TEMPLATE_ID requires a template with variables: +# {{to_email}}, {{subject}}, {{html_content}} +# Set the template body type to HTML in the EmailJS dashboard. +# +# Option B - Resend (recommended for production) +RESEND_API_KEY= +EMAIL_FROM="ClientFlow " + +# Reply-to / support address shown in email footers +RESEND_REPLY_TO_EMAIL=support@yourdomain.com + +# Resend webhook signing secret (from Resend dashboard → Webhooks → endpoint). +# Protects /api/webhooks/resend against forged bounce / complaint events. +RESEND_WEBHOOK_SECRET=whsec_... + +# ─── OAuth (optional) ────────────────────────────────────────────────────── +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# ─── Web push notifications ──────────────────────────────────────────────── +# Generate VAPID keys with: npx web-push generate-vapid-keys +NEXT_PUBLIC_VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:admin@yourdomain.com + +# ─── Stripe billing ──────────────────────────────────────────────────────── +STRIPE_SECRET_KEY=sk_test_... +STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +# Legacy fallback - only used if a plan has no stripe_monthly_price_id in DB. +# DB-driven plans (created via admin UI) populate these IDs automatically. +# Run `npm run stripe:backfill-plans` to retro-fit pre-seeded plans. +STRIPE_PRICE_ID_STARTER=price_... +STRIPE_PRICE_ID_PROFESSIONAL=price_... + +# ─── Seed overrides (dev / CI) ───────────────────────────────────────────── +SEED_ORGANIZATION_NAME=ClientFlow Seed Org +SEED_ORGANIZATION_SLUG=clientflow-seed-org +SEED_ADMIN_NAME=ClientFlow Admin +SEED_ADMIN_EMAIL=admin@clientflow.local +SEED_ADMIN_PASSWORD=Admin@123456 +SEED_USER_NAME=ClientFlow User +SEED_USER_EMAIL=user@clientflow.local +SEED_USER_PASSWORD=User@123456 + +# ─── Bot protection (Cloudflare Turnstile) ───────────────────────────────── +# When BOTH are set, /contact, /sign-up and /forgot-password require a valid +# Turnstile token. When unset, the widget hides and server-side checks soft-pass +# so local dev isn't blocked. Get keys from Cloudflare dashboard → Turnstile. +NEXT_PUBLIC_TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= + +# ─── Product analytics (PostHog) ─────────────────────────────────────────── +# Public env vars - exposed to the browser. Loaded only after the user grants +# analytics consent (see lib/consent.ts and components/analytics/PostHogProvider). +# Get the key from PostHog → Project Settings → Project API Keys. +NEXT_PUBLIC_POSTHOG_KEY= +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com + +# ─── India GST (platform-side seller info) ───────────────────────────────── +# Two-digit GST state code for the platform's GST registration (e.g. "27" for +# Maharashtra, "29" for Karnataka). When set AND a customer org has a GSTIN +# on file AND the invoice currency is INR, every Stripe-paid invoice gets a +# GST breakdown (CGST/SGST for intra-state, IGST for inter-state) snapshotted +# onto the invoice row at `invoice.paid` time, alongside HSN/SAC code 998314 +# and the buyer's GSTIN. Leave blank for non-Indian platforms - the GST module +# soft-skips rather than failing when this is unset. +# +# Note: this only captures the *snapshot* on the invoice. Stripe invoices are +# still gross-priced; full Stripe Tax integration (which reverse-engineers the +# pricing into a tax-exclusive base + tax line) is the next step. +PLATFORM_GST_STATE_CODE= + +# ─── Async background jobs (Inngest) ─────────────────────────────────────── +# Inngest hosts a serverless queue we use to decouple email sends (and +# eventually other side effects like webhook dispatch) from request handlers. +# When INNGEST_EVENT_KEY is unset, the app falls back to synchronous email +# sends so local dev and prod-without-Inngest both work fine - the queue is +# strictly an opt-in production hardening. +# +# Operator setup: +# 1. Sign up at inngest.com (free tier covers low-volume launch traffic). +# 2. Create an "app" - copy the Event Key + Signing Key here. +# 3. Set the app's "Serve URL" to https://client-flow.in/api/inngest. +INNGEST_EVENT_KEY= +INNGEST_SIGNING_KEY= + +# ─── Cron jobs ───────────────────────────────────────────────────────────── +# Shared secret for cron-job.org → /api/cron/* endpoints. +# Generate: `openssl rand -hex 32` or `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` +# Also paste the SAME value (with "Bearer " prefix) into every cron-job.org +# entry's Authorization header. +CRON_SECRET=replace-with-a-random-hex-string + +# ─── Security (CSP rollback switch) ──────────────────────────────────────── +# Production sends Content-Security-Policy in *enforcing* mode (the browser +# blocks resources outside the directive list in next.config.ts). If a real +# user-impacting block lands, set CSP_REPORT_ONLY=1 in Vercel env and redeploy +# - the header key flips to Content-Security-Policy-Report-Only and nothing +# is blocked, while violations still surface in the browser console for +# diagnosis. Unset (or set to anything other than "1") to restore enforcing. +CSP_REPORT_ONLY= + +# ─── Testing (E2E) ───────────────────────────────────────────────────────── +PLAYWRIGHT_BASE_URL=http://localhost:3000 +TEST_USER_EMAIL=playwright@test.clientflow.dev +TEST_USER_PASSWORD=Playwright123! diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 692cecb..90dc890 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,65 +1,65 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - verify: - name: Typecheck · Lint · Test · Build - runs-on: ubuntu-latest - timeout-minutes: 15 - - env: - # Build-time placeholders. Real secrets live in Vercel. - # These exist only so `next build` and unit tests don't fail at module-load. - DATABASE_URL: postgres://ci:ci@localhost:5432/ci - BETTER_AUTH_SECRET: ci-only-not-a-real-secret-ci-only-not-a-real-secret - BETTER_AUTH_URL: http://localhost:3000 - NEXT_PUBLIC_APP_URL: http://localhost:3000 - EMAIL_FROM: ci@example.com - RESEND_API_KEY: re_ci_dummy - RESEND_WEBHOOK_SECRET: whsec_ci_dummy - STRIPE_SECRET_KEY: sk_test_ci_dummy - STRIPE_WEBHOOK_SECRET: whsec_ci_dummy - CRON_SECRET: ci-cron-secret - NEXT_TELEMETRY_DISABLED: "1" - - steps: - - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Typecheck - run: npm run typecheck - - - name: Lint - run: npm run lint - - - name: Unit tests - run: npm test - - - name: Build - run: npm run build - - - name: Bundle-size budget - run: npm run bundle:budget - - - name: Dependency vulnerability audit - # Non-blocking - flags new high/critical CVEs in dependencies but doesn't - # fail the build (an indirect transitive vuln we can't fix shouldn't block - # unrelated PRs). Track findings in GitHub Security tab and triage weekly. - run: npm audit --audit-level=high || true +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Typecheck · Lint · Test · Build + runs-on: ubuntu-latest + timeout-minutes: 15 + + env: + # Build-time placeholders. Real secrets live in Vercel. + # These exist only so `next build` and unit tests don't fail at module-load. + DATABASE_URL: postgres://ci:ci@localhost:5432/ci + BETTER_AUTH_SECRET: ci-only-not-a-real-secret-ci-only-not-a-real-secret + BETTER_AUTH_URL: http://localhost:3000 + NEXT_PUBLIC_APP_URL: http://localhost:3000 + EMAIL_FROM: ci@example.com + RESEND_API_KEY: re_ci_dummy + RESEND_WEBHOOK_SECRET: whsec_ci_dummy + STRIPE_SECRET_KEY: sk_test_ci_dummy + STRIPE_WEBHOOK_SECRET: whsec_ci_dummy + CRON_SECRET: ci-cron-secret + NEXT_TELEMETRY_DISABLED: "1" + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm test + + - name: Build + run: npm run build + + - name: Bundle-size budget + run: npm run bundle:budget + + - name: Dependency vulnerability audit + # Non-blocking - flags new high/critical CVEs in dependencies but doesn't + # fail the build (an indirect transitive vuln we can't fix shouldn't block + # unrelated PRs). Track findings in GitHub Security tab and triage weekly. + run: npm audit --audit-level=high || true diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 4574d96..dac1485 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -1,65 +1,65 @@ -name: Lighthouse - -on: - pull_request: - branches: [main] - # Also run nightly against main so we have a baseline to compare PRs against. - schedule: - - cron: "30 2 * * *" - -# Non-blocking by design - perf scores are noisy and we don't want flaky -# Lighthouse runs to block merges. Use the GitHub status check + the assertion -# results uploaded to LHCI temp public storage to spot regressions trend-wise. -jobs: - lighthouse: - name: Public-page perf budget - runs-on: ubuntu-latest - timeout-minutes: 12 - continue-on-error: true - - env: - DATABASE_URL: postgres://ci:ci@localhost:5432/ci - BETTER_AUTH_SECRET: ci-only-not-a-real-secret-ci-only-not-a-real-secret - BETTER_AUTH_URL: http://localhost:3000 - NEXT_PUBLIC_APP_URL: http://localhost:3000 - EMAIL_FROM: ci@example.com - RESEND_API_KEY: re_ci_dummy - RESEND_WEBHOOK_SECRET: whsec_ci_dummy - STRIPE_SECRET_KEY: sk_test_ci_dummy - STRIPE_WEBHOOK_SECRET: whsec_ci_dummy - CRON_SECRET: ci-cron-secret - NEXT_TELEMETRY_DISABLED: "1" - - steps: - - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Lighthouse CI - uses: treosh/lighthouse-ci-action@v12 - with: - # Limit to public marketing pages - protected pages need auth and - # would either require a synthetic session or always score badly. - urls: | - http://localhost:3000/ - http://localhost:3000/pricing - http://localhost:3000/features - http://localhost:3000/about - http://localhost:3000/contact - # Boot Next.js for the run. - runs: 1 - # Budget assertions are advisory - regressions land in the PR check - # output but don't fail the workflow (continue-on-error above). - configPath: ./.lighthouserc.json - uploadArtifacts: true - temporaryPublicStorage: true +name: Lighthouse + +on: + pull_request: + branches: [main] + # Also run nightly against main so we have a baseline to compare PRs against. + schedule: + - cron: "30 2 * * *" + +# Non-blocking by design - perf scores are noisy and we don't want flaky +# Lighthouse runs to block merges. Use the GitHub status check + the assertion +# results uploaded to LHCI temp public storage to spot regressions trend-wise. +jobs: + lighthouse: + name: Public-page perf budget + runs-on: ubuntu-latest + timeout-minutes: 12 + continue-on-error: true + + env: + DATABASE_URL: postgres://ci:ci@localhost:5432/ci + BETTER_AUTH_SECRET: ci-only-not-a-real-secret-ci-only-not-a-real-secret + BETTER_AUTH_URL: http://localhost:3000 + NEXT_PUBLIC_APP_URL: http://localhost:3000 + EMAIL_FROM: ci@example.com + RESEND_API_KEY: re_ci_dummy + RESEND_WEBHOOK_SECRET: whsec_ci_dummy + STRIPE_SECRET_KEY: sk_test_ci_dummy + STRIPE_WEBHOOK_SECRET: whsec_ci_dummy + CRON_SECRET: ci-cron-secret + NEXT_TELEMETRY_DISABLED: "1" + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Lighthouse CI + uses: treosh/lighthouse-ci-action@v12 + with: + # Limit to public marketing pages - protected pages need auth and + # would either require a synthetic session or always score badly. + urls: | + http://localhost:3000/ + http://localhost:3000/pricing + http://localhost:3000/features + http://localhost:3000/about + http://localhost:3000/contact + # Boot Next.js for the run. + runs: 1 + # Budget assertions are advisory - regressions land in the PR check + # output but don't fail the workflow (continue-on-error above). + configPath: ./.lighthouserc.json + uploadArtifacts: true + temporaryPublicStorage: true diff --git a/.gitignore b/.gitignore index aca0839..ecc3c7c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,50 +1,50 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# env files (can opt-in for committing if needed) -.env - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts - -/docs -/.claudetests/e2e/.auth-state.json - -/test-results -/playwright-report -/.claude -# Sentry Config File -.env.sentry-build-plugin +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +/docs +/.claudetests/e2e/.auth-state.json + +/test-results +/playwright-report +/.claude +# Sentry Config File +.env.sentry-build-plugin +config.bat diff --git a/README.md b/README.md index fe96596..896979e 100644 --- a/README.md +++ b/README.md @@ -1,651 +1,651 @@ -# ClientFlow - -### Production-Grade Multi-Tenant SaaS Platform for Agencies & Service-Based Businesses - ---- - -## Overview - -ClientFlow is a fully-featured, multi-tenant SaaS platform built for agencies, consultants, and service teams to manage clients, projects, tasks, billing, files, and collaboration from a single system. - -The platform is built to production standards - not as a demo. It demonstrates real-world SaaS engineering: strict tenant isolation, subscription monetization, event-driven processing, multi-channel notifications, external integrations, and a white-label client portal. - -**Live:** [client-flow.in](https://client-flow.in) - -**Production readiness:** **~95 % aggregate** across 32 audit categories (post the v3 audit + roadmap stretch). The full scorecard lives at [`docs/production-readiness-report-v3.md`](./docs/production-readiness-report-v3.md), and the path beyond is in [`docs/production-readiness-roadmap-to-100.md`](./docs/production-readiness-roadmap-to-100.md). The remaining gap is mostly vendor / operator decisions (live-chat vendor, branch protection, Inngest signup) and content (knowledge-base growth) - not code. - ---- - -## Tech Stack - -### Frontend - -| Layer | Technology | -| ----------- | --------------------------------- | -| Framework | Next.js 16 (App Router) | -| Language | TypeScript 5 | -| Styling | Tailwind CSS v4 + shadcn/ui | -| State | TanStack Query v5 | -| URL State | nuqs v2 | -| Forms | React Hook Form + Zod v4 | -| Rich Text | Tiptap v3 (mentions, attachments) | -| Drag & Drop | dnd-kit | -| Charts | Recharts | -| Animations | Framer Motion | -| Markdown | react-markdown + remark-gfm | -| Analytics | PostHog (consent-gated) | - -### Backend - -| Layer | Technology | -| ------------------ | ----------------------------------- | -| API | Next.js Route Handlers | -| Auth | BetterAuth v1.5 (+ emailOTP plugin) | -| Database | PostgreSQL (Neon serverless) | -| ORM | Drizzle ORM | -| Cache / Pub-Sub | Upstash Redis (REST + ioredis TCP) | -| Rate Limiting | @upstash/ratelimit (sliding window) | -| Async jobs | Inngest (optional, soft-skip) | -| Payments | Stripe v20 (with circuit breaker) | -| Email | Resend / EmailJS (dual-provider) | -| File Storage | Cloudinary | -| Push Notifications | Web Push (VAPID) | -| Real-Time | Server-Sent Events (SSE) | -| Bot Protection | Cloudflare Turnstile (soft-skip) | -| Error Tracking | Sentry (PII-scrubbed, 10 % traces) | - ---- - -## Architecture - -ClientFlow uses a **modular monolith** with a shared-database, tenant-scoped multi-tenancy model. Every primary table carries an `organization_id`. All queries are tenant-scoped at the service layer; high-traffic detail fetchers add a defence-in-depth `assertSameTenant` post-fetch check via `server/auth/tenant-guard.ts`. A staged Postgres RLS rollout (`scripts/rls/`) is documented and ships disabled-by-default for opt-in DB-layer enforcement. - -``` -app/ -├── (public)/ # Marketing, pricing, legal, /help (KB), /status -├── (protected)/ # Authenticated workspace + client portal -├── (onboarding)/ # New-user onboarding flow -├── auth/ # Auth pages (sign-in, sign-up, email-OTP, MFA, SSO) -├── admin/ # Platform admin (users, orgs, plans, flags, both webhook DLQs) -├── api/ -│ ├── v1/ # Public REST API (X-API-Key, GET + POST + Idempotency-Key) -│ ├── cron/ # Vercel Cron handlers (7 jobs, runCron-wrapped) -│ ├── webhooks/ # Inbound (Stripe, Resend bounce/complaint) -│ ├── inngest/ # Inngest function dispatch endpoint -│ ├── openapi.json/ # Hand-written OpenAPI 3.1 spec for /api/v1 -│ └── ... # Internal session-authed endpoints -│ -core/ # Use-cases per domain (framework-agnostic) -lib/ # Shared utilities, service functions -│ ├── analytics/ # PostHog client + server event capture -│ ├── billing/ # India GST module (HSN/SAC, GSTIN, CGST/SGST/IGST snapshot) -│ └── feature-flags.ts # In-house DB-backed flag evaluation (per-org overrides) -server/ -│ ├── api/ # ApiError + helpers, v1 mutations, idempotency -│ ├── auth/ # BetterAuth + permissions + tenant guard -│ ├── billing/ # Stripe event handlers, dunning, GST snapshot -│ ├── cron/ # runCron wrapper (Sentry capture + duration) -│ ├── db/ # Drizzle client + setTenantContext (RLS-ready) -│ ├── email/ # Send pipeline (suppression + category opt-out + queue) -│ ├── observability/ # Structured logger -│ ├── queue/ # Inngest client + functions -│ ├── security/ # Audit log, IP allowlist, Turnstile -│ └── third-party/ # Stripe (with circuit breaker), Resend, Cloudinary -db/ -├── schemas/ # Drizzle schema files per domain -│ ├── access.ts # Orgs, roles, memberships, API keys, outbound webhooks + DLQ -│ ├── work.ts # Clients, projects, tasks, time entries -│ ├── billing.ts # Plans, subscriptions, invoices (incl. GST snapshot fields) -│ ├── platform.ts # Notifications, prefs (per-event + per-category), feature flags -│ └── support.ts # Support tickets -emails/templates/ # 46 HTML email templates -config/ -│ ├── kb-articles.ts # Knowledge-base article content (markdown) -│ ├── navigation.ts # User sidebar nav -│ └── ... -scripts/ -│ ├── bundle-budget.mjs # Per-route JS-size gate (CI step) -│ └── rls/ # Postgres row-level security rollout (disabled-by-default) -docs/ # Production-readiness audits (v1, v2, v3), roadmap, runbooks -drizzle/ # Migration files (0000-0024+) -``` - ---- - -## Features - -### Authentication & Security - -- **Email / Password** authentication via BetterAuth -- **Google OAuth** (configurable) -- **Two-Factor Authentication (TOTP)** with backup code regeneration -- **Email-OTP fallback sign-in** - 6-digit code emailed for users without password access (`/auth/sign-in-otp`) -- **Single Sign-On (SSO)** - OIDC/SAML, Google Workspace, Azure AD, Okta -- **SSO enforcement** per organization (blocks non-SSO logins when enabled) -- **Password complexity policy** enforced server-side via BetterAuth `hooks.before` (8 chars + upper + lower + digit + symbol) -- **Per-account sign-in lockout** - 5 failed attempts / 15-minute window, Upstash-backed, IP-independent -- **Cloudflare Turnstile** on sign-up, contact, and forgot-password forms (soft-skipped when keys absent) -- **Session management** - configurable timeout per org, per-session revocation, "Sign Out All Other Devices" button -- **IP allowlist** enforcement at protected layout (CIDR + IPv6-mapped IPv4 normalisation) -- **Risky sign-in detection** with email alerts -- **Rate limiting** - 10 req/10s on auth endpoints, 120 req/60s on all API routes (per-IP, Upstash sliding window); 1,000 req/min per API key for `/api/v1` traffic; monthly per-key usage counter visible in the API key UI - ---- - -### Security Hardening - -- **Content-Security-Policy** in _enforcing_ mode in production. `CSP_REPORT_ONLY=1` env flag flips to Report-Only as an emergency rollback path without a code change. -- **Strict-Transport-Security** (`max-age=63072000; includeSubDomains; preload`) - production only -- **X-Frame-Options: DENY**, **X-Content-Type-Options: nosniff**, **Referrer-Policy: strict-origin-when-cross-origin** -- **Permissions-Policy** locking down camera / mic / geolocation / payment except for Stripe -- **Cookie hardening** - `Secure`, `HttpOnly`, `SameSite=Lax` (production) -- **Sentry hardening** - `sendDefaultPii: false`, `tracesSampleRate: 0.1` in prod, request-body scrubbing for `/api/billing/webhook` and `/api/webhooks/*` -- **`npm audit` step** in CI (non-blocking, weekly review) -- **Postgres Row-Level Security** policies for every tenant-scoped table, ready to enable via the staged plan in `scripts/rls/README.md` - ---- - -### Privacy & GDPR Compliance - -- **Self-service data export (Article 20)** - `/api/settings/my-data-export` returns a JSON attachment of the user's account, preferences, activity, and work; redacts session tokens, OAuth secrets, password hashes, 2FA secrets, API key hashes; audit-logged -- **Self-service account deletion (Article 17)** - 30-day grace period with cancel-anytime banner; sole-owner-with-members blocker; nightly anonymisation transaction NULLs FKs and hard-deletes session/account/notifications/push -- **Cookie consent banner** - essential / analytics / marketing tiers, `cf_consent` cookie, server-side honored -- **Signed unsubscribe footer** on every outbound email (HMAC-SHA256, timing-safe verify) -- **Email suppression list** with critical-module bypass (auth / billing / security always send) -- **Per-category email preferences** - product / billing / marketing opt-in toggles surfaced at `/notifications/preferences`; mutations audit-logged - ---- - -### Multi-Tenant Organization System - -- Users can belong to multiple organizations with independent roles -- **Organization switcher** in the app header -- Per-organization **settings**: logo, brand color, timezone, currency, session timeout, SSO config, IP allowlist, GSTIN -- **White-label branding** - brand color propagates across the full UI (buttons, badges, sidebar, backgrounds, gradients) via server-injected CSS variable overrides -- **Custom role permissions** - per-role feature access configurable by org admins -- **Member permission overrides** - individual permission exceptions per member -- **Defence-in-depth tenant isolation** - `assertSameTenant` post-fetch checks on `getClientDetailForUser`, `getProjectDetailForUser`, `getInvoiceForUser`, `getTaskDetailForUser`; Postgres RLS available as a staged opt-in (`scripts/rls/`) - ---- - -### Role-Based Access Control (RBAC) - -Five predefined roles with granular permission flags: - -| Role | Description | -| ------- | ---------------------------------- | -| Owner | Full access, billing, org deletion | -| Admin | Team management, settings | -| Manager | Project & task management | -| Member | Standard workspace access | -| Client | Read-only client portal access | - -Permissions enforced at API layer, service layer (via `resolveModulePermissionsForUser`), and UI visibility level. - ---- - -### Project & Client Management - -- Full **CRUD for clients** with detail pages and project associations -- Full **CRUD for projects** with budget tracking, status, and member access -- **Project templates** - create reusable project blueprints with pre-defined tasks -- **Project-level membership** - control who sees each project -- **Time entries** per project and task with full UI (filters, paginated list, log-time dialog) -- **File uploads** (Cloudinary) attached to projects and tasks - ---- - -### Task Management (Kanban) - -- **Kanban board** with drag-and-drop columns and cards (dnd-kit) -- Custom board columns with color coding and ordering -- Per-task: title, description, status, priority, due date, assignees, estimates -- **Subtasks** with individual completion tracking -- **Task comments** with rich text (Tiptap) and @mention notifications -- **File attachments** per task with signed URL delivery -- **Task activity log** - full change history with old/new values -- **Comment limit enforcement** per plan - ---- - -### Billing & Subscriptions (Stripe) - -- **Multi-plan support** (Starter, Professional) with feature flags and usage limits -- Stripe **Checkout** and **Customer Portal** integration -- **In-UI proration preview** before plan upgrade or downgrade - line-item breakdown via `/api/billing/preview-plan-change` -- **Mid-cycle plan changes** in either direction - upgrade flow charges the prorated difference now; downgrade flow applies prorated credit to the next invoice. Direction-aware copy in `PlanChangePreviewDialog` ("Upgrade plan" / "Downgrade plan" / "Change plan"). -- **Trial period** management -- **Cancel-at-period-end** support -- **Usage counters** - members, projects, clients, tasks, comments, files tracked monthly -- **Plan limit enforcement** - 402 errors with upgrade prompts at quota boundaries -- **Invoice management** - Stripe automated invoices + manual invoices with line items -- **Stripe webhook processing** with idempotency key storage and event logging -- **Refund tracking** - `charge.refunded` updates `invoices.{status, refundedAt, amountRefundedCents, refundReason}` and dispatches outbound `invoice.refunded` event -- **Dunning cadence** - day 1 / 3 / 7 / 14 reminder sequence, idempotent stage walk, integrated with `daily-expirations` cron -- **Stripe circuit breaker** - in-process state machine (closed → open → half_open) with 5-failure threshold + 30 s cooldown; trips on 5xx / network / timeout; 4xx don't count. Stripe SDK timeout pinned to 10 s. -- **Billing email notifications** - payment failures, expiring cards, plan changes, overdue reminders - ---- - -### India GST Capture - -When `PLATFORM_GST_STATE_CODE` is set AND a customer org has a GSTIN AND the invoice currency is INR, every Stripe-paid invoice gets a GST snapshot at `invoice.paid` time: - -- `subtotalCents` (tax-exclusive base, gross-to-net decomposed from the Stripe invoice amount) -- `taxBreakdown` JSONB - `{regime: "intra_state" | "inter_state", cgstCents, sgstCents, igstCents, totalTaxCents}` -- `gstinAtInvoice` - buyer's GSTIN at the time of payment -- `hsnSacCode` - 998314 (SaaS default) - -Implementation at `lib/billing/india-gst.ts`. Customer GSTIN management at `/settings/gst`. The custom invoice PDF (`components/invoices/InvoicePDFDocument.tsx`) renders the GST breakdown for the buyer. - -This module is intentionally independent of Stripe Tax - it works on any Stripe account regardless of the account's country, so a US-based Stripe account can still issue GST-formatted invoices to Indian B2B buyers. - ---- - -### Notifications (Multi-Channel) - -| Channel | Implementation | -| --------- | -------------------------------------------- | -| In-App | `notifications` table, unread badge, popover | -| Email | 46 HTML templates via Resend or EmailJS | -| Web Push | VAPID-signed push subscriptions | -| Real-Time | SSE stream via Upstash Redis pub/sub | - -- **Per-event preferences** - users control in-app and email per notification type -- **Per-category email preferences** - coarser product / billing / marketing opt-out (see Privacy & GDPR section) -- **Bulk preference updates** -- **30s polling fallback** when SSE is unavailable -- **Exponential backoff** reconnection for SSE in production - ---- - -### Outbound Webhooks - -- Organization-scoped webhook endpoints -- **12 event types**: `project.created/updated/deleted`, `task.created/updated/completed`, `client.created/updated`, `invoice.paid/overdue/refunded`, `team.member_added/removed` -- **HMAC-SHA256** signed payloads (`X-ClientFlow-Signature` header) -- **3 retry attempts** with exponential backoff (1s, 2s, 4s delays) -- **Outbound DLQ** - every dispatch logged to `outbound_webhook_deliveries` with status (`delivered` / `permanent_fail` / `exhausted`), attempt count, response status, and error -- **Admin replay UI** at `/admin/webhook-deliveries` for `exhausted` rows; 4xx classified as permanent (no replay) -- **Test delivery** button in the UI - sends live signed ping to endpoint -- Concurrent delivery via `Promise.allSettled` - -### Inbound Webhooks - -- **Stripe** - signature-verified, idempotent via `billing_webhook_events` table, processing errors recorded for observability. **Admin DLQ + replay UI** at `/admin/billing-webhook-events` for events that failed processing - the Stripe handlers are idempotent so a manual replay is safe. -- **Resend bounce / complaint webhook** at `/api/webhooks/resend` with manual Svix-style HMAC verification (5-min replay window, timing-safe compare). Hard bounces and complaints flow into `email_suppressions` and are honored on every subsequent send (critical modules bypass). - ---- - -### Public REST API (`/api/v1`) - -- **Strict `X-API-Key` authentication** via `requireV1Auth()` - no session-cookie fallback so third-party SDKs can't accidentally inherit a browser session -- **OpenAPI 3.1 spec** served at `/api/openapi.json` - importable into Postman / Insomnia / OpenAPI Generator -- **Idempotency-Key header** support on POST endpoints (24-hour cache, race-safe via unique index, `Idempotency-Replayed: true` response header on cache hit) -- **Per-API-key rate limit** - 1,000 req/min sliding window via Upstash; monthly usage counter surfaced to the customer in the API key UI -- Endpoints today: - - `GET /api/v1/clients?limit=&offset=` - - `POST /api/v1/clients` (with `Idempotency-Key`) - - `GET /api/v1/projects?limit=&offset=` - - `POST /api/v1/projects` (with `Idempotency-Key`) - - `GET /api/v1/tasks?projectId=&status=&limit=&offset=` - - `POST /api/v1/tasks` (with `Idempotency-Key`) - - `GET /api/v1/invoices?status=&limit=&offset=` -- Pagination clamped server-side (limit 1-200, offset >= 0) -- Standard error shape `{error: string}` with appropriate HTTP status - -### API Keys & Developer Access - -- Organization-scoped API key generation -- SHA-256 hashed storage (key shown once at creation) -- Key prefix display for identification -- Expiration options (30 days / 90 days / 1 year / no expiry) -- Per-key revocation and deletion -- Last-used timestamp tracking -- **Monthly usage counter** per key (Redis-backed, surfaces as "Calls (this month)" column) -- **In-app API reference docs** at `/developer` - ---- - -### Observability & Reliability - -- **Public status page** at `/status` with live multi-service probes: - - DB ping (`SELECT 1`) - - Stripe `/v1/balance` (5 s timeout, soft-skipped without `STRIPE_SECRET_KEY`) - - Resend `/domains` (5 s timeout, soft-skipped without `RESEND_API_KEY`) - - 60 s ISR refresh; overall pill goes amber on any _monitored_ upstream failure -- **Liveness probe** at `/api/health` returning `{status, ts, version, region, db, latencyMs}` with 200 / 503 -- **Sentry** with PII scrubbing, 10 % trace sampling in prod, webhook-body scrubbing in `beforeSend` -- **Structured logging** via `server/observability/logger.ts` -- **Request correlation IDs** - middleware mints `x-request-id` (or trusts Vercel's), forwards to handlers, echoes on response, stamped on every log line + Sentry tag -- **Email-send retry** - 3 attempts with jittered exponential backoff (250 ms / 1 s / 4 s) -- **Async email queue** (Inngest) - when `INNGEST_EVENT_KEY` is set, emails enqueue rather than send synchronously, decoupling Resend latency from the request path. Falls back to sync send when unset, so existing flows are unaffected. - ---- - -### Cron Jobs - -7 jobs defined in `vercel.json`, staggered UTC schedules, `maxDuration: 300`: - -| Cron | Schedule | Purpose | -| -------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------- | -| `task-notifications` | Every hour | Due-soon and overdue task notifications | -| `support-sla-sweep` | Every 15 min | Surface tickets approaching SLA breach | -| `daily-expirations` | 01:00 UTC | Trial-expiry handling + dunning sweep (day 1 / 3 / 7 / 14) | -| `analytics-daily-rollup` | 02:30 UTC | Materialise daily org metrics | -| `nightly-housekeeping` | 03:00 UTC | Sessions purge + soft-delete purge + webhook event purge + Stripe reconciliation + GDPR anonymisation + log retention | -| `payment-method-reminders` | 09:00 UTC | Card-expiring soon notifications | -| `monthly-rollover` | 02:00 (1st) | Reset usage counters at billing period boundary | - -Each cron is gated by `CRON_SECRET` (Bearer token) via `server/cron/guard.ts`. The `runCron(name, fn)` wrapper captures uncaught throws to Sentry with the cron name + duration tag, so silent failures show up in alerting. - ---- - -### Audit & Activity Logs - -- **Audit logs** - actor, action, entity type/ID, IP, user agent, metadata - admin-only, 365-day retention enforced via nightly housekeeping -- **Task audit logs** - field-level change history (oldValues / newValues) -- **Activity logs** - broader activity feed accessible to team members -- **Admin search/filter UI** at `/admin/audit-logs` - filter by actor (name or email, debounced), entity type, date range, search by action / entity type / entity ID -- **CSV export** for both audit and activity logs -- Audit-logged actions include: `user.data_export_requested`, `user.deletion_scheduled/cancelled`, `user.email_preferences_updated`, `replay_webhook_delivery`, `replay_billing_webhook_event`, `org.gst_settings_updated`, `feature_flag.toggled` - ---- - -### Analytics & Feature Flags - -- **PostHog** integrated as a **consent-gated** provider (`components/analytics/PostHogProvider.tsx`) - reads `cf_consent` cookie on mount, listens to `cf:consent-updated` events to switch state when the user toggles consent later -- Manual `$pageview` on every App Router navigation -- Session recording disabled by default -- **5 activation funnel events** firing end-to-end: - - `sign_up_started` (client - email & Google) - - `sign_up_done` (client) - - `first_project_created` (server - on `projectCount === 1`) - - `first_invoice_paid` (server - on `paidCount === 1` from Stripe webhook) - - `plan_upgraded` (server - on `customer.subscription.updated` price change) -- **In-house feature flag system** (`lib/feature-flags.ts` + `feature_flags` + `feature_flag_overrides` tables): - - Two-tier evaluation: per-org override wins over global default - - Bulk variant for fetching all flags for an org in one round-trip - - 60 s in-process cache - - **Admin UI at `/admin/feature-flags`** - global toggles + per-org override management; toggles audit-logged - ---- - -### Analytics Dashboard - -- Total clients, active projects, completed projects, files, revenue -- Projects by status breakdown -- Monthly project creation trend -- Monthly revenue trend (currency-aware) -- Recent project activity feed -- Client-scoped filtering - ---- - -### Knowledge Base & Help Center - -- **Real article content** (not stub copy) at `/help` - 11 articles covering workspace setup, team invitations, first client, Kanban best practices, invoice anatomy + India GST, plan changes/cancellation, 2FA, API key management, GDPR export/deletion, webhook configuration, and email troubleshooting -- Article content lives in `config/kb-articles.ts` (typed Markdown, ships with the deployment) -- Per-article static pages at `/help/[slug]` rendered with `react-markdown` + `remark-gfm`, full SEO metadata, `generateStaticParams` for pre-rendering -- Browse-by-category index with **client-side search** across titles + excerpts - ---- - -### Client Portal - -A separate, role-gated interface for external clients: - -- `/client-portal` - Summary dashboard -- `/client-portal/projects` - Read-only project list and details -- `/client-portal/tasks` - Read-only task list -- `/client-portal/files` - Access to shared project files -- `/client-portal/invoices` - View and download invoices - -Portal inherits org branding (logo, brand color). - ---- - -### Email System - -Dual-provider routing: **EmailJS** when `EMAILJS_PUBLIC_KEY` is set, otherwise **Resend**. - -When `INNGEST_EVENT_KEY` is set, `sendEmail()` enqueues to Inngest and returns immediately - the actual provider call happens in a queued worker (`server/queue/functions/send-email.ts`). Without Inngest configured, the call is synchronous (existing behaviour preserved). - -46 HTML templates across categories: - -| Category | Templates | -| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| Auth | Verify email, password reset, **sign-in OTP**, invite, invite expired/revoked, suspicious login, membership suspended | -| Organization | Role changed, account status changed, ownership transfer | -| Tasks | Assigned, status changed, comment added, mentioned, due soon, overdue, attachment added | -| Billing | Subscription changed, invoice available/overdue, payment failed/method expiring/changed, usage warning, quota reached, upgrade request | -| Security | Session activity notice, forced logout notice | -| Operations | Export ready, webhook failures, API key exposure, rate limit abuse, event/billing delays | -| Files & Portal | Shared file uploaded, client portal enabled | -| Public | Contact form acknowledgement, internal submission | - -Every outbound email carries a signed unsubscribe footer (HMAC-SHA256). Suppression and per-category opt-out checks happen before send; critical modules (auth / billing / security) bypass both. - ---- - -### Global Search & Command Palette - -- **⌘K command palette** (cmdk-based) with grouped sections: - - **Create** shortcuts - New Client, New Project, Invite Teammate, New Invoice - - **Search results** - clients, projects, tasks (real data via `/api/search`) - - **Recent history** (localStorage) - - **Quick navigation** - all sidebar destinations -- **Keyboard-shortcut help modal** - press `?` anywhere to see all shortcuts -- **G-chord navigation** - `g d` (Dashboard), `g c` (Clients), `g p` (Projects), `g t` (Tasks), `g i` (Invoices); skips when target is editable - ---- - -### Testing - -- **Vitest** unit tests with `@testing-library/react` + `vitest-axe` matchers -- **Playwright** E2E suite (~9 specs covering auth, projects, tasks, invoices, clients, settings, org-security) -- **Accessibility tests** - `jest-axe` smoke suite on UI primitives + Playwright a11y specs (`a11y-public.spec.ts`, `a11y-protected.spec.ts`) using `@axe-core/playwright` -- **Lighthouse CI** workflow runs on every PR + nightly against `main`. Captures LCP / INP / CLS / TBT / Web Vitals against budgets defined in `.lighthouserc.json`. Non-blocking; results uploaded to LHCI temporary public storage. - ---- - -### CI/CD & DevEx - -- **GitHub Actions** - typecheck + lint + Vitest + build + bundle-size budget + `npm audit` on every PR and push to main -- **Bundle-size budget gate** - `scripts/bundle-budget.mjs` reads `.next/app-build-manifest.json`, sums each route's JS chunks, fails the build at 450 KB / route by default (per-route override map at the top of the script) -- **Lighthouse CI** for performance regressions (see Testing) -- **Dependabot** - weekly Monday cadence for npm + GitHub Actions, separate prod / dev groups, ignores Next/React major bumps (need coordinated upgrades) -- **Husky + lint-staged + Prettier** with Tailwind plugin -- **Conventional Commits** enforced via `commitlint` and the `.husky/commit-msg` hook -- `CHANGELOG.md` (Keep-a-Changelog format) and `.github/pull_request_template.md` -- See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for local setup, branching, commit conventions, and PR process - ---- - -## Database Schema - -PostgreSQL via Neon. Managed with Drizzle ORM. - -**Tenant safety:** child records use composite tenant-scoped foreign keys (e.g. `(organization_id, project_id)`) to block cross-tenant references at the database level. Application-layer `assertSameTenant` checks add a defence-in-depth layer in detail fetchers. Postgres RLS policies are pre-written in `scripts/rls/01-create-policies.sql` and ship inert; enable via the staged plan in `scripts/rls/README.md` when ready. - -**Schema domains:** - -| Domain | Key Tables | -| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Access | `organizations`, `organization_settings`, `organization_memberships`, `organization_invitations`, `roles`, `permissions`, `role_permissions`, `api_keys`, `outbound_webhooks`, `outbound_webhook_deliveries` | -| Work | `clients`, `projects`, `project_files`, `project_members`, `project_templates`, `task_board_columns`, `tasks`, `task_comments`, `task_attachments`, `task_audit_logs`, `task_assignees`, `time_entries` | -| Billing | `plans`, `plan_feature_limits`, `subscriptions`, `organization_current_subscriptions`, `invoices` (incl. India GST snapshot fields), `usage_counters`, `billing_webhook_events`, `api_idempotency_keys` | -| Platform | `notifications`, `notification_deliveries`, `notification_preferences`, `email_suppressions`, `email_category_preferences`, `push_subscriptions`, `feature_flags`, `feature_flag_overrides`, `analytics_daily_org_metrics`, `audit_logs`, `platform_admin_actions` | -| Support | `support_tickets`, `support_messages` | - -**Migrations:** managed via `drizzle-kit`. The convention is documented in [`drizzle/README.md`](./drizzle/README.md) - prefer semantic names (`add_invoice_tax_breakdown`) over auto-generated ones; this is a code-review rule, not enforced. - ---- - -## Environment Variables - -See [`.env.example`](./.env.example) for the full annotated list with operator-config notes. Headline groups: - -```bash -# ─── Core app ────────────────────────────────────────────────────────────── -NEXT_PUBLIC_APP_URL= -BETTER_AUTH_URL= -BETTER_AUTH_SECRET= -BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION= - -# ─── Database ────────────────────────────────────────────────────────────── -NEON_DATABASE_URL= - -# ─── Rate limiting (Upstash Redis) - REQUIRED ────────────────────────────── -UPSTASH_REDIS_REST_URL= -UPSTASH_REDIS_REST_TOKEN= -UPSTASH_REDIS_URL= # ioredis TCP for SSE pub/sub - -# ─── File storage (Cloudinary) ───────────────────────────────────────────── -CLOUDINARY_CLOUD_NAME= -CLOUDINARY_API_KEY= -CLOUDINARY_API_SECRET= - -# ─── Email delivery ──────────────────────────────────────────────────────── -RESEND_API_KEY= -EMAIL_FROM= -RESEND_REPLY_TO_EMAIL= -RESEND_WEBHOOK_SECRET= # Svix-style HMAC for bounce/complaint webhook -# Optional EmailJS (takes priority over Resend when set) -EMAILJS_PUBLIC_KEY= -EMAILJS_PRIVATE_KEY= -EMAILJS_SERVICE_ID= -EMAILJS_TEMPLATE_ID= -EMAILJS_TRANSACTIONAL_TEMPLATE_ID= - -# ─── Web Push ────────────────────────────────────────────────────────────── -NEXT_PUBLIC_VAPID_PUBLIC_KEY= -VAPID_PRIVATE_KEY= -VAPID_SUBJECT= - -# ─── OAuth (optional) ────────────────────────────────────────────────────── -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= - -# ─── Stripe ──────────────────────────────────────────────────────────────── -STRIPE_SECRET_KEY= -STRIPE_PUBLISHABLE_KEY= -STRIPE_PRICE_ID_STARTER= -STRIPE_PRICE_ID_PROFESSIONAL= -STRIPE_WEBHOOK_SECRET= - -# ─── Bot protection (Cloudflare Turnstile) ───────────────────────────────── -NEXT_PUBLIC_TURNSTILE_SITE_KEY= -TURNSTILE_SECRET_KEY= - -# ─── Product analytics (PostHog) ─────────────────────────────────────────── -NEXT_PUBLIC_POSTHOG_KEY= # phc_… project key (NOT a personal API key) -NEXT_PUBLIC_POSTHOG_HOST= - -# ─── India GST (optional, only for B2B India) ────────────────────────────── -PLATFORM_GST_STATE_CODE= # Two-digit state code (e.g. "27" Maharashtra) - -# ─── Async background jobs (Inngest, optional) ───────────────────────────── -INNGEST_EVENT_KEY= # When set, emails enqueue instead of sending sync -INNGEST_SIGNING_KEY= - -# ─── Security (CSP rollback switch, leave unset normally) ────────────────── -CSP_REPORT_ONLY= # Set to "1" only as emergency rollback - -# ─── Cron jobs ───────────────────────────────────────────────────────────── -CRON_SECRET= # Bearer token guarding /api/cron/* - -# ─── Error tracking ──────────────────────────────────────────────────────── -SENTRY_DSN= -NEXT_PUBLIC_SENTRY_DSN= -``` - ---- - -## Local Setup - -```bash -# 1. Install -npm install - -# 2. Copy env template and fill in values -cp .env.example .env - -# 3. Generate auth schema, run migrations, seed roles + plans -npm run db:setup -npm run db:seed:roles -npm run db:seed:plans -npm run db:seed:platform-admin - -# 4. Start dev server -npm run dev -``` - -Common scripts: - -| Script | Purpose | -| ----------------------------------------------- | --------------------------------------- | -| `npm run dev` | Start Next.js dev server | -| `npm run typecheck` | Run `tsc --noEmit` | -| `npm run lint` | Run ESLint | -| `npm test` | Run Vitest unit tests | -| `npm run test:e2e` | Run Playwright E2E suite | -| `npm run build` | Production build | -| `npm run bundle:budget` | Per-route bundle-size gate (post-build) | -| `npm run analyze` | Build with bundle analyzer | -| `npm run db:generate -- --name ` | Generate a new Drizzle migration | -| `npm run db:migrate` | Apply pending migrations | -| `npm run db:studio` | Open Drizzle Studio | - ---- - -## Deployment - -**Platform:** Vercel (serverless) -**Database:** Neon PostgreSQL -**Cache / Pub-Sub:** Upstash Redis -**File Storage:** Cloudinary -**Domain:** client-flow.in (GoDaddy → Vercel DNS) - -Per-route function configuration in [`vercel.json`](./vercel.json): - -- `regions: ["bom1"]` (Mumbai pinning for the India audience) -- `functions.maxDuration` per route family (30 s billing webhook, 60 s PDF/exports, 300 s crons) -- 7 cron schedules (see Cron Jobs section) - -The app is stateless by design - all session state, pub/sub, and rate-limit state live in Upstash Redis. Horizontal scaling requires no additional configuration. - ---- - -## System Design Notes - -**Multi-tenancy:** Shared database, tenant-scoped queries. Every primary table has `organization_id`; high-traffic detail fetchers add `assertSameTenant` post-fetch as a defence-in-depth layer. Composite indexes on `(organization_id, created_at)` and similar patterns. Postgres RLS policies are pre-written and ship inert - enable via the staged plan in `scripts/rls/README.md`. - -**Event-driven internals:** Domain events table captures business events. A job queue handles asynchronous work (notification delivery, webhook dispatch) with deduplication, scheduling, and distributed locking. The Inngest queue (when configured) handles email send asynchronously so a slow Resend doesn't propagate into request latency. - -**Billing isolation:** Billing logic is contained in its own schema domain and service files, structurally ready for extraction into an independent service. The Stripe circuit breaker at `server/third-party/stripe.ts` keeps a degraded Stripe API from exhausting function-time budget across the rest of the app. - -**Email provider flexibility:** A single `sendEmail()` function routes to EmailJS or Resend based on environment configuration - no call sites need to change when switching providers. Suppression and per-category opt-out checks happen before send; critical modules (auth / billing / security) bypass both. With Inngest configured, `sendEmail()` enqueues; the worker calls `sendEmailNow()` which runs the same checks. - -**SSE + polling:** Real-time notification delivery via SSE with Redis pub/sub in production. A 30-second polling fallback in `useNotifications` ensures notifications are never missed if SSE is unavailable. - -**Security layers:** Rate limiting at middleware (per-IP + per-API-key), RBAC at service layer, tenant scoping at query layer, defence-in-depth tenant assertions in detail fetchers, optional Postgres RLS as a fourth layer, HMAC signing on webhooks (in and out), hashed storage for API keys, composite FK constraints at database layer, full security-header set including enforcing CSP. - -**Observability:** Request correlation IDs forwarded across handlers and stamped on every log line and Sentry event. Structured logger. Sentry with PII scrubbing and 10 % trace sampling in production. Public status page probes DB + Stripe + Resend with neutral "unmonitored" pills when API keys are absent. Cron failures captured to Sentry via `runCron` wrapper with the cron name + duration tag. - ---- - -## Documentation - -| Document | Purpose | -| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -| [`docs/production-readiness-audit-scope.md`](./docs/production-readiness-audit-scope.md) | The 32-category audit framework | -| [`docs/production-readiness-report.md`](./docs/production-readiness-report.md) | v1 audit (75 % overall) | -| [`docs/production-readiness-report-v2.md`](./docs/production-readiness-report-v2.md) | v2 audit (87 % overall) | -| [`docs/production-readiness-report-v3.md`](./docs/production-readiness-report-v3.md) | **v3 audit (90 % overall)** - latest scorecard | -| [`docs/production-readiness-roadmap-to-100.md`](./docs/production-readiness-roadmap-to-100.md) | Sequenced roadmap to ~96 % (code) / ~98 % (with vendors) | -| [`docs/vercel-resend-infrastructure-checklist.md`](./docs/vercel-resend-infrastructure-checklist.md) | Stack-specific operator checklist | -| [`scripts/rls/README.md`](./scripts/rls/README.md) | Postgres RLS staged-rollout plan | -| [`CONTRIBUTING.md`](./CONTRIBUTING.md) | Local setup, branching, commits, PR process | -| [`CHANGELOG.md`](./CHANGELOG.md) | Release history (Keep-a-Changelog format) | -| [`drizzle/README.md`](./drizzle/README.md) | Migration naming convention and review procedure | +# ClientFlow + +### Production-Grade Multi-Tenant SaaS Platform for Agencies & Service-Based Businesses + +--- + +## Overview + +ClientFlow is a fully-featured, multi-tenant SaaS platform built for agencies, consultants, and service teams to manage clients, projects, tasks, billing, files, and collaboration from a single system. + +The platform is built to production standards - not as a demo. It demonstrates real-world SaaS engineering: strict tenant isolation, subscription monetization, event-driven processing, multi-channel notifications, external integrations, and a white-label client portal. + +**Live:** [client-flow.in](https://client-flow.in) + +**Production readiness:** **~95 % aggregate** across 32 audit categories (post the v3 audit + roadmap stretch). The full scorecard lives at [`docs/production-readiness-report-v3.md`](./docs/production-readiness-report-v3.md), and the path beyond is in [`docs/production-readiness-roadmap-to-100.md`](./docs/production-readiness-roadmap-to-100.md). The remaining gap is mostly vendor / operator decisions (live-chat vendor, branch protection, Inngest signup) and content (knowledge-base growth) - not code. + +--- + +## Tech Stack + +### Frontend + +| Layer | Technology | +| ----------- | --------------------------------- | +| Framework | Next.js 16 (App Router) | +| Language | TypeScript 5 | +| Styling | Tailwind CSS v4 + shadcn/ui | +| State | TanStack Query v5 | +| URL State | nuqs v2 | +| Forms | React Hook Form + Zod v4 | +| Rich Text | Tiptap v3 (mentions, attachments) | +| Drag & Drop | dnd-kit | +| Charts | Recharts | +| Animations | Framer Motion | +| Markdown | react-markdown + remark-gfm | +| Analytics | PostHog (consent-gated) | + +### Backend + +| Layer | Technology | +| ------------------ | ----------------------------------- | +| API | Next.js Route Handlers | +| Auth | BetterAuth v1.5 (+ emailOTP plugin) | +| Database | PostgreSQL (Neon serverless) | +| ORM | Drizzle ORM | +| Cache / Pub-Sub | Upstash Redis (REST + ioredis TCP) | +| Rate Limiting | @upstash/ratelimit (sliding window) | +| Async jobs | Inngest (optional, soft-skip) | +| Payments | Stripe v20 (with circuit breaker) | +| Email | Resend / EmailJS (dual-provider) | +| File Storage | Cloudinary | +| Push Notifications | Web Push (VAPID) | +| Real-Time | Server-Sent Events (SSE) | +| Bot Protection | Cloudflare Turnstile (soft-skip) | +| Error Tracking | Sentry (PII-scrubbed, 10 % traces) | + +--- + +## Architecture + +ClientFlow uses a **modular monolith** with a shared-database, tenant-scoped multi-tenancy model. Every primary table carries an `organization_id`. All queries are tenant-scoped at the service layer; high-traffic detail fetchers add a defence-in-depth `assertSameTenant` post-fetch check via `server/auth/tenant-guard.ts`. A staged Postgres RLS rollout (`scripts/rls/`) is documented and ships disabled-by-default for opt-in DB-layer enforcement. + +``` +app/ +├── (public)/ # Marketing, pricing, legal, /help (KB), /status +├── (protected)/ # Authenticated workspace + client portal +├── (onboarding)/ # New-user onboarding flow +├── auth/ # Auth pages (sign-in, sign-up, email-OTP, MFA, SSO) +├── admin/ # Platform admin (users, orgs, plans, flags, both webhook DLQs) +├── api/ +│ ├── v1/ # Public REST API (X-API-Key, GET + POST + Idempotency-Key) +│ ├── cron/ # Vercel Cron handlers (7 jobs, runCron-wrapped) +│ ├── webhooks/ # Inbound (Stripe, Resend bounce/complaint) +│ ├── inngest/ # Inngest function dispatch endpoint +│ ├── openapi.json/ # Hand-written OpenAPI 3.1 spec for /api/v1 +│ └── ... # Internal session-authed endpoints +│ +core/ # Use-cases per domain (framework-agnostic) +lib/ # Shared utilities, service functions +│ ├── analytics/ # PostHog client + server event capture +│ ├── billing/ # India GST module (HSN/SAC, GSTIN, CGST/SGST/IGST snapshot) +│ └── feature-flags.ts # In-house DB-backed flag evaluation (per-org overrides) +server/ +│ ├── api/ # ApiError + helpers, v1 mutations, idempotency +│ ├── auth/ # BetterAuth + permissions + tenant guard +│ ├── billing/ # Stripe event handlers, dunning, GST snapshot +│ ├── cron/ # runCron wrapper (Sentry capture + duration) +│ ├── db/ # Drizzle client + setTenantContext (RLS-ready) +│ ├── email/ # Send pipeline (suppression + category opt-out + queue) +│ ├── observability/ # Structured logger +│ ├── queue/ # Inngest client + functions +│ ├── security/ # Audit log, IP allowlist, Turnstile +│ └── third-party/ # Stripe (with circuit breaker), Resend, Cloudinary +db/ +├── schemas/ # Drizzle schema files per domain +│ ├── access.ts # Orgs, roles, memberships, API keys, outbound webhooks + DLQ +│ ├── work.ts # Clients, projects, tasks, time entries +│ ├── billing.ts # Plans, subscriptions, invoices (incl. GST snapshot fields) +│ ├── platform.ts # Notifications, prefs (per-event + per-category), feature flags +│ └── support.ts # Support tickets +emails/templates/ # 46 HTML email templates +config/ +│ ├── kb-articles.ts # Knowledge-base article content (markdown) +│ ├── navigation.ts # User sidebar nav +│ └── ... +scripts/ +│ ├── bundle-budget.mjs # Per-route JS-size gate (CI step) +│ └── rls/ # Postgres row-level security rollout (disabled-by-default) +docs/ # Production-readiness audits (v1, v2, v3), roadmap, runbooks +drizzle/ # Migration files (0000-0024+) +``` + +--- + +## Features + +### Authentication & Security + +- **Email / Password** authentication via BetterAuth +- **Google OAuth** (configurable) +- **Two-Factor Authentication (TOTP)** with backup code regeneration +- **Email-OTP fallback sign-in** - 6-digit code emailed for users without password access (`/auth/sign-in-otp`) +- **Single Sign-On (SSO)** - OIDC/SAML, Google Workspace, Azure AD, Okta +- **SSO enforcement** per organization (blocks non-SSO logins when enabled) +- **Password complexity policy** enforced server-side via BetterAuth `hooks.before` (8 chars + upper + lower + digit + symbol) +- **Per-account sign-in lockout** - 5 failed attempts / 15-minute window, Upstash-backed, IP-independent +- **Cloudflare Turnstile** on sign-up, contact, and forgot-password forms (soft-skipped when keys absent) +- **Session management** - configurable timeout per org, per-session revocation, "Sign Out All Other Devices" button +- **IP allowlist** enforcement at protected layout (CIDR + IPv6-mapped IPv4 normalisation) +- **Risky sign-in detection** with email alerts +- **Rate limiting** - 10 req/10s on auth endpoints, 120 req/60s on all API routes (per-IP, Upstash sliding window); 1,000 req/min per API key for `/api/v1` traffic; monthly per-key usage counter visible in the API key UI + +--- + +### Security Hardening + +- **Content-Security-Policy** in _enforcing_ mode in production. `CSP_REPORT_ONLY=1` env flag flips to Report-Only as an emergency rollback path without a code change. +- **Strict-Transport-Security** (`max-age=63072000; includeSubDomains; preload`) - production only +- **X-Frame-Options: DENY**, **X-Content-Type-Options: nosniff**, **Referrer-Policy: strict-origin-when-cross-origin** +- **Permissions-Policy** locking down camera / mic / geolocation / payment except for Stripe +- **Cookie hardening** - `Secure`, `HttpOnly`, `SameSite=Lax` (production) +- **Sentry hardening** - `sendDefaultPii: false`, `tracesSampleRate: 0.1` in prod, request-body scrubbing for `/api/billing/webhook` and `/api/webhooks/*` +- **`npm audit` step** in CI (non-blocking, weekly review) +- **Postgres Row-Level Security** policies for every tenant-scoped table, ready to enable via the staged plan in `scripts/rls/README.md` + +--- + +### Privacy & GDPR Compliance + +- **Self-service data export (Article 20)** - `/api/settings/my-data-export` returns a JSON attachment of the user's account, preferences, activity, and work; redacts session tokens, OAuth secrets, password hashes, 2FA secrets, API key hashes; audit-logged +- **Self-service account deletion (Article 17)** - 30-day grace period with cancel-anytime banner; sole-owner-with-members blocker; nightly anonymisation transaction NULLs FKs and hard-deletes session/account/notifications/push +- **Cookie consent banner** - essential / analytics / marketing tiers, `cf_consent` cookie, server-side honored +- **Signed unsubscribe footer** on every outbound email (HMAC-SHA256, timing-safe verify) +- **Email suppression list** with critical-module bypass (auth / billing / security always send) +- **Per-category email preferences** - product / billing / marketing opt-in toggles surfaced at `/notifications/preferences`; mutations audit-logged + +--- + +### Multi-Tenant Organization System + +- Users can belong to multiple organizations with independent roles +- **Organization switcher** in the app header +- Per-organization **settings**: logo, brand color, timezone, currency, session timeout, SSO config, IP allowlist, GSTIN +- **White-label branding** - brand color propagates across the full UI (buttons, badges, sidebar, backgrounds, gradients) via server-injected CSS variable overrides +- **Custom role permissions** - per-role feature access configurable by org admins +- **Member permission overrides** - individual permission exceptions per member +- **Defence-in-depth tenant isolation** - `assertSameTenant` post-fetch checks on `getClientDetailForUser`, `getProjectDetailForUser`, `getInvoiceForUser`, `getTaskDetailForUser`; Postgres RLS available as a staged opt-in (`scripts/rls/`) + +--- + +### Role-Based Access Control (RBAC) + +Five predefined roles with granular permission flags: + +| Role | Description | +| ------- | ---------------------------------- | +| Owner | Full access, billing, org deletion | +| Admin | Team management, settings | +| Manager | Project & task management | +| Member | Standard workspace access | +| Client | Read-only client portal access | + +Permissions enforced at API layer, service layer (via `resolveModulePermissionsForUser`), and UI visibility level. + +--- + +### Project & Client Management + +- Full **CRUD for clients** with detail pages and project associations +- Full **CRUD for projects** with budget tracking, status, and member access +- **Project templates** - create reusable project blueprints with pre-defined tasks +- **Project-level membership** - control who sees each project +- **Time entries** per project and task with full UI (filters, paginated list, log-time dialog) +- **File uploads** (Cloudinary) attached to projects and tasks + +--- + +### Task Management (Kanban) + +- **Kanban board** with drag-and-drop columns and cards (dnd-kit) +- Custom board columns with color coding and ordering +- Per-task: title, description, status, priority, due date, assignees, estimates +- **Subtasks** with individual completion tracking +- **Task comments** with rich text (Tiptap) and @mention notifications +- **File attachments** per task with signed URL delivery +- **Task activity log** - full change history with old/new values +- **Comment limit enforcement** per plan + +--- + +### Billing & Subscriptions (Stripe) + +- **Multi-plan support** (Starter, Professional) with feature flags and usage limits +- Stripe **Checkout** and **Customer Portal** integration +- **In-UI proration preview** before plan upgrade or downgrade - line-item breakdown via `/api/billing/preview-plan-change` +- **Mid-cycle plan changes** in either direction - upgrade flow charges the prorated difference now; downgrade flow applies prorated credit to the next invoice. Direction-aware copy in `PlanChangePreviewDialog` ("Upgrade plan" / "Downgrade plan" / "Change plan"). +- **Trial period** management +- **Cancel-at-period-end** support +- **Usage counters** - members, projects, clients, tasks, comments, files tracked monthly +- **Plan limit enforcement** - 402 errors with upgrade prompts at quota boundaries +- **Invoice management** - Stripe automated invoices + manual invoices with line items +- **Stripe webhook processing** with idempotency key storage and event logging +- **Refund tracking** - `charge.refunded` updates `invoices.{status, refundedAt, amountRefundedCents, refundReason}` and dispatches outbound `invoice.refunded` event +- **Dunning cadence** - day 1 / 3 / 7 / 14 reminder sequence, idempotent stage walk, integrated with `daily-expirations` cron +- **Stripe circuit breaker** - in-process state machine (closed → open → half_open) with 5-failure threshold + 30 s cooldown; trips on 5xx / network / timeout; 4xx don't count. Stripe SDK timeout pinned to 10 s. +- **Billing email notifications** - payment failures, expiring cards, plan changes, overdue reminders + +--- + +### India GST Capture + +When `PLATFORM_GST_STATE_CODE` is set AND a customer org has a GSTIN AND the invoice currency is INR, every Stripe-paid invoice gets a GST snapshot at `invoice.paid` time: + +- `subtotalCents` (tax-exclusive base, gross-to-net decomposed from the Stripe invoice amount) +- `taxBreakdown` JSONB - `{regime: "intra_state" | "inter_state", cgstCents, sgstCents, igstCents, totalTaxCents}` +- `gstinAtInvoice` - buyer's GSTIN at the time of payment +- `hsnSacCode` - 998314 (SaaS default) + +Implementation at `lib/billing/india-gst.ts`. Customer GSTIN management at `/settings/gst`. The custom invoice PDF (`components/invoices/InvoicePDFDocument.tsx`) renders the GST breakdown for the buyer. + +This module is intentionally independent of Stripe Tax - it works on any Stripe account regardless of the account's country, so a US-based Stripe account can still issue GST-formatted invoices to Indian B2B buyers. + +--- + +### Notifications (Multi-Channel) + +| Channel | Implementation | +| --------- | -------------------------------------------- | +| In-App | `notifications` table, unread badge, popover | +| Email | 46 HTML templates via Resend or EmailJS | +| Web Push | VAPID-signed push subscriptions | +| Real-Time | SSE stream via Upstash Redis pub/sub | + +- **Per-event preferences** - users control in-app and email per notification type +- **Per-category email preferences** - coarser product / billing / marketing opt-out (see Privacy & GDPR section) +- **Bulk preference updates** +- **30s polling fallback** when SSE is unavailable +- **Exponential backoff** reconnection for SSE in production + +--- + +### Outbound Webhooks + +- Organization-scoped webhook endpoints +- **12 event types**: `project.created/updated/deleted`, `task.created/updated/completed`, `client.created/updated`, `invoice.paid/overdue/refunded`, `team.member_added/removed` +- **HMAC-SHA256** signed payloads (`X-ClientFlow-Signature` header) +- **3 retry attempts** with exponential backoff (1s, 2s, 4s delays) +- **Outbound DLQ** - every dispatch logged to `outbound_webhook_deliveries` with status (`delivered` / `permanent_fail` / `exhausted`), attempt count, response status, and error +- **Admin replay UI** at `/admin/webhook-deliveries` for `exhausted` rows; 4xx classified as permanent (no replay) +- **Test delivery** button in the UI - sends live signed ping to endpoint +- Concurrent delivery via `Promise.allSettled` + +### Inbound Webhooks + +- **Stripe** - signature-verified, idempotent via `billing_webhook_events` table, processing errors recorded for observability. **Admin DLQ + replay UI** at `/admin/billing-webhook-events` for events that failed processing - the Stripe handlers are idempotent so a manual replay is safe. +- **Resend bounce / complaint webhook** at `/api/webhooks/resend` with manual Svix-style HMAC verification (5-min replay window, timing-safe compare). Hard bounces and complaints flow into `email_suppressions` and are honored on every subsequent send (critical modules bypass). + +--- + +### Public REST API (`/api/v1`) + +- **Strict `X-API-Key` authentication** via `requireV1Auth()` - no session-cookie fallback so third-party SDKs can't accidentally inherit a browser session +- **OpenAPI 3.1 spec** served at `/api/openapi.json` - importable into Postman / Insomnia / OpenAPI Generator +- **Idempotency-Key header** support on POST endpoints (24-hour cache, race-safe via unique index, `Idempotency-Replayed: true` response header on cache hit) +- **Per-API-key rate limit** - 1,000 req/min sliding window via Upstash; monthly usage counter surfaced to the customer in the API key UI +- Endpoints today: + - `GET /api/v1/clients?limit=&offset=` + - `POST /api/v1/clients` (with `Idempotency-Key`) + - `GET /api/v1/projects?limit=&offset=` + - `POST /api/v1/projects` (with `Idempotency-Key`) + - `GET /api/v1/tasks?projectId=&status=&limit=&offset=` + - `POST /api/v1/tasks` (with `Idempotency-Key`) + - `GET /api/v1/invoices?status=&limit=&offset=` +- Pagination clamped server-side (limit 1-200, offset >= 0) +- Standard error shape `{error: string}` with appropriate HTTP status + +### API Keys & Developer Access + +- Organization-scoped API key generation +- SHA-256 hashed storage (key shown once at creation) +- Key prefix display for identification +- Expiration options (30 days / 90 days / 1 year / no expiry) +- Per-key revocation and deletion +- Last-used timestamp tracking +- **Monthly usage counter** per key (Redis-backed, surfaces as "Calls (this month)" column) +- **In-app API reference docs** at `/developer` + +--- + +### Observability & Reliability + +- **Public status page** at `/status` with live multi-service probes: + - DB ping (`SELECT 1`) + - Stripe `/v1/balance` (5 s timeout, soft-skipped without `STRIPE_SECRET_KEY`) + - Resend `/domains` (5 s timeout, soft-skipped without `RESEND_API_KEY`) + - 60 s ISR refresh; overall pill goes amber on any _monitored_ upstream failure +- **Liveness probe** at `/api/health` returning `{status, ts, version, region, db, latencyMs}` with 200 / 503 +- **Sentry** with PII scrubbing, 10 % trace sampling in prod, webhook-body scrubbing in `beforeSend` +- **Structured logging** via `server/observability/logger.ts` +- **Request correlation IDs** - middleware mints `x-request-id` (or trusts Vercel's), forwards to handlers, echoes on response, stamped on every log line + Sentry tag +- **Email-send retry** - 3 attempts with jittered exponential backoff (250 ms / 1 s / 4 s) +- **Async email queue** (Inngest) - when `INNGEST_EVENT_KEY` is set, emails enqueue rather than send synchronously, decoupling Resend latency from the request path. Falls back to sync send when unset, so existing flows are unaffected. + +--- + +### Cron Jobs + +7 jobs defined in `vercel.json`, staggered UTC schedules, `maxDuration: 300`: + +| Cron | Schedule | Purpose | +| -------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------- | +| `task-notifications` | Every hour | Due-soon and overdue task notifications | +| `support-sla-sweep` | Every 15 min | Surface tickets approaching SLA breach | +| `daily-expirations` | 01:00 UTC | Trial-expiry handling + dunning sweep (day 1 / 3 / 7 / 14) | +| `analytics-daily-rollup` | 02:30 UTC | Materialise daily org metrics | +| `nightly-housekeeping` | 03:00 UTC | Sessions purge + soft-delete purge + webhook event purge + Stripe reconciliation + GDPR anonymisation + log retention | +| `payment-method-reminders` | 09:00 UTC | Card-expiring soon notifications | +| `monthly-rollover` | 02:00 (1st) | Reset usage counters at billing period boundary | + +Each cron is gated by `CRON_SECRET` (Bearer token) via `server/cron/guard.ts`. The `runCron(name, fn)` wrapper captures uncaught throws to Sentry with the cron name + duration tag, so silent failures show up in alerting. + +--- + +### Audit & Activity Logs + +- **Audit logs** - actor, action, entity type/ID, IP, user agent, metadata - admin-only, 365-day retention enforced via nightly housekeeping +- **Task audit logs** - field-level change history (oldValues / newValues) +- **Activity logs** - broader activity feed accessible to team members +- **Admin search/filter UI** at `/admin/audit-logs` - filter by actor (name or email, debounced), entity type, date range, search by action / entity type / entity ID +- **CSV export** for both audit and activity logs +- Audit-logged actions include: `user.data_export_requested`, `user.deletion_scheduled/cancelled`, `user.email_preferences_updated`, `replay_webhook_delivery`, `replay_billing_webhook_event`, `org.gst_settings_updated`, `feature_flag.toggled` + +--- + +### Analytics & Feature Flags + +- **PostHog** integrated as a **consent-gated** provider (`components/analytics/PostHogProvider.tsx`) - reads `cf_consent` cookie on mount, listens to `cf:consent-updated` events to switch state when the user toggles consent later +- Manual `$pageview` on every App Router navigation +- Session recording disabled by default +- **5 activation funnel events** firing end-to-end: + - `sign_up_started` (client - email & Google) + - `sign_up_done` (client) + - `first_project_created` (server - on `projectCount === 1`) + - `first_invoice_paid` (server - on `paidCount === 1` from Stripe webhook) + - `plan_upgraded` (server - on `customer.subscription.updated` price change) +- **In-house feature flag system** (`lib/feature-flags.ts` + `feature_flags` + `feature_flag_overrides` tables): + - Two-tier evaluation: per-org override wins over global default + - Bulk variant for fetching all flags for an org in one round-trip + - 60 s in-process cache + - **Admin UI at `/admin/feature-flags`** - global toggles + per-org override management; toggles audit-logged + +--- + +### Analytics Dashboard + +- Total clients, active projects, completed projects, files, revenue +- Projects by status breakdown +- Monthly project creation trend +- Monthly revenue trend (currency-aware) +- Recent project activity feed +- Client-scoped filtering + +--- + +### Knowledge Base & Help Center + +- **Real article content** (not stub copy) at `/help` - 11 articles covering workspace setup, team invitations, first client, Kanban best practices, invoice anatomy + India GST, plan changes/cancellation, 2FA, API key management, GDPR export/deletion, webhook configuration, and email troubleshooting +- Article content lives in `config/kb-articles.ts` (typed Markdown, ships with the deployment) +- Per-article static pages at `/help/[slug]` rendered with `react-markdown` + `remark-gfm`, full SEO metadata, `generateStaticParams` for pre-rendering +- Browse-by-category index with **client-side search** across titles + excerpts + +--- + +### Client Portal + +A separate, role-gated interface for external clients: + +- `/client-portal` - Summary dashboard +- `/client-portal/projects` - Read-only project list and details +- `/client-portal/tasks` - Read-only task list +- `/client-portal/files` - Access to shared project files +- `/client-portal/invoices` - View and download invoices + +Portal inherits org branding (logo, brand color). + +--- + +### Email System + +Dual-provider routing: **EmailJS** when `EMAILJS_PUBLIC_KEY` is set, otherwise **Resend**. + +When `INNGEST_EVENT_KEY` is set, `sendEmail()` enqueues to Inngest and returns immediately - the actual provider call happens in a queued worker (`server/queue/functions/send-email.ts`). Without Inngest configured, the call is synchronous (existing behaviour preserved). + +46 HTML templates across categories: + +| Category | Templates | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Auth | Verify email, password reset, **sign-in OTP**, invite, invite expired/revoked, suspicious login, membership suspended | +| Organization | Role changed, account status changed, ownership transfer | +| Tasks | Assigned, status changed, comment added, mentioned, due soon, overdue, attachment added | +| Billing | Subscription changed, invoice available/overdue, payment failed/method expiring/changed, usage warning, quota reached, upgrade request | +| Security | Session activity notice, forced logout notice | +| Operations | Export ready, webhook failures, API key exposure, rate limit abuse, event/billing delays | +| Files & Portal | Shared file uploaded, client portal enabled | +| Public | Contact form acknowledgement, internal submission | + +Every outbound email carries a signed unsubscribe footer (HMAC-SHA256). Suppression and per-category opt-out checks happen before send; critical modules (auth / billing / security) bypass both. + +--- + +### Global Search & Command Palette + +- **⌘K command palette** (cmdk-based) with grouped sections: + - **Create** shortcuts - New Client, New Project, Invite Teammate, New Invoice + - **Search results** - clients, projects, tasks (real data via `/api/search`) + - **Recent history** (localStorage) + - **Quick navigation** - all sidebar destinations +- **Keyboard-shortcut help modal** - press `?` anywhere to see all shortcuts +- **G-chord navigation** - `g d` (Dashboard), `g c` (Clients), `g p` (Projects), `g t` (Tasks), `g i` (Invoices); skips when target is editable + +--- + +### Testing + +- **Vitest** unit tests with `@testing-library/react` + `vitest-axe` matchers +- **Playwright** E2E suite (~9 specs covering auth, projects, tasks, invoices, clients, settings, org-security) +- **Accessibility tests** - `jest-axe` smoke suite on UI primitives + Playwright a11y specs (`a11y-public.spec.ts`, `a11y-protected.spec.ts`) using `@axe-core/playwright` +- **Lighthouse CI** workflow runs on every PR + nightly against `main`. Captures LCP / INP / CLS / TBT / Web Vitals against budgets defined in `.lighthouserc.json`. Non-blocking; results uploaded to LHCI temporary public storage. + +--- + +### CI/CD & DevEx + +- **GitHub Actions** - typecheck + lint + Vitest + build + bundle-size budget + `npm audit` on every PR and push to main +- **Bundle-size budget gate** - `scripts/bundle-budget.mjs` reads `.next/app-build-manifest.json`, sums each route's JS chunks, fails the build at 450 KB / route by default (per-route override map at the top of the script) +- **Lighthouse CI** for performance regressions (see Testing) +- **Dependabot** - weekly Monday cadence for npm + GitHub Actions, separate prod / dev groups, ignores Next/React major bumps (need coordinated upgrades) +- **Husky + lint-staged + Prettier** with Tailwind plugin +- **Conventional Commits** enforced via `commitlint` and the `.husky/commit-msg` hook +- `CHANGELOG.md` (Keep-a-Changelog format) and `.github/pull_request_template.md` +- See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for local setup, branching, commit conventions, and PR process + +--- + +## Database Schema + +PostgreSQL via Neon. Managed with Drizzle ORM. + +**Tenant safety:** child records use composite tenant-scoped foreign keys (e.g. `(organization_id, project_id)`) to block cross-tenant references at the database level. Application-layer `assertSameTenant` checks add a defence-in-depth layer in detail fetchers. Postgres RLS policies are pre-written in `scripts/rls/01-create-policies.sql` and ship inert; enable via the staged plan in `scripts/rls/README.md` when ready. + +**Schema domains:** + +| Domain | Key Tables | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Access | `organizations`, `organization_settings`, `organization_memberships`, `organization_invitations`, `roles`, `permissions`, `role_permissions`, `api_keys`, `outbound_webhooks`, `outbound_webhook_deliveries` | +| Work | `clients`, `projects`, `project_files`, `project_members`, `project_templates`, `task_board_columns`, `tasks`, `task_comments`, `task_attachments`, `task_audit_logs`, `task_assignees`, `time_entries` | +| Billing | `plans`, `plan_feature_limits`, `subscriptions`, `organization_current_subscriptions`, `invoices` (incl. India GST snapshot fields), `usage_counters`, `billing_webhook_events`, `api_idempotency_keys` | +| Platform | `notifications`, `notification_deliveries`, `notification_preferences`, `email_suppressions`, `email_category_preferences`, `push_subscriptions`, `feature_flags`, `feature_flag_overrides`, `analytics_daily_org_metrics`, `audit_logs`, `platform_admin_actions` | +| Support | `support_tickets`, `support_messages` | + +**Migrations:** managed via `drizzle-kit`. The convention is documented in [`drizzle/README.md`](./drizzle/README.md) - prefer semantic names (`add_invoice_tax_breakdown`) over auto-generated ones; this is a code-review rule, not enforced. + +--- + +## Environment Variables + +See [`.env.example`](./.env.example) for the full annotated list with operator-config notes. Headline groups: + +```bash +# ─── Core app ────────────────────────────────────────────────────────────── +NEXT_PUBLIC_APP_URL= +BETTER_AUTH_URL= +BETTER_AUTH_SECRET= +BETTER_AUTH_REQUIRE_EMAIL_VERIFICATION= + +# ─── Database ────────────────────────────────────────────────────────────── +NEON_DATABASE_URL= + +# ─── Rate limiting (Upstash Redis) - REQUIRED ────────────────────────────── +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= +UPSTASH_REDIS_URL= # ioredis TCP for SSE pub/sub + +# ─── File storage (Cloudinary) ───────────────────────────────────────────── +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= + +# ─── Email delivery ──────────────────────────────────────────────────────── +RESEND_API_KEY= +EMAIL_FROM= +RESEND_REPLY_TO_EMAIL= +RESEND_WEBHOOK_SECRET= # Svix-style HMAC for bounce/complaint webhook +# Optional EmailJS (takes priority over Resend when set) +EMAILJS_PUBLIC_KEY= +EMAILJS_PRIVATE_KEY= +EMAILJS_SERVICE_ID= +EMAILJS_TEMPLATE_ID= +EMAILJS_TRANSACTIONAL_TEMPLATE_ID= + +# ─── Web Push ────────────────────────────────────────────────────────────── +NEXT_PUBLIC_VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT= + +# ─── OAuth (optional) ────────────────────────────────────────────────────── +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# ─── Stripe ──────────────────────────────────────────────────────────────── +STRIPE_SECRET_KEY= +STRIPE_PUBLISHABLE_KEY= +STRIPE_PRICE_ID_STARTER= +STRIPE_PRICE_ID_PROFESSIONAL= +STRIPE_WEBHOOK_SECRET= + +# ─── Bot protection (Cloudflare Turnstile) ───────────────────────────────── +NEXT_PUBLIC_TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= + +# ─── Product analytics (PostHog) ─────────────────────────────────────────── +NEXT_PUBLIC_POSTHOG_KEY= # phc_… project key (NOT a personal API key) +NEXT_PUBLIC_POSTHOG_HOST= + +# ─── India GST (optional, only for B2B India) ────────────────────────────── +PLATFORM_GST_STATE_CODE= # Two-digit state code (e.g. "27" Maharashtra) + +# ─── Async background jobs (Inngest, optional) ───────────────────────────── +INNGEST_EVENT_KEY= # When set, emails enqueue instead of sending sync +INNGEST_SIGNING_KEY= + +# ─── Security (CSP rollback switch, leave unset normally) ────────────────── +CSP_REPORT_ONLY= # Set to "1" only as emergency rollback + +# ─── Cron jobs ───────────────────────────────────────────────────────────── +CRON_SECRET= # Bearer token guarding /api/cron/* + +# ─── Error tracking ──────────────────────────────────────────────────────── +SENTRY_DSN= +NEXT_PUBLIC_SENTRY_DSN= +``` + +--- + +## Local Setup + +```bash +# 1. Install +npm install + +# 2. Copy env template and fill in values +cp .env.example .env + +# 3. Generate auth schema, run migrations, seed roles + plans +npm run db:setup +npm run db:seed:roles +npm run db:seed:plans +npm run db:seed:platform-admin + +# 4. Start dev server +npm run dev +``` + +Common scripts: + +| Script | Purpose | +| ----------------------------------------------- | --------------------------------------- | +| `npm run dev` | Start Next.js dev server | +| `npm run typecheck` | Run `tsc --noEmit` | +| `npm run lint` | Run ESLint | +| `npm test` | Run Vitest unit tests | +| `npm run test:e2e` | Run Playwright E2E suite | +| `npm run build` | Production build | +| `npm run bundle:budget` | Per-route bundle-size gate (post-build) | +| `npm run analyze` | Build with bundle analyzer | +| `npm run db:generate -- --name ` | Generate a new Drizzle migration | +| `npm run db:migrate` | Apply pending migrations | +| `npm run db:studio` | Open Drizzle Studio | + +--- + +## Deployment + +**Platform:** Vercel (serverless) +**Database:** Neon PostgreSQL +**Cache / Pub-Sub:** Upstash Redis +**File Storage:** Cloudinary +**Domain:** client-flow.in (GoDaddy → Vercel DNS) + +Per-route function configuration in [`vercel.json`](./vercel.json): + +- `regions: ["bom1"]` (Mumbai pinning for the India audience) +- `functions.maxDuration` per route family (30 s billing webhook, 60 s PDF/exports, 300 s crons) +- 7 cron schedules (see Cron Jobs section) + +The app is stateless by design - all session state, pub/sub, and rate-limit state live in Upstash Redis. Horizontal scaling requires no additional configuration. + +--- + +## System Design Notes + +**Multi-tenancy:** Shared database, tenant-scoped queries. Every primary table has `organization_id`; high-traffic detail fetchers add `assertSameTenant` post-fetch as a defence-in-depth layer. Composite indexes on `(organization_id, created_at)` and similar patterns. Postgres RLS policies are pre-written and ship inert - enable via the staged plan in `scripts/rls/README.md`. + +**Event-driven internals:** Domain events table captures business events. A job queue handles asynchronous work (notification delivery, webhook dispatch) with deduplication, scheduling, and distributed locking. The Inngest queue (when configured) handles email send asynchronously so a slow Resend doesn't propagate into request latency. + +**Billing isolation:** Billing logic is contained in its own schema domain and service files, structurally ready for extraction into an independent service. The Stripe circuit breaker at `server/third-party/stripe.ts` keeps a degraded Stripe API from exhausting function-time budget across the rest of the app. + +**Email provider flexibility:** A single `sendEmail()` function routes to EmailJS or Resend based on environment configuration - no call sites need to change when switching providers. Suppression and per-category opt-out checks happen before send; critical modules (auth / billing / security) bypass both. With Inngest configured, `sendEmail()` enqueues; the worker calls `sendEmailNow()` which runs the same checks. + +**SSE + polling:** Real-time notification delivery via SSE with Redis pub/sub in production. A 30-second polling fallback in `useNotifications` ensures notifications are never missed if SSE is unavailable. + +**Security layers:** Rate limiting at middleware (per-IP + per-API-key), RBAC at service layer, tenant scoping at query layer, defence-in-depth tenant assertions in detail fetchers, optional Postgres RLS as a fourth layer, HMAC signing on webhooks (in and out), hashed storage for API keys, composite FK constraints at database layer, full security-header set including enforcing CSP. + +**Observability:** Request correlation IDs forwarded across handlers and stamped on every log line and Sentry event. Structured logger. Sentry with PII scrubbing and 10 % trace sampling in production. Public status page probes DB + Stripe + Resend with neutral "unmonitored" pills when API keys are absent. Cron failures captured to Sentry via `runCron` wrapper with the cron name + duration tag. + +--- + +## Documentation + +| Document | Purpose | +| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| [`docs/production-readiness-audit-scope.md`](./docs/production-readiness-audit-scope.md) | The 32-category audit framework | +| [`docs/production-readiness-report.md`](./docs/production-readiness-report.md) | v1 audit (75 % overall) | +| [`docs/production-readiness-report-v2.md`](./docs/production-readiness-report-v2.md) | v2 audit (87 % overall) | +| [`docs/production-readiness-report-v3.md`](./docs/production-readiness-report-v3.md) | **v3 audit (90 % overall)** - latest scorecard | +| [`docs/production-readiness-roadmap-to-100.md`](./docs/production-readiness-roadmap-to-100.md) | Sequenced roadmap to ~96 % (code) / ~98 % (with vendors) | +| [`docs/vercel-resend-infrastructure-checklist.md`](./docs/vercel-resend-infrastructure-checklist.md) | Stack-specific operator checklist | +| [`scripts/rls/README.md`](./scripts/rls/README.md) | Postgres RLS staged-rollout plan | +| [`CONTRIBUTING.md`](./CONTRIBUTING.md) | Local setup, branching, commits, PR process | +| [`CHANGELOG.md`](./CHANGELOG.md) | Release history (Keep-a-Changelog format) | +| [`drizzle/README.md`](./drizzle/README.md) | Migration naming convention and review procedure | diff --git a/app/(protected)/client-portal/support/new/page.tsx b/app/(protected)/client-portal/support/new/page.tsx index 25ba63e..5ce227b 100644 --- a/app/(protected)/client-portal/support/new/page.tsx +++ b/app/(protected)/client-portal/support/new/page.tsx @@ -1,37 +1,37 @@ -import { redirect } from "next/navigation"; -import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; -import { getServerSession } from "@/server/auth/session"; -import { getOrganizationSettingsContextForUser } from "@/server/organization-settings"; -import { NewTicketForm } from "@/components/support"; - -export default async function NewTicketPage() { - const session = await getServerSession(); - if (!session?.user) redirect("/auth/sign-in"); - - const ctx = await getOrganizationSettingsContextForUser(session.user.id); - if (!ctx || ctx.roleKey !== "client") redirect("/dashboard"); - - return ( -
- - - Back to tickets - - -
-

New Support Ticket

-

- Describe your issue and we'll get back to you as soon as possible. -

-
- -
- -
-
- ); -} +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { getServerSession } from "@/server/auth/session"; +import { getOrganizationSettingsContextForUser } from "@/server/organization-settings"; +import { NewTicketForm } from "@/components/support"; + +export default async function NewTicketPage() { + const session = await getServerSession(); + if (!session?.user) redirect("/auth/sign-in"); + + const ctx = await getOrganizationSettingsContextForUser(session.user.id); + if (!ctx || ctx.roleKey !== "client") redirect("/dashboard"); + + return ( +
+ + + Back to tickets + + +
+

New Support Ticket

+

+ Describe your issue and we'll get back to you as soon as possible. +

+
+ +
+ +
+
+ ); +} diff --git a/app/(protected)/clients/index.tsx b/app/(protected)/clients/index.tsx index 8b25be9..6dc4744 100644 --- a/app/(protected)/clients/index.tsx +++ b/app/(protected)/clients/index.tsx @@ -1,71 +1,71 @@ -import Link from "next/link"; -import { Plus } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { ListPageLayout } from "@/components/layout/templates/ListPageLayout"; -import { getServerSession } from "@/server/auth/session"; -import { listClientsForUser } from "@/server/clients"; -import { ClientsTable } from "@/components/tables/ClientsTable"; -import { clientsSearchParamsCache } from "@/core/clients/searchParams"; - -type ClientsPageProps = { - searchParams: Promise>; -}; - -const ClientsPage = async ({ searchParams }: ClientsPageProps) => { - const session = await getServerSession(); - - const { q, page, pageSize, sort, order, status, dateFrom, dateTo } = - clientsSearchParamsCache.parse(await searchParams); - - const result = await listClientsForUser(session!.user.id, { - query: q, - page, - pageSize, - sort, - // When no column is actively sorted fall back to desc (newest first). - // When a column is sorted use the explicit order from the URL. - order: sort ? (order === "asc" ? "asc" : "desc") : "desc", - status: status || undefined, - dateFrom: dateFrom ? new Date(dateFrom) : undefined, - dateTo: dateTo ? new Date(dateTo) : undefined, - }); - - const canWrite = result.access?.canWrite ?? false; - const orgName = result.access?.organizationName; - - return ( - - - Add Client - - - ) : undefined - } - > - {!result.access ? ( -
-

- Complete workspace bootstrap before managing clients. -

-
- ) : ( - - )} -
- ); -}; - -export default ClientsPage; +import Link from "next/link"; +import { Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ListPageLayout } from "@/components/layout/templates/ListPageLayout"; +import { getServerSession } from "@/server/auth/session"; +import { listClientsForUser } from "@/server/clients"; +import { ClientsTable } from "@/components/tables/ClientsTable"; +import { clientsSearchParamsCache } from "@/core/clients/searchParams"; + +type ClientsPageProps = { + searchParams: Promise>; +}; + +const ClientsPage = async ({ searchParams }: ClientsPageProps) => { + const session = await getServerSession(); + + const { q, page, pageSize, sort, order, status, dateFrom, dateTo } = + clientsSearchParamsCache.parse(await searchParams); + + const result = await listClientsForUser(session!.user.id, { + query: q, + page, + pageSize, + sort, + // When no column is actively sorted fall back to desc (newest first). + // When a column is sorted use the explicit order from the URL. + order: sort ? (order === "asc" ? "asc" : "desc") : "desc", + status: status || undefined, + dateFrom: dateFrom ? new Date(dateFrom) : undefined, + dateTo: dateTo ? new Date(dateTo) : undefined, + }); + + const canWrite = result.access?.canWrite ?? false; + const orgName = result.access?.organizationName; + + return ( + + + Add Client + + + ) : undefined + } + > + {!result.access ? ( +
+

+ Complete workspace bootstrap before managing clients. +

+
+ ) : ( + + )} +
+ ); +}; + +export default ClientsPage; diff --git a/app/(protected)/layout.tsx b/app/(protected)/layout.tsx index 520627d..687be3c 100644 --- a/app/(protected)/layout.tsx +++ b/app/(protected)/layout.tsx @@ -1,108 +1,108 @@ -import { redirect } from "next/navigation"; -import { headers } from "next/headers"; -import type { Metadata } from "next"; -import type { ReactNode } from "react"; - -export const dynamic = "force-dynamic"; - -// Protected app routes are not indexable - they're per-tenant and require auth. -export const metadata: Metadata = { - robots: { index: false, follow: false, nocache: true }, -}; -import AppShell from "@/components/layout/app/AppShell"; -import { DeletionPendingBanner } from "@/components/security"; -import { KeyboardShortcutsModal } from "@/components/common/KeyboardShortcutsModal"; -import { GChordNavigation } from "@/components/common/GChordNavigation"; -import { authRoutes } from "@/core/auth"; -import { auth } from "@/server/auth/auth"; -import { getServerSession } from "@/server/auth/session"; -import { getSubscriptionContextForUser } from "@/server/subscription/context"; -import { - getOrganizationSettingsContextForUser, - listOrganizationsForUser, -} from "@/server/organization-settings"; -import { getActiveOrgIdFromCookie } from "@/server/auth/active-org"; -import { isIpAllowed, getClientIp } from "@/server/security/ip-allowlist"; - -export default async function ProtectedLayout({ children }: { children: ReactNode }) { - const session = await getServerSession(); - - if (!session?.user) { - redirect(authRoutes.signIn); - } - - // Platform admins belong in /admin, not the workspace UI - if (session.user.isPlatformAdmin) { - redirect("/admin"); - } - - // Subscription gate - const [sub, orgCtx, orgs, activeOrgId] = await Promise.all([ - getSubscriptionContextForUser(session.user.id), - getOrganizationSettingsContextForUser(session.user.id), - listOrganizationsForUser(session.user.id), - getActiveOrgIdFromCookie(), - ]); - - if (!sub || !sub.hasAccess) { - redirect("/plans"); - } - - // Onboarding gate - first-time owners/admins/managers/members land here - // after sign-up and subscription/trial activation. Clients are invited - // into an existing org and skip this flow. - if (orgCtx && !orgCtx.onboardingCompletedAt && sub.roleKey !== "client") { - redirect("/onboarding"); - } - - // Enforce org-level email verification policy from DB setting - if (orgCtx?.requireEmailVerification && !session.user.emailVerified) { - redirect(`${authRoutes.verifyEmail}?email=${encodeURIComponent(session.user.email)}`); - } - - const reqHeaders = await headers(); - - // Enforce session timeout if the org has one configured - if (orgCtx?.sessionTimeoutHours && session.session?.createdAt) { - const timeoutMs = orgCtx.sessionTimeoutHours * 60 * 60 * 1000; - // This is a Server Component, not a render-phase hook - reading the wall - // clock is intentional and safe. The react-hooks/purity rule misfires here. - // eslint-disable-next-line react-hooks/purity - const sessionAge = Date.now() - new Date(session.session.createdAt).getTime(); - if (sessionAge > timeoutMs) { - await auth.api.signOut({ headers: reqHeaders }).catch(() => {}); - redirect(`${authRoutes.signIn}?reason=session_expired`); - } - } - - // Enforce IP allowlist if the org has one configured - if (orgCtx?.ipAllowlist && orgCtx.ipAllowlist.length > 0) { - const clientIp = getClientIp(reqHeaders); - if (!isIpAllowed(clientIp, orgCtx.ipAllowlist)) { - redirect("/ip-blocked"); - } - } - - return ( - <> - - - - - {children} - - - ); -} +import { redirect } from "next/navigation"; +import { headers } from "next/headers"; +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const dynamic = "force-dynamic"; + +// Protected app routes are not indexable - they're per-tenant and require auth. +export const metadata: Metadata = { + robots: { index: false, follow: false, nocache: true }, +}; +import AppShell from "@/components/layout/app/AppShell"; +import { DeletionPendingBanner } from "@/components/security"; +import { KeyboardShortcutsModal } from "@/components/common/KeyboardShortcutsModal"; +import { GChordNavigation } from "@/components/common/GChordNavigation"; +import { authRoutes } from "@/core/auth"; +import { auth } from "@/server/auth/auth"; +import { getServerSession } from "@/server/auth/session"; +import { getSubscriptionContextForUser } from "@/server/subscription/context"; +import { + getOrganizationSettingsContextForUser, + listOrganizationsForUser, +} from "@/server/organization-settings"; +import { getActiveOrgIdFromCookie } from "@/server/auth/active-org"; +import { isIpAllowed, getClientIp } from "@/server/security/ip-allowlist"; + +export default async function ProtectedLayout({ children }: { children: ReactNode }) { + const session = await getServerSession(); + + if (!session?.user) { + redirect(authRoutes.signIn); + } + + // Platform admins belong in /admin, not the workspace UI + if (session.user.isPlatformAdmin) { + redirect("/admin"); + } + + // Subscription gate + const [sub, orgCtx, orgs, activeOrgId] = await Promise.all([ + getSubscriptionContextForUser(session.user.id), + getOrganizationSettingsContextForUser(session.user.id), + listOrganizationsForUser(session.user.id), + getActiveOrgIdFromCookie(), + ]); + + if (!sub || !sub.hasAccess) { + redirect("/plans"); + } + + // Onboarding gate - first-time owners/admins/managers/members land here + // after sign-up and subscription/trial activation. Clients are invited + // into an existing org and skip this flow. + if (orgCtx && !orgCtx.onboardingCompletedAt && sub.roleKey !== "client") { + redirect("/onboarding"); + } + + // Enforce org-level email verification policy from DB setting + if (orgCtx?.requireEmailVerification && !session.user.emailVerified) { + redirect(`${authRoutes.verifyEmail}?email=${encodeURIComponent(session.user.email)}`); + } + + const reqHeaders = await headers(); + + // Enforce session timeout if the org has one configured + if (orgCtx?.sessionTimeoutHours && session.session?.createdAt) { + const timeoutMs = orgCtx.sessionTimeoutHours * 60 * 60 * 1000; + // This is a Server Component, not a render-phase hook - reading the wall + // clock is intentional and safe. The react-hooks/purity rule misfires here. + // eslint-disable-next-line react-hooks/purity + const sessionAge = Date.now() - new Date(session.session.createdAt).getTime(); + if (sessionAge > timeoutMs) { + await auth.api.signOut({ headers: reqHeaders }).catch(() => {}); + redirect(`${authRoutes.signIn}?reason=session_expired`); + } + } + + // Enforce IP allowlist if the org has one configured + if (orgCtx?.ipAllowlist && orgCtx.ipAllowlist.length > 0) { + const clientIp = getClientIp(reqHeaders); + if (!isIpAllowed(clientIp, orgCtx.ipAllowlist)) { + redirect("/ip-blocked"); + } + } + + return ( + <> + + + + + {children} + + + ); +} diff --git a/app/(protected)/projects/index.tsx b/app/(protected)/projects/index.tsx index db6a485..37ba8ea 100644 --- a/app/(protected)/projects/index.tsx +++ b/app/(protected)/projects/index.tsx @@ -1,98 +1,98 @@ -import Link from "next/link"; -import { Plus, LayoutTemplate, Archive, ArrowLeft } from "lucide-react"; -import { listProjectsForUser } from "@/server/projects"; -import { projectsSearchParamsCache } from "@/core/projects/searchParams"; -import { ListPageLayout } from "@/components/layout/templates/ListPageLayout"; -import { ProjectsTable } from "@/components/tables/ProjectsTable"; -import { getServerSession } from "@/server/auth/session"; - -type ProjectsPageProps = { - searchParams: Promise>; -}; - -export default async function ProjectsPage({ searchParams }: ProjectsPageProps) { - const session = await getServerSession(); - const { q, page, pageSize, sort, order, status, priority, dateFrom, dateTo, view } = - projectsSearchParamsCache.parse(await searchParams); - - const archivedOnly = view === "archived"; - - const { access, projects, pagination } = await listProjectsForUser( - session!.user.id, - { - query: q, - page, - pageSize, - sort, - order: sort ? (order === "asc" ? "asc" : "desc") : "desc", - status: status || undefined, - priority: priority || undefined, - dateFrom: dateFrom ? new Date(dateFrom) : undefined, - dateTo: dateTo ? new Date(dateTo) : undefined, - archivedOnly, - }, - ); - - if (!access) { - return ( -
-

- No active organization found. -

-
- ); - } - - return ( - - {archivedOnly ? ( - - - Back to active - - ) : ( - - - Archived - - )} - {access.canWrite && !archivedOnly && ( - <> - - - Templates - - - - New Project - - - )} - - } - > - - - ); -} +import Link from "next/link"; +import { Plus, LayoutTemplate, Archive, ArrowLeft } from "lucide-react"; +import { listProjectsForUser } from "@/server/projects"; +import { projectsSearchParamsCache } from "@/core/projects/searchParams"; +import { ListPageLayout } from "@/components/layout/templates/ListPageLayout"; +import { ProjectsTable } from "@/components/tables/ProjectsTable"; +import { getServerSession } from "@/server/auth/session"; + +type ProjectsPageProps = { + searchParams: Promise>; +}; + +export default async function ProjectsPage({ searchParams }: ProjectsPageProps) { + const session = await getServerSession(); + const { q, page, pageSize, sort, order, status, priority, dateFrom, dateTo, view } = + projectsSearchParamsCache.parse(await searchParams); + + const archivedOnly = view === "archived"; + + const { access, projects, pagination } = await listProjectsForUser( + session!.user.id, + { + query: q, + page, + pageSize, + sort, + order: sort ? (order === "asc" ? "asc" : "desc") : "desc", + status: status || undefined, + priority: priority || undefined, + dateFrom: dateFrom ? new Date(dateFrom) : undefined, + dateTo: dateTo ? new Date(dateTo) : undefined, + archivedOnly, + }, + ); + + if (!access) { + return ( +
+

+ No active organization found. +

+
+ ); + } + + return ( + + {archivedOnly ? ( + + + Back to active + + ) : ( + + + Archived + + )} + {access.canWrite && !archivedOnly && ( + <> + + + Templates + + + + New Project + + + )} + + } + > + + + ); +} diff --git a/app/(protected)/settings/layout.tsx b/app/(protected)/settings/layout.tsx index 3031eed..6e655fa 100644 --- a/app/(protected)/settings/layout.tsx +++ b/app/(protected)/settings/layout.tsx @@ -1,85 +1,85 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { Building2, Palette, Key, HardDrive, Webhook, ShieldCheck, ShieldUser } from "lucide-react"; -import { cn } from "@/utils/cn"; - -const SETTINGS_NAV = [ - { href: "/settings", label: "Organization", icon: Building2, exact: true }, - { href: "/settings/branding", label: "Branding", icon: Palette, exact: false }, - { href: "/settings/roles", label: "Role Permissions", icon: ShieldUser, exact: false }, - { href: "/settings/api-keys", label: "API Keys", icon: Key, exact: false }, - { href: "/settings/data", label: "Data Export", icon: HardDrive, exact: false }, - { href: "/settings/webhooks", label: "Webhooks", icon: Webhook, exact: false }, - { href: "/settings/sso", label: "SSO", icon: ShieldCheck, exact: false }, -]; - -export default function SettingsLayout({ - children, -}: { - children: React.ReactNode; -}) { - const pathname = usePathname(); - - return ( -
- {/* Sidebar nav */} - - - {/* Mobile tab bar */} -
- {SETTINGS_NAV.map((item) => { - const active = item.exact - ? pathname === item.href - : pathname.startsWith(item.href); - return ( - - - {item.label} - - ); - })} -
- - {/* Content */} -
{children}
-
- ); -} +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Building2, Palette, Key, HardDrive, Webhook, ShieldCheck, ShieldUser } from "lucide-react"; +import { cn } from "@/utils/cn"; + +const SETTINGS_NAV = [ + { href: "/settings", label: "Organization", icon: Building2, exact: true }, + { href: "/settings/branding", label: "Branding", icon: Palette, exact: false }, + { href: "/settings/roles", label: "Role Permissions", icon: ShieldUser, exact: false }, + { href: "/settings/api-keys", label: "API Keys", icon: Key, exact: false }, + { href: "/settings/data", label: "Data Export", icon: HardDrive, exact: false }, + { href: "/settings/webhooks", label: "Webhooks", icon: Webhook, exact: false }, + { href: "/settings/sso", label: "SSO", icon: ShieldCheck, exact: false }, +]; + +export default function SettingsLayout({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + + return ( +
+ {/* Sidebar nav */} + + + {/* Mobile tab bar */} +
+ {SETTINGS_NAV.map((item) => { + const active = item.exact + ? pathname === item.href + : pathname.startsWith(item.href); + return ( + + + {item.label} + + ); + })} +
+ + {/* Content */} +
{children}
+
+ ); +} diff --git a/app/(public)/api-docs/index.tsx b/app/(public)/api-docs/index.tsx index 15caf1f..9a630ae 100644 --- a/app/(public)/api-docs/index.tsx +++ b/app/(public)/api-docs/index.tsx @@ -1,106 +1,106 @@ -"use client"; -import { endpoints, features } from "@/config/api-docs"; -import { motion } from "framer-motion"; -import { Code } from "lucide-react"; - -const methodColors: Record = { - GET: "bg-emerald-100 text-emerald-700", - POST: "bg-blue-100 text-blue-700", - PATCH: "bg-amber-100 text-amber-700", - DELETE: "bg-red-100 text-red-700", -}; - -const ApiDocsPage = () => ( - <> -
-
-
-
- -
- - REST API v1 -
-

- API Documentation -

-

- Build custom integrations with the ClientFlow REST API. Full CRUD - operations for all core resources. -

-
- Base URL: https://api.clientflow.io/v1 -
-
-
-
- -
-
-
-
-

- Endpoints -

-
- {endpoints.map((ep) => ( -
- - {ep.method} - -
- - {ep.path} - -

- {ep.desc} -

-
-
- ))} -
-
- -
-

- Features -

-
- {features.map((f) => ( -
-
- -

- {f.title} -

-
-

- {f.desc} -

-
- ))} -
-
-
-
-
- -); - -export default ApiDocsPage; +"use client"; +import { endpoints, features } from "@/config/api-docs"; +import { motion } from "framer-motion"; +import { Code } from "lucide-react"; + +const methodColors: Record = { + GET: "bg-emerald-100 text-emerald-700", + POST: "bg-blue-100 text-blue-700", + PATCH: "bg-amber-100 text-amber-700", + DELETE: "bg-red-100 text-red-700", +}; + +const ApiDocsPage = () => ( + <> +
+
+
+
+ +
+ + REST API v1 +
+

+ API Documentation +

+

+ Build custom integrations with the ClientFlow REST API. Full CRUD + operations for all core resources. +

+
+ Base URL: https://api.clientflow.io/v1 +
+
+
+
+ +
+
+
+
+

+ Endpoints +

+
+ {endpoints.map((ep) => ( +
+ + {ep.method} + +
+ + {ep.path} + +

+ {ep.desc} +

+
+
+ ))} +
+
+ +
+

+ Features +

+
+ {features.map((f) => ( +
+
+ +

+ {f.title} +

+
+

+ {f.desc} +

+
+ ))} +
+
+
+
+
+ +); + +export default ApiDocsPage; diff --git a/app/(public)/blogs/index.tsx b/app/(public)/blogs/index.tsx index dffbf4a..a64ed20 100644 --- a/app/(public)/blogs/index.tsx +++ b/app/(public)/blogs/index.tsx @@ -1,84 +1,84 @@ -"use client"; -import { motion } from "framer-motion"; -import { ArrowRight } from "lucide-react"; -import Link from "next/link"; -import { useMotionStagger } from "@/hooks/use-home-motion"; -import { posts } from "@/config/blogs"; - -const BlogPage = () => { - const motionStagger = useMotionStagger({ - step: 0.05, - initialY: 10, - duration: 0.3, - }); - - return ( - <> -
-
-
-
- -

- Blog -

-

- Insights on agency management, product updates, and best - practices. -

-
-
-
- -
-
- - {posts.map((post) => ( - -
- - {post.category} - - {post.date} - · {post.readTime} -
-

- {post.title} -

-

- {post.excerpt} -

- - Read More - -
- ))} -
-
-
- - ); -}; - -export default BlogPage; +"use client"; +import { motion } from "framer-motion"; +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; +import { useMotionStagger } from "@/hooks/use-home-motion"; +import { posts } from "@/config/blogs"; + +const BlogPage = () => { + const motionStagger = useMotionStagger({ + step: 0.05, + initialY: 10, + duration: 0.3, + }); + + return ( + <> +
+
+
+
+ +

+ Blog +

+

+ Insights on agency management, product updates, and best + practices. +

+
+
+
+ +
+
+ + {posts.map((post) => ( + +
+ + {post.category} + + {post.date} + · {post.readTime} +
+

+ {post.title} +

+

+ {post.excerpt} +

+ + Read More + +
+ ))} +
+
+
+ + ); +}; + +export default BlogPage; diff --git a/app/(public)/blogs/page.tsx b/app/(public)/blogs/page.tsx index 6e427f7..cab8ce1 100644 --- a/app/(public)/blogs/page.tsx +++ b/app/(public)/blogs/page.tsx @@ -1,11 +1,11 @@ -import { buildMetadata } from "@/lib/seo"; -import BlogPage from "."; - -export const metadata = buildMetadata({ - title: "Blog", - description: - "Read the latest articles, insights, and updates from the ClientFlow team. Stay informed about industry trends, best practices, and tips for optimizing your client management strategies.", - path: "/blogs", -}); - -export default BlogPage; +import { buildMetadata } from "@/lib/seo"; +import BlogPage from "."; + +export const metadata = buildMetadata({ + title: "Blog", + description: + "Read the latest articles, insights, and updates from the ClientFlow team. Stay informed about industry trends, best practices, and tips for optimizing your client management strategies.", + path: "/blogs", +}); + +export default BlogPage; diff --git a/app/(public)/careers/index.tsx b/app/(public)/careers/index.tsx index 853fc33..ea26b27 100644 --- a/app/(public)/careers/index.tsx +++ b/app/(public)/careers/index.tsx @@ -1,110 +1,110 @@ -"use client"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; -import { motion } from "framer-motion"; -import { Briefcase, MapPin, ArrowRight } from "lucide-react"; -import { useMotionStagger } from "@/hooks/use-home-motion"; -import { openings, perks } from "@/config/careers"; - -const CareersPage = () => { - const motionStagger = useMotionStagger({ - step: 0.04, - initialY: 10, - duration: 0.3, - }); - - return ( - <> -
-
-
-
- -

- Join the{" "} - ClientFlow team -

-

- We're building the operating system for modern agencies. Come - help us shape how teams work. -

-
-
-
- -
-
-

- Open Positions -

- - {openings.map((job) => ( - -
-

- {job.title} -

-
- - {job.team} - - - {job.location} - - - {job.type} - -
-
- -
- ))} -
-
-
- -
-
-

- Why ClientFlow? -

-
- {perks.map((p) => ( -
-
- {p} -
- ))} -
-
-
- - ); -}; - -export default CareersPage; +"use client"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { motion } from "framer-motion"; +import { Briefcase, MapPin, ArrowRight } from "lucide-react"; +import { useMotionStagger } from "@/hooks/use-home-motion"; +import { openings, perks } from "@/config/careers"; + +const CareersPage = () => { + const motionStagger = useMotionStagger({ + step: 0.04, + initialY: 10, + duration: 0.3, + }); + + return ( + <> +
+
+
+
+ +

+ Join the{" "} + ClientFlow team +

+

+ We're building the operating system for modern agencies. Come + help us shape how teams work. +

+
+
+
+ +
+
+

+ Open Positions +

+ + {openings.map((job) => ( + +
+

+ {job.title} +

+
+ + {job.team} + + + {job.location} + + + {job.type} + +
+
+ +
+ ))} +
+
+
+ +
+
+

+ Why ClientFlow? +

+
+ {perks.map((p) => ( +
+
+ {p} +
+ ))} +
+
+
+ + ); +}; + +export default CareersPage; diff --git a/app/(public)/careers/page.tsx b/app/(public)/careers/page.tsx index db9473a..d3470ff 100644 --- a/app/(public)/careers/page.tsx +++ b/app/(public)/careers/page.tsx @@ -1,11 +1,11 @@ -import { buildMetadata } from "@/lib/seo"; -import CareersPage from "."; - -export const metadata = buildMetadata({ - title: "Careers", - description: - "Explore career opportunities at ClientFlow. Join our team and help us revolutionize client management with innovative solutions. Check out our current job openings and apply today.", - path: "/careers", -}); - -export default CareersPage; +import { buildMetadata } from "@/lib/seo"; +import CareersPage from "."; + +export const metadata = buildMetadata({ + title: "Careers", + description: + "Explore career opportunities at ClientFlow. Join our team and help us revolutionize client management with innovative solutions. Check out our current job openings and apply today.", + path: "/careers", +}); + +export default CareersPage; diff --git a/app/(public)/changelog/index.tsx b/app/(public)/changelog/index.tsx index 3325144..74aeb60 100644 --- a/app/(public)/changelog/index.tsx +++ b/app/(public)/changelog/index.tsx @@ -1,76 +1,76 @@ -import { releases } from "@/config/changelog"; -import { motion } from "framer-motion"; -import { Tag } from "lucide-react"; - -const typeBadge: Record = { - feature: "bg-emerald-100 text-emerald-700", - improvement: "bg-blue-100 text-blue-700", - fix: "bg-amber-100 text-amber-700", -}; - -const ChangelogPage = () => ( - <> -
-
-
-
- -

- Changelog -

-

- Product updates, new features, and improvements. -

-
-
-
- -
-
-
- {releases.map((r) => ( - -
-
- - v{r.version} - - -
-
    - {r.items.map((item, i) => ( -
  • - - {item.type} - - {item.text} -
  • - ))} -
- - ))} -
-
-
- -); - -export default ChangelogPage; +import { releases } from "@/config/changelog"; +import { motion } from "framer-motion"; +import { Tag } from "lucide-react"; + +const typeBadge: Record = { + feature: "bg-emerald-100 text-emerald-700", + improvement: "bg-blue-100 text-blue-700", + fix: "bg-amber-100 text-amber-700", +}; + +const ChangelogPage = () => ( + <> +
+
+
+
+ +

+ Changelog +

+

+ Product updates, new features, and improvements. +

+
+
+
+ +
+
+
+ {releases.map((r) => ( + +
+
+ + v{r.version} + + +
+
    + {r.items.map((item, i) => ( +
  • + + {item.type} + + {item.text} +
  • + ))} +
+ + ))} +
+
+
+ +); + +export default ChangelogPage; diff --git a/app/(public)/docs/index.tsx b/app/(public)/docs/index.tsx index 13a2e76..799c74c 100644 --- a/app/(public)/docs/index.tsx +++ b/app/(public)/docs/index.tsx @@ -1,67 +1,67 @@ -import { Button } from "@/components/ui/button"; -import { sections } from "@/config/docs"; -import { motion } from "framer-motion"; -import { ArrowRight } from "lucide-react"; -import Link from "next/link"; - -const DocsPage = () => ( - <> -
-
-
-
- -

- Documentation -

-

- Everything you need to build, integrate, and scale with ClientFlow. -

-
-
-
- -
-
-
- {sections.map((s) => ( - -
- -
-
-

- {s.title} -

-

- {s.desc} -

-
- -
- ))} -
-
-
- -); - -export default DocsPage; +import { Button } from "@/components/ui/button"; +import { sections } from "@/config/docs"; +import { motion } from "framer-motion"; +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; + +const DocsPage = () => ( + <> +
+
+
+
+ +

+ Documentation +

+

+ Everything you need to build, integrate, and scale with ClientFlow. +

+
+
+
+ +
+
+
+ {sections.map((s) => ( + +
+ +
+
+

+ {s.title} +

+

+ {s.desc} +

+
+ +
+ ))} +
+
+
+ +); + +export default DocsPage; diff --git a/app/(public)/index.tsx b/app/(public)/index.tsx index 8f52811..44f7aed 100644 --- a/app/(public)/index.tsx +++ b/app/(public)/index.tsx @@ -1,25 +1,25 @@ -import { - HeroSection, - FeaturesSection, - TestimonialsSection, - StatsSection, - PricingSection, - CTASection, -} from "@/components/homepage"; -import { getPublicPlans } from "@/server/public/plans"; - -const Landing = async () => { - const plans = await getPublicPlans(); - return ( - <> - - - - - - - - ); -}; - -export default Landing; +import { + HeroSection, + FeaturesSection, + TestimonialsSection, + StatsSection, + PricingSection, + CTASection, +} from "@/components/homepage"; +import { getPublicPlans } from "@/server/public/plans"; + +const Landing = async () => { + const plans = await getPublicPlans(); + return ( + <> + + + + + + + + ); +}; + +export default Landing; diff --git a/app/(public)/integrations/index.tsx b/app/(public)/integrations/index.tsx index b7e25cb..c78e887 100644 --- a/app/(public)/integrations/index.tsx +++ b/app/(public)/integrations/index.tsx @@ -1,112 +1,112 @@ -"use client"; -import Link from "next/link"; -import { useMotionStagger } from "@/hooks/use-home-motion"; -import { Button } from "@/components/ui/button"; -import { motion } from "framer-motion"; -import { Puzzle, ArrowRight, Zap } from "lucide-react"; -import { categories } from "@/config/integrations"; - -const IntegrationsPage = () => { - const motionStagger = useMotionStagger({ - step: 0.04, - initialY: 10, - duration: 0.3, - }); - - return ( - <> -
-
-
-
- -
- - Connect your stack -
-

- Integrations that{" "} - - power your workflow - -

-

- ClientFlow connects with the tools your agency already uses. No - context switching - just seamless workflows. -

-
-
-
- - {categories.map((cat, i) => ( -
-
-

- {cat.name} -

- - {cat.integrations.map((int) => ( - -
- -
-

- {int.name} -

-

- {int.desc} -

-
- ))} -
-
-
- ))} - -
-
-

- Need a custom integration? -

-

- Use the ClientFlow REST API and webhooks to build custom - integrations for your unique workflows. -

-
- - -
-
-
- - ); -}; - -export default IntegrationsPage; +"use client"; +import Link from "next/link"; +import { useMotionStagger } from "@/hooks/use-home-motion"; +import { Button } from "@/components/ui/button"; +import { motion } from "framer-motion"; +import { Puzzle, ArrowRight, Zap } from "lucide-react"; +import { categories } from "@/config/integrations"; + +const IntegrationsPage = () => { + const motionStagger = useMotionStagger({ + step: 0.04, + initialY: 10, + duration: 0.3, + }); + + return ( + <> +
+
+
+
+ +
+ + Connect your stack +
+

+ Integrations that{" "} + + power your workflow + +

+

+ ClientFlow connects with the tools your agency already uses. No + context switching - just seamless workflows. +

+
+
+
+ + {categories.map((cat, i) => ( +
+
+

+ {cat.name} +

+ + {cat.integrations.map((int) => ( + +
+ +
+

+ {int.name} +

+

+ {int.desc} +

+
+ ))} +
+
+
+ ))} + +
+
+

+ Need a custom integration? +

+

+ Use the ClientFlow REST API and webhooks to build custom + integrations for your unique workflows. +

+
+ + +
+
+
+ + ); +}; + +export default IntegrationsPage; diff --git a/app/(public)/partners/index.tsx b/app/(public)/partners/index.tsx index b71e595..849b5e7 100644 --- a/app/(public)/partners/index.tsx +++ b/app/(public)/partners/index.tsx @@ -1,96 +1,96 @@ -"use client"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; -import { motion } from "framer-motion"; -import { Handshake, ArrowRight } from "lucide-react"; -import { useMotionStagger } from "@/hooks/use-home-motion"; -import { tiers } from "@/config/partners"; - -const PartnersPage = () => { - const motionStagger = useMotionStagger({ - step: 0.06, - initialY: 10, - duration: 0.3, - }); - - return ( - <> -
-
-
-
- -
- - Partner Program -
-

- Grow with{" "} - ClientFlow -

-

- Join our partner ecosystem and earn recurring revenue while - helping agencies succeed. -

-
-
-
- -
-
- - {tiers.map((t) => ( - -
- -
-

- {t.title} -

-

- {t.desc} -

-
    - {t.benefits.map((b) => ( -
  • -
    - {b} -
  • - ))} -
- -
- ))} -
-
-
- - ); -}; - -export default PartnersPage; +"use client"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { motion } from "framer-motion"; +import { Handshake, ArrowRight } from "lucide-react"; +import { useMotionStagger } from "@/hooks/use-home-motion"; +import { tiers } from "@/config/partners"; + +const PartnersPage = () => { + const motionStagger = useMotionStagger({ + step: 0.06, + initialY: 10, + duration: 0.3, + }); + + return ( + <> +
+
+
+
+ +
+ + Partner Program +
+

+ Grow with{" "} + ClientFlow +

+

+ Join our partner ecosystem and earn recurring revenue while + helping agencies succeed. +

+
+
+
+ +
+
+ + {tiers.map((t) => ( + +
+ +
+

+ {t.title} +

+

+ {t.desc} +

+
    + {t.benefits.map((b) => ( +
  • +
    + {b} +
  • + ))} +
+ +
+ ))} +
+
+
+ + ); +}; + +export default PartnersPage; diff --git a/app/(public)/partners/page.tsx b/app/(public)/partners/page.tsx index 1b7e30d..0074443 100644 --- a/app/(public)/partners/page.tsx +++ b/app/(public)/partners/page.tsx @@ -1,11 +1,11 @@ -import { buildMetadata } from "@/lib/seo"; -import PartnersPage from "."; - -export const metadata = buildMetadata({ - title: "Partners", - description: - "Discover the benefits of partnering with ClientFlow. Learn about our partnership program and the opportunities available for our partners.", - path: "/partners", -}); - -export default PartnersPage; +import { buildMetadata } from "@/lib/seo"; +import PartnersPage from "."; + +export const metadata = buildMetadata({ + title: "Partners", + description: + "Discover the benefits of partnering with ClientFlow. Learn about our partnership program and the opportunities available for our partners.", + path: "/partners", +}); + +export default PartnersPage; diff --git a/app/(public)/press/index.tsx b/app/(public)/press/index.tsx index 9b0ce23..35d08eb 100644 --- a/app/(public)/press/index.tsx +++ b/app/(public)/press/index.tsx @@ -1,95 +1,95 @@ -import Link from "next/link"; -import { Button } from "@/components/ui/button"; -import { motion } from "framer-motion"; -import { Newspaper, Download, ArrowRight } from "lucide-react"; -import { pressReleases } from "@/config/press"; - -const PressPage = () => ( - <> -
-
-
-
- -

- Press & Media -

-

- News, press releases, and media resources from ClientFlow. -

-
- -
-
-
-
- -
-
-

- Press Releases -

-
- {pressReleases.map((pr) => ( - - -

- {pr.title} -

-

- {pr.excerpt} -

- -
- ))} -
-
-
- -
-
- -

- Media Inquiries -

-

- For press inquiries, interviews, or media partnerships, reach out to - our communications team. -

-

- press@clientflow.io -

-
-
- -); - -export default PressPage; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { motion } from "framer-motion"; +import { Newspaper, Download, ArrowRight } from "lucide-react"; +import { pressReleases } from "@/config/press"; + +const PressPage = () => ( + <> +
+
+
+
+ +

+ Press & Media +

+

+ News, press releases, and media resources from ClientFlow. +

+
+ +
+
+
+
+ +
+
+

+ Press Releases +

+
+ {pressReleases.map((pr) => ( + + +

+ {pr.title} +

+

+ {pr.excerpt} +

+ +
+ ))} +
+
+
+ +
+
+ +

+ Media Inquiries +

+

+ For press inquiries, interviews, or media partnerships, reach out to + our communications team. +

+

+ press@clientflow.io +

+
+
+ +); + +export default PressPage; diff --git a/app/(public)/press/page.tsx b/app/(public)/press/page.tsx index 3375851..e371972 100644 --- a/app/(public)/press/page.tsx +++ b/app/(public)/press/page.tsx @@ -1,11 +1,11 @@ -import { buildMetadata } from "@/lib/seo"; -import PressPage from "."; - -export const metadata = buildMetadata({ - title: "Press", - description: - "Explore the latest news and media coverage about ClientFlow. Stay updated with our press releases, announcements, and industry impact.", - path: "/press", -}); - -export default PressPage; +import { buildMetadata } from "@/lib/seo"; +import PressPage from "."; + +export const metadata = buildMetadata({ + title: "Press", + description: + "Explore the latest news and media coverage about ClientFlow. Stay updated with our press releases, announcements, and industry impact.", + path: "/press", +}); + +export default PressPage; diff --git a/app/(public)/security/index.tsx b/app/(public)/security/index.tsx index ad9009a..88fae87 100644 --- a/app/(public)/security/index.tsx +++ b/app/(public)/security/index.tsx @@ -1,143 +1,143 @@ -"use client"; -import Link from "next/link"; -import { useMotionStagger } from "@/hooks/use-home-motion"; -import { Button } from "@/components/ui/button"; -import { motion } from "framer-motion"; -import { CheckCircle2, Shield } from "lucide-react"; -import { certifications, practices } from "@/config/security"; - -const SecurityPage = () => { - const motionStagger = useMotionStagger({ - step: 0.05, - initialY: 10, - duration: 0.3, - }); - - return ( - <> -
-
-
-
- -
- - Enterprise-grade security -
-

- Your data is our{" "} - top priority -

-

- ClientFlow is built with security-first architecture. We protect - your agency' data with industry-leading practices and - certifications. -

-
-
-
- -
-
-

- Certifications & Compliance -

-

- Independent verification of our security commitments. -

- - {certifications.map((c) => ( - - -

- {c.label} -

-

- {c.desc} -

-
- ))} -
-
-
- -
-
-

- Security Practices -

-

- How we keep your data safe every day. -

- - {practices.map((p) => ( - -
- -
-

- {p.title} -

-

- {p.desc} -

-
- ))} -
-
-
- -
-
-

- Report a Vulnerability -

-

- We welcome responsible security research. If you discover a - vulnerability, please report it through our disclosure program. -

-
- - -
-
-
- - ); -}; - -export default SecurityPage; +"use client"; +import Link from "next/link"; +import { useMotionStagger } from "@/hooks/use-home-motion"; +import { Button } from "@/components/ui/button"; +import { motion } from "framer-motion"; +import { CheckCircle2, Shield } from "lucide-react"; +import { certifications, practices } from "@/config/security"; + +const SecurityPage = () => { + const motionStagger = useMotionStagger({ + step: 0.05, + initialY: 10, + duration: 0.3, + }); + + return ( + <> +
+
+
+
+ +
+ + Enterprise-grade security +
+

+ Your data is our{" "} + top priority +

+

+ ClientFlow is built with security-first architecture. We protect + your agency' data with industry-leading practices and + certifications. +

+
+
+
+ +
+
+

+ Certifications & Compliance +

+

+ Independent verification of our security commitments. +

+ + {certifications.map((c) => ( + + +

+ {c.label} +

+

+ {c.desc} +

+
+ ))} +
+
+
+ +
+
+

+ Security Practices +

+

+ How we keep your data safe every day. +

+ + {practices.map((p) => ( + +
+ +
+

+ {p.title} +

+

+ {p.desc} +

+
+ ))} +
+
+
+ +
+
+

+ Report a Vulnerability +

+

+ We welcome responsible security research. If you discover a + vulnerability, please report it through our disclosure program. +

+
+ + +
+
+
+ + ); +}; + +export default SecurityPage; diff --git a/app/(public)/status/index.tsx b/app/(public)/status/index.tsx index 60a611b..0b3d2a6 100644 --- a/app/(public)/status/index.tsx +++ b/app/(public)/status/index.tsx @@ -1,149 +1,149 @@ -"use client"; - -import { incidents, services } from "@/config/status"; -import { motion } from "framer-motion"; -import { CheckCircle2, AlertTriangle, Clock, MinusCircle } from "lucide-react"; - -const statusIcon = (s: string) => { - if (s === "operational") return ; - if (s === "degraded") return ; - if (s === "unmonitored") return ; - return ; -}; - -type Probe = { ok: boolean; latencyMs: number; skipped?: boolean }; - -type Props = { - dbHealth?: Probe; - stripeHealth?: Probe; - resendHealth?: Probe; -}; - -function probeToStatus(p: Probe | undefined): string { - if (!p) return "operational"; - if (p.skipped) return "unmonitored"; - return p.ok ? "operational" : "degraded"; -} - -const StatusPage = ({ dbHealth, stripeHealth, resendHealth }: Props) => { - // Overall pill goes amber if any *monitored* upstream is down. An unmonitored - // (no API key configured) probe is neutral and doesn't affect overall state. - const probes: Array<{ name: string; probe: Probe | undefined }> = [ - { name: "db", probe: dbHealth }, - { name: "stripe", probe: stripeHealth }, - { name: "resend", probe: resendHealth }, - ]; - const anyMonitoredDown = probes.some(({ probe }) => probe && !probe.skipped && !probe.ok); - const overallStatus = anyMonitoredDown ? "Investigating an issue" : "All Systems Operational"; - const pillClass = anyMonitoredDown - ? "bg-amber-100 text-amber-700" - : "bg-emerald-100 text-emerald-700"; - - // Map live probes onto the matching marketing rows. Anything not probed - // keeps its config-defined value. - const apiOk = dbHealth?.ok ?? true; - const stripeStatus = probeToStatus(stripeHealth); - const resendStatus = probeToStatus(resendHealth); - - const liveServices = services.map((s) => { - if (s.name === "Web Application" || s.name === "REST API") { - return { ...s, status: apiOk ? "operational" : "degraded" }; - } - if (s.name === "Billing & Payments") { - return { ...s, status: stripeStatus }; - } - if (s.name === "Email Notifications") { - return { ...s, status: resendStatus }; - } - return s; - }); - - return ( - <> -
-
-
-
- -

- System Status -

-
- {anyMonitoredDown ? : } - {overallStatus} -
- {dbHealth ? ( -

- Database round-trip: {dbHealth.latencyMs}ms · refreshed every 60s -

- ) : null} -
-
-
- -
-
-

Services

-
- {liveServices.map((s, i) => ( -
-
- {statusIcon(s.status)} - {s.name} -
-
- {s.uptime} uptime - - {s.status} - -
-
- ))} -
-
-
- -
-
-

Recent Incidents

-
- {incidents.map((inc) => ( -
-
- - - {inc.status} - -
-

- {inc.title} -

-

{inc.desc}

-
- ))} -
-
-
- - ); -}; - -export default StatusPage; +"use client"; + +import { incidents, services } from "@/config/status"; +import { motion } from "framer-motion"; +import { CheckCircle2, AlertTriangle, Clock, MinusCircle } from "lucide-react"; + +const statusIcon = (s: string) => { + if (s === "operational") return ; + if (s === "degraded") return ; + if (s === "unmonitored") return ; + return ; +}; + +type Probe = { ok: boolean; latencyMs: number; skipped?: boolean }; + +type Props = { + dbHealth?: Probe; + stripeHealth?: Probe; + resendHealth?: Probe; +}; + +function probeToStatus(p: Probe | undefined): string { + if (!p) return "operational"; + if (p.skipped) return "unmonitored"; + return p.ok ? "operational" : "degraded"; +} + +const StatusPage = ({ dbHealth, stripeHealth, resendHealth }: Props) => { + // Overall pill goes amber if any *monitored* upstream is down. An unmonitored + // (no API key configured) probe is neutral and doesn't affect overall state. + const probes: Array<{ name: string; probe: Probe | undefined }> = [ + { name: "db", probe: dbHealth }, + { name: "stripe", probe: stripeHealth }, + { name: "resend", probe: resendHealth }, + ]; + const anyMonitoredDown = probes.some(({ probe }) => probe && !probe.skipped && !probe.ok); + const overallStatus = anyMonitoredDown ? "Investigating an issue" : "All Systems Operational"; + const pillClass = anyMonitoredDown + ? "bg-amber-100 text-amber-700" + : "bg-emerald-100 text-emerald-700"; + + // Map live probes onto the matching marketing rows. Anything not probed + // keeps its config-defined value. + const apiOk = dbHealth?.ok ?? true; + const stripeStatus = probeToStatus(stripeHealth); + const resendStatus = probeToStatus(resendHealth); + + const liveServices = services.map((s) => { + if (s.name === "Web Application" || s.name === "REST API") { + return { ...s, status: apiOk ? "operational" : "degraded" }; + } + if (s.name === "Billing & Payments") { + return { ...s, status: stripeStatus }; + } + if (s.name === "Email Notifications") { + return { ...s, status: resendStatus }; + } + return s; + }); + + return ( + <> +
+
+
+
+ +

+ System Status +

+
+ {anyMonitoredDown ? : } + {overallStatus} +
+ {dbHealth ? ( +

+ Database round-trip: {dbHealth.latencyMs}ms · refreshed every 60s +

+ ) : null} +
+
+
+ +
+
+

Services

+
+ {liveServices.map((s, i) => ( +
+
+ {statusIcon(s.status)} + {s.name} +
+
+ {s.uptime} uptime + + {s.status} + +
+
+ ))} +
+
+
+ +
+
+

Recent Incidents

+
+ {incidents.map((inc) => ( +
+
+ + + {inc.status} + +
+

+ {inc.title} +

+

{inc.desc}

+
+ ))} +
+
+
+ + ); +}; + +export default StatusPage; diff --git a/app/(public)/status/page.tsx b/app/(public)/status/page.tsx index d0953f2..3c1354a 100644 --- a/app/(public)/status/page.tsx +++ b/app/(public)/status/page.tsx @@ -1,78 +1,78 @@ -import { sql } from "drizzle-orm"; -import { buildMetadata } from "@/lib/seo"; -import { db } from "@/server/db/client"; -import StatusPage from "."; - -export const metadata = buildMetadata({ - title: "Status", - description: - "Check the current status of ClientFlow's services. Stay informed about ongoing issues, maintenance schedules, and overall platform health.", - path: "/status", -}); - -// Re-render the status board every minute. Fresh enough for visitors, -// cheap enough that we won't hammer upstreams on a viral incident. -export const revalidate = 60; - -export type ProbeResult = { - ok: boolean; - latencyMs: number; - // `skipped` means the upstream isn't configured in this environment, so we - // intentionally surface it as a neutral "not monitored" pill rather than red. - skipped?: boolean; -}; - -async function pingDatabase(): Promise { - const startedAt = Date.now(); - try { - await db.execute(sql`SELECT 1`); - return { ok: true, latencyMs: Date.now() - startedAt }; - } catch { - return { ok: false, latencyMs: Date.now() - startedAt }; - } -} - -// Cheap account-scoped read against Stripe. We hit /v1/balance directly via -// fetch instead of the SDK so health-check failures stay out of the app-side -// circuit breaker (a probe failure shouldn't trip live billing traffic). -async function pingStripe(): Promise { - const key = process.env.STRIPE_SECRET_KEY; - if (!key) return { ok: true, latencyMs: 0, skipped: true }; - const startedAt = Date.now(); - try { - const res = await fetch("https://api.stripe.com/v1/balance", { - headers: { Authorization: `Bearer ${key}` }, - signal: AbortSignal.timeout(5_000), - cache: "no-store", - }); - return { ok: res.ok, latencyMs: Date.now() - startedAt }; - } catch { - return { ok: false, latencyMs: Date.now() - startedAt }; - } -} - -// Lists configured sender domains - cheapest authenticated read on Resend. -async function pingResend(): Promise { - const key = process.env.RESEND_API_KEY; - if (!key) return { ok: true, latencyMs: 0, skipped: true }; - const startedAt = Date.now(); - try { - const res = await fetch("https://api.resend.com/domains", { - headers: { Authorization: `Bearer ${key}` }, - signal: AbortSignal.timeout(5_000), - cache: "no-store", - }); - return { ok: res.ok, latencyMs: Date.now() - startedAt }; - } catch { - return { ok: false, latencyMs: Date.now() - startedAt }; - } -} - -export default async function Page() { - const [dbHealth, stripeHealth, resendHealth] = await Promise.all([ - pingDatabase(), - pingStripe(), - pingResend(), - ]); - return ; -} +import { sql } from "drizzle-orm"; +import { buildMetadata } from "@/lib/seo"; +import { db } from "@/server/db/client"; +import StatusPage from "."; + +export const metadata = buildMetadata({ + title: "Status", + description: + "Check the current status of ClientFlow's services. Stay informed about ongoing issues, maintenance schedules, and overall platform health.", + path: "/status", +}); + +// Re-render the status board every minute. Fresh enough for visitors, +// cheap enough that we won't hammer upstreams on a viral incident. +export const revalidate = 60; + +export type ProbeResult = { + ok: boolean; + latencyMs: number; + // `skipped` means the upstream isn't configured in this environment, so we + // intentionally surface it as a neutral "not monitored" pill rather than red. + skipped?: boolean; +}; + +async function pingDatabase(): Promise { + const startedAt = Date.now(); + try { + await db.execute(sql`SELECT 1`); + return { ok: true, latencyMs: Date.now() - startedAt }; + } catch { + return { ok: false, latencyMs: Date.now() - startedAt }; + } +} + +// Cheap account-scoped read against Stripe. We hit /v1/balance directly via +// fetch instead of the SDK so health-check failures stay out of the app-side +// circuit breaker (a probe failure shouldn't trip live billing traffic). +async function pingStripe(): Promise { + const key = process.env.STRIPE_SECRET_KEY; + if (!key) return { ok: true, latencyMs: 0, skipped: true }; + const startedAt = Date.now(); + try { + const res = await fetch("https://api.stripe.com/v1/balance", { + headers: { Authorization: `Bearer ${key}` }, + signal: AbortSignal.timeout(5_000), + cache: "no-store", + }); + return { ok: res.ok, latencyMs: Date.now() - startedAt }; + } catch { + return { ok: false, latencyMs: Date.now() - startedAt }; + } +} + +// Lists configured sender domains - cheapest authenticated read on Resend. +async function pingResend(): Promise { + const key = process.env.RESEND_API_KEY; + if (!key) return { ok: true, latencyMs: 0, skipped: true }; + const startedAt = Date.now(); + try { + const res = await fetch("https://api.resend.com/domains", { + headers: { Authorization: `Bearer ${key}` }, + signal: AbortSignal.timeout(5_000), + cache: "no-store", + }); + return { ok: res.ok, latencyMs: Date.now() - startedAt }; + } catch { + return { ok: false, latencyMs: Date.now() - startedAt }; + } +} + +export default async function Page() { + const [dbHealth, stripeHealth, resendHealth] = await Promise.all([ + pingDatabase(), + pingStripe(), + pingResend(), + ]); + return ; +} diff --git a/app/admin/users/[id]/index.tsx b/app/admin/users/[id]/index.tsx index c6f9ead..0763fbc 100644 --- a/app/admin/users/[id]/index.tsx +++ b/app/admin/users/[id]/index.tsx @@ -1,142 +1,142 @@ -import { formatDistanceToNow } from "date-fns"; -import { MailCheck, KeyRound, ShieldCheck } from "lucide-react"; -import { RevokeSessionButton } from "@/components/admin/users"; -import type { getAdminUserDetail } from "@/server/admin/users"; - -type Detail = NonNullable>>; - -export default function AdminUserDetailPage({ detail }: { detail: Detail }) { - const { user: u, orgs, sessions, apiKeys, auditLogs } = detail; - - return ( -
- {/* Profile */} -
-
- {u.image ? ( - // eslint-disable-next-line @next/next/no-img-element - {u.name} - ) : ( -
- {u.name.slice(0, 2).toUpperCase()} -
- )} -
-
-

{u.name}

- {u.isPlatformAdmin && ( - - Admin - - )} -
-

{u.email}

-
-
-
-
- - {u.emailVerified ? "Email verified" : "Email unverified"} -
-
- - {u.twoFactorEnabled ? "MFA enabled" : "MFA disabled"} -
-
- Joined {formatDistanceToNow(new Date(u.createdAt), { addSuffix: true })} -
-
-
- - {/* Organizations */} -
-
-

Organizations ({orgs.length})

-
- - - - - - - - - - {orgs.length === 0 ? ( - - ) : orgs.map((o) => ( - - - - - - ))} - -
OrganizationRoleStatus
No organizations.
{o.orgName}{o.roleName} - - {o.status} - -
-
- - {/* Active Sessions */} -
-
-

Active Sessions ({sessions.length})

-
- - - - - - - - - - - {sessions.length === 0 ? ( - - ) : sessions.map((s) => ( - - - - - - - - ))} - -
IP AddressUser AgentCreatedExpires -
No active sessions.
{s.ipAddress ?? "-"}{s.userAgent ?? "-"} - {formatDistanceToNow(new Date(s.createdAt), { addSuffix: true })} - - {formatDistanceToNow(new Date(s.expiresAt), { addSuffix: true })} - - -
-
- - {/* Recent Activity */} -
-
-

Recent Activity

-
-
- {auditLogs.length === 0 ? ( -

No activity found.

- ) : auditLogs.map((log) => ( -
-
-

{log.action}

-

{log.entityType} · {log.ipAddress ?? "Unknown IP"}

-
-

- {formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })} -

-
- ))} -
-
-
- ); -} +import { formatDistanceToNow } from "date-fns"; +import { MailCheck, KeyRound, ShieldCheck } from "lucide-react"; +import { RevokeSessionButton } from "@/components/admin/users"; +import type { getAdminUserDetail } from "@/server/admin/users"; + +type Detail = NonNullable>>; + +export default function AdminUserDetailPage({ detail }: { detail: Detail }) { + const { user: u, orgs, sessions, apiKeys, auditLogs } = detail; + + return ( +
+ {/* Profile */} +
+
+ {u.image ? ( + // eslint-disable-next-line @next/next/no-img-element + {u.name} + ) : ( +
+ {u.name.slice(0, 2).toUpperCase()} +
+ )} +
+
+

{u.name}

+ {u.isPlatformAdmin && ( + + Admin + + )} +
+

{u.email}

+
+
+
+
+ + {u.emailVerified ? "Email verified" : "Email unverified"} +
+
+ + {u.twoFactorEnabled ? "MFA enabled" : "MFA disabled"} +
+
+ Joined {formatDistanceToNow(new Date(u.createdAt), { addSuffix: true })} +
+
+
+ + {/* Organizations */} +
+
+

Organizations ({orgs.length})

+
+ + + + + + + + + + {orgs.length === 0 ? ( + + ) : orgs.map((o) => ( + + + + + + ))} + +
OrganizationRoleStatus
No organizations.
{o.orgName}{o.roleName} + + {o.status} + +
+
+ + {/* Active Sessions */} +
+
+

Active Sessions ({sessions.length})

+
+ + + + + + + + + + + {sessions.length === 0 ? ( + + ) : sessions.map((s) => ( + + + + + + + + ))} + +
IP AddressUser AgentCreatedExpires +
No active sessions.
{s.ipAddress ?? "-"}{s.userAgent ?? "-"} + {formatDistanceToNow(new Date(s.createdAt), { addSuffix: true })} + + {formatDistanceToNow(new Date(s.expiresAt), { addSuffix: true })} + + +
+
+ + {/* Recent Activity */} +
+
+

Recent Activity

+
+
+ {auditLogs.length === 0 ? ( +

No activity found.

+ ) : auditLogs.map((log) => ( +
+
+

{log.action}

+

{log.entityType} · {log.ipAddress ?? "Unknown IP"}

+
+

+ {formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })} +

+
+ ))} +
+
+
+ ); +} diff --git a/app/api/billing/webhook/route.ts b/app/api/billing/webhook/route.ts index 2d436b7..0630783 100644 --- a/app/api/billing/webhook/route.ts +++ b/app/api/billing/webhook/route.ts @@ -1,68 +1,68 @@ -import { NextResponse } from "next/server"; -import type Stripe from "stripe"; -import { eq } from "drizzle-orm"; -import { stripe } from "@/server/third-party/stripe"; -import { db } from "@/server/db/client"; -import { billingWebhookEvents } from "@/db/schema"; -import { dispatchBillingEvent } from "@/server/billing/event-handlers"; -import { logger } from "@/server/observability/logger"; - -export async function POST(req: Request) { - const body = await req.text(); - const sig = req.headers.get("stripe-signature"); - - if (!sig) { - return NextResponse.json({ error: "Missing signature" }, { status: 400 }); - } - - let event: Stripe.Event; - try { - event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!); - } catch { - return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); - } - - // Idempotency - skip if already processed - const existing = await db - .select({ id: billingWebhookEvents.id }) - .from(billingWebhookEvents) - .where(eq(billingWebhookEvents.eventId, event.id)) - .limit(1); - - if (existing[0]) { - return NextResponse.json({ received: true, skipped: true }); - } - - // Log the event immediately - const logId = crypto.randomUUID(); - await db.insert(billingWebhookEvents).values({ - id: logId, - provider: "stripe", - eventId: event.id, - eventType: event.type, - payload: event as unknown as Record, - receivedAt: new Date(), - }); - - try { - await dispatchBillingEvent(event); - - await db - .update(billingWebhookEvents) - .set({ processedAt: new Date() }) - .where(eq(billingWebhookEvents.id, logId)); - } catch (err) { - await db - .update(billingWebhookEvents) - .set({ processingError: String(err) }) - .where(eq(billingWebhookEvents.id, logId)); - - logger.error("webhook.processing_failed", err, { - eventId: event.id, - eventType: event.type, - }); - return NextResponse.json({ error: "Processing failed" }, { status: 500 }); - } - - return NextResponse.json({ received: true }); -} +import { NextResponse } from "next/server"; +import type Stripe from "stripe"; +import { eq } from "drizzle-orm"; +import { stripe } from "@/server/third-party/stripe"; +import { db } from "@/server/db/client"; +import { billingWebhookEvents } from "@/db/schema"; +import { dispatchBillingEvent } from "@/server/billing/event-handlers"; +import { logger } from "@/server/observability/logger"; + +export async function POST(req: Request) { + const body = await req.text(); + const sig = req.headers.get("stripe-signature"); + + if (!sig) { + return NextResponse.json({ error: "Missing signature" }, { status: 400 }); + } + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!); + } catch { + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); + } + + // Idempotency - skip if already processed + const existing = await db + .select({ id: billingWebhookEvents.id }) + .from(billingWebhookEvents) + .where(eq(billingWebhookEvents.eventId, event.id)) + .limit(1); + + if (existing[0]) { + return NextResponse.json({ received: true, skipped: true }); + } + + // Log the event immediately + const logId = crypto.randomUUID(); + await db.insert(billingWebhookEvents).values({ + id: logId, + provider: "stripe", + eventId: event.id, + eventType: event.type, + payload: event as unknown as Record, + receivedAt: new Date(), + }); + + try { + await dispatchBillingEvent(event); + + await db + .update(billingWebhookEvents) + .set({ processedAt: new Date() }) + .where(eq(billingWebhookEvents.id, logId)); + } catch (err) { + await db + .update(billingWebhookEvents) + .set({ processingError: String(err) }) + .where(eq(billingWebhookEvents.id, logId)); + + logger.error("webhook.processing_failed", err, { + eventId: event.id, + eventType: event.type, + }); + return NextResponse.json({ error: "Processing failed" }, { status: 500 }); + } + + return NextResponse.json({ received: true }); +} diff --git a/app/auth/sign-in-otp/index.tsx b/app/auth/sign-in-otp/index.tsx index 09e3a01..aed5d54 100644 --- a/app/auth/sign-in-otp/index.tsx +++ b/app/auth/sign-in-otp/index.tsx @@ -1,151 +1,151 @@ -"use client"; - -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useState } from "react"; -import { toast } from "sonner"; -import AuthNotice from "@/components/auth/AuthNotice"; -import AuthSplitLayout from "@/components/layout/auth/AuthSplitLayout"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { authClient } from "@/utils/auth-client"; -import { authRoutes } from "@/core/auth"; - -type Stage = "request" | "verify"; - -const SignInOtp = () => { - const router = useRouter(); - const searchParams = useSearchParams(); - const redirectTo = searchParams.get("redirectTo") || authRoutes.dashboard; - - const [stage, setStage] = useState("request"); - const [email, setEmail] = useState(""); - const [otp, setOtp] = useState(""); - const [pending, setPending] = useState(false); - const [error, setError] = useState(null); - - async function requestOtp() { - setError(null); - if (!email) return; - setPending(true); - try { - const { error: err } = await authClient.emailOtp.sendVerificationOtp({ - email: email.trim(), - type: "sign-in", - }); - if (err) throw new Error(err.message ?? "Could not send code."); - toast.success("Code sent. Check your email."); - setStage("verify"); - } catch (e) { - setError(e instanceof Error ? e.message : "Could not send code."); - } finally { - setPending(false); - } - } - - async function verifyOtp() { - setError(null); - if (otp.length < 6) return; - setPending(true); - try { - const { error: err } = await authClient.signIn.emailOtp({ - email: email.trim(), - otp, - }); - if (err) throw new Error(err.message ?? "Invalid or expired code."); - toast.success("Signed in successfully."); - router.push(redirectTo); - } catch (e) { - setError(e instanceof Error ? e.message : "Invalid or expired code."); - } finally { - setPending(false); - } - } - - return ( - -
- {error && } - - {stage === "request" ? ( - <> -
- - setEmail(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && requestOtp()} - placeholder="you@company.com" - autoComplete="email" - autoFocus - /> -
- - - ) : ( - <> -
- - setOtp(e.target.value.replace(/\D/g, "").slice(0, 6))} - onKeyDown={(e) => e.key === "Enter" && otp.length === 6 && verifyOtp()} - placeholder="000000" - maxLength={6} - inputMode="numeric" - className="text-center font-mono text-lg tracking-widest" - autoFocus - /> -
- - - - )} - -
- Remembered your password?{" "} - - Sign in - -
-
-
- ); -}; - -export default SignInOtp; +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import AuthNotice from "@/components/auth/AuthNotice"; +import AuthSplitLayout from "@/components/layout/auth/AuthSplitLayout"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/utils/auth-client"; +import { authRoutes } from "@/core/auth"; + +type Stage = "request" | "verify"; + +const SignInOtp = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + const redirectTo = searchParams.get("redirectTo") || authRoutes.dashboard; + + const [stage, setStage] = useState("request"); + const [email, setEmail] = useState(""); + const [otp, setOtp] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + async function requestOtp() { + setError(null); + if (!email) return; + setPending(true); + try { + const { error: err } = await authClient.emailOtp.sendVerificationOtp({ + email: email.trim(), + type: "sign-in", + }); + if (err) throw new Error(err.message ?? "Could not send code."); + toast.success("Code sent. Check your email."); + setStage("verify"); + } catch (e) { + setError(e instanceof Error ? e.message : "Could not send code."); + } finally { + setPending(false); + } + } + + async function verifyOtp() { + setError(null); + if (otp.length < 6) return; + setPending(true); + try { + const { error: err } = await authClient.signIn.emailOtp({ + email: email.trim(), + otp, + }); + if (err) throw new Error(err.message ?? "Invalid or expired code."); + toast.success("Signed in successfully."); + router.push(redirectTo); + } catch (e) { + setError(e instanceof Error ? e.message : "Invalid or expired code."); + } finally { + setPending(false); + } + } + + return ( + +
+ {error && } + + {stage === "request" ? ( + <> +
+ + setEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && requestOtp()} + placeholder="you@company.com" + autoComplete="email" + autoFocus + /> +
+ + + ) : ( + <> +
+ + setOtp(e.target.value.replace(/\D/g, "").slice(0, 6))} + onKeyDown={(e) => e.key === "Enter" && otp.length === 6 && verifyOtp()} + placeholder="000000" + maxLength={6} + inputMode="numeric" + className="text-center font-mono text-lg tracking-widest" + autoFocus + /> +
+ + + + )} + +
+ Remembered your password?{" "} + + Sign in + +
+
+
+ ); +}; + +export default SignInOtp; diff --git a/app/auth/sign-in/index.tsx b/app/auth/sign-in/index.tsx index fb6c009..4c3980f 100644 --- a/app/auth/sign-in/index.tsx +++ b/app/auth/sign-in/index.tsx @@ -1,262 +1,262 @@ -"use client"; - -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import AuthNotice from "@/components/auth/AuthNotice"; -import AuthSplitLayout from "@/components/layout/auth/AuthSplitLayout"; -import { ControlledInput } from "@/components/form"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { authRoutes, getAuthErrorMessage, useGoogleSignIn, useSignIn } from "@/core/auth"; -import { useMutation } from "@tanstack/react-query"; -import { verifyTwoFactorCode } from "@/core/auth/repository"; -import { toast } from "sonner"; -import GooogleIcon from "@/components/ui/google-icon"; - -const signInSchema = z.object({ - email: z.string().email({ message: "Enter a valid email address." }), - password: z.string().min(1, { message: "Password is required." }), -}); - -type SignInFormValues = z.infer; - -const SignIn = () => { - const router = useRouter(); - const searchParams = useSearchParams(); - const redirectTo = searchParams.get("redirectTo") || authRoutes.dashboard; - - const reason = searchParams.get("reason"); - - const signIn = useSignIn(); - const googleSignIn = useGoogleSignIn(); - const [apiError, setApiError] = useState(null); - const [twoFactorRequired, setTwoFactorRequired] = useState(false); - const [totpCode, setTotpCode] = useState(""); - - const verifyTotp = useMutation({ - mutationFn: () => verifyTwoFactorCode(totpCode), - onSuccess: () => { - // IP was already checked before the MFA screen was shown - toast.success("Signed in successfully."); - router.push(redirectTo); - }, - onError: (err) => { - setApiError(getAuthErrorMessage(err, "Invalid verification code.")); - }, - }); - - const { - control, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(signInSchema), - defaultValues: { email: "", password: "" }, - }); - - const onSubmit = async (values: SignInFormValues) => { - setApiError(null); - try { - // Check if org requires SSO before attempting password auth. - // Fall back to allowing password auth if the check itself fails - - // a transient SSO endpoint outage shouldn't block all sign-ins. - const ssoCheck = (await fetch( - `/api/auth/sso/check?email=${encodeURIComponent(values.email.trim())}`, - ) - .then((r) => (r.ok ? r.json() : { ssoRequired: false })) - .catch(() => ({ ssoRequired: false }))) as { ssoRequired: boolean }; - - if (ssoCheck.ssoRequired) { - router.push( - `/auth/sso?email=${encodeURIComponent(values.email.trim())}&reason=sso_required`, - ); - return; - } - - const result = await signIn.mutateAsync({ - email: values.email, - password: values.password, - callbackURL: redirectTo, - }); - - // Check IP allowlist immediately after credentials are verified - - // before MFA screen or dashboard redirect - so a blocked IP never - // progresses further in the auth flow. - const ipCheck = (await fetch( - `/api/auth/ip-check?email=${encodeURIComponent(values.email.trim())}`, - ) - .then((r) => r.json()) - .catch(() => ({ blocked: false }))) as { blocked: boolean }; - - if (ipCheck.blocked) { - router.push("/ip-blocked"); - return; - } - - if (result && "twoFactorRequired" in result && result.twoFactorRequired) { - setTwoFactorRequired(true); - return; - } - toast.success("Signed in successfully."); - router.push(redirectTo); - } catch (err) { - setApiError(getAuthErrorMessage(err, "Unable to sign in.")); - } - }; - - async function handleGoogleSignIn() { - setApiError(null); - try { - await googleSignIn.mutateAsync(redirectTo); - } catch (err) { - setApiError(getAuthErrorMessage(err, "Unable to continue with Google.")); - } - } - - if (twoFactorRequired) { - return ( - -
- {apiError && } -
- - setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))} - onKeyDown={(e) => e.key === "Enter" && totpCode.length === 6 && verifyTotp.mutate()} - placeholder="000000" - maxLength={6} - className="text-center font-mono text-lg tracking-widest" - autoFocus - /> -
- -
- -
-
-
- ); - } - - return ( - -
- {reason === "session_expired" && ( - - )} - {apiError && } - - - -
-
- OR -
-
- - - - - Forgot password? - - } - /> - - - - -
- Don't have an account?{" "} - - Sign up - -
- -
- - Sign in with SSO → - - · - - Email me a sign-in code - -
- - ); -}; - -export default SignIn; +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import AuthNotice from "@/components/auth/AuthNotice"; +import AuthSplitLayout from "@/components/layout/auth/AuthSplitLayout"; +import { ControlledInput } from "@/components/form"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { authRoutes, getAuthErrorMessage, useGoogleSignIn, useSignIn } from "@/core/auth"; +import { useMutation } from "@tanstack/react-query"; +import { verifyTwoFactorCode } from "@/core/auth/repository"; +import { toast } from "sonner"; +import GooogleIcon from "@/components/ui/google-icon"; + +const signInSchema = z.object({ + email: z.string().email({ message: "Enter a valid email address." }), + password: z.string().min(1, { message: "Password is required." }), +}); + +type SignInFormValues = z.infer; + +const SignIn = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + const redirectTo = searchParams.get("redirectTo") || authRoutes.dashboard; + + const reason = searchParams.get("reason"); + + const signIn = useSignIn(); + const googleSignIn = useGoogleSignIn(); + const [apiError, setApiError] = useState(null); + const [twoFactorRequired, setTwoFactorRequired] = useState(false); + const [totpCode, setTotpCode] = useState(""); + + const verifyTotp = useMutation({ + mutationFn: () => verifyTwoFactorCode(totpCode), + onSuccess: () => { + // IP was already checked before the MFA screen was shown + toast.success("Signed in successfully."); + router.push(redirectTo); + }, + onError: (err) => { + setApiError(getAuthErrorMessage(err, "Invalid verification code.")); + }, + }); + + const { + control, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(signInSchema), + defaultValues: { email: "", password: "" }, + }); + + const onSubmit = async (values: SignInFormValues) => { + setApiError(null); + try { + // Check if org requires SSO before attempting password auth. + // Fall back to allowing password auth if the check itself fails - + // a transient SSO endpoint outage shouldn't block all sign-ins. + const ssoCheck = (await fetch( + `/api/auth/sso/check?email=${encodeURIComponent(values.email.trim())}`, + ) + .then((r) => (r.ok ? r.json() : { ssoRequired: false })) + .catch(() => ({ ssoRequired: false }))) as { ssoRequired: boolean }; + + if (ssoCheck.ssoRequired) { + router.push( + `/auth/sso?email=${encodeURIComponent(values.email.trim())}&reason=sso_required`, + ); + return; + } + + const result = await signIn.mutateAsync({ + email: values.email, + password: values.password, + callbackURL: redirectTo, + }); + + // Check IP allowlist immediately after credentials are verified - + // before MFA screen or dashboard redirect - so a blocked IP never + // progresses further in the auth flow. + const ipCheck = (await fetch( + `/api/auth/ip-check?email=${encodeURIComponent(values.email.trim())}`, + ) + .then((r) => r.json()) + .catch(() => ({ blocked: false }))) as { blocked: boolean }; + + if (ipCheck.blocked) { + router.push("/ip-blocked"); + return; + } + + if (result && "twoFactorRequired" in result && result.twoFactorRequired) { + setTwoFactorRequired(true); + return; + } + toast.success("Signed in successfully."); + router.push(redirectTo); + } catch (err) { + setApiError(getAuthErrorMessage(err, "Unable to sign in.")); + } + }; + + async function handleGoogleSignIn() { + setApiError(null); + try { + await googleSignIn.mutateAsync(redirectTo); + } catch (err) { + setApiError(getAuthErrorMessage(err, "Unable to continue with Google.")); + } + } + + if (twoFactorRequired) { + return ( + +
+ {apiError && } +
+ + setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6))} + onKeyDown={(e) => e.key === "Enter" && totpCode.length === 6 && verifyTotp.mutate()} + placeholder="000000" + maxLength={6} + className="text-center font-mono text-lg tracking-widest" + autoFocus + /> +
+ +
+ +
+
+
+ ); + } + + return ( + +
+ {reason === "session_expired" && ( + + )} + {apiError && } + + + +
+
+ OR +
+
+ + + + + Forgot password? + + } + /> + + + + +
+ Don't have an account?{" "} + + Sign up + +
+ +
+ + Sign in with SSO → + + · + + Email me a sign-in code + +
+ + ); +}; + +export default SignIn; diff --git a/app/auth/sign-up/index.tsx b/app/auth/sign-up/index.tsx index fcc53f4..e8d88d1 100644 --- a/app/auth/sign-up/index.tsx +++ b/app/auth/sign-up/index.tsx @@ -1,228 +1,228 @@ -"use client"; - -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import AuthNotice from "@/components/auth/AuthNotice"; -import AuthSplitLayout from "@/components/layout/auth/AuthSplitLayout"; -import { ControlledInput } from "@/components/form"; -import { Button } from "@/components/ui/button"; -import { TurnstileWidget } from "@/components/security/TurnstileWidget"; -import { authRoutes, getAuthErrorMessage, useGoogleSignIn, useSignUp } from "@/core/auth"; -import { toast } from "sonner"; -import GooogleIcon from "@/components/ui/google-icon"; -import { captureClientEvent } from "@/lib/analytics/client"; -import { FUNNEL_EVENTS } from "@/lib/analytics/events"; - -const signUpSchema = z - .object({ - firstName: z.string().min(1, { message: "First name is required." }), - lastName: z.string().min(1, { message: "Last name is required." }), - email: z.string().email({ message: "Enter a valid email address." }), - password: z - .string() - .min(8, { message: "Password must be at least 8 characters." }) - .refine((v) => /[A-Z]/.test(v), { - message: "Password must include at least one uppercase letter.", - }) - .refine((v) => /[a-z]/.test(v), { - message: "Password must include at least one lowercase letter.", - }) - .refine((v) => /[0-9]/.test(v), { - message: "Password must include at least one number.", - }) - .refine((v) => /[^A-Za-z0-9]/.test(v), { - message: "Password must include at least one symbol.", - }), - confirmPassword: z.string().min(1, { message: "Please confirm your password." }), - }) - .refine((data) => data.password === data.confirmPassword, { - message: "Passwords do not match.", - path: ["confirmPassword"], - }); - -type SignUpFormValues = z.infer; - -const SignUp = () => { - const router = useRouter(); - const searchParams = useSearchParams(); - const redirectTo = searchParams.get("redirectTo") || ""; - const signUp = useSignUp(); - const googleSignIn = useGoogleSignIn(); - const [apiError, setApiError] = useState(null); - const [captchaToken, setCaptchaToken] = useState(""); - - useEffect(() => { - captureClientEvent(FUNNEL_EVENTS.signUpStarted, { - method: "email", - redirectTo: redirectTo || null, - }); - }, [redirectTo]); - - const { - control, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(signUpSchema), - defaultValues: { - firstName: "", - lastName: "", - email: "", - password: "", - confirmPassword: "", - }, - }); - - const onSubmit = async (values: SignUpFormValues) => { - setApiError(null); - try { - await signUp.mutateAsync({ - firstName: values.firstName, - lastName: values.lastName, - email: values.email, - password: values.password, - callbackURL: authRoutes.signIn, - cfTurnstileResponse: captchaToken || undefined, - }); - captureClientEvent(FUNNEL_EVENTS.signUpDone, { method: "email" }); - toast.success("Account created. Please verify your email."); - const verifyUrl = `${authRoutes.verifyEmail}?email=${encodeURIComponent(values.email.trim())}${redirectTo ? `&redirectTo=${encodeURIComponent(redirectTo)}` : ""}`; - router.push(verifyUrl); - } catch (err) { - setApiError(getAuthErrorMessage(err, "Unable to create account.")); - } - }; - - async function handleGoogleSignUp() { - setApiError(null); - captureClientEvent(FUNNEL_EVENTS.signUpStarted, { method: "google" }); - try { - await googleSignIn.mutateAsync(authRoutes.dashboard); - } catch (err) { - setApiError(getAuthErrorMessage(err, "Unable to continue with Google.")); - } - } - - return ( - -
- {apiError && } - - - -
-
- OR -
-
- -
- - -
- - - - - - - - - - - - -

- By signing up, you agree to our{" "} - - Terms - {" "} - and{" "} - - Privacy Policy - - . -

- -
- Already have an account?{" "} - - Sign in - -
- - ); -}; - -export default SignUp; +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import AuthNotice from "@/components/auth/AuthNotice"; +import AuthSplitLayout from "@/components/layout/auth/AuthSplitLayout"; +import { ControlledInput } from "@/components/form"; +import { Button } from "@/components/ui/button"; +import { TurnstileWidget } from "@/components/security/TurnstileWidget"; +import { authRoutes, getAuthErrorMessage, useGoogleSignIn, useSignUp } from "@/core/auth"; +import { toast } from "sonner"; +import GooogleIcon from "@/components/ui/google-icon"; +import { captureClientEvent } from "@/lib/analytics/client"; +import { FUNNEL_EVENTS } from "@/lib/analytics/events"; + +const signUpSchema = z + .object({ + firstName: z.string().min(1, { message: "First name is required." }), + lastName: z.string().min(1, { message: "Last name is required." }), + email: z.string().email({ message: "Enter a valid email address." }), + password: z + .string() + .min(8, { message: "Password must be at least 8 characters." }) + .refine((v) => /[A-Z]/.test(v), { + message: "Password must include at least one uppercase letter.", + }) + .refine((v) => /[a-z]/.test(v), { + message: "Password must include at least one lowercase letter.", + }) + .refine((v) => /[0-9]/.test(v), { + message: "Password must include at least one number.", + }) + .refine((v) => /[^A-Za-z0-9]/.test(v), { + message: "Password must include at least one symbol.", + }), + confirmPassword: z.string().min(1, { message: "Please confirm your password." }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords do not match.", + path: ["confirmPassword"], + }); + +type SignUpFormValues = z.infer; + +const SignUp = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + const redirectTo = searchParams.get("redirectTo") || ""; + const signUp = useSignUp(); + const googleSignIn = useGoogleSignIn(); + const [apiError, setApiError] = useState(null); + const [captchaToken, setCaptchaToken] = useState(""); + + useEffect(() => { + captureClientEvent(FUNNEL_EVENTS.signUpStarted, { + method: "email", + redirectTo: redirectTo || null, + }); + }, [redirectTo]); + + const { + control, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(signUpSchema), + defaultValues: { + firstName: "", + lastName: "", + email: "", + password: "", + confirmPassword: "", + }, + }); + + const onSubmit = async (values: SignUpFormValues) => { + setApiError(null); + try { + await signUp.mutateAsync({ + firstName: values.firstName, + lastName: values.lastName, + email: values.email, + password: values.password, + callbackURL: authRoutes.signIn, + cfTurnstileResponse: captchaToken || undefined, + }); + captureClientEvent(FUNNEL_EVENTS.signUpDone, { method: "email" }); + toast.success("Account created. Please verify your email."); + const verifyUrl = `${authRoutes.verifyEmail}?email=${encodeURIComponent(values.email.trim())}${redirectTo ? `&redirectTo=${encodeURIComponent(redirectTo)}` : ""}`; + router.push(verifyUrl); + } catch (err) { + setApiError(getAuthErrorMessage(err, "Unable to create account.")); + } + }; + + async function handleGoogleSignUp() { + setApiError(null); + captureClientEvent(FUNNEL_EVENTS.signUpStarted, { method: "google" }); + try { + await googleSignIn.mutateAsync(authRoutes.dashboard); + } catch (err) { + setApiError(getAuthErrorMessage(err, "Unable to continue with Google.")); + } + } + + return ( + +
+ {apiError && } + + + +
+
+ OR +
+
+ +
+ + +
+ + + + + + + + + + + + +

+ By signing up, you agree to our{" "} + + Terms + {" "} + and{" "} + + Privacy Policy + + . +

+ +
+ Already have an account?{" "} + + Sign in + +
+ + ); +}; + +export default SignUp; diff --git a/app/auth/verify-email/index.tsx b/app/auth/verify-email/index.tsx index bd5d62e..6897569 100644 --- a/app/auth/verify-email/index.tsx +++ b/app/auth/verify-email/index.tsx @@ -1,92 +1,92 @@ -"use client"; - -import Link from "next/link"; -import { useSearchParams } from "next/navigation"; -import { useState } from "react"; -import AuthNotice from "@/components/auth/AuthNotice"; -import AuthSplitLayout from "@/components/layout/auth/AuthSplitLayout"; -import { Button } from "@/components/ui/button"; -import { - authRoutes, - getAuthErrorMessage, - useResendVerificationEmail, -} from "@/core/auth"; - -const VerifyEmailPage = () => { - const searchParams = useSearchParams(); - const email = searchParams.get("email"); - const redirectTo = searchParams.get("redirectTo") || ""; - const resendVerificationEmail = useResendVerificationEmail(); - const [feedback, setFeedback] = useState(null); - const [error, setError] = useState(null); - - async function handleResend() { - setFeedback(null); - setError(null); - - if (!email) { - setError("Missing email address for verification."); - return; - } - - try { - await resendVerificationEmail.mutateAsync({ email }); - setFeedback("A fresh verification link has been prepared."); - } catch (currentError) { - setError( - getAuthErrorMessage( - currentError, - "Unable to resend the verification email." - ) - ); - } - } - - return ( - -
- {email ? ( - - ) : null} - {feedback ? : null} - {error ? : null} - - - - - -
- - Back to sign in - -
-
-
- ); -}; - -export default VerifyEmailPage; +"use client"; + +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useState } from "react"; +import AuthNotice from "@/components/auth/AuthNotice"; +import AuthSplitLayout from "@/components/layout/auth/AuthSplitLayout"; +import { Button } from "@/components/ui/button"; +import { + authRoutes, + getAuthErrorMessage, + useResendVerificationEmail, +} from "@/core/auth"; + +const VerifyEmailPage = () => { + const searchParams = useSearchParams(); + const email = searchParams.get("email"); + const redirectTo = searchParams.get("redirectTo") || ""; + const resendVerificationEmail = useResendVerificationEmail(); + const [feedback, setFeedback] = useState(null); + const [error, setError] = useState(null); + + async function handleResend() { + setFeedback(null); + setError(null); + + if (!email) { + setError("Missing email address for verification."); + return; + } + + try { + await resendVerificationEmail.mutateAsync({ email }); + setFeedback("A fresh verification link has been prepared."); + } catch (currentError) { + setError( + getAuthErrorMessage( + currentError, + "Unable to resend the verification email." + ) + ); + } + } + + return ( + +
+ {email ? ( + + ) : null} + {feedback ? : null} + {error ? : null} + + + + + +
+ + Back to sign in + +
+
+
+ ); +}; + +export default VerifyEmailPage; diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx index 794ec11..9633017 100644 --- a/app/invite/[token]/page.tsx +++ b/app/invite/[token]/page.tsx @@ -1,182 +1,182 @@ -import { redirect } from "next/navigation"; -import Link from "next/link"; -import { CheckCircle2, XCircle, Building2, UserCheck } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { getInvitationByToken } from "@/server/invitations"; -import { getServerSession } from "@/server/auth/session"; -import { acceptInviteAction } from "@/server/actions/invite"; - -type Props = { - params: Promise<{ token: string }>; - searchParams: Promise<{ error?: string }>; -}; - -export default async function InviteAcceptPage({ params, searchParams }: Props) { - const [{ token }, { error: errorParam }] = await Promise.all([ - params, - searchParams, - ]); - - const invitation = await getInvitationByToken(token); - - // Invalid token - if (!invitation) { - return ( - - } - title="Invalid invitation" - description="This invitation link is invalid or has already been used." - action={} - /> - - ); - } - - // Expired / revoked / accepted already - if (invitation.status !== "pending") { - const messages: Record = { - expired: { - title: "Invitation expired", - description: "This invitation link has expired. Ask your team admin to send a new one.", - }, - revoked: { - title: "Invitation revoked", - description: "This invitation has been revoked. Contact your team admin for assistance.", - }, - accepted: { - title: "Already accepted", - description: "This invitation has already been accepted.", - }, - }; - const msg = messages[invitation.status] ?? { - title: "Invitation unavailable", - description: "This invitation is no longer valid.", - }; - return ( - - - : - } - title={msg.title} - description={msg.description} - action={ - invitation.status === "accepted" - ? - : - } - /> - - ); - } - - // Valid invite - require login - const session = await getServerSession(); - if (!session) { - redirect(`/auth/sign-in?redirectTo=/invite/${token}`); - } - - // Enforce email binding: the signed-in account must match the invited email - if (session.user.email?.toLowerCase() !== invitation.email.toLowerCase()) { - return ( - - } - title="Wrong account" - description={`This invitation was sent to ${invitation.email}. You are signed in as ${session.user.email}. Please sign in with the correct account to accept.`} - action={ - - } - /> - - ); - } - - // Bind the token into the server action - const accept = acceptInviteAction.bind(null, token); - - return ( - -
-
-
- -
-
- -

- You've been invited -

-

- Join {invitation.organizationName} as{" "} - {invitation.roleName} -

- - {errorParam && ( -
- {errorParam} -
- )} - -

- Signed in as {session.user.email} -

- -
-
- -
- -
-
-
- ); -} - -// ─── Layout helpers ──────────────────────────────────────────────────────────── - -function InviteLayout({ children }: { children: React.ReactNode }) { - return ( -
-
- - - ClientFlow - -
- {children} -
- ); -} - -function StatusCard({ - icon, - title, - description, - action, -}: { - icon: React.ReactNode; - title: string; - description: string; - action?: React.ReactNode; -}) { - return ( -
-
{icon}
-

- {title} -

-

{description}

- {action &&
{action}
} -
- ); -} +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { CheckCircle2, XCircle, Building2, UserCheck } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { getInvitationByToken } from "@/server/invitations"; +import { getServerSession } from "@/server/auth/session"; +import { acceptInviteAction } from "@/server/actions/invite"; + +type Props = { + params: Promise<{ token: string }>; + searchParams: Promise<{ error?: string }>; +}; + +export default async function InviteAcceptPage({ params, searchParams }: Props) { + const [{ token }, { error: errorParam }] = await Promise.all([ + params, + searchParams, + ]); + + const invitation = await getInvitationByToken(token); + + // Invalid token + if (!invitation) { + return ( + + } + title="Invalid invitation" + description="This invitation link is invalid or has already been used." + action={} + /> + + ); + } + + // Expired / revoked / accepted already + if (invitation.status !== "pending") { + const messages: Record = { + expired: { + title: "Invitation expired", + description: "This invitation link has expired. Ask your team admin to send a new one.", + }, + revoked: { + title: "Invitation revoked", + description: "This invitation has been revoked. Contact your team admin for assistance.", + }, + accepted: { + title: "Already accepted", + description: "This invitation has already been accepted.", + }, + }; + const msg = messages[invitation.status] ?? { + title: "Invitation unavailable", + description: "This invitation is no longer valid.", + }; + return ( + + + : + } + title={msg.title} + description={msg.description} + action={ + invitation.status === "accepted" + ? + : + } + /> + + ); + } + + // Valid invite - require login + const session = await getServerSession(); + if (!session) { + redirect(`/auth/sign-in?redirectTo=/invite/${token}`); + } + + // Enforce email binding: the signed-in account must match the invited email + if (session.user.email?.toLowerCase() !== invitation.email.toLowerCase()) { + return ( + + } + title="Wrong account" + description={`This invitation was sent to ${invitation.email}. You are signed in as ${session.user.email}. Please sign in with the correct account to accept.`} + action={ + + } + /> + + ); + } + + // Bind the token into the server action + const accept = acceptInviteAction.bind(null, token); + + return ( + +
+
+
+ +
+
+ +

+ You've been invited +

+

+ Join {invitation.organizationName} as{" "} + {invitation.roleName} +

+ + {errorParam && ( +
+ {errorParam} +
+ )} + +

+ Signed in as {session.user.email} +

+ +
+
+ +
+ +
+
+
+ ); +} + +// ─── Layout helpers ──────────────────────────────────────────────────────────── + +function InviteLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+ + + ClientFlow + +
+ {children} +
+ ); +} + +function StatusCard({ + icon, + title, + description, + action, +}: { + icon: React.ReactNode; + title: string; + description: string; + action?: React.ReactNode; +}) { + return ( +
+
{icon}
+

+ {title} +

+

{description}

+ {action &&
{action}
} +
+ ); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index b7d15c5..bc14411 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,44 +1,44 @@ -import type { MetadataRoute } from "next"; -import { seoConfig } from "@/config/seo"; - -/** - * Static public routes that should appear in the sitemap. Protected / - * admin / auth routes are intentionally excluded - they're per-tenant or - * sign-in gated and have no SEO value. - */ -const STATIC_PUBLIC_ROUTES: Array<{ - path: string; - priority: number; - changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; -}> = [ - { path: "", priority: 1.0, changeFrequency: "weekly" }, - { path: "/pricing", priority: 0.9, changeFrequency: "monthly" }, - { path: "/features", priority: 0.9, changeFrequency: "monthly" }, - { path: "/about", priority: 0.7, changeFrequency: "monthly" }, - { path: "/contact", priority: 0.7, changeFrequency: "monthly" }, - { path: "/docs", priority: 0.8, changeFrequency: "weekly" }, - { path: "/api-docs", priority: 0.7, changeFrequency: "weekly" }, - { path: "/help", priority: 0.7, changeFrequency: "weekly" }, - { path: "/blogs", priority: 0.7, changeFrequency: "weekly" }, - { path: "/changelog", priority: 0.6, changeFrequency: "weekly" }, - { path: "/careers", priority: 0.5, changeFrequency: "monthly" }, - { path: "/integrations", priority: 0.6, changeFrequency: "monthly" }, - { path: "/partners", priority: 0.4, changeFrequency: "monthly" }, - { path: "/press", priority: 0.3, changeFrequency: "monthly" }, - { path: "/security", priority: 0.6, changeFrequency: "monthly" }, - { path: "/status", priority: 0.4, changeFrequency: "daily" }, - { path: "/legal", priority: 0.3, changeFrequency: "yearly" }, -]; - -export default function sitemap(): MetadataRoute.Sitemap { - const now = new Date(); - return STATIC_PUBLIC_ROUTES.map(({ path, priority, changeFrequency }) => ({ - url: `${seoConfig.siteUrl}${path}`, - lastModified: now, - changeFrequency, - priority, - })); - - // TODO (Phase D / post-launch): append dynamic routes here by querying the - // DB for published blog posts, public help articles, etc. -} +import type { MetadataRoute } from "next"; +import { seoConfig } from "@/config/seo"; + +/** + * Static public routes that should appear in the sitemap. Protected / + * admin / auth routes are intentionally excluded - they're per-tenant or + * sign-in gated and have no SEO value. + */ +const STATIC_PUBLIC_ROUTES: Array<{ + path: string; + priority: number; + changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; +}> = [ + { path: "", priority: 1.0, changeFrequency: "weekly" }, + { path: "/pricing", priority: 0.9, changeFrequency: "monthly" }, + { path: "/features", priority: 0.9, changeFrequency: "monthly" }, + { path: "/about", priority: 0.7, changeFrequency: "monthly" }, + { path: "/contact", priority: 0.7, changeFrequency: "monthly" }, + { path: "/docs", priority: 0.8, changeFrequency: "weekly" }, + { path: "/api-docs", priority: 0.7, changeFrequency: "weekly" }, + { path: "/help", priority: 0.7, changeFrequency: "weekly" }, + { path: "/blogs", priority: 0.7, changeFrequency: "weekly" }, + { path: "/changelog", priority: 0.6, changeFrequency: "weekly" }, + { path: "/careers", priority: 0.5, changeFrequency: "monthly" }, + { path: "/integrations", priority: 0.6, changeFrequency: "monthly" }, + { path: "/partners", priority: 0.4, changeFrequency: "monthly" }, + { path: "/press", priority: 0.3, changeFrequency: "monthly" }, + { path: "/security", priority: 0.6, changeFrequency: "monthly" }, + { path: "/status", priority: 0.4, changeFrequency: "daily" }, + { path: "/legal", priority: 0.3, changeFrequency: "yearly" }, +]; + +export default function sitemap(): MetadataRoute.Sitemap { + const now = new Date(); + return STATIC_PUBLIC_ROUTES.map(({ path, priority, changeFrequency }) => ({ + url: `${seoConfig.siteUrl}${path}`, + lastModified: now, + changeFrequency, + priority, + })); + + // TODO (Phase D / post-launch): append dynamic routes here by querying the + // DB for published blog posts, public help articles, etc. +} diff --git a/components/admin/billing/RefundInvoiceDialog.tsx b/components/admin/billing/RefundInvoiceDialog.tsx index ced65b4..124682c 100644 --- a/components/admin/billing/RefundInvoiceDialog.tsx +++ b/components/admin/billing/RefundInvoiceDialog.tsx @@ -1,253 +1,253 @@ -"use client"; - -import { useEffect, useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; -import { Loader2, RefreshCcw } from "lucide-react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { toast } from "sonner"; -import { refundInvoiceAction } from "@/server/actions/admin/billing"; - -type RefundableInvoice = { - id: string; - externalInvoiceId: string | null; - status: string; - amountPaidCents: number; - currencyCode: string | null; - paidAt: string | null; - invoiceUrl: string | null; -}; - -type Props = { - open: boolean; - onOpenChange: (open: boolean) => void; - subscriptionId: string; - orgName: string; -}; - -function formatCents(cents: number, currency: string | null) { - return (cents / 100).toLocaleString("en-US", { - style: "currency", - currency: currency ?? "USD", - }); -} - -export function RefundInvoiceDialog({ open, onOpenChange, subscriptionId, orgName }: Props) { - const router = useRouter(); - const [isPending, startTransition] = useTransition(); - const [invoices, setInvoices] = useState([]); - const [loading, setLoading] = useState(false); - const [selectedId, setSelectedId] = useState(null); - const [amountDollars, setAmountDollars] = useState(""); - const [reason, setReason] = useState<"requested_by_customer" | "duplicate" | "fraudulent">( - "requested_by_customer", - ); - - useEffect(() => { - // Resets dialog state on close + fetches refundable invoices on open. - // setState-in-effect is the correct pattern here: the dialog open/close is - // the external system this effect synchronises with. - /* eslint-disable react-hooks/set-state-in-effect */ - if (!open) { - setInvoices([]); - setSelectedId(null); - setAmountDollars(""); - return; - } - - setLoading(true); - fetch(`/api/admin/subscriptions/${subscriptionId}/refundable-invoices`) - .then(async (r) => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const data = (await r.json()) as { invoices: RefundableInvoice[] }; - setInvoices(data.invoices); - }) - .catch(() => toast.error("Failed to load refundable invoices.")) - .finally(() => setLoading(false)); - /* eslint-enable react-hooks/set-state-in-effect */ - }, [open, subscriptionId]); - - const selected = invoices.find((i) => i.id === selectedId) ?? null; - - function handleSubmit() { - if (!selected) { - toast.error("Select an invoice to refund."); - return; - } - - const fullAmountCents = selected.amountPaidCents; - const trimmed = amountDollars.trim(); - let amountCents: number | undefined; - - if (trimmed.length > 0) { - const parsed = Number(trimmed); - if (!Number.isFinite(parsed) || parsed <= 0) { - toast.error("Enter a valid refund amount."); - return; - } - amountCents = Math.round(parsed * 100); - if (amountCents > fullAmountCents) { - toast.error("Refund amount cannot exceed the invoice total."); - return; - } - } - - startTransition(async () => { - const result = await refundInvoiceAction({ - invoiceId: selected.id, - amountCents, - reason, - }); - if (result.error) { - toast.error(result.error); - return; - } - toast.success(`Refunded ${formatCents(result.refund!.amountCents, selected.currencyCode)}.`); - onOpenChange(false); - router.refresh(); - }); - } - - return ( - - - - - Refund invoice - - - Issue a full or partial refund for {orgName}. Refunds are processed through Stripe and - cannot be undone. - - - - {loading ? ( -
- -
- ) : invoices.length === 0 ? ( -
- No paid, Stripe-synced invoices on this subscription. -
- ) : ( -
-
- -
- {invoices.map((inv) => ( - - ))} -
-
- -
- - setAmountDollars(e.target.value)} - /> -

- Leave blank to refund the full amount. Enter a smaller amount for a partial refund. -

-
- -
- - -
-
- )} - - - - - -
-
- ); -} +"use client"; + +import { useEffect, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, RefreshCcw } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; +import { refundInvoiceAction } from "@/server/actions/admin/billing"; + +type RefundableInvoice = { + id: string; + externalInvoiceId: string | null; + status: string; + amountPaidCents: number; + currencyCode: string | null; + paidAt: string | null; + invoiceUrl: string | null; +}; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + subscriptionId: string; + orgName: string; +}; + +function formatCents(cents: number, currency: string | null) { + return (cents / 100).toLocaleString("en-US", { + style: "currency", + currency: currency ?? "USD", + }); +} + +export function RefundInvoiceDialog({ open, onOpenChange, subscriptionId, orgName }: Props) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [amountDollars, setAmountDollars] = useState(""); + const [reason, setReason] = useState<"requested_by_customer" | "duplicate" | "fraudulent">( + "requested_by_customer", + ); + + useEffect(() => { + // Resets dialog state on close + fetches refundable invoices on open. + // setState-in-effect is the correct pattern here: the dialog open/close is + // the external system this effect synchronises with. + /* eslint-disable react-hooks/set-state-in-effect */ + if (!open) { + setInvoices([]); + setSelectedId(null); + setAmountDollars(""); + return; + } + + setLoading(true); + fetch(`/api/admin/subscriptions/${subscriptionId}/refundable-invoices`) + .then(async (r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data = (await r.json()) as { invoices: RefundableInvoice[] }; + setInvoices(data.invoices); + }) + .catch(() => toast.error("Failed to load refundable invoices.")) + .finally(() => setLoading(false)); + /* eslint-enable react-hooks/set-state-in-effect */ + }, [open, subscriptionId]); + + const selected = invoices.find((i) => i.id === selectedId) ?? null; + + function handleSubmit() { + if (!selected) { + toast.error("Select an invoice to refund."); + return; + } + + const fullAmountCents = selected.amountPaidCents; + const trimmed = amountDollars.trim(); + let amountCents: number | undefined; + + if (trimmed.length > 0) { + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || parsed <= 0) { + toast.error("Enter a valid refund amount."); + return; + } + amountCents = Math.round(parsed * 100); + if (amountCents > fullAmountCents) { + toast.error("Refund amount cannot exceed the invoice total."); + return; + } + } + + startTransition(async () => { + const result = await refundInvoiceAction({ + invoiceId: selected.id, + amountCents, + reason, + }); + if (result.error) { + toast.error(result.error); + return; + } + toast.success(`Refunded ${formatCents(result.refund!.amountCents, selected.currencyCode)}.`); + onOpenChange(false); + router.refresh(); + }); + } + + return ( + + + + + Refund invoice + + + Issue a full or partial refund for {orgName}. Refunds are processed through Stripe and + cannot be undone. + + + + {loading ? ( +
+ +
+ ) : invoices.length === 0 ? ( +
+ No paid, Stripe-synced invoices on this subscription. +
+ ) : ( +
+
+ +
+ {invoices.map((inv) => ( + + ))} +
+
+ +
+ + setAmountDollars(e.target.value)} + /> +

+ Leave blank to refund the full amount. Enter a smaller amount for a partial refund. +

+
+ +
+ + +
+
+ )} + + + + + +
+
+ ); +} diff --git a/components/admin/organizations/OrgDetailTabs.tsx b/components/admin/organizations/OrgDetailTabs.tsx index 6a83624..e5fa332 100644 --- a/components/admin/organizations/OrgDetailTabs.tsx +++ b/components/admin/organizations/OrgDetailTabs.tsx @@ -1,215 +1,215 @@ -"use client"; - -import { useState } from "react"; -import { formatDistanceToNow } from "date-fns"; -import type { getAdminOrgDetail } from "@/server/admin/organizations"; - -type Detail = NonNullable>>; - -const TABS = ["Overview", "Members", "Projects", "Clients", "Subscription", "Settings"] as const; -type Tab = typeof TABS[number]; - -const ROLE_BADGE: Record = { - owner: "bg-brand-100 text-primary", - admin: "bg-cf-accent-100 text-cf-accent-600", - manager: "bg-info/10 text-info", - member: "bg-secondary text-muted-foreground", -}; - -export function OrgDetailTabs({ detail }: { detail: Detail }) { - const [activeTab, setActiveTab] = useState("Overview"); - const { org, settings, members, projects, clients, subscription } = detail; - - return ( -
- {/* Tab bar */} -
- {TABS.map((tab) => ( - - ))} -
- - {/* Overview */} - {activeTab === "Overview" && ( -
- {[ - ["Name", org.name], - ["Slug", org.slug], - ["Status", org.isActive ? "Active" : "Suspended"], - ["Created", new Date(org.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })], - ["Timezone", org.timezone ?? "-"], - ["Currency", org.currencyCode ?? "-"], - ].map(([label, value]) => ( -
-

{label}

-

{value}

-
- ))} -
- )} - - {/* Members */} - {activeTab === "Members" && ( -
- - - - - - - - - - - {members.map((m) => ( - - - - - - - ))} - -
MemberRoleStatusJoined
-

{m.userName}

-

{m.userEmail}

-
- - {m.roleName} - - - - {m.status} - - - {m.joinedAt ? formatDistanceToNow(new Date(m.joinedAt), { addSuffix: true }) : "-"} -
-
- )} - - {/* Projects */} - {activeTab === "Projects" && ( -
- - - - - - - - - - {projects.length === 0 ? ( - - ) : projects.map((p) => ( - - - - - - ))} - -
ProjectStatusCreated
No projects.
{p.name} - {p.status} - - {formatDistanceToNow(new Date(p.createdAt), { addSuffix: true })} -
-
- )} - - {/* Clients */} - {activeTab === "Clients" && ( -
- - - - - - - - - - {clients.length === 0 ? ( - - ) : clients.map((c) => ( - - - - - - ))} - -
ClientStatusCreated
No clients.
{c.name} - {c.status} - - {formatDistanceToNow(new Date(c.createdAt), { addSuffix: true })} -
-
- )} - - {/* Subscription */} - {activeTab === "Subscription" && ( -
- {subscription ? ( - <> - {[ - ["Plan", subscription.planName], - ["Status", subscription.status], - ["Billing cycle", subscription.billingCycle ?? "-"], - ["Current period end", subscription.currentPeriodEnd ? new Date(subscription.currentPeriodEnd).toLocaleDateString() : "-"], - ["Stripe Customer ID", subscription.stripeCustomerId ?? "-"], - ["Stripe Subscription ID", subscription.stripeSubscriptionId ?? "-"], - ].map(([label, value]) => ( -
-

{label}

-

{value}

-
- ))} - - ) : ( -
- No active subscription. -
- )} -
- )} - - {/* Settings */} - {activeTab === "Settings" && ( -
- {settings ? ( - <> - {[ - ["Email verification required", settings.requireEmailVerification ? "Yes" : "No"], - ["Session timeout", settings.sessionTimeoutHours ? `${settings.sessionTimeoutHours}h` : "None"], - ["IP allowlist", settings.ipAllowlist?.length ? settings.ipAllowlist.join(", ") : "None"], - ["Brand color", settings.brandColor ?? "Default"], - ["Logo", settings.logoUrl ? "Set" : "None"], - ["Onboarding completed", settings.onboardingCompletedAt ? new Date(settings.onboardingCompletedAt).toLocaleDateString() : "No"], - ].map(([label, value]) => ( -
-

{label}

-

{value}

-
- ))} - - ) : ( -
- No settings found. -
- )} -
- )} -
- ); -} +"use client"; + +import { useState } from "react"; +import { formatDistanceToNow } from "date-fns"; +import type { getAdminOrgDetail } from "@/server/admin/organizations"; + +type Detail = NonNullable>>; + +const TABS = ["Overview", "Members", "Projects", "Clients", "Subscription", "Settings"] as const; +type Tab = typeof TABS[number]; + +const ROLE_BADGE: Record = { + owner: "bg-brand-100 text-primary", + admin: "bg-cf-accent-100 text-cf-accent-600", + manager: "bg-info/10 text-info", + member: "bg-secondary text-muted-foreground", +}; + +export function OrgDetailTabs({ detail }: { detail: Detail }) { + const [activeTab, setActiveTab] = useState("Overview"); + const { org, settings, members, projects, clients, subscription } = detail; + + return ( +
+ {/* Tab bar */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Overview */} + {activeTab === "Overview" && ( +
+ {[ + ["Name", org.name], + ["Slug", org.slug], + ["Status", org.isActive ? "Active" : "Suspended"], + ["Created", new Date(org.createdAt).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })], + ["Timezone", org.timezone ?? "-"], + ["Currency", org.currencyCode ?? "-"], + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+ )} + + {/* Members */} + {activeTab === "Members" && ( +
+ + + + + + + + + + + {members.map((m) => ( + + + + + + + ))} + +
MemberRoleStatusJoined
+

{m.userName}

+

{m.userEmail}

+
+ + {m.roleName} + + + + {m.status} + + + {m.joinedAt ? formatDistanceToNow(new Date(m.joinedAt), { addSuffix: true }) : "-"} +
+
+ )} + + {/* Projects */} + {activeTab === "Projects" && ( +
+ + + + + + + + + + {projects.length === 0 ? ( + + ) : projects.map((p) => ( + + + + + + ))} + +
ProjectStatusCreated
No projects.
{p.name} + {p.status} + + {formatDistanceToNow(new Date(p.createdAt), { addSuffix: true })} +
+
+ )} + + {/* Clients */} + {activeTab === "Clients" && ( +
+ + + + + + + + + + {clients.length === 0 ? ( + + ) : clients.map((c) => ( + + + + + + ))} + +
ClientStatusCreated
No clients.
{c.name} + {c.status} + + {formatDistanceToNow(new Date(c.createdAt), { addSuffix: true })} +
+
+ )} + + {/* Subscription */} + {activeTab === "Subscription" && ( +
+ {subscription ? ( + <> + {[ + ["Plan", subscription.planName], + ["Status", subscription.status], + ["Billing cycle", subscription.billingCycle ?? "-"], + ["Current period end", subscription.currentPeriodEnd ? new Date(subscription.currentPeriodEnd).toLocaleDateString() : "-"], + ["Stripe Customer ID", subscription.stripeCustomerId ?? "-"], + ["Stripe Subscription ID", subscription.stripeSubscriptionId ?? "-"], + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} + + ) : ( +
+ No active subscription. +
+ )} +
+ )} + + {/* Settings */} + {activeTab === "Settings" && ( +
+ {settings ? ( + <> + {[ + ["Email verification required", settings.requireEmailVerification ? "Yes" : "No"], + ["Session timeout", settings.sessionTimeoutHours ? `${settings.sessionTimeoutHours}h` : "None"], + ["IP allowlist", settings.ipAllowlist?.length ? settings.ipAllowlist.join(", ") : "None"], + ["Brand color", settings.brandColor ?? "Default"], + ["Logo", settings.logoUrl ? "Set" : "None"], + ["Onboarding completed", settings.onboardingCompletedAt ? new Date(settings.onboardingCompletedAt).toLocaleDateString() : "No"], + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} + + ) : ( +
+ No settings found. +
+ )} +
+ )} +
+ ); +} diff --git a/components/admin/organizations/OrganizationsTable.tsx b/components/admin/organizations/OrganizationsTable.tsx index df9696c..ba33101 100644 --- a/components/admin/organizations/OrganizationsTable.tsx +++ b/components/admin/organizations/OrganizationsTable.tsx @@ -1,367 +1,367 @@ -"use client"; - -import Link from "next/link"; -import { useMemo, useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; -import { formatDistanceToNow } from "date-fns"; -import { Building2, ExternalLink, Loader2, PowerOff, Power, X } from "lucide-react"; -import { parseAsString, useQueryStates } from "nuqs"; -import { toast } from "sonner"; -import { DataTable, FiltersPopover, type ColumnDef } from "@/components/data-table"; -import { StatusBadge } from "@/components/shared/StatusBadge"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { AdminOrgActions } from "./AdminOrgActions"; -import { - bulkSuspendOrgsAction, - bulkRestoreOrgsAction, -} from "@/server/actions/admin/organizations"; -import type { AdminOrgRow } from "@/server/admin/organizations"; -import type { PaginationMeta } from "@/utils/pagination"; - -const PLAN_COLORS: Record = { - free: "bg-secondary text-muted-foreground", - starter: "bg-info/10 text-info", - professional: "bg-brand-100 text-primary", - enterprise: "bg-success/10 text-success", -}; - -const PLAN_OPTIONS = [ - { value: "free", label: "Free" }, - { value: "starter", label: "Starter" }, - { value: "professional", label: "Professional" }, - { value: "enterprise", label: "Enterprise" }, -]; - -const STATUS_OPTIONS = [ - { value: "active", label: "Active" }, - { value: "suspended", label: "Suspended" }, -]; - -function buildColumns( - selected: Set, - toggleRow: (id: string) => void, - allChecked: boolean, - someChecked: boolean, - toggleAll: (checked: boolean) => void, -): ColumnDef[] { - return [ - { - key: "select", - header: "", - headerClassName: "w-8", - cell: (org) => ( - toggleRow(org.id)} - aria-label={`Select ${org.name}`} - onClick={(e) => e.stopPropagation()} - /> - ), - }, - { - key: "actions", - header: "Actions", - headerClassName: "w-16", - cell: (org) => ( -
- - - - -
- ), - }, - { - key: "name", - header: ( -
- toggleAll(v === true)} - aria-label="Select all on this page" - /> - Organization -
- ), - sortable: true, - cell: (org) => ( -
-
- -
-
-

{org.name}

-

{org.slug}

-
-
- ), - }, - { - key: "plan", - header: "Plan", - cell: (org) => , - }, - { - key: "memberCount", - header: "Members", - hideOnMobile: true, - cell: (org) => {org.memberCount}, - }, - { - key: "projectCount", - header: "Projects", - hideOnTablet: true, - cell: (org) => {org.projectCount}, - }, - { - key: "clientCount", - header: "Clients", - hideOnTablet: true, - cell: (org) => {org.clientCount}, - }, - { - key: "createdAt", - header: "Created", - sortable: true, - hideOnMobile: true, - cell: (org) => ( - - {formatDistanceToNow(new Date(org.createdAt), { addSuffix: true })} - - ), - }, - { - key: "status", - header: "Status", - hideOnMobile: true, - cell: (org) => - org.subscriptionStatus ? ( - - ) : ( - - - ), - }, - ]; -} - -type Props = { - data: AdminOrgRow[]; - pagination: PaginationMeta; -}; - -export function OrganizationsTable({ data, pagination }: Props) { - const router = useRouter(); - const [, startUrlTransition] = useTransition(); - const [isBulkPending, startBulkTransition] = useTransition(); - const [selected, setSelected] = useState>(new Set()); - const [suspendDialogOpen, setSuspendDialogOpen] = useState(false); - const [suspendReason, setSuspendReason] = useState(""); - - const [{ plan, status }, setFilters] = useQueryStates( - { - plan: parseAsString.withDefault(""), - status: parseAsString.withDefault(""), - page: parseAsString.withDefault(""), - }, - { shallow: false, startTransition: startUrlTransition, clearOnDefault: true }, - ); - - const pageIds = useMemo(() => data.map((r) => r.id), [data]); - const selectedOnPage = useMemo( - () => pageIds.filter((id) => selected.has(id)), - [pageIds, selected], - ); - const allChecked = pageIds.length > 0 && selectedOnPage.length === pageIds.length; - const someChecked = selectedOnPage.length > 0 && !allChecked; - - function toggleRow(id: string) { - setSelected((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - } - - function toggleAll(check: boolean) { - setSelected((prev) => { - const next = new Set(prev); - if (check) pageIds.forEach((id) => next.add(id)); - else pageIds.forEach((id) => next.delete(id)); - return next; - }); - } - - function clearSelection() { - setSelected(new Set()); - } - - function handleBulkRestore() { - const ids = Array.from(selected); - if (ids.length === 0) return; - startBulkTransition(async () => { - const result = await bulkRestoreOrgsAction({ orgIds: ids }); - if (result.error) { - toast.error(result.error); - return; - } - toast.success(`Restored ${result.count} organization${result.count === 1 ? "" : "s"}.`); - clearSelection(); - router.refresh(); - }); - } - - function handleBulkSuspendConfirm() { - const ids = Array.from(selected); - if (ids.length === 0) return; - startBulkTransition(async () => { - const result = await bulkSuspendOrgsAction({ orgIds: ids, reason: suspendReason }); - if (result.error) { - toast.error(result.error); - return; - } - toast.success(`Suspended ${result.count} organization${result.count === 1 ? "" : "s"}.`); - setSuspendDialogOpen(false); - setSuspendReason(""); - clearSelection(); - router.refresh(); - }); - } - - const columns = buildColumns(selected, toggleRow, allChecked, someChecked, toggleAll); - - return ( - <> - {selected.size > 0 && ( -
-
- {selected.size} - selected - -
-
- - -
-
- )} - - row.id} - searchPlaceholder="Search organizations…" - searchExtra={ - setFilters({ plan: v || null, page: null }), - }, - { - key: "status", - label: "Status", - options: STATUS_OPTIONS, - value: status, - onChange: (v) => setFilters({ status: v || null, page: null }), - }, - ]} - /> - } - pagination={pagination} - emptyTitle="No organizations found." - emptyDescription="Try adjusting your search or filters." - /> - - - - - Suspend {selected.size} organization{selected.size === 1 ? "" : "s"}? - - All members of the selected organizations will lose access immediately. They will see - a suspension notice until you restore the org. - - - -
- -