8 Commits
Author SHA1 Message Date
Leon SerfatyandClaude Opus 5 1d02598786 chore: sync in-progress work across marketing, admin, API and tests
Snapshot of uncommitted work that had accumulated in the tree alongside
the Turnstile changes:

- marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and
  structured data
- admin billing actions and a per-user portfolio view, plus an admin
  error boundary
- rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface
- CSP and proxy adjustments, accounting/webhook lib updates
- Playwright config and an e2e/unit test suite
- next bumped to ^16.3.4 with the lockfile regenerated
- generated AGENTS.md / CLAUDE.md

Authored by other sessions working in this tree; committed here so the
Turnstile work could be pushed without leaving the tree dirty.
Typecheck passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:27:16 -04:00
Leon SerfatyandClaude Opus 5 8f90347659 feat(auth): finish Turnstile coverage across every auth entry point
Turnstile protected sign-in, sign-up and password-reset, but three gaps
remained:

- updatePassword had no verification and its page had no widget, so the
  final step of the reset flow was unprotected. The reset token is now
  carried through the failure redirect so a failed challenge doesn't
  strand the user on a form whose emailed link can't be replayed.

- /api/auth/[...all] exposed better-auth's handler directly, accepting
  unlimited credential guesses and email sends with no bot protection --
  a full bypass of the page-level checks. Credential-bearing POSTs now
  require a verified token. The gate lives in the route handler, so the
  server actions (which call auth.api.* in-process) are unaffected. GET
  is untouched for OAuth callbacks and verify-email links, and
  /sign-in/social stays open since it only redirects to the provider.

- Turnstile tokens expire after ~5 minutes and the widget never reset,
  so a form left open submitted a stale token and failed with "complete
  the verification challenge" despite the challenge visibly passing.

Verified with Cloudflare's test keys: all four auth forms block an
invalid token, pass a valid one through to real auth logic, and the API
gate returns 403 without a token and 401 with one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 16:26:16 -04:00
Leon SerfatyandClaude Opus 4.8 595a5e3e04 feat(auth): hide Google sign-in until OAuth is configured
Google social login has no credentials in production, so the "Continue with
Google" button (and its divider) errored on click. Gate the button + divider on
a new isGoogleConfigured() helper (requires GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET)
on both the login and signup pages, and guard the signInWithGoogle action as
defense in depth. The button reappears automatically once both env vars are set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:26:08 -04:00
Leon SerfatyandClaude Opus 4.8 e5e987eb4c fix(csp): whitelist Umami analytics origin so tracking works
The CSP script-src/connect-src didn't include the Umami host
(fickanalytics.phluit.net), so the browser blocked both loading script.js and
the event beacons (POST /api/send) — analytics recorded 0 visits despite the
site being live. Add a umamiOrigin() helper (derived from NEXT_PUBLIC_UMAMI_SRC,
defaulting to the shared phluit instance) and include it in script-src and
connect-src.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 07:37:35 -04:00
Leon SerfatyandClaude Fable 5 5a555c715e Build GDPR compliance system: data export, account deletion, consent
- Data export (Art. 15/20): GET /api/gdpr/export serves a full JSON export
  of the user's data (credentials/tokens excluded, exclusions declared)
- Right to erasure (Art. 17): self-service deletion with 30-day grace
  period (Settings -> Privacy & Data), cancellable; daily /api/cron/gdpr
  drain cancels Stripe billing, purges Spaces files, cascade-deletes the
  account, anonymizes consent rows, and writes audit evidence
- Migration 0011: account_deletion_requests (partial unique index = one
  pending per user) + FK-less consent_log (survives erasure)
- Consent: terms/privacy acceptance logged at signup (email + Google);
  cookie banner with analytics opt-out (umami.disabled), choices logged
  server-side for signed-in users via POST /api/gdpr/consent
- Admin deleteUser upgraded to the same full purge (was leaving Spaces
  files and Stripe subscriptions orphaned)
- /gdpr legal page now points at the self-service tools
- scripts/verify-gdpr.ts: end-to-end verification vs live dev DB (22/22)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 06:03:27 -04:00
Leon SerfatyandClaude Opus 4.8 0d11018019 fix(csp): allow inline scripts so the app hydrates on Turbopack builds
Next.js 16 builds with Turbopack, which does NOT stamp the middleware CSP
nonce onto its inline hydration scripts (self.__next_f.push). The nonce-based
`script-src 'self' 'nonce-…'` therefore blocked those inline scripts, React
never hydrated, and the marketing/app pages rendered as a blank/black shell
(header + framer-motion sections stuck at opacity:0).

Switch `script-src` to 'self' 'unsafe-inline' (Turbopack-compatible) and drop
the now-unused nonce plumbing. All other CSP directives stay strict
(object-src 'none', frame-ancestors 'none', locked connect-src/frame-src).
Verified in a local production container: served script-src is correct and
the page hydrates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 05:30:39 -04:00
Leon SerfatyandClaude Opus 4.8 5495b94924 Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes
Deploy config:
- .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead
  of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the
  propertymanagement.network domain so they bake into the client bundle; add
  custom domains block (apex + www); wire Sentry DSN (server + browser).

Included pending work from the audit-fixes branch:
- AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default)
- Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010
- Outbound webhooks / Zapier integration
- PayPal removal (Stripe-only billing)
- Storage hardening (fail-loud when Spaces unconfigured), security fixes

Verified: full production Docker build (same build-args as DO) passes clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 04:45:24 -04:00
Leon SerfatyandClaude Opus 4.8 917a06ee85 docs: overhaul README and mark project proprietary
- Rewrite README: accurate current feature set (public API, webhooks/Zapier,
  PayPal, accounting sync, e-sign, team, branding, inspections, maps, ~30 tables,
  4 cron jobs), emoji section headers, clearer setup and security sections.
- Remove the inline env-variable example block; point to .env.example / DIGITALOCEAN.md.
- Fix stale "local disk" storage references to DigitalOcean Spaces.
- License: change MIT -> Proprietary; add LICENSE file and package.json "UNLICENSED".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:51:59 -04:00
166 changed files with 16669 additions and 2166 deletions
+91 -47
View File
@@ -1,30 +1,28 @@
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# DigitalOcean App Platform spec — Property Management Network # DigitalOcean App Platform spec — Property Management Network
# #
# Deploy: doctl apps create --spec .do/app.yaml # Deploy: doctl apps create --spec .do/app.yaml (or the DO MCP apps-create)
# Update: doctl apps update <APP_ID> --spec .do/app.yaml # Update: doctl apps update <APP_ID> --spec .do/app.yaml
# #
# SOURCE: image-based from DigitalOcean Container Registry (DOCR). The app's git # SOURCE: App Platform builds the Dockerfile directly from GitHub
# lives on self-hosted Gitea, which App Platform cannot pull, so we build the # (github.com/silkoserfo/property-management-network). Pushes to `main`
# Docker image ourselves and push it to DOCR. See DIGITALOCEAN.md for the full # auto-redeploy (deploy_on_push). No DOCR image build/push needed.
# build/push/deploy walkthrough.
# #
# SECRETS: values marked `type: SECRET` are placeholders — set the real values in # SECRETS: values marked `type: SECRET` are placeholders — set the real values in
# the App Platform dashboard (App → Settings → Environment Variables) or via # the App Platform dashboard (App → Settings → Environment Variables) or via the
# `doctl`. Never commit real secrets to this file. # create spec. Never commit real secrets to this file.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
name: property-management-network name: property-management-network
region: nyc region: nyc
services: services:
- name: web - name: web
# Pre-built image pushed to DOCR (repository must exist in your registry). # Built by App Platform from GitHub using the repo Dockerfile.
image: github:
registry_type: DOCR repo: silkoserfo/property-management-network
repository: property-management-network branch: main
tag: latest deploy_on_push: true
deploy_on_push: dockerfile_path: Dockerfile
enabled: true
instance_count: 1 instance_count: 1
instance_size_slug: apps-s-1vcpu-1gb instance_size_slug: apps-s-1vcpu-1gb
http_port: 3000 http_port: 3000
@@ -37,32 +35,35 @@ services:
failure_threshold: 3 failure_threshold: 3
envs: envs:
# ── App URLs ────────────────────────────────────────────────────────── # ── App URLs ──────────────────────────────────────────────────────────
# ${APP_URL} resolves to the app's public URL at runtime. NOTE: the client # NEXT_PUBLIC_* are inlined into the client bundle at BUILD time, so they
# bundle bakes NEXT_PUBLIC_APP_URL at *image build* time (see Dockerfile / # must be RUN_AND_BUILD_TIME with the literal domain we serve on.
# DIGITALOCEAN.md), so build the image with the same URL you serve on.
- key: NEXT_PUBLIC_APP_URL - key: NEXT_PUBLIC_APP_URL
scope: RUN_TIME scope: RUN_AND_BUILD_TIME
value: ${APP_URL} value: https://propertymanagement.network
- key: BETTER_AUTH_URL - key: BETTER_AUTH_URL
scope: RUN_TIME scope: RUN_TIME
value: ${APP_URL} value: https://propertymanagement.network
- key: NEXT_PUBLIC_APP_NAME - key: NEXT_PUBLIC_APP_NAME
scope: RUN_TIME scope: RUN_AND_BUILD_TIME
value: Property Management Network value: Property Management Network
# ── Database (managed Postgres — use the PRIVATE host; see DIGITALOCEAN.md) ── # ── Admin & auth policy ───────────────────────────────────────────────
- key: ADMIN_EMAILS
scope: RUN_TIME
value: leon@phluit.com
- key: REQUIRE_EMAIL_VERIFICATION
scope: RUN_TIME
value: "true"
# ── Database (managed Postgres — PRIVATE host, direct port 25060) ──
- key: DATABASE_URL - key: DATABASE_URL
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD value: REPLACE_IN_DASHBOARD
# Verified TLS (encrypted + certificate-checked). DO Managed Postgres uses # Verified TLS: DO's Managed Postgres CA isn't in the system trust store, so
# a CA that isn't in the system trust store, so paste the cluster's CA cert # paste the cluster CA PEM (repo root ca-certificate.crt) into DATABASE_CA.
# into DATABASE_CA: DO control panel → Database → Connection Details → # With `require` + a valid CA the app connects verified; without a valid CA
# "Download CA certificate", then paste its PEM contents as the DATABASE_CA # it fails loud rather than run unverified.
# secret in the App Platform dashboard. Without a valid CA the app will
# refuse to connect (fail loud) rather than run unverified.
# Emergency fallback ONLY (not for production): DATABASE_SSL=no-verify is
# encrypted but does NOT verify the server certificate.
- key: DATABASE_SSL - key: DATABASE_SSL
scope: RUN_TIME scope: RUN_TIME
value: require value: require
@@ -70,8 +71,7 @@ services:
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD value: REPLACE_IN_DASHBOARD
# Schema is migrated out-of-band (as doadmin), NOT on boot — the app user # Schema is migrated out-of-band (as doadmin), NOT on boot.
# intentionally lacks DDL rights. Keep this false; run migrations manually.
- key: RUN_MIGRATIONS_ON_START - key: RUN_MIGRATIONS_ON_START
scope: RUN_TIME scope: RUN_TIME
value: "false" value: "false"
@@ -81,14 +81,15 @@ services:
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD value: REPLACE_IN_DASHBOARD
# Google OAuth (optional — leave blank to disable the Google button).
- key: GOOGLE_CLIENT_ID - key: GOOGLE_CLIENT_ID
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD value: ""
- key: GOOGLE_CLIENT_SECRET - key: GOOGLE_CLIENT_SECRET
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD value: ""
# ── Stripe ──────────────────────────────────────────────────────────── # ── Stripe ────────────────────────────────────────────────────────────
- key: STRIPE_SECRET_KEY - key: STRIPE_SECRET_KEY
@@ -99,22 +100,21 @@ services:
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD value: REPLACE_IN_DASHBOARD
# No STRIPE_*_PRICE_ID vars — prices are resolved by lookup key and
# auto-created on first checkout (lib/stripe/prices.ts). Going live only
# needs the two live secrets above + the live publishable key below.
# ── OpenAI ──────────────────────────────────────────────────────────── # ── AI provider (Anthropic default; OpenAI optional) ──────────────────
- key: OPENAI_API_KEY - key: ANTHROPIC_API_KEY
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD value: REPLACE_IN_DASHBOARD
- key: ANTHROPIC_MODEL
scope: RUN_TIME
value: claude-haiku-4-5
- key: OPENAI_API_KEY
scope: RUN_TIME
type: SECRET
value: ""
# ── Email (SMTP — SMTP2GO) ──────────────────────────────────────────── # ── Email (SMTP — SMTP2GO) ────────────────────────────────────────────
# The app sends mail via SMTP only (nodemailer). Email is silently skipped
# unless SMTP_HOST + SMTP_USER + SMTP_PASS are all set — password resets,
# email verification, rent/overdue/lease reminders, team invites, and
# payment links all depend on this. EMAIL_FROM is a bare address; the app
# wraps it as "Property Management Network <…>".
- key: SMTP_HOST - key: SMTP_HOST
scope: RUN_TIME scope: RUN_TIME
value: mail.smtp2go.com value: mail.smtp2go.com
@@ -133,10 +133,9 @@ services:
scope: RUN_TIME scope: RUN_TIME
value: postmaster@propertymanagement.network value: postmaster@propertymanagement.network
# ── Cloudflare Turnstile (site key is public; baked into the client bundle # ── Cloudflare Turnstile (site key public; baked at build time) ──
# at image build time — keep it in sync when you build) ──
- key: NEXT_PUBLIC_TURNSTILE_SITE_KEY - key: NEXT_PUBLIC_TURNSTILE_SITE_KEY
scope: RUN_TIME scope: RUN_AND_BUILD_TIME
value: 0x4AAAAAADuDQverznfv1a60 value: 0x4AAAAAADuDQverznfv1a60
- key: TURNSTILE_SECRET_KEY - key: TURNSTILE_SECRET_KEY
scope: RUN_TIME scope: RUN_TIME
@@ -165,8 +164,53 @@ services:
scope: RUN_TIME scope: RUN_TIME
value: https://nyc3.cdn.digitaloceanspaces.com value: https://nyc3.cdn.digitaloceanspaces.com
# ── Accounting sync (optional — per-landlord QuickBooks / Xero OAuth) ──
- key: QBO_CLIENT_ID
scope: RUN_TIME
type: SECRET
value: ""
- key: QBO_CLIENT_SECRET
scope: RUN_TIME
type: SECRET
value: ""
- key: QBO_ENVIRONMENT
scope: RUN_TIME
value: production
- key: XERO_CLIENT_ID
scope: RUN_TIME
type: SECRET
value: ""
- key: XERO_CLIENT_SECRET
scope: RUN_TIME
type: SECRET
value: ""
- key: XERO_SALES_ACCOUNT_CODE
scope: RUN_TIME
value: "200"
- key: XERO_EXPENSE_ACCOUNT_CODE
scope: RUN_TIME
value: "400"
# ── Error monitoring (Sentry — DSN is public; browser DSN baked at build) ──
- key: SENTRY_DSN
scope: RUN_TIME
value: https://ef6aa585a080711e14a855b6cc024e9a@o4509830676873216.ingest.us.sentry.io/4511667160219648
- key: NEXT_PUBLIC_SENTRY_DSN
scope: RUN_AND_BUILD_TIME
value: https://ef6aa585a080711e14a855b6cc024e9a@o4509830676873216.ingest.us.sentry.io/4511667160219648
- key: SENTRY_ENVIRONMENT
scope: RUN_TIME
value: production
# ── Cron (Bearer token the DO Function sends to /api/cron/*) ── # ── Cron (Bearer token the DO Function sends to /api/cron/*) ──
- key: CRON_SECRET - key: CRON_SECRET
scope: RUN_TIME scope: RUN_TIME
type: SECRET type: SECRET
value: REPLACE_IN_DASHBOARD value: REPLACE_IN_DASHBOARD
# ── Custom domains (DNS hosted on Cloudflare — set CNAMEs there, DNS-only) ──
domains:
- domain: propertymanagement.network
type: PRIMARY
- domain: www.propertymanagement.network
type: ALIAS
+36 -27
View File
@@ -50,24 +50,16 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your-publishable-key
# auto-creates them on first checkout (lib/stripe/prices.ts), so going live is a # auto-creates them on first checkout (lib/stripe/prices.ts), so going live is a
# pure key swap. Optionally pre-create the catalog: node scripts/stripe-setup.mjs # pure key swap. Optionally pre-create the catalog: node scripts/stripe-setup.mjs
# === PAYPAL (optional — alternative subscription checkout) === # === AI PROVIDER (OpenAI and/or Anthropic) ===
# Lets landlords pay for their plan with PayPal alongside Stripe. Leave blank to # The active provider is chosen by an admin in Settings → System. Configure the
# hide the PayPal buttons. Create a REST app at https://developer.paypal.com; # key(s) for whichever provider(s) you want available; the app falls back to the
# keep PAYPAL_ENVIRONMENT=sandbox for testing. Create a webhook pointing to # configured one if the selected provider's key is missing.
# <APP_URL>/api/paypal/webhook and set its id as PAYPAL_WEBHOOK_ID. Generate the # OpenAI — https://platform.openai.com/api-keys
# plan IDs once with `node scripts/paypal-setup-plans.mjs` and paste them below.
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_ENVIRONMENT=sandbox
PAYPAL_WEBHOOK_ID=
PAYPAL_PRO_MONTHLY_PLAN_ID=
PAYPAL_PRO_YEARLY_PLAN_ID=
PAYPAL_LANDLORD_MONTHLY_PLAN_ID=
PAYPAL_LANDLORD_YEARLY_PLAN_ID=
# === AI (OpenAI) ===
# Get from: https://platform.openai.com/api-keys
OPENAI_API_KEY=sk-your-api-key OPENAI_API_KEY=sk-your-api-key
# OPENAI_MODEL=gpt-4o-mini
# Anthropic (Claude) — https://console.anthropic.com/settings/keys
ANTHROPIC_API_KEY=
# ANTHROPIC_MODEL=claude-haiku-4-5 # cheapest; use claude-sonnet-5 / claude-opus-4-8 for more capability
# === EMAIL (SMTP — e.g. SMTP2GO) === # === EMAIL (SMTP — e.g. SMTP2GO) ===
# Any SMTP provider works. Port 465 = implicit SSL; 587/2525 = STARTTLS. # Any SMTP provider works. Port 465 = implicit SSL; 587/2525 = STARTTLS.
@@ -87,16 +79,18 @@ QBO_ENVIRONMENT=sandbox
XERO_CLIENT_ID= XERO_CLIENT_ID=
XERO_CLIENT_SECRET= XERO_CLIENT_SECRET=
# === E-SIGNATURE (optional — DocuSign / Dropbox Sign) === # === E-SIGNATURE (optional — per-landlord: each connects their OWN account) ===
# Dropbox Sign: API-key auth. Set DROPBOX_SIGN_TEST_MODE=true while testing. # DocuSign: register ONE DocuSign app (integration key) here; each landlord then
DROPBOX_SIGN_API_KEY= # connects their own DocuSign account via OAuth from Settings → Integrations.
DROPBOX_SIGN_TEST_MODE=true # Redirect URI to register in the DocuSign app: <APP_URL>/api/esign/docusign/callback
# DocuSign: uses a pre-obtained access token (JWT/OAuth). Webhook: DocuSign # DOCUSIGN_OAUTH_BASE: account-d.docusign.com (demo) or account.docusign.com (prod).
# Connect → <APP_URL>/api/esign/docusign/webhook ; Dropbox Sign callback → DOCUSIGN_CLIENT_ID=
# <APP_URL>/api/esign/dropbox_sign/webhook DOCUSIGN_CLIENT_SECRET=
DOCUSIGN_ACCESS_TOKEN= DOCUSIGN_OAUTH_BASE=account-d.docusign.com
DOCUSIGN_ACCOUNT_ID= # Dropbox Sign: no server credentials — landlords paste their own API key in the
DOCUSIGN_BASE_URI=https://demo.docusign.net # app and set their account callback URL to <APP_URL>/api/esign/dropbox_sign/webhook.
# DROPBOX_SIGN_TEST_MODE applies test mode to all outbound requests (optional).
DROPBOX_SIGN_TEST_MODE=false
# === APP === # === APP ===
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000
@@ -113,6 +107,14 @@ CRON_SECRET=your-random-secret-string
NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js
NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8 NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8
# === ERROR MONITORING (Sentry — optional) ===
# Paste the DSN from your Sentry project (Settings → Client Keys / DSN). It's
# public (ships in the browser bundle). Sentry stays inert until this is set.
NEXT_PUBLIC_SENTRY_DSN=
# Build-time only: uploads source maps for readable stack traces. Create at
# Sentry → Settings → Auth Tokens. Keep secret; leave blank to skip upload.
SENTRY_AUTH_TOKEN=
# === MAPS / GEOCODING (OpenStreetMap — free, no key) === # === MAPS / GEOCODING (OpenStreetMap — free, no key) ===
# Property addresses are geocoded on save via OpenStreetMap Nominatim and shown # Property addresses are geocoded on save via OpenStreetMap Nominatim and shown
# on a Leaflet map (both keyless & free). Nominatim's policy requires an # on a Leaflet map (both keyless & free). Nominatim's policy requires an
@@ -124,3 +126,10 @@ GEOCODER_USER_AGENT=PropertyManagementNetwork/1.0 (https://propertymanagement.ne
# Leave both blank to disable the captcha (auth forms still work). # Leave both blank to disable the captcha (auth forms still work).
NEXT_PUBLIC_TURNSTILE_SITE_KEY= NEXT_PUBLIC_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY= TURNSTILE_SECRET_KEY=
# === SECRETS AT REST ===
# AES-256-GCM key for the OAuth tokens stored for QuickBooks / Xero / DocuSign /
# Dropbox Sign. If unset, the key is derived from BETTER_AUTH_SECRET — which
# means rotating BETTER_AUTH_SECRET would make every stored token undecryptable.
# Set a dedicated value in production so the two can rotate independently.
ACCOUNTING_ENCRYPTION_KEY=replace-with-a-random-string
+27 -25
View File
@@ -68,21 +68,6 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
# first checkout, so going live is ONLY the three values above (live keys + live # first checkout, so going live is ONLY the three values above (live keys + live
# webhook secret). Optionally pre-create the catalog: node scripts/stripe-setup.mjs # webhook secret). Optionally pre-create the catalog: node scripts/stripe-setup.mjs
# === PAYPAL (optional — alternative subscription checkout) ===
# Landlords can pay for their plan with PayPal alongside Stripe. Leave blank to
# hide the PayPal buttons. Create a REST app at https://developer.paypal.com and
# set PAYPAL_ENVIRONMENT=live for production. Create a webhook there pointing to
# <APP_URL>/api/paypal/webhook and put its id in PAYPAL_WEBHOOK_ID. Generate the
# plan IDs with `node scripts/paypal-setup-plans.mjs`.
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_ENVIRONMENT=live
PAYPAL_WEBHOOK_ID=
PAYPAL_PRO_MONTHLY_PLAN_ID=
PAYPAL_PRO_YEARLY_PLAN_ID=
PAYPAL_LANDLORD_MONTHLY_PLAN_ID=
PAYPAL_LANDLORD_YEARLY_PLAN_ID=
# === AI (OpenAI) === # === AI (OpenAI) ===
OPENAI_API_KEY=sk-xxx OPENAI_API_KEY=sk-xxx
@@ -103,17 +88,17 @@ QBO_ENVIRONMENT=production
XERO_CLIENT_ID= XERO_CLIENT_ID=
XERO_CLIENT_SECRET= XERO_CLIENT_SECRET=
# === E-SIGNATURE (optional — DocuSign / Dropbox Sign) === # === E-SIGNATURE (optional — per-landlord: each connects their OWN account) ===
# Leave blank to hide/disable a provider on the lease page. Configure the # DocuSign: register ONE DocuSign app; landlords connect their own account via
# provider callbacks to point at this app: # OAuth from Settings → Integrations. Register this redirect URI in the app:
# Dropbox Sign callback → <APP_URL>/api/esign/dropbox_sign/webhook # <APP_URL>/api/esign/docusign/callback
# DocuSign Connect → <APP_URL>/api/esign/docusign/webhook # Use account.docusign.com in production (account-d.docusign.com for demo).
# In production set DROPBOX_SIGN_TEST_MODE=false to send legally-binding docs. DOCUSIGN_CLIENT_ID=
DROPBOX_SIGN_API_KEY= DOCUSIGN_CLIENT_SECRET=
DOCUSIGN_OAUTH_BASE=account.docusign.com
# Dropbox Sign: no server credentials — landlords paste their own API key and set
# their account callback URL to <APP_URL>/api/esign/dropbox_sign/webhook.
DROPBOX_SIGN_TEST_MODE=false DROPBOX_SIGN_TEST_MODE=false
DOCUSIGN_ACCESS_TOKEN=
DOCUSIGN_ACCOUNT_ID=
DOCUSIGN_BASE_URI=https://www.docusign.net
# === APP (NEXT_PUBLIC_* — also set as Build Variables) === # === APP (NEXT_PUBLIC_* — also set as Build Variables) ===
NEXT_PUBLIC_APP_URL=https://propertymanagement.network NEXT_PUBLIC_APP_URL=https://propertymanagement.network
@@ -128,6 +113,14 @@ GOOGLE_SITE_VERIFICATION=
NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js NEXT_PUBLIC_UMAMI_SRC=https://fickanalytics.phluit.net/script.js
NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8 NEXT_PUBLIC_UMAMI_WEBSITE_ID=4066c359-596f-4d0e-9636-c035c2adfbe8
# === ERROR MONITORING (Sentry) ===
# DSN from your Sentry project (public — inlined in the browser bundle, so set
# it as a Build Variable too). Error monitoring is disabled until this is set.
NEXT_PUBLIC_SENTRY_DSN=
# Build-time secret: uploads source maps so prod stack traces are un-minified.
# Sentry → Settings → Auth Tokens. Set as a Build Variable; leave blank to skip.
SENTRY_AUTH_TOKEN=
# === MAPS / GEOCODING (OpenStreetMap — free, no key) === # === MAPS / GEOCODING (OpenStreetMap — free, no key) ===
# Addresses are geocoded via OpenStreetMap Nominatim; the map uses Leaflet + OSM # Addresses are geocoded via OpenStreetMap Nominatim; the map uses Leaflet + OSM
# tiles. No API key or billing. Nominatim REQUIRES an identifying User-Agent — # tiles. No API key or billing. Nominatim REQUIRES an identifying User-Agent —
@@ -142,5 +135,14 @@ CRON_SECRET=replace-with-a-random-string
# Create a widget at https://dash.cloudflare.com/?to=/:account/turnstile # Create a widget at https://dash.cloudflare.com/?to=/:account/turnstile
# NEXT_PUBLIC_TURNSTILE_SITE_KEY is inlined at build time — also set it as a # NEXT_PUBLIC_TURNSTILE_SITE_KEY is inlined at build time — also set it as a
# Build Variable in Coolify. Leave both blank to disable the captcha. # Build Variable in Coolify. Leave both blank to disable the captcha.
# NOTE: in production a blank TURNSTILE_SECRET_KEY now FAILS CLOSED — auth
# forms are rejected rather than silently losing bot protection.
NEXT_PUBLIC_TURNSTILE_SITE_KEY= NEXT_PUBLIC_TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY= TURNSTILE_SECRET_KEY=
# === SECRETS AT REST ===
# AES-256-GCM key for the OAuth tokens stored for QuickBooks / Xero / DocuSign /
# Dropbox Sign. If unset, the key is derived from BETTER_AUTH_SECRET — which
# means rotating BETTER_AUTH_SECRET would make every stored token undecryptable.
# Set a dedicated value in production so the two can rotate independently.
ACCOUNTING_ENCRYPTION_KEY=replace-with-a-random-string
+9
View File
@@ -34,6 +34,9 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# MCP config — contains a DigitalOcean API token; keep local, never commit.
.mcp.json
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example !.env.example
@@ -46,3 +49,9 @@ next-env.d.ts
DOCS/ DOCS/
.env*.local .env*.local
# playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+12 -1
View File
@@ -94,11 +94,19 @@ docker build \
--build-arg NEXT_PUBLIC_APP_URL=https://<your-domain> \ --build-arg NEXT_PUBLIC_APP_URL=https://<your-domain> \
--build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \ --build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \
--build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADuDQverznfv1a60 \ --build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADuDQverznfv1a60 \
--build-arg NEXT_PUBLIC_SENTRY_DSN=<your-sentry-dsn> \
--build-arg SENTRY_AUTH_TOKEN=<optional-for-source-maps> \
-t $REG/property-management-network:latest . -t $REG/property-management-network:latest .
docker push $REG/property-management-network:latest docker push $REG/property-management-network:latest
``` ```
> **Sentry:** the browser DSN is baked at build time, so it must be a `--build-arg`
> (setting `NEXT_PUBLIC_SENTRY_DSN` only in the dashboard won't reach the client). The
> server/edge runtimes read `SENTRY_DSN` at runtime (set in the dashboard). Both stay inert
> until a DSN is provided, so it's safe to omit until you're ready. `SENTRY_AUTH_TOKEN` is
> optional and only uploads source maps for readable stack traces.
> First deploy chicken-and-egg: if you don't have a domain yet, deploy once to get the > First deploy chicken-and-egg: if you don't have a domain yet, deploy once to get the
> `*.ondigitalocean.app` URL, then rebuild/push with that URL as `NEXT_PUBLIC_APP_URL`. > `*.ondigitalocean.app` URL, then rebuild/push with that URL as `NEXT_PUBLIC_APP_URL`.
@@ -114,7 +122,10 @@ Then set every `type: SECRET` value (App → Settings → Environment Variables)
`.do/app.yaml` before applying. Secrets to fill: `DATABASE_URL`, `DATABASE_CA`, `.do/app.yaml` before applying. Secrets to fill: `DATABASE_URL`, `DATABASE_CA`,
`BETTER_AUTH_SECRET`, `GOOGLE_CLIENT_ID/SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `BETTER_AUTH_SECRET`, `GOOGLE_CLIENT_ID/SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`,
`OPENAI_API_KEY`, `SMTP_USER`, `SMTP_PASS`, `TURNSTILE_SECRET_KEY`, `SPACES_KEY`, `OPENAI_API_KEY`, `SMTP_USER`, `SMTP_PASS`, `TURNSTILE_SECRET_KEY`, `SPACES_KEY`,
`SPACES_SECRET`, `CRON_SECRET` (plus the Stripe price IDs). Email sends via **SMTP `SPACES_SECRET`, `CRON_SECRET`. Optional integrations (leave blank to keep hidden):
`QBO_CLIENT_ID/SECRET` + `XERO_CLIENT_ID/SECRET` (accounting), `DOCUSIGN_CLIENT_ID/SECRET`
(e-signature — not yet in the spec; add if used), and `SENTRY_DSN` (error monitoring —
plus the `NEXT_PUBLIC_SENTRY_DSN` build-arg above). Email sends via **SMTP
(SMTP2GO)** — `SMTP_HOST`/`SMTP_PORT`/`EMAIL_FROM` ship as non-secret defaults; without (SMTP2GO)** — `SMTP_HOST`/`SMTP_PORT`/`EMAIL_FROM` ship as non-secret defaults; without
`SMTP_USER` + `SMTP_PASS` all outbound email is silently skipped. `${APP_URL}` auto-resolves `SMTP_USER` + `SMTP_PASS` all outbound email is silently skipped. `${APP_URL}` auto-resolves
for `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` at runtime. for `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` at runtime.
+8
View File
@@ -24,9 +24,17 @@ ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_APP_URL ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_APP_NAME="Property Management Network" ARG NEXT_PUBLIC_APP_NAME="Property Management Network"
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
# Client-side Sentry DSN — inlined into the browser bundle. Without it, only
# server/edge errors are reported (SENTRY_DSN at runtime); the browser stays inert.
ARG NEXT_PUBLIC_SENTRY_DSN
# Optional: a Sentry auth token uploads source maps for readable stack traces.
# The build still succeeds without it.
ARG SENTRY_AUTH_TOKEN
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN npm run build RUN npm run build
+22
View File
@@ -0,0 +1,22 @@
PROPRIETARY SOFTWARE LICENSE
Copyright (c) 2026 Property Management Network. All rights reserved.
This software and its source code (the "Software") are proprietary and
confidential. The Software is licensed, not sold.
No permission is granted to any person or entity to use, copy, reproduce,
modify, merge, publish, distribute, sublicense, sell, or create derivative
works of the Software, in whole or in part, by any means, without the prior
express written consent of the copyright holder.
Unauthorized copying, distribution, or use of the Software, via any medium,
is strictly prohibited.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF,
OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+132 -135
View File
@@ -5,64 +5,99 @@
</picture> </picture>
</p> </p>
# Property Management Network <h1 align="center">🏠 Property Management Network</h1>
**Property management SaaS for independent landlords.** Track properties, tenants, rent, maintenance, leases, and expenses — all in one clean dashboard. <p align="center">
<strong>The all-in-one property-management platform for independent landlords.</strong><br>
Properties, tenants, rent, maintenance, leases, expenses, AI insights, and integrations — in one clean dashboard.
</p>
Built with Next.js 16, PostgreSQL (Drizzle ORM), Better Auth, Stripe, and OpenAI. Deploys to DigitalOcean App Platform (see [DIGITALOCEAN.md](DIGITALOCEAN.md)). <p align="center">
<img alt="Next.js" src="https://img.shields.io/badge/Next.js-16-black?logo=nextdotjs">
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white">
<img alt="PostgreSQL" src="https://img.shields.io/badge/PostgreSQL-Drizzle_ORM-4169E1?logo=postgresql&logoColor=white">
<img alt="License" src="https://img.shields.io/badge/license-Proprietary-red">
</p>
--- ---
## What it does ## ✨ Overview
Property Management Network replaces the spreadsheet + WhatsApp chaos that most small landlords live with. Key capabilities: Property Management Network replaces the spreadsheet-and-WhatsApp chaos that most small landlords live with. It gives a solo landlord or a small team a single source of truth for their whole portfolio — and the automation, AI, and integrations to run it hands-off.
- **Properties & units** — manage your entire portfolio with occupancy tracking Everything is **multi-tenant and team-aware**: each landlord operates on their own isolated portfolio, and Landlord/Lifetime accounts can invite teammates with scoped roles.
- **Tenant profiles** — contact info, lease history, payment records, and a private tenant portal
- **Rent tracking** — log payments, send Stripe payment links, auto-mark overdue balances ### 🧰 What you can do
- **Maintenance requests** — status workflow (Open → In Progress → Resolved), tenant submissions via portal
- **Lease management** — expiry countdowns, automated 60/30/7-day email alerts **Core operations**
- **Expenses** — categorized logging with recurring expense support - 🏢 **Properties & units**manage your whole portfolio with live occupancy tracking and a map view (addresses are auto-geocoded).
- **Documents** — file vault per property with drag-and-drop upload to local disk, served through an auth-gated route - 👥 **Tenants**profiles, lease history, payment records, and a private **tenant portal** (token-based, no login required).
- **AI features** — AI-powered recommendations, predictions, and impact tracking (Pro+) - 💵 **Rent tracking**log payments, send **Stripe** payment links, and auto-mark balances overdue with automatic late fees.
- **Automated emails** — rent reminders, overdue alerts, lease expiry notifications via SMTP (SMTP2GO) - 🔧 **Maintenance**full status workflow (Open → In Progress → Resolved), with tenant-submitted requests from the portal.
- **Tenant portal** — token-based (no login), tenants can view rent history and submit maintenance - 📄 **Leases**expiry countdowns, automated 60/30/7-day email alerts, and **e-signature** (DocuSign / Dropbox Sign).
- 🧾 **Expenses** — categorized logging with recurring-expense support.
- 🗂️ **Documents** — a per-property file vault stored in object storage and served through an auth-gated route.
- 🔎 **Inspections & vendors** — move-in/out/routine inspection checklists and a vendor directory.
- 📊 **Reports & exports** — portfolio analytics with CSV export.
- 📅 **Calendar** — an in-app calendar plus a read-only **iCal (ICS) feed** you can subscribe to.
**Automation & AI**
- 🤖 **AI features** — recommendations, predictions, impact tracking, and a portfolio assistant (OpenAI). *(Pro and up.)*
- ✉️ **Automated email** — rent reminders, overdue notices, and lease-expiry alerts, plus a configurable **follow-up engine**.
- 🎨 **White-label branding** — put your own brand on the tenant portal. *(Landlord / Lifetime.)*
- 🛡️ **Admin dashboard** — superadmin tools with a full audit log.
--- ---
## Revenue model ## 🔌 Integrations & developer platform
| Plan | Price | Limits | | Capability | Details |
|------|-------|--------| |---|---|
| Starter | Free | 1 property, 3 tenants, no AI | | 🌐 **Public REST API** | Versioned `/api/v1` endpoints (properties, tenants, payments, maintenance, webhooks) authenticated with Bearer **API keys**. See `/api-docs`. |
| Pro | $29/mo | 10 properties, unlimited tenants, AI (50 calls/mo) | | 🪝 **Outbound webhooks / Zapier** | Subscribe to events (`tenant.created`, `payment.paid`, `maintenance.updated`, …). Deliveries are **HMAC-signed**, retried with backoff, and Zapier-compatible via the REST-hook subscribe/unsubscribe pattern. |
| Landlord | $59/mo | Unlimited properties, team access, white-label, AI (200/mo) | | 💳 **Payments** | Stripe (subscriptions + rent payment links). |
| Lifetime | $199 one-time | Everything in Landlord, forever | | 📚 **Accounting sync** | One-way push of income & expenses to **QuickBooks Online** or **Xero** (OAuth). |
| ✍️ **E-signature** | Send leases for signature via **DocuSign** or **Dropbox Sign**. |
| 🔑 **Auth** | Email/password and Google OAuth (Better Auth). |
Subscription billing via Stripe. Lifetime deal is ideal for Flippa buyers who want to offer an LTD to early customers. Every integration is env-gated: unconfigured providers show a clean “not configured” state instead of a broken button.
--- ---
## Tech stack ## 💳 Plans & pricing
| Layer | Tech | | Plan | Price | Highlights |
|-------|------| |------|-------|------------|
| Framework | Next.js 16.2 (App Router, TypeScript) | | 🆓 **Starter** | Free | 1 property, 3 tenants, no AI |
| Styling | Tailwind CSS + Geist font | | 🚀 **Pro** | $29/mo | 10 properties, unlimited tenants, AI (50 calls/mo) |
| Database | PostgreSQL (via Drizzle ORM) | | 🏆 **Landlord** | $59/mo | Unlimited properties, team access, white-label, AI (200/mo) |
| ♾️ **Lifetime** | $199 once | Everything in Landlord, forever |
Billing runs through **Stripe**. Products/prices are resolved by stable lookup keys and auto-created on first checkout, so going live is just an API-key swap — no price IDs to wire up.
---
## 🧱 Tech stack
| Layer | Technology |
|-------|------------|
| Framework | Next.js 16.2 (App Router, TypeScript, React 19) |
| Styling | Tailwind CSS + Geist |
| Database | PostgreSQL via **Drizzle ORM** |
| Auth | Better Auth (email/password + Google OAuth) | | Auth | Better Auth (email/password + Google OAuth) |
| Storage | DigitalOcean Spaces (S3-compatible, CDN, auth-gated) | | Object storage | DigitalOcean Spaces (S3-compatible, CDN, auth-gated) |
| Payments | Stripe (subscriptions + payment links) | | Payments | Stripe |
| AI | OpenAI (gpt-4o-mini) | | AI | OpenAI (`gpt-4o-mini`) |
| Email | SMTP (SMTP2GO) | | Email | SMTP (SMTP2GO) |
| Maps | Leaflet + OpenStreetMap / Nominatim geocoding |
| Cron | DigitalOcean Functions (scheduled triggers) | | Cron | DigitalOcean Functions (scheduled triggers) |
| Deploy | DigitalOcean App Platform (Docker image via DOCR) | | Deploy | DigitalOcean App Platform (Docker image via DOCR) |
--- ---
## Setup ## 🚀 Getting started
### 1. Clone and install ### 1. Clone & install
```bash ```bash
git clone <your-repo> git clone <your-repo>
@@ -70,155 +105,117 @@ cd property-management-network
npm install npm install
``` ```
### 2. Configure environment variables ### 2. ⚙️ Configure environment
Copy the template and fill in your own values:
```bash ```bash
cp .env.example .env.local cp .env.example .env.local
``` ```
Fill in `.env.local`: `.env.local` holds your database URL, auth secret, and credentials for Stripe, OpenAI, SMTP, and object storage. **Every variable is documented inline in `.env.example`**, and the full production reference lives in **[DIGITALOCEAN.md](DIGITALOCEAN.md)**. Never commit real secrets.
```env ### 3. 🗄️ Run migrations
# Database (PostgreSQL via Drizzle ORM)
DATABASE_URL=
# Auth (Better Auth) The schema is managed by Drizzle (see `drizzle.config.ts`). Point `DATABASE_URL` at your PostgreSQL instance, then:
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=your-random-secret-string
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# File storage (local disk)
STORAGE_DIR=./storage
# Stripe (no price IDs needed — resolved by lookup key, auto-created on first checkout)
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
# OpenAI
OPENAI_API_KEY=
# Email (SMTP — e.g. SMTP2GO)
SMTP_HOST=mail.smtp2go.com
SMTP_PORT=2525
SMTP_USER=
SMTP_PASS=
EMAIL_FROM=postmaster@yourdomain.com
# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
CRON_SECRET=your-random-secret-string
```
### 3. Run database migrations
The schema is managed with Drizzle ORM (see `drizzle.config.ts`). Point `DATABASE_URL` at your PostgreSQL instance in `.env.local`, then apply the migrations from `lib/db/migrations`:
```bash ```bash
npm run db:migrate npm run db:migrate # apply migrations
npm run db:generate # regenerate after schema changes
npm run db:push # push schema directly (quick local prototyping)
``` ```
To regenerate migrations after changing the schema, use `npm run db:generate`. For quick local prototyping you can push the schema directly with `npm run db:push`. ### 4. 🔌 Wire up services (as needed)
### 4. Configure Stripe - **Stripe** — set the API keys, then add a webhook at `https://yourdomain.com/api/stripe/webhook` for `checkout.session.completed`, the `customer.subscription.*` events, `invoice.payment_failed`, and `payment_intent.succeeded`.
- **Email** — verify a sending domain with your SMTP provider (e.g. SMTP2GO) and set the `SMTP_*` + `EMAIL_FROM` vars.
- **Google / OpenAI / accounting / e-sign** — each is optional and activates once its env vars are present.
Add your API keys (`STRIPE_SECRET_KEY`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`) — that's it. Products and prices are resolved by stable **lookup keys** and auto-created on first checkout (Pro $29/mo, Landlord $59/mo, Lifetime $199, plus annual), so there are **no price IDs to configure** and going live is just an API-key swap. To pre-create the catalog, optionally run `node scripts/stripe-setup.mjs`. ### 5. ▶️ Run locally
Set up a webhook at `https://yourdomain.com/api/stripe/webhook` listening to:
- `checkout.session.completed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_failed`
- `payment_intent.succeeded`
### 5. Configure email (SMTP)
Use any SMTP provider (e.g. SMTP2GO). Verify your sending domain with the provider, then set `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, and `EMAIL_FROM`.
### 6. (Optional) Google OAuth
Create OAuth credentials in the Google Cloud Console and set `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` to enable Google sign-in via Better Auth.
### 7. Run locally
```bash ```bash
npm run dev npm run dev
``` ```
Open [http://localhost:3000](http://localhost:3000). Open **[http://localhost:3000](http://localhost:3000)**.
### 8. Deploy (DigitalOcean App Platform) ### 6. 🚢 Deploy
The repo ships a production `Dockerfile` (Next.js standalone output), an App Platform spec at [`.do/app.yaml`](.do/app.yaml), DO Functions cron under [`functions/`](functions/), and a `/api/health` liveness probe. See **[DIGITALOCEAN.md](DIGITALOCEAN.md)** for the full walkthrough: build/push the image to DOCR, create the app, wire up Managed Postgres + Spaces, and deploy the scheduled cron functions. The repo ships a production `Dockerfile` (Next.js standalone), an App Platform spec at [`.do/app.yaml`](.do/app.yaml), DO Functions cron under [`functions/`](functions/), and a `/api/health` probe. Follow **[DIGITALOCEAN.md](DIGITALOCEAN.md)** for the full walkthrough.
--- ---
## Project structure ## 🗂️ Project structure
``` ```
app/ app/
├── (marketing)/ # Landing page, pricing, legal ├── (marketing)/ # Landing page, pricing, legal, API docs
├── (auth)/ # Login, signup, password reset ├── (auth)/ # Login, signup, password reset
├── (dashboard)/ # All dashboard pages (auth-gated) ├── (dashboard)/ # Auth-gated app (properties, tenants, rent, maintenance,
├── dashboard/ # Overview + stats # leases, expenses, inspections, vendors, reports,
├── properties/ # Property + unit management # calendar, AI, onboarding, settings)
│ ├── tenants/ # Tenant profiles ├── (admin)/ # Superadmin dashboard
│ ├── rent/ # Payment tracking
│ ├── maintenance/ # Maintenance requests
│ ├── leases/ # Lease tracking
│ ├── expenses/ # Expense logging
│ └── settings/ # Billing + profile
├── api/ ├── api/
│ ├── properties/ # CRUD │ ├── v1/ # 🌐 Public REST API (Bearer API keys)
│ ├── tenants/ # CRUD + auto unit assignment │ ├── webhooks + cron/ # 🪝 Outbound webhook delivery + scheduled jobs
│ ├── rent/ # CRUD + Stripe payment links │ ├── stripe/ # 💳 Billing + payment links + provider webhooks
│ ├── maintenance/ # CRUD + status workflow │ ├── integrations/ # 📚 QuickBooks / Xero OAuth
│ ├── leases/ # CRUD │ ├── esign/ # ✍️ DocuSign / Dropbox Sign
── expenses/ # CRUD ── # Properties, tenants, rent, maintenance, documents, AI
│ ├── documents/ # Document metadata (files on local disk)
│ ├── ai/ # Rent receipts + maintenance summaries
│ ├── notifications/ # Send emails via SMTP (SMTP2GO)
│ ├── stripe/ # Checkout, portal, webhook
│ └── cron/ # Rent reminders + lease expiry alerts
└── tenant-portal/[token]/ # Public tenant portal (no login) └── tenant-portal/[token]/ # Public tenant portal (no login)
lib/ lib/
├── db/ # Drizzle schema, queries, migrations ├── db/ # Drizzle schema, queries, migrations
├── auth.ts # Better Auth config ├── auth.ts account.ts # Better Auth + team/account scoping
├── storage.ts # Local-disk file storage helpers ├── storage.ts # Object storage (Spaces) with local-disk dev fallback
├── stripe/ # Client, plans, payment links ├── webhooks/ # Event catalog, HMAC signing, SSRF guard, delivery
├── stripe/ # Billing clients & plans
├── accounting/ esign/ # QuickBooks/Xero & DocuSign/Dropbox Sign
├── ai/ # OpenAI client + prompts ├── ai/ # OpenAI client + prompts
├── email/ # SMTP (SMTP2GO) client + HTML templates ├── email/ # SMTP (SMTP2GO) client + HTML templates
└── validations/ # Zod schemas for all entities └── validations/ # Zod schemas for all entities
drizzle.config.ts # Drizzle ORM config (DATABASE_URL, migrations dir)
``` ```
--- ---
## Database schema ## 🗄️ Data model & isolation
11 tables, managed via Drizzle ORM: The schema spans **~30 tables** managed via Drizzle ORM, grouped roughly as:
`profiles` · `properties` · `units` · `tenants` · `rent_payments` · `maintenance_requests` · `leases` · `expenses` · `documents` · `notifications` · `usage_events` - **Core** — `profiles`, `properties`, `units`, `tenants`, `rent_payments`, `maintenance_requests`, `leases`, `expenses`, `documents`, `inspections`, `vendors`
- **Automation & AI** — `notifications`, `follow_up_rules`, `follow_up_log`, `ai_recommendations`, `ai_predictions`, `activity_log`, `usage_events`
- **Accounts & platform** — `account_members`, `api_keys`, `app_settings`, `admin_audit_log`, `accounting_connections`, `signature_requests`, `webhook_endpoints`, `webhook_deliveries`
- **Auth (Better Auth)** — `user`, `session`, `account`, `verification`
Data isolation is enforced in the application layer: every API route authenticates via `getSessionUser()` and scopes its queries by `user_id`. There is no database-level RLS, so this query scoping must be maintained carefully on every new route and query. > 🔐 **Tenancy is enforced in the application layer.** Every query scopes by the resolved **account owner id** (team-aware), never the raw session user. There is no database RLS, so this scoping must be preserved on every new route — see `lib/account.ts` (`getEffectiveOwnerId`).
--- ---
## Cron jobs ## ⏰ Scheduled jobs
| Job | Schedule | What it does | Cron is driven by DigitalOcean Functions hitting `CRON_SECRET`-protected endpoints (`functions/project.yml`):
|-----|----------|--------------|
| Rent reminders | Daily 9am UTC | Marks overdue payments, sends 3-day reminder emails |
| Lease expiry | Daily 10am UTC | Sends 60/30/7-day expiry alerts to landlord |
Cron routes are protected with `CRON_SECRET` (Bearer token in `Authorization` header). | Job | Schedule (UTC) | What it does |
|-----|----------------|--------------|
| `daily` | 09:00 | Rent reminders, overdue marking, 60/30/7-day lease-expiry alerts |
| `late-fees` | 08:00 | Applies late fees past the grace period |
| `follow-ups` | 10:00 | Runs each account's active follow-up rules |
| `webhooks` | every 5 min | Retries pending outbound webhook deliveries |
--- ---
## License ## 🔒 Security highlights
MIT - 🔑 API keys are stored as SHA-256 hashes; the plaintext is shown once.
- 🪝 Webhook payloads are **HMAC-SHA256 signed** (`X-PMN-Signature`); endpoint URLs are **SSRF-guarded** (private/loopback/metadata ranges blocked).
- 📁 Uploaded files are served only through an auth-gated route; object storage is required in production (uploads **fail loud** rather than silently hit ephemeral disk).
- 🛢️ Verified TLS to Postgres in production (`DATABASE_SSL=require` + CA).
- ⏱️ Cron endpoints use a constant-time bearer check and fail closed.
---
## 📜 License
**Proprietary — © 2026 Property Management Network. All rights reserved.**
This source code is proprietary and confidential. No license or permission is granted to use, copy, modify, merge, publish, distribute, sublicense, or sell any part of it without the prior written consent of the copyright holder. See [LICENSE](LICENSE).
+57
View File
@@ -0,0 +1,57 @@
"use client"
import { useEffect } from "react"
import Link from "next/link"
import { AlertTriangle, RefreshCw, ArrowLeft } from "lucide-react"
/**
* Error boundary for the admin surface. Without this, a failure in any admin
* page (a Stripe call, an aggregate query) fell through to app/global-error.tsx,
* which replaces the whole document and drops the admin chrome — leaving no way
* back except editing the URL.
*
* Admin pages read across every account, so the message is shown verbatim: the
* audience is staff, and the detail is what makes the failure diagnosable.
*/
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error("[admin]", error)
}, [error])
return (
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-xl border border-red-500/10 bg-red-500/5 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-500/10">
<AlertTriangle className="h-6 w-6 text-red-400" />
</div>
<h2 className="text-base font-semibold text-white">Admin page failed to load</h2>
<p className="mt-2 max-w-md text-sm text-white/50">
{error.message || "An unexpected error occurred."}
</p>
{error.digest && (
<p className="mt-1 font-mono text-xs text-white/25">digest: {error.digest}</p>
)}
<div className="mt-6 flex items-center gap-3">
<button
onClick={reset}
className="flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 transition hover:border-white/20 hover:text-white"
>
<RefreshCw className="h-4 w-4" />
Try again
</button>
<Link
href="/admin"
className="flex items-center gap-2 rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 transition hover:border-white/20 hover:text-white"
>
<ArrowLeft className="h-4 w-4" />
Back to overview
</Link>
</div>
</div>
)
}
+16 -1
View File
@@ -1,6 +1,8 @@
import { getSystemCounts, getEnvHealth } from "@/lib/db/admin-queries" import { getSystemCounts, getEnvHealth } from "@/lib/db/admin-queries"
import { getMaintenanceMode } from "@/lib/settings" import { getMaintenanceMode } from "@/lib/settings"
import { aiProviderStatus } from "@/lib/ai/provider"
import { MaintenanceToggle } from "@/components/admin/maintenance-toggle" import { MaintenanceToggle } from "@/components/admin/maintenance-toggle"
import { AiProviderToggle } from "@/components/admin/ai-provider-toggle"
import { formatDate } from "@/lib/utils" import { formatDate } from "@/lib/utils"
import { Settings, Database, Table2 } from "lucide-react" import { Settings, Database, Table2 } from "lucide-react"
@@ -13,10 +15,11 @@ function humanize(name: string): string {
} }
export default async function AdminSystemPage() { export default async function AdminSystemPage() {
const [{ counts, cronLastRun }, env, maintenance] = await Promise.all([ const [{ counts, cronLastRun }, env, maintenance, aiProvider] = await Promise.all([
getSystemCounts(), getSystemCounts(),
Promise.resolve(getEnvHealth()), Promise.resolve(getEnvHealth()),
getMaintenanceMode(), getMaintenanceMode(),
aiProviderStatus(),
]) ])
return ( return (
@@ -37,6 +40,18 @@ export default async function AdminSystemPage() {
/> />
</div> </div>
{/* AI provider selection */}
<div className="mb-5">
<AiProviderToggle
selected={aiProvider.selected}
effective={aiProvider.effective}
openaiConfigured={aiProvider.openaiConfigured}
anthropicConfigured={aiProvider.anthropicConfigured}
openaiModel={aiProvider.openaiModel}
anthropicModel={aiProvider.anthropicModel}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 mb-5"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5 mb-5">
{/* ── Environment configuration ───────────────────────────────── */} {/* ── Environment configuration ───────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden"> <div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
+27
View File
@@ -1,3 +1,4 @@
import Link from "next/link"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { import {
Building2, Building2,
@@ -11,12 +12,15 @@ import {
ShieldAlert, ShieldAlert,
Ban, Ban,
Activity, Activity,
Table2,
} from "lucide-react" } from "lucide-react"
import { getUserDetail } from "@/lib/db/admin-queries" import { getUserDetail } from "@/lib/db/admin-queries"
import { requireAdmin } from "@/lib/session" import { requireAdmin } from "@/lib/session"
import { BackButton } from "@/components/ui/back-button" import { BackButton } from "@/components/ui/back-button"
import { CopyButton } from "@/components/shared/copy-button" import { CopyButton } from "@/components/shared/copy-button"
import { UserActions } from "@/components/admin/user-actions" import { UserActions } from "@/components/admin/user-actions"
import { BillingActions } from "@/components/admin/billing-actions"
import { getSubscriptionSummary, listUserCharges } from "@/lib/admin/billing"
import { formatDate, initials, cn } from "@/lib/utils" import { formatDate, initials, cn } from "@/lib/utils"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -49,6 +53,15 @@ export default async function AdminUserDetailPage({
if (!detail) notFound() if (!detail) notFound()
// Live billing state, read straight from Stripe rather than the mirrored
// columns — the admin needs the truth, not our cached copy of it. Both helpers
// return empty/null rather than throwing when Stripe is unreachable or unset,
// so the page still renders without billing.
const [subscription, charges] = await Promise.all([
getSubscriptionSummary(id),
listUserCharges(id, 10),
])
const { profile, account, counts, recentActivity } = detail const { profile, account, counts, recentActivity } = detail
const isSelf = me.id === profile.id const isSelf = me.id === profile.id
const planKey = profile.plan ?? "starter" const planKey = profile.plan ?? "starter"
@@ -107,6 +120,15 @@ export default async function AdminUserDetailPage({
</div> </div>
{/* Counts grid */} {/* Counts grid */}
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Portfolio</h2>
<Link
href={`/admin/users/${profile.id}/portfolio`}
className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60 transition hover:border-white/20 hover:text-white"
>
<Table2 className="h-3.5 w-3.5" /> View records
</Link>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{COUNT_META.map(({ key, label, icon: Icon }) => ( {COUNT_META.map(({ key, label, icon: Icon }) => (
<div <div
@@ -197,6 +219,9 @@ export default async function AdminUserDetailPage({
</ul> </ul>
)} )}
</div> </div>
{/* Payments + refunds */}
<BillingActions userId={profile.id} charges={charges} />
</div> </div>
{/* Right: actions */} {/* Right: actions */}
@@ -207,6 +232,8 @@ export default async function AdminUserDetailPage({
currentPlan={planKey} currentPlan={planKey}
banned={!!account?.banned} banned={!!account?.banned}
isSelf={isSelf} isSelf={isSelf}
isAdminRole={account?.role === "admin"}
subscription={subscription}
/> />
</div> </div>
</div> </div>
@@ -0,0 +1,293 @@
import { notFound } from "next/navigation"
import { Building2, Home, Users as UsersIcon, FileText, CreditCard, Wrench } from "lucide-react"
import { getUserDetail, getUserPortfolio } from "@/lib/db/admin-queries"
import { requireAdmin } from "@/lib/session"
import { BackButton } from "@/components/ui/back-button"
import { formatCurrency, formatDate, cn } from "@/lib/utils"
export const dynamic = "force-dynamic"
/**
* Read-only support view of one user's actual records.
*
* This exists so an admin can answer "what does this customer actually have?"
* WITHOUT impersonating them — impersonation mutates the user's session and
* lands in their own activity trail, which is a heavy tool for a support lookup.
* Nothing on this page mutates anything.
*/
const STATUS_TONE: Record<string, string> = {
active: "bg-emerald-500/10 text-emerald-300",
occupied: "bg-emerald-500/10 text-emerald-300",
paid: "bg-emerald-500/10 text-emerald-300",
vacant: "bg-white/[0.06] text-white/50",
pending: "bg-amber-500/10 text-amber-300",
open: "bg-amber-500/10 text-amber-300",
in_progress: "bg-sky-500/10 text-sky-300",
overdue: "bg-red-500/10 text-red-300",
expired: "bg-red-500/10 text-red-300",
}
function Pill({ value }: { value: string | null | undefined }) {
if (!value) return <span className="text-white/25"></span>
return (
<span
className={cn(
"rounded-md px-1.5 py-0.5 text-xs font-medium",
STATUS_TONE[value] ?? "bg-white/[0.06] text-white/50"
)}
>
{value.replace(/_/g, " ")}
</span>
)
}
function Section({
title,
icon: Icon,
count,
shown,
children,
}: {
title: string
icon: typeof Building2
count: number
shown: number
children: React.ReactNode
}) {
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-white">
<Icon className="h-4 w-4 text-white/40" />
<h2 className="text-sm font-semibold">{title}</h2>
<span className="text-xs text-white/30">({count})</span>
</div>
{shown < count && (
<span className="text-xs text-white/30">showing first {shown}</span>
)}
</div>
{count === 0 ? (
<p className="text-xs text-white/30">None.</p>
) : (
<div className="overflow-x-auto">{children}</div>
)}
</div>
)
}
const TH = "pb-2 text-left text-xs font-medium text-white/40"
const TD = "py-2.5 text-sm text-white/70"
export default async function AdminUserPortfolioPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
await requireAdmin()
const [detail, portfolio] = await Promise.all([getUserDetail(id), getUserPortfolio(id)])
if (!detail) notFound()
const { profile, counts } = detail
const unitsByProperty = new Map<string, number>()
for (const u of portfolio.units) {
unitsByProperty.set(u.property_id ?? "", (unitsByProperty.get(u.property_id ?? "") ?? 0) + 1)
}
return (
<div className="space-y-6">
<BackButton href={`/admin/users/${id}`} label="Back to user" />
<div>
<h1 className="text-xl font-bold text-white">Portfolio</h1>
<p className="mt-0.5 text-sm text-white/40">
Read-only view of {profile.email}&apos;s records. Nothing here can be edited.
</p>
</div>
<Section
title="Properties"
icon={Building2}
count={counts.propertyCount}
shown={portfolio.properties.length}
>
<table className="w-full min-w-[560px]">
<thead>
<tr className="border-b border-white/[0.06]">
<th className={TH}>Name</th>
<th className={TH}>Address</th>
<th className={TH}>Units</th>
<th className={TH}>Added</th>
</tr>
</thead>
<tbody>
{portfolio.properties.map((p) => (
<tr key={p.id} className="border-b border-white/[0.04] last:border-0">
<td className={cn(TD, "text-white")}>{p.name}</td>
<td className={TD}>
{[p.address_line1, p.city, p.state].filter(Boolean).join(", ") || "—"}
</td>
<td className={TD}>{unitsByProperty.get(p.id) ?? p.total_units ?? 0}</td>
<td className={TD}>{p.created_at ? formatDate(p.created_at) : "—"}</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section title="Units" icon={Home} count={counts.unitCount} shown={portfolio.units.length}>
<table className="w-full min-w-[420px]">
<thead>
<tr className="border-b border-white/[0.06]">
<th className={TH}>Unit</th>
<th className={TH}>Rent</th>
<th className={TH}>Status</th>
</tr>
</thead>
<tbody>
{portfolio.units.map((u) => (
<tr key={u.id} className="border-b border-white/[0.04] last:border-0">
<td className={cn(TD, "text-white")}>{u.unit_number}</td>
<td className={TD}>
{u.rent_amount != null ? formatCurrency(Number(u.rent_amount)) : "—"}
</td>
<td className={TD}>
<Pill value={u.status} />
</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section
title="Tenants"
icon={UsersIcon}
count={counts.tenantCount}
shown={portfolio.tenants.length}
>
<table className="w-full min-w-[600px]">
<thead>
<tr className="border-b border-white/[0.06]">
<th className={TH}>Name</th>
<th className={TH}>Email</th>
<th className={TH}>Phone</th>
<th className={TH}>Status</th>
<th className={TH}>Moved in</th>
</tr>
</thead>
<tbody>
{portfolio.tenants.map((t) => (
<tr key={t.id} className="border-b border-white/[0.04] last:border-0">
<td className={cn(TD, "text-white")}>
{t.first_name} {t.last_name}
</td>
<td className={TD}>{t.email ?? "—"}</td>
<td className={TD}>{t.phone ?? "—"}</td>
<td className={TD}>
<Pill value={t.status} />
</td>
<td className={TD}>{t.move_in_date ? formatDate(t.move_in_date) : "—"}</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section
title="Leases"
icon={FileText}
count={counts.leaseCount}
shown={portfolio.leases.length}
>
<table className="w-full min-w-[480px]">
<thead>
<tr className="border-b border-white/[0.06]">
<th className={TH}>Term</th>
<th className={TH}>Rent</th>
<th className={TH}>Status</th>
</tr>
</thead>
<tbody>
{portfolio.leases.map((l) => (
<tr key={l.id} className="border-b border-white/[0.04] last:border-0">
<td className={TD}>
{l.lease_start ? formatDate(l.lease_start) : "—"} {" "}
{l.lease_end ? formatDate(l.lease_end) : "—"}
</td>
<td className={TD}>
{l.rent_amount != null ? formatCurrency(Number(l.rent_amount)) : "—"}
</td>
<td className={TD}>
<Pill value={l.status} />
</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section
title="Recent rent payments"
icon={CreditCard}
count={counts.paymentCount}
shown={portfolio.payments.length}
>
<table className="w-full min-w-[480px]">
<thead>
<tr className="border-b border-white/[0.06]">
<th className={TH}>Due</th>
<th className={TH}>Amount</th>
<th className={TH}>Paid</th>
<th className={TH}>Status</th>
</tr>
</thead>
<tbody>
{portfolio.payments.map((p) => (
<tr key={p.id} className="border-b border-white/[0.04] last:border-0">
<td className={TD}>{p.due_date ? formatDate(p.due_date) : "—"}</td>
<td className={cn(TD, "text-white")}>{formatCurrency(Number(p.amount))}</td>
<td className={TD}>{p.paid_date ? formatDate(p.paid_date) : "—"}</td>
<td className={TD}>
<Pill value={p.status} />
</td>
</tr>
))}
</tbody>
</table>
</Section>
<Section
title="Recent maintenance"
icon={Wrench}
count={counts.maintenanceCount}
shown={portfolio.maintenance.length}
>
<table className="w-full min-w-[480px]">
<thead>
<tr className="border-b border-white/[0.06]">
<th className={TH}>Title</th>
<th className={TH}>Priority</th>
<th className={TH}>Status</th>
<th className={TH}>Opened</th>
</tr>
</thead>
<tbody>
{portfolio.maintenance.map((m) => (
<tr key={m.id} className="border-b border-white/[0.04] last:border-0">
<td className={cn(TD, "text-white")}>{m.title}</td>
<td className={TD}>{m.priority ?? "—"}</td>
<td className={TD}>
<Pill value={m.status} />
</td>
<td className={TD}>{m.created_at ? formatDate(m.created_at) : "—"}</td>
</tr>
))}
</tbody>
</table>
</Section>
</div>
)
}
+6
View File
@@ -1,8 +1,14 @@
import type { Metadata } from "next"
import Link from "next/link" import Link from "next/link"
import { Logo } from "@/components/shared/logo" import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget" import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { resetPassword } from "@/app/actions/auth" import { resetPassword } from "@/app/actions/auth"
export const metadata: Metadata = {
title: "Reset password",
robots: { index: false, follow: true },
}
export default async function ForgotPasswordPage({ export default async function ForgotPasswordPage({
searchParams, searchParams,
}: { }: {
+11
View File
@@ -1,7 +1,14 @@
import type { Metadata } from "next"
import Link from "next/link" import Link from "next/link"
import { Logo } from "@/components/shared/logo" import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget" import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { signIn, signInWithGoogle } from "@/app/actions/auth" import { signIn, signInWithGoogle } from "@/app/actions/auth"
import { isGoogleConfigured } from "@/lib/auth"
export const metadata: Metadata = {
title: "Sign in",
robots: { index: false, follow: true },
}
export default async function LoginPage({ export default async function LoginPage({
searchParams, searchParams,
@@ -22,6 +29,8 @@ export default async function LoginPage({
</div> </div>
<div className="rounded-xl border border-white/10 bg-[#111118] p-8"> <div className="rounded-xl border border-white/10 bg-[#111118] p-8">
{isGoogleConfigured() && (
<>
{/* Google OAuth */} {/* Google OAuth */}
<form action={signInWithGoogle}> <form action={signInWithGoogle}>
<button <button
@@ -41,6 +50,8 @@ export default async function LoginPage({
<span className="bg-[#111118] px-3 text-white/40">or continue with email</span> <span className="bg-[#111118] px-3 text-white/40">or continue with email</span>
</div> </div>
</div> </div>
</>
)}
{/* Error / Success messages */} {/* Error / Success messages */}
{error && ( {error && (
+17
View File
@@ -2,6 +2,19 @@ import Link from "next/link"
import { Logo } from "@/components/shared/logo" import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget" import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { signUp, signInWithGoogle } from "@/app/actions/auth" import { signUp, signInWithGoogle } from "@/app/actions/auth"
import { isGoogleConfigured } from "@/lib/auth"
import { pageMetadata } from "@/lib/seo"
// /signup is listed in the sitemap as a conversion landing page, so it needs
// its own title, description and canonical rather than inheriting the "Sign in"
// title from app/(auth)/layout.tsx.
export const metadata = pageMetadata({
title: "Create your free Property Management Network account",
absoluteTitle: true,
description:
"Create a free landlord account — track rent, maintenance, leases and expenses for your first property. No credit card required.",
path: "/signup",
})
export default async function SignupPage({ export default async function SignupPage({
searchParams, searchParams,
@@ -47,6 +60,8 @@ export default async function SignupPage({
</div> </div>
<div className="rounded-xl border border-white/10 bg-[#111118] p-8"> <div className="rounded-xl border border-white/10 bg-[#111118] p-8">
{isGoogleConfigured() && (
<>
{/* Google OAuth */} {/* Google OAuth */}
<form action={signInWithGoogle}> <form action={signInWithGoogle}>
<button <button
@@ -66,6 +81,8 @@ export default async function SignupPage({
<span className="bg-[#111118] px-3 text-white/40">or sign up with email</span> <span className="bg-[#111118] px-3 text-white/40">or sign up with email</span>
</div> </div>
</div> </div>
</>
)}
{error && ( {error && (
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400"> <div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
+11
View File
@@ -1,7 +1,16 @@
import type { Metadata } from "next"
import Link from "next/link" import Link from "next/link"
import { Logo } from "@/components/shared/logo" import { Logo } from "@/components/shared/logo"
import { TurnstileWidget } from "@/components/shared/turnstile-widget"
import { updatePassword } from "@/app/actions/auth" import { updatePassword } from "@/app/actions/auth"
// A password-reset form reached from a one-time emailed link — nothing here
// should ever enter the index.
export const metadata: Metadata = {
title: "Set a new password",
robots: { index: false, follow: false },
}
export default async function UpdatePasswordPage({ export default async function UpdatePasswordPage({
searchParams, searchParams,
}: { }: {
@@ -43,6 +52,8 @@ export default async function UpdatePasswordPage({
/> />
</div> </div>
<TurnstileWidget />
<button <button
type="submit" type="submit"
className="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]" className="w-full rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]"
+13 -19
View File
@@ -4,13 +4,16 @@ import { db } from "@/lib/db"
import { leases as leasesTable } from "@/lib/db/schema" import { leases as leasesTable } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account" import { getAccountContext } from "@/lib/account"
import { listAdapters, listRequestsForLease } from "@/lib/esign" import { listEsignConnections, listRequestsForLease } from "@/lib/esign"
import Link from "next/link" import Link from "next/link"
import { FileText, ExternalLink } from "lucide-react" import { FileText } from "lucide-react"
import { formatCurrency, formatDate, daysUntil } from "@/lib/utils" import { formatCurrency, formatDate, daysUntil } from "@/lib/utils"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { LeaseActions } from "@/components/forms/lease-actions" import { LeaseActions } from "@/components/forms/lease-actions"
import { EsignLease } from "@/components/forms/esign-lease" import { EsignLease } from "@/components/forms/esign-lease"
import { LeaseDocument } from "@/components/forms/lease-document"
const ESIGN_LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_sign: "Dropbox Sign" }
export const metadata = { title: "Lease" } export const metadata = { title: "Lease" }
@@ -56,8 +59,12 @@ export default async function LeaseDetailPage({ params }: { params: Promise<{ le
if (!lease) notFound() if (!lease) notFound()
const esignRequests = await listRequestsForLease(ownerId, leaseId) const esignRequests = await listRequestsForLease(ownerId, leaseId)
const esignProviders = listAdapters() const esignConnections = await listEsignConnections(ownerId)
const canSendEsign = ctx.canWrite && !!lease.document_url && !!lease.tenant?.email const connectedProviders = esignConnections
.filter((c) => c.status !== "revoked")
.map((c) => ({ id: c.provider, label: ESIGN_LABEL[c.provider] ?? c.provider }))
const canSendEsign =
ctx.canWrite && !!lease.document_url && !!lease.tenant?.email && connectedProviders.length > 0
const esignDisabledReason = !ctx.canWrite const esignDisabledReason = !ctx.canWrite
? "You have read-only access." ? "You have read-only access."
: !lease.document_url : !lease.document_url
@@ -196,24 +203,11 @@ export default async function LeaseDetailPage({ params }: { params: Promise<{ le
</div> </div>
</div> </div>
{lease.document_url && ( <LeaseDocument leaseId={leaseId} documentUrl={lease.document_url} canWrite={ctx.canWrite} />
<a
href={lease.document_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between rounded-xl border border-white/[0.06] bg-[#16161f] p-5 transition hover:border-white/15"
>
<div className="flex items-center gap-2 text-sm font-medium text-white">
<FileText className="h-4 w-4 text-indigo-400" />
Lease document
</div>
<ExternalLink className="h-4 w-4 text-white/40" />
</a>
)}
<EsignLease <EsignLease
leaseId={leaseId} leaseId={leaseId}
providers={esignProviders} connected={connectedProviders}
requests={esignRequests} requests={esignRequests}
canSend={canSendEsign} canSend={canSendEsign}
disabledReason={esignDisabledReason} disabledReason={esignDisabledReason}
+3 -19
View File
@@ -5,9 +5,7 @@ import { profiles, properties, tenants } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { CheckoutButton } from "@/components/forms/checkout-button" import { CheckoutButton } from "@/components/forms/checkout-button"
import { PortalButton } from "@/components/forms/portal-button" import { PortalButton } from "@/components/forms/portal-button"
import { PaypalCancelButton } from "@/components/forms/paypal-cancel-button"
import { getPlanLabel, PLAN_LIMITS, annualEnabled } from "@/lib/stripe/plans" import { getPlanLabel, PLAN_LIMITS, annualEnabled } from "@/lib/stripe/plans"
import { paypalConfigured } from "@/lib/paypal/client"
import { Check } from "lucide-react" import { Check } from "lucide-react"
import type { Plan } from "@/types" import type { Plan } from "@/types"
@@ -59,7 +57,7 @@ const PLANS = [
export default async function BillingPage({ export default async function BillingPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ success?: string; canceled?: string; error?: string }> searchParams: Promise<{ success?: string; canceled?: string }>
}) { }) {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) redirect("/login") if (!user) redirect("/login")
@@ -72,16 +70,12 @@ export default async function BillingPage({
plan_expires_at: true, plan_expires_at: true,
stripe_customer_id: true, stripe_customer_id: true,
stripe_subscription_id: true, stripe_subscription_id: true,
paypal_subscription_id: true,
billing_provider: true,
}, },
}) })
const params = await searchParams const params = await searchParams
const currentPlan = (profile?.plan ?? "starter") as Plan const currentPlan = (profile?.plan ?? "starter") as Plan
const hasStripeAccount = !!profile?.stripe_customer_id const hasStripeAccount = !!profile?.stripe_customer_id
const isPaypal = profile?.billing_provider === "paypal" || !!profile?.paypal_subscription_id
const paypalEnabled = paypalConfigured()
const limits = PLAN_LIMITS[currentPlan] const limits = PLAN_LIMITS[currentPlan]
const canBillAnnually = annualEnabled() const canBillAnnually = annualEnabled()
@@ -113,12 +107,6 @@ export default async function BillingPage({
Checkout canceled no charge was made. Checkout canceled no charge was made.
</div> </div>
)} )}
{params.error === "paypal" && (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-5 py-4 text-sm text-red-400">
We couldn&apos;t complete your PayPal payment. No charge was made please try again.
</div>
)}
{/* Current plan */} {/* Current plan */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5"> <div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -129,12 +117,9 @@ export default async function BillingPage({
<p className="mt-0.5 text-xs text-white/40 capitalize">Status: {profile.subscription_status}</p> <p className="mt-0.5 text-xs text-white/40 capitalize">Status: {profile.subscription_status}</p>
)} )}
</div> </div>
{currentPlan !== "starter" && currentPlan !== "lifetime" && {hasStripeAccount && currentPlan !== "starter" && currentPlan !== "lifetime" && (
(isPaypal ? (
<PaypalCancelButton />
) : hasStripeAccount ? (
<PortalButton /> <PortalButton />
) : null)} )}
</div> </div>
</div> </div>
@@ -197,7 +182,6 @@ export default async function BillingPage({
label={plan.cta} label={plan.cta}
highlight={plan.highlight} highlight={plan.highlight}
annualAvailable={canBillAnnually && plan.key !== "lifetime"} annualAvailable={canBillAnnually && plan.key !== "lifetime"}
paypalEnabled={paypalEnabled}
/> />
)} )}
</div> </div>
+29 -2
View File
@@ -2,7 +2,9 @@ import { redirect } from "next/navigation"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account" import { getAccountContext } from "@/lib/account"
import { listProviders, listConnections } from "@/lib/accounting" import { listProviders, listConnections } from "@/lib/accounting"
import { listEsignAdapters, listEsignConnections } from "@/lib/esign"
import { AccountingIntegrations } from "@/components/dashboard/accounting-integrations" import { AccountingIntegrations } from "@/components/dashboard/accounting-integrations"
import { EsignIntegrations } from "@/components/dashboard/esign-integrations"
export const metadata = { title: "Integrations" } export const metadata = { title: "Integrations" }
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -20,10 +22,34 @@ export default async function IntegrationsPage({
const providers = listProviders() const providers = listProviders()
const connections = ctx.isOwner ? await listConnections(ctx.ownerId) : [] const connections = ctx.isOwner ? await listConnections(ctx.ownerId) : []
const esignAdapters = listEsignAdapters()
const esignConnections = ctx.isOwner ? await listEsignConnections(ctx.ownerId) : []
const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
// DocuSign vs Dropbox Sign flash messages are keyed by provider id, so a single
// connected/error param drives whichever card the user just acted on.
const esignFlash = { connected: sp.connected, error: sp.error }
return ( return (
<div className="max-w-3xl mx-auto space-y-6"> <div className="max-w-3xl mx-auto space-y-8">
<div className="space-y-4">
<div> <div>
<h2 className="text-lg font-bold text-white">Integrations</h2> <h2 className="text-lg font-bold text-white">E-signature</h2>
<p className="text-sm text-white/40 mt-0.5">
Connect your own DocuSign or Dropbox Sign account to send leases for signature.
</p>
</div>
<EsignIntegrations
adapters={esignAdapters}
connections={esignConnections}
isOwner={ctx.isOwner}
flash={esignFlash}
webhookUrl={`${appUrl}/api/esign/dropbox_sign/webhook`}
/>
</div>
<div className="space-y-4">
<div>
<h2 className="text-lg font-bold text-white">Accounting</h2>
<p className="text-sm text-white/40 mt-0.5"> <p className="text-sm text-white/40 mt-0.5">
Connect your accounting software to automatically push rent income and expenses into your books. Connect your accounting software to automatically push rent income and expenses into your books.
</p> </p>
@@ -35,5 +61,6 @@ export default async function IntegrationsPage({
flash={{ connected: sp.connected, error: sp.error }} flash={{ connected: sp.connected, error: sp.error }}
/> />
</div> </div>
</div>
) )
} }
+55
View File
@@ -0,0 +1,55 @@
import { redirect } from "next/navigation"
import Link from "next/link"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { account_deletion_requests } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { LEGAL } from "@/lib/legal"
import { PrivacyManager } from "@/components/dashboard/privacy-manager"
export const metadata = { title: "Privacy & Data" }
export default async function PrivacySettingsPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const pending = await db.query.account_deletion_requests.findFirst({
where: and(
eq(account_deletion_requests.user_id, user.id),
eq(account_deletion_requests.status, "pending")
),
})
return (
<div className="max-w-2xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Privacy &amp; Data</h2>
<p className="text-sm text-white/40">
Exercise your data rights under the GDPR export a copy of your data or delete your
account. Details are in our{" "}
<Link
href="/gdpr"
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
>
GDPR &amp; Data Rights
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
>
Privacy Policy
</Link>{" "}
pages.
</p>
</div>
<PrivacyManager
accountEmail={user.email}
graceDays={LEGAL.dataDeletionDays}
pendingDeletion={
pending ? { scheduled_for: pending.scheduled_for, created_at: pending.created_at } : null
}
/>
</div>
)
}
+4 -3
View File
@@ -1,11 +1,12 @@
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Acceptable Use Policy", title: "Acceptable Use Policy",
description: `The rules that govern acceptable use of the ${LEGAL.service} platform and the activities that are prohibited.`, description: `The rules that govern acceptable use of the ${LEGAL.service} platform and the activities that are prohibited.`,
alternates: { canonical: "/acceptable-use" }, path: "/acceptable-use",
} })
export default function Page() { export default function Page() {
return ( return (
+6 -4
View File
@@ -1,12 +1,14 @@
import Link from "next/link" import Link from "next/link"
import { Code2, Lock, Zap, BookOpen, Webhook } from "lucide-react" import { Code2, Lock, Zap, BookOpen, Webhook } from "lucide-react"
import { WEBHOOK_EVENTS } from "@/lib/webhooks/events" import { WEBHOOK_EVENTS } from "@/lib/webhooks/events"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "API Docs", title: "API Docs",
description: "Property Management Network REST API documentation for developers.", description:
alternates: { canonical: "/api-docs" }, "REST API reference for Property Management Network — endpoints for properties, tenants, rent payments, maintenance and webhooks, with API key auth.",
} path: "/api-docs",
})
// The real, deployed origin. Falls back to a placeholder only when the env var // The real, deployed origin. Falls back to a placeholder only when the env var
// isn't set (e.g. local docs previews). // isn't set (e.g. local docs previews).
+5 -5
View File
@@ -1,12 +1,12 @@
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Cookie Policy", title: "Cookie Policy",
description: description: "How we use cookies and similar technologies, what we deliberately avoid, and how to manage them in your browser.",
"How we use cookies and similar technologies, what we deliberately avoid, and how to manage them in your browser.", path: "/cookie-policy",
alternates: { canonical: "/cookie-policy" }, })
}
const COOKIE_ROWS: { name: string; type: string; purpose: string; expires: string }[] = [ const COOKIE_ROWS: { name: string; type: string; purpose: string; expires: string }[] = [
{ {
+5 -5
View File
@@ -1,12 +1,12 @@
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Disclaimer", title: "Disclaimer",
description: description: "Important limitations on the information and outputs provided by the Service, including AI-generated content and financial reports.",
"Important limitations on the information and outputs provided by the Service, including AI-generated content and financial reports.", path: "/disclaimer",
alternates: { canonical: "/disclaimer" }, })
}
export default function Page() { export default function Page() {
return ( return (
+4 -3
View File
@@ -1,12 +1,13 @@
import Link from "next/link" import Link from "next/link"
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Data Processing Addendum", title: "Data Processing Addendum",
description: `The Data Processing Addendum governing how ${LEGAL.entity} processes personal data on behalf of Customers using ${LEGAL.service}.`, description: `The Data Processing Addendum governing how ${LEGAL.entity} processes personal data on behalf of Customers using ${LEGAL.service}.`,
alternates: { canonical: "/dpa" }, path: "/dpa",
} })
export default function DpaPage() { export default function DpaPage() {
return ( return (
+25 -4
View File
@@ -1,12 +1,13 @@
import Link from "next/link" import Link from "next/link"
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "GDPR & Data Rights", title: "GDPR & Data Rights",
description: `How ${LEGAL.entity} complies with the GDPR and UK GDPR, and the data rights available to you as a data subject.`, description: `How ${LEGAL.entity} complies with the GDPR and UK GDPR, and the data rights available to you as a data subject.`,
alternates: { canonical: "/gdpr" }, path: "/gdpr",
} })
export default function GdprPage() { export default function GdprPage() {
return ( return (
@@ -134,7 +135,27 @@ export default function GdprPage() {
<Section id="exercise" heading="8. How to exercise your rights"> <Section id="exercise" heading="8. How to exercise your rights">
<p> <p>
To exercise any of the rights described above, contact us at{" "} You can exercise the most common rights yourself, instantly, from{" "}
<strong>Settings &rarr; Privacy &amp; Data</strong> in your dashboard:
</p>
<ul>
<li>
<strong>Access &amp; portability</strong> &mdash; download a complete,
machine-readable JSON export of your personal data and portfolio records.
</li>
<li>
<strong>Erasure</strong> &mdash; delete your account. Deletion is scheduled{" "}
{LEGAL.dataDeletionDays} days out (during which you can cancel), after which your
account, data, and uploaded files are permanently erased and any active
subscription is cancelled.
</li>
<li>
<strong>Rectification</strong> &mdash; correct your details at any time in{" "}
<strong>Settings &rarr; Profile</strong>.
</li>
</ul>
<p>
For any other request, contact us at{" "}
<a href={`mailto:${LEGAL.privacyEmail}`}>{LEGAL.privacyEmail}</a>. For <a href={`mailto:${LEGAL.privacyEmail}`}>{LEGAL.privacyEmail}</a>. For
data-protection matters, you may also contact our data-protection team at{" "} data-protection matters, you may also contact our data-protection team at{" "}
<a href={`mailto:${LEGAL.dpoEmail}`}>{LEGAL.dpoEmail}</a>. You also have the right <a href={`mailto:${LEGAL.dpoEmail}`}>{LEGAL.dpoEmail}</a>. You also have the right
+2 -2
View File
@@ -1,6 +1,6 @@
import { Navbar } from "@/components/marketing/navbar" import { Navbar } from "@/components/marketing/navbar"
import { Footer } from "@/components/marketing/footer" import { Footer } from "@/components/marketing/footer"
import { StructuredData } from "@/components/marketing/structured-data" import { SiteStructuredData } from "@/components/marketing/structured-data"
import { getSession, isAdminUser } from "@/lib/session" import { getSession, isAdminUser } from "@/lib/session"
import { getMaintenanceMode } from "@/lib/settings" import { getMaintenanceMode } from "@/lib/settings"
import { MaintenanceScreen } from "@/components/shared/maintenance-screen" import { MaintenanceScreen } from "@/components/shared/maintenance-screen"
@@ -17,7 +17,7 @@ export default async function MarketingLayout({ children }: { children: React.Re
return ( return (
<div className="min-h-screen bg-[#09090b] text-white"> <div className="min-h-screen bg-[#09090b] text-white">
<StructuredData /> <SiteStructuredData />
<Navbar /> <Navbar />
{children} {children}
<Footer /> <Footer />
+11 -11
View File
@@ -7,23 +7,23 @@ import { Testimonials } from "@/components/marketing/testimonials"
import { PricingSection } from "@/components/marketing/pricing-section" import { PricingSection } from "@/components/marketing/pricing-section"
import { FAQ } from "@/components/marketing/faq" import { FAQ } from "@/components/marketing/faq"
import { CtaBanner } from "@/components/marketing/cta-banner" import { CtaBanner } from "@/components/marketing/cta-banner"
import { HomeStructuredData } from "@/components/marketing/structured-data"
import { annualEnabled } from "@/lib/stripe/plans" import { annualEnabled } from "@/lib/stripe/plans"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: { absolute: "Property Management Software for Independent Landlords" }, title: "Property Management Software for Independent Landlords",
description: "Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords. Free to start.", absoluteTitle: true,
alternates: { canonical: "/" }, description:
openGraph: { "Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords. Free to start.",
title: "Property management without the chaos", path: "/",
description: "Track rent, manage maintenance, monitor leases, and keep expenses organised — all in one dashboard built for independent landlords. Free to start.", socialTitle: "Property management without the chaos",
url: "/", })
type: "website",
},
}
export default function LandingPage() { export default function LandingPage() {
return ( return (
<> <>
<HomeStructuredData />
<Hero /> <Hero />
<Marquee /> <Marquee />
<Problem /> <Problem />
+5 -5
View File
@@ -1,12 +1,12 @@
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL, SUBPROCESSORS } from "@/lib/legal" import { LEGAL, SUBPROCESSORS } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Privacy Policy", title: "Privacy Policy",
description: description: "How we collect, use, share, and protect personal data, and the privacy rights available to you under the GDPR and California law.",
"How we collect, use, share, and protect personal data, and the privacy rights available to you under the GDPR and California law.", path: "/privacy",
alternates: { canonical: "/privacy" }, })
}
export default function Page() { export default function Page() {
return ( return (
+5 -4
View File
@@ -1,11 +1,12 @@
import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, Callout, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Refund & Cancellation Policy", title: "Refund & Cancellation",
description: `How subscriptions, cancellations, and refunds work for the ${LEGAL.service} platform.`, description: `How subscriptions, cancellations, and refunds work for the ${LEGAL.service} platform.`,
alternates: { canonical: "/refund-policy" }, path: "/refund-policy",
} })
export default function Page() { export default function Page() {
return ( return (
+4 -3
View File
@@ -1,12 +1,13 @@
import Link from "next/link" import Link from "next/link"
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL, SUBPROCESSORS } from "@/lib/legal" import { LEGAL, SUBPROCESSORS } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Sub-processors", title: "Sub-processors",
description: `The third-party sub-processors that ${LEGAL.entity} engages to process personal data on behalf of Customers of ${LEGAL.service}.`, description: `The third-party sub-processors that ${LEGAL.entity} engages to process personal data on behalf of Customers of ${LEGAL.service}.`,
alternates: { canonical: "/subprocessors" }, path: "/subprocessors",
} })
export default function SubprocessorsPage() { export default function SubprocessorsPage() {
return ( return (
+6 -4
View File
@@ -1,11 +1,13 @@
import Link from "next/link" import Link from "next/link"
import { Building2, CheckCircle2, Smartphone, Bell, FileText, ArrowRight } from "lucide-react" import { Building2, CheckCircle2, Smartphone, Bell, FileText, ArrowRight } from "lucide-react"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Tenant Portal", title: "Tenant Portal",
description: "Give your tenants a dedicated portal to pay rent, submit maintenance requests, and view lease details.", description:
alternates: { canonical: "/tenant-portal-info" }, "Give your tenants a dedicated portal to pay rent, submit maintenance requests, and view lease details.",
} path: "/tenant-portal-info",
})
const FEATURES = [ const FEATURES = [
{ icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20", title: "Online Rent Payment", desc: "Tenants pay rent securely via Stripe — card or bank transfer. Auto-receipts sent by email." }, { icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20", title: "Online Rent Payment", desc: "Tenants pay rent securely via Stripe — card or bank transfer. Auto-receipts sent by email." },
+4 -3
View File
@@ -1,11 +1,12 @@
import { LegalPage, Section, LegalContact } from "@/components/marketing/legal" import { LegalPage, Section, LegalContact } from "@/components/marketing/legal"
import { LEGAL } from "@/lib/legal" import { LEGAL } from "@/lib/legal"
import { pageMetadata } from "@/lib/seo"
export const metadata = { export const metadata = pageMetadata({
title: "Terms of Service", title: "Terms of Service",
description: `The terms and conditions that govern your access to and use of the ${LEGAL.service} platform.`, description: `The terms and conditions that govern your access to and use of the ${LEGAL.service} platform.`,
alternates: { canonical: "/terms" }, path: "/terms",
} })
export default function Page() { export default function Page() {
return ( return (
+164 -8
View File
@@ -8,9 +8,18 @@ import { z } from "zod"
import { getAdminSession } from "@/lib/session" import { getAdminSession } from "@/lib/session"
import { logAdminAction } from "@/lib/admin/audit" import { logAdminAction } from "@/lib/admin/audit"
import { setMaintenanceMode } from "@/lib/settings" import { setMaintenanceMode } from "@/lib/settings"
import { setAiProvider, type AiProvider } from "@/lib/ai/provider"
import { auth } from "@/lib/auth" import { auth } from "@/lib/auth"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { profiles, user as userTable } from "@/lib/db/schema" import { profiles, user as userTable } from "@/lib/db/schema"
import { executeAccountDeletion } from "@/lib/gdpr/delete"
import {
changePlanInStripe,
cancelSubscription,
resumeSubscription,
refundCharge,
} from "@/lib/admin/billing"
import type { Plan } from "@/types"
// ── gate ──────────────────────────────────────────────────────────────────── // ── gate ────────────────────────────────────────────────────────────────────
// Every server action re-verifies the caller is an admin. NEVER skip — these // Every server action re-verifies the caller is an admin. NEVER skip — these
@@ -24,24 +33,152 @@ async function guard() {
const planSchema = z.enum(["starter", "pro", "landlord", "lifetime"]) const planSchema = z.enum(["starter", "pro", "landlord", "lifetime"])
// ── change plan ───────────────────────────────────────────────────────────── // ── change plan ─────────────────────────────────────────────────────────────
export async function changeUserPlan(userId: string, plan: string) { // TWO DISTINCT OPERATIONS, deliberately not merged:
//
// "comp" — grant the entitlement in our database only. Stripe is untouched,
// so the user is billed exactly as before. This is the right choice
// for a free upgrade, a support gesture, or a user with no
// subscription at all.
// "stripe" — actually move their Stripe subscription (prorated), then mirror
// it locally. This CHARGES OR CREDITS REAL MONEY.
//
// Before this split, the only behaviour was "comp" while the UI called it
// "change plan" — so an admin granting Pro left the customer on their old
// Stripe subscription, silently desyncing entitlement from billing.
export async function changeUserPlan(
userId: string,
plan: string,
mode: "comp" | "stripe" = "comp",
interval: "month" | "year" = "month"
) {
const a = await guard() const a = await guard()
const nextPlan = planSchema.parse(plan) const nextPlan = planSchema.parse(plan)
const existing = await db.query.profiles.findFirst({ where: eq(profiles.id, userId) }) const existing = await db.query.profiles.findFirst({ where: eq(profiles.id, userId) })
const oldPlan = existing?.plan ?? null const oldPlan = existing?.plan ?? null
if (mode === "stripe") {
const result = await changePlanInStripe(userId, nextPlan as Plan, interval)
if (!result.ok) return { ok: false as const, error: result.error }
await logAdminAction({
adminId: a.user.id,
action: "plan_change_stripe",
targetUserId: userId,
metadata: { from: oldPlan, to: nextPlan, interval, detail: result.detail },
})
revalidatePath(`/admin/users/${userId}`)
return { ok: true as const, detail: result.detail }
}
// Comp: entitlement only. Recorded as such so the audit trail distinguishes a
// deliberate free grant from a paid upgrade.
await db.update(profiles).set({ plan: nextPlan }).where(eq(profiles.id, userId)) await db.update(profiles).set({ plan: nextPlan }).where(eq(profiles.id, userId))
await logAdminAction({ await logAdminAction({
adminId: a.user.id, adminId: a.user.id,
action: "plan_change", action: "plan_change",
targetUserId: userId, targetUserId: userId,
metadata: { from: oldPlan, to: nextPlan }, metadata: { from: oldPlan, to: nextPlan, mode: "comp", billingUnchanged: true },
}) })
revalidatePath(`/admin/users/${userId}`) revalidatePath(`/admin/users/${userId}`)
return { ok: true } return {
ok: true as const,
detail: `Plan set to ${nextPlan} as a comp. Stripe billing was NOT changed.`,
}
}
// ── subscription lifecycle ──────────────────────────────────────────────────
export async function cancelUserSubscription(userId: string, immediate = false) {
const a = await guard()
const result = await cancelSubscription(userId, immediate)
if (!result.ok) return { ok: false as const, error: result.error }
await logAdminAction({
adminId: a.user.id,
action: "cancel_subscription",
targetUserId: userId,
metadata: { immediate, detail: result.detail },
})
revalidatePath(`/admin/users/${userId}`)
return { ok: true as const, detail: result.detail }
}
export async function resumeUserSubscription(userId: string) {
const a = await guard()
const result = await resumeSubscription(userId)
if (!result.ok) return { ok: false as const, error: result.error }
await logAdminAction({
adminId: a.user.id,
action: "resume_subscription",
targetUserId: userId,
metadata: { detail: result.detail },
})
revalidatePath(`/admin/users/${userId}`)
return { ok: true as const, detail: result.detail }
}
// ── refunds ─────────────────────────────────────────────────────────────────
// `amountCents` omitted refunds everything still outstanding on the charge. The
// charge is verified to belong to this user inside refundCharge().
export async function refundUserCharge(
userId: string,
chargeId: string,
amountCents?: number,
reason?: "duplicate" | "fraudulent" | "requested_by_customer"
) {
const a = await guard()
const result = await refundCharge(userId, chargeId, amountCents, reason)
if (!result.ok) return { ok: false as const, error: result.error }
await logAdminAction({
adminId: a.user.id,
action: "refund",
targetUserId: userId,
metadata: {
chargeId,
refundId: result.data.refundId,
amountCents: result.data.amount,
reason: reason ?? null,
},
})
revalidatePath(`/admin/users/${userId}`)
return { ok: true as const, detail: result.detail }
}
// ── admin role management ───────────────────────────────────────────────────
// Promotes/demotes via the Better Auth admin plugin, which writes `user.role`.
// This replaces the previous situation where the ONLY way to create an admin was
// editing ADMIN_USER_IDS in env and redeploying.
//
// Self-demotion is blocked: an admin removing their own last access would need a
// redeploy to undo, and ADMIN_USER_IDS remains the break-glass path.
export async function setUserRole(userId: string, role: "admin" | "user") {
const a = await guard()
if (userId === a.user.id) {
throw new Error("You cannot change your own role. Ask another admin.")
}
await auth.api.setRole({
body: { userId, role },
headers: await headers(),
})
await logAdminAction({
adminId: a.user.id,
action: "set_role",
targetUserId: userId,
metadata: { role },
})
revalidatePath(`/admin/users/${userId}`)
return { ok: true as const, detail: role === "admin" ? "User promoted to admin." : "Admin access revoked." }
} }
// ── ban ───────────────────────────────────────────────────────────────────── // ── ban ─────────────────────────────────────────────────────────────────────
@@ -108,15 +245,15 @@ export async function deleteUser(userId: string) {
const a = await guard() const a = await guard()
if (userId === a.user.id) throw new Error("You cannot delete yourself") if (userId === a.user.id) throw new Error("You cannot delete yourself")
await auth.api.removeUser({ // Full GDPR-grade purge: cancels Stripe billing, deletes stored files, and
body: { userId }, // removes the user row (FK cascade erases the whole portfolio + sessions).
headers: await headers(), const outcome = await executeAccountDeletion(userId)
})
await logAdminAction({ await logAdminAction({
adminId: a.user.id, adminId: a.user.id,
action: "delete_user", action: "delete_user",
targetUserId: userId, targetUserId: userId,
metadata: { ...outcome },
}) })
redirect("/admin/users") redirect("/admin/users")
@@ -130,7 +267,7 @@ export async function markEmailVerified(userId: string) {
await logAdminAction({ await logAdminAction({
adminId: a.user.id, adminId: a.user.id,
action: "resend_verification", action: "mark_email_verified",
targetUserId: userId, targetUserId: userId,
metadata: { markedVerified: true }, metadata: { markedVerified: true },
}) })
@@ -158,3 +295,22 @@ export async function setSiteMaintenance(enabled: boolean, message?: string) {
revalidatePath("/", "layout") revalidatePath("/", "layout")
return { ok: true } return { ok: true }
} }
// ── AI provider ───────────────────────────────────────────────────────────────
// Chooses which LLM provider powers all AI features (OpenAI or Anthropic/Claude),
// persisted in app_settings. Applies immediately to every AI route.
export async function setAiProviderAction(provider: string) {
const a = await guard()
if (provider !== "openai" && provider !== "anthropic") throw new Error("Invalid AI provider")
await setAiProvider(provider as AiProvider)
await logAdminAction({
adminId: a.user.id,
action: "ai_provider",
metadata: { provider },
})
revalidatePath("/admin/system")
return { ok: true }
}
+24 -3
View File
@@ -3,7 +3,7 @@
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { headers } from "next/headers" import { headers } from "next/headers"
import { APIError } from "better-auth/api" import { APIError } from "better-auth/api"
import { auth } from "@/lib/auth" import { auth, isGoogleConfigured } from "@/lib/auth"
import { verifyTurnstile } from "@/lib/turnstile" import { verifyTurnstile } from "@/lib/turnstile"
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000" const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"
@@ -36,7 +36,12 @@ export async function signUp(formData: FormData) {
headers: h, headers: h,
}) })
} catch (e) { } catch (e) {
const msg = e instanceof APIError ? e.message : "Sign up failed" const raw = e instanceof APIError ? e.message : "Sign up failed"
// Don't reveal that an email is already registered (user enumeration) — the
// "already exists" path must not be distinguishable from other failures.
const msg = /exist|registered|already|taken/i.test(raw)
? "We couldn't complete your sign-up. Please try a different email or sign in."
: raw
redirect(`/signup?error=${encodeURIComponent(msg)}`) redirect(`/signup?error=${encodeURIComponent(msg)}`)
} }
@@ -72,6 +77,11 @@ export async function signIn(formData: FormData) {
} }
export async function signInWithGoogle() { export async function signInWithGoogle() {
// Defense in depth: the auth pages hide the Google button when it isn't
// configured, but guard the action too in case it's POSTed directly.
if (!isGoogleConfigured()) {
redirect(`/login?error=${encodeURIComponent("Google sign-in isn't available right now.")}`)
}
let url: string | undefined let url: string | undefined
try { try {
const res = await auth.api.signInSocial({ const res = await auth.api.signInSocial({
@@ -121,15 +131,26 @@ export async function signOut() {
export async function updatePassword(formData: FormData) { export async function updatePassword(formData: FormData) {
const password = formData.get("password") as string const password = formData.get("password") as string
const token = formData.get("token") as string const token = formData.get("token") as string
const captchaToken = formData.get("cf-turnstile-response") as string | null
if (!token) { if (!token) {
redirect(`/update-password?error=${encodeURIComponent("Reset link is invalid or expired.")}`) redirect(`/update-password?error=${encodeURIComponent("Reset link is invalid or expired.")}`)
} }
const h = await headers()
// Same bot protection as the other credential forms. The reset token is
// carried through so a failed challenge doesn't strand the user on a form
// whose link can't be replayed.
if (!(await verifyTurnstile(captchaToken, h.get("x-forwarded-for")))) {
redirect(
`/update-password?error=${encodeURIComponent(CAPTCHA_ERROR)}&token=${encodeURIComponent(token)}`
)
}
try { try {
await auth.api.resetPassword({ await auth.api.resetPassword({
body: { newPassword: password, token }, body: { newPassword: password, token },
headers: await headers(), headers: h,
}) })
} catch (e) { } catch (e) {
const msg = e instanceof APIError ? e.message : "Could not update password" const msg = e instanceof APIError ? e.message : "Could not update password"
+63 -1
View File
@@ -1,9 +1,27 @@
"use server" "use server"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { leases } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account" import { getAccountContext } from "@/lib/account"
import { sendLeaseForSignature, getAdapter, type ESignProvider } from "@/lib/esign" import { keyBelongsToOwner } from "@/lib/storage"
import {
sendLeaseForSignature,
getAdapter,
saveEsignConnection,
disconnectEsign,
type ESignProvider,
} from "@/lib/esign"
async function ownerGuard() {
const user = await getSessionUser()
if (!user) throw new Error("Unauthorized")
const ctx = await getAccountContext(user.id)
if (!ctx.isOwner) throw new Error("Only the account owner can manage integrations")
return ctx
}
export async function sendLeaseForSignatureAction(leaseId: string, provider: string) { export async function sendLeaseForSignatureAction(leaseId: string, provider: string) {
const user = await getSessionUser() const user = await getSessionUser()
@@ -15,3 +33,47 @@ export async function sendLeaseForSignatureAction(leaseId: string, provider: str
revalidatePath(`/leases/${leaseId}`) revalidatePath(`/leases/${leaseId}`)
return { ok: true } return { ok: true }
} }
/** Connect Dropbox Sign by validating and storing the landlord's API key. */
export async function connectDropboxSign(apiKey: string) {
const ctx = await ownerGuard()
const adapter = getAdapter("dropbox_sign")
if (!adapter) throw new Error("Unknown provider")
const tokens = await adapter.connectApiKey(typeof apiKey === "string" ? apiKey : "")
await saveEsignConnection(ctx.ownerId, "dropbox_sign", tokens)
revalidatePath("/settings/integrations")
return { ok: true, accountName: tokens.accountName }
}
export async function disconnectEsignAction(provider: string) {
const ctx = await ownerGuard()
if (!getAdapter(provider)) throw new Error("Unknown provider")
await disconnectEsign(ctx.ownerId, provider as ESignProvider)
revalidatePath("/settings/integrations")
return { ok: true }
}
/**
* Attach an already-uploaded document (via /api/upload) to a lease. Validates
* the file belongs to the caller's namespace to prevent cross-tenant refs.
*/
export async function setLeaseDocument(leaseId: string, fileUrl: string) {
const user = await getSessionUser()
if (!user) throw new Error("Unauthorized")
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) throw new Error("You don't have permission to do that")
const prefix = "/api/files/"
if (typeof fileUrl !== "string" || !fileUrl.startsWith(prefix)) throw new Error("Invalid document reference")
if (!keyBelongsToOwner(fileUrl.slice(prefix.length), ctx.ownerId)) throw new Error("Invalid document reference")
const [row] = await db
.update(leases)
.set({ document_url: fileUrl })
.where(and(eq(leases.id, leaseId), eq(leases.user_id, ctx.ownerId)))
.returning({ id: leases.id })
if (!row) throw new Error("Lease not found")
revalidatePath(`/leases/${leaseId}`)
return { ok: true, url: fileUrl }
}
+109
View File
@@ -0,0 +1,109 @@
"use server"
import { revalidatePath } from "next/cache"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { account_deletion_requests } from "@/lib/db/schema"
import { getSessionUser, isAdminUser } from "@/lib/session"
import { LEGAL } from "@/lib/legal"
import { sendEmail, accountDeletionRequestedHtml } from "@/lib/email/send"
// ============================================================================
// GDPR self-service actions (Settings → Privacy & Data).
//
// Deletion is a two-step, grace-period flow: the request schedules a hard
// delete LEGAL.dataDeletionDays out (the retention window promised on /gdpr);
// the gdpr cron executes it. Until then the account stays usable and the user
// can cancel. Data export is a GET route (/api/gdpr/export), not an action,
// so the browser can download it as a file.
// ============================================================================
const SETTINGS_PATH = "/settings/privacy"
export type DeletionRequestDTO = {
id: string
status: "pending" | "cancelled" | "completed"
scheduled_for: string
created_at: string
}
export async function requestAccountDeletion(input: {
confirmEmail: string
reason?: string
}): Promise<DeletionRequestDTO> {
const user = await getSessionUser()
if (!user) throw new Error("Unauthorized")
// Admins manage the platform — deleting one from self-service risks locking
// everyone out. They can be removed via the admin panel by another admin.
if (isAdminUser(user as { id?: string; email?: string; role?: string | null })) {
throw new Error("Admin accounts cannot be deleted from self-service. Contact another administrator.")
}
const typed = (input.confirmEmail ?? "").trim().toLowerCase()
if (!typed || typed !== user.email.toLowerCase()) {
throw new Error("The email you typed doesn't match your account email.")
}
const existing = await db.query.account_deletion_requests.findFirst({
where: and(
eq(account_deletion_requests.user_id, user.id),
eq(account_deletion_requests.status, "pending")
),
})
if (existing) throw new Error("Your account is already scheduled for deletion.")
const scheduledFor = new Date(Date.now() + LEGAL.dataDeletionDays * 24 * 60 * 60 * 1000).toISOString()
let row: typeof account_deletion_requests.$inferSelect
try {
;[row] = await db
.insert(account_deletion_requests)
.values({
user_id: user.id,
email: user.email,
reason: (input.reason ?? "").trim().slice(0, 500) || null,
scheduled_for: scheduledFor,
})
.returning()
} catch {
// The partial unique index makes a double-submit race land here.
throw new Error("Your account is already scheduled for deletion.")
}
await sendEmail({
to: user.email,
subject: "Your account deletion is scheduled",
html: accountDeletionRequestedHtml({
name: user.name || user.email,
scheduledDate: new Date(scheduledFor).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}),
graceDays: LEGAL.dataDeletionDays,
}),
})
revalidatePath(SETTINGS_PATH)
return { id: row.id, status: row.status, scheduled_for: row.scheduled_for, created_at: row.created_at }
}
export async function cancelAccountDeletion(): Promise<void> {
const user = await getSessionUser()
if (!user) throw new Error("Unauthorized")
const [row] = await db
.update(account_deletion_requests)
.set({ status: "cancelled", cancelled_at: new Date().toISOString() })
.where(
and(
eq(account_deletion_requests.user_id, user.id),
eq(account_deletion_requests.status, "pending")
)
)
.returning({ id: account_deletion_requests.id })
if (!row) throw new Error("No pending deletion request found.")
revalidatePath(SETTINGS_PATH)
}
+16 -6
View File
@@ -13,7 +13,8 @@ import {
} from "@/lib/db/schema" } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account" import { getEffectiveOwnerId } from "@/lib/account"
import { openai } from "@/lib/ai/client" import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { enforceAiQuota } from "@/lib/ai/usage" import { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts" import { dataBlock } from "@/lib/ai/prompts"
@@ -21,6 +22,9 @@ export async function POST(request: Request) {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_ask") const quota = await enforceAiQuota(user.id, "ai_ask")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -154,16 +158,22 @@ ${dataBlock("EXPIRING LEASES", JSON.stringify(expiringLeases, null, 2))}
Answer the landlord's question in a helpful, concise, and professional manner. Use bullet points where appropriate. Be specific with numbers from the data above. If the question is unrelated to property management, politely redirect. Answer the landlord's question in a helpful, concise, and professional manner. Use bullet points where appropriate. Be specific with numbers from the data above. If the question is unrelated to property management, politely redirect.
` `
const completion = await openai.chat.completions.create({ let answer: string
model: "gpt-4o-mini", try {
max_tokens: 1024, answer = await aiComplete({
messages: [ messages: [
{ role: "system", content: context }, { role: "system", content: context },
{ role: "user", content: question }, { role: "user", content: question },
], ],
maxTokens: 1024,
}) })
} catch (err) {
const answer = completion.choices[0].message.content ?? "" console.error("[ai/ask] AI request failed:", err)
return NextResponse.json(
{ error: "The AI service is temporarily unavailable. Please try again in a moment." },
{ status: 502 }
)
}
return NextResponse.json({ answer, usage: { used: quota.used, limit: quota.limit } }) return NextResponse.json({ answer, usage: { used: quota.used, limit: quota.limit } })
} }
+9 -7
View File
@@ -4,7 +4,8 @@ import { db } from "@/lib/db"
import { properties, maintenance_requests } from "@/lib/db/schema" import { properties, maintenance_requests } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account" import { getEffectiveOwnerId } from "@/lib/account"
import { openai } from "@/lib/ai/client" import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { MAINTENANCE_SUMMARY_PROMPT, dataBlock } from "@/lib/ai/prompts" import { MAINTENANCE_SUMMARY_PROMPT, dataBlock } from "@/lib/ai/prompts"
import { enforceAiQuota } from "@/lib/ai/usage" import { enforceAiQuota } from "@/lib/ai/usage"
@@ -12,6 +13,9 @@ export async function POST(request: Request) {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_maintenance_summary") const quota = await enforceAiQuota(user.id, "ai_maintenance_summary")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -39,9 +43,7 @@ export async function POST(request: Request) {
columns: { name: true }, columns: { name: true },
}) })
const completion = await openai.chat.completions.create({ const text = await aiComplete({
model: "gpt-4o-mini",
max_tokens: 1024,
messages: [ messages: [
{ role: "system", content: MAINTENANCE_SUMMARY_PROMPT }, { role: "system", content: MAINTENANCE_SUMMARY_PROMPT },
{ {
@@ -49,13 +51,13 @@ export async function POST(request: Request) {
content: `${dataBlock("PROPERTY NAME", property?.name ?? "Unknown")}\n\n${dataBlock("MAINTENANCE REQUESTS", JSON.stringify(requests, null, 2))}`, content: `${dataBlock("PROPERTY NAME", property?.name ?? "Unknown")}\n\n${dataBlock("MAINTENANCE REQUESTS", JSON.stringify(requests, null, 2))}`,
}, },
], ],
maxTokens: 1024,
json: true,
}) })
const text = completion.choices[0].message.content ?? ""
let summary let summary
try { try {
summary = JSON.parse(text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim()) summary = JSON.parse(text)
} catch { } catch {
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 }) return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
} }
+26 -10
View File
@@ -13,7 +13,8 @@ import {
} from "@/lib/db/schema" } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { openai } from "@/lib/ai/client" import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { logActivity } from "@/lib/activity" import { logActivity } from "@/lib/activity"
import { enforceAiQuota } from "@/lib/ai/usage" import { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts" import { dataBlock } from "@/lib/ai/prompts"
@@ -34,10 +35,26 @@ export async function GET() {
return NextResponse.json(data) return NextResponse.json(data)
} }
// Shape of one item in the model's JSON response. Every field is optional
// because the model is not a trusted schema — the insert below supplies a
// fallback for each, so a missing key degrades instead of throwing.
type AiPrediction = {
type?: string
title?: string
prediction?: string
confidence?: string
timeframe?: string
risk_level?: string
data?: Record<string, unknown> | null
}
export async function POST() { export async function POST() {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_predictions") const quota = await enforceAiQuota(user.id, "ai_predictions")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -174,16 +191,15 @@ Generate a JSON object with key "predictions" containing an array of 5-7 predict
Only return valid JSON, no other text.` Only return valid JSON, no other text.`
const completion = await openai.chat.completions.create({ const content = await aiComplete({
model: "gpt-4o-mini",
max_tokens: 2000,
messages: [{ role: "user", content: prompt }], messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" }, maxTokens: 2000,
json: true,
}) })
let predictions: any[] = [] let predictions: AiPrediction[] = []
try { try {
const parsed = JSON.parse(completion.choices[0].message.content ?? "{}") const parsed = JSON.parse(content || "{}")
predictions = Array.isArray(parsed) ? parsed : (parsed.predictions ?? []) predictions = Array.isArray(parsed) ? parsed : (parsed.predictions ?? [])
} catch { } catch {
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 }) return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
@@ -192,11 +208,11 @@ Only return valid JSON, no other text.`
// Replace old predictions // Replace old predictions
await db.delete(ai_predictions).where(eq(ai_predictions.user_id, ownerId)) await db.delete(ai_predictions).where(eq(ai_predictions.user_id, ownerId))
const toInsert = predictions.map((p: any) => ({ const toInsert = predictions.map((p: AiPrediction) => ({
user_id: ownerId, user_id: ownerId,
type: p.type ?? "growth_opportunity", type: p.type ?? "growth_opportunity",
title: p.title, title: p.title ?? "Untitled prediction",
prediction: p.prediction, prediction: p.prediction ?? "",
confidence: p.confidence ?? "medium", confidence: p.confidence ?? "medium",
timeframe: p.timeframe ?? "Next 30 days", timeframe: p.timeframe ?? "Next 30 days",
risk_level: p.risk_level ?? "low", risk_level: p.risk_level ?? "low",
+29 -13
View File
@@ -13,7 +13,8 @@ import {
} from "@/lib/db/schema" } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { openai } from "@/lib/ai/client" import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { logActivity } from "@/lib/activity" import { logActivity } from "@/lib/activity"
import { enforceAiQuota } from "@/lib/ai/usage" import { enforceAiQuota } from "@/lib/ai/usage"
import { dataBlock } from "@/lib/ai/prompts" import { dataBlock } from "@/lib/ai/prompts"
@@ -33,10 +34,25 @@ export async function GET() {
return NextResponse.json(data) return NextResponse.json(data)
} }
// Shape of one item in the model's JSON response. Optional for the same reason
// as AiPrediction: the model output is untrusted input, not a schema.
type AiRecommendation = {
type?: string
title?: string
description?: string
impact?: string
priority?: string
action_label?: string
action_data?: Record<string, unknown> | null
}
export async function POST() { export async function POST() {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_recommendations") const quota = await enforceAiQuota(user.id, "ai_recommendations")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -165,18 +181,18 @@ Return a JSON object with key "recommendations" containing an array. Each recomm
Only return valid JSON, no other text.` Only return valid JSON, no other text.`
let recommendations: any[] = [] let recommendations: AiRecommendation[] = []
try { try {
const completion = await openai.chat.completions.create({ const content = await aiComplete({
model: "gpt-4o-mini",
max_tokens: 1500,
messages: [{ role: "user", content: prompt }], messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" }, maxTokens: 1500,
json: true,
}) })
const parsed = JSON.parse(completion.choices[0].message.content ?? "{}") const parsed = JSON.parse(content || "{}")
recommendations = Array.isArray(parsed) ? parsed : (parsed.recommendations ?? []) recommendations = Array.isArray(parsed) ? parsed : (parsed.recommendations ?? [])
} catch (err: any) { } catch (err) {
return NextResponse.json({ error: err?.message ?? "AI generation failed" }, { status: 500 }) const message = err instanceof Error ? err.message : "AI generation failed"
return NextResponse.json({ error: message }, { status: 500 })
} }
// Delete old pending recommendations and insert new ones // Delete old pending recommendations and insert new ones
@@ -184,12 +200,12 @@ Only return valid JSON, no other text.`
.delete(ai_recommendations) .delete(ai_recommendations)
.where(and(eq(ai_recommendations.user_id, ownerId), eq(ai_recommendations.status, "pending"))) .where(and(eq(ai_recommendations.user_id, ownerId), eq(ai_recommendations.status, "pending")))
const toInsert = recommendations.map((r: any) => ({ const toInsert = recommendations.map((r: AiRecommendation) => ({
user_id: ownerId, user_id: ownerId,
type: r.type ?? "opportunity", type: r.type ?? "opportunity",
title: r.title, title: r.title ?? "Untitled recommendation",
description: r.description, description: r.description ?? "",
impact: r.impact, impact: r.impact ?? "",
priority: r.priority ?? "medium", priority: r.priority ?? "medium",
status: "pending", status: "pending",
action_label: r.action_label ?? "Apply", action_label: r.action_label ?? "Apply",
+12 -8
View File
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { z } from "zod" import { z } from "zod"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { openai } from "@/lib/ai/client" import { aiConfigured, AI_UNCONFIGURED_ERROR } from "@/lib/ai/client"
import { aiComplete } from "@/lib/ai/provider"
import { RENT_RECEIPT_PROMPT, dataBlock } from "@/lib/ai/prompts" import { RENT_RECEIPT_PROMPT, dataBlock } from "@/lib/ai/prompts"
import { enforceAiQuota } from "@/lib/ai/usage" import { enforceAiQuota } from "@/lib/ai/usage"
@@ -25,6 +26,9 @@ export async function POST(request: Request) {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Before the quota check so an unconfigured server never burns a call.
if (!aiConfigured()) return NextResponse.json({ error: AI_UNCONFIGURED_ERROR }, { status: 503 })
const quota = await enforceAiQuota(user.id, "ai_rent_receipt") const quota = await enforceAiQuota(user.id, "ai_rent_receipt")
if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status }) if (!quota.ok) return NextResponse.json({ error: quota.error }, { status: quota.status })
@@ -34,11 +38,11 @@ export async function POST(request: Request) {
} }
// Pass only the whitelisted, validated fields to the model. // Pass only the whitelisted, validated fields to the model.
const { payment_id, ...receiptFields } = parsed.data // payment_id identifies the row but must not reach the model — destructured
// out deliberately, hence the leading underscore.
const { payment_id: _payment_id, ...receiptFields } = parsed.data
const completion = await openai.chat.completions.create({ const text = await aiComplete({
model: "gpt-4o-mini",
max_tokens: 1024,
messages: [ messages: [
{ role: "system", content: RENT_RECEIPT_PROMPT }, { role: "system", content: RENT_RECEIPT_PROMPT },
{ {
@@ -46,13 +50,13 @@ export async function POST(request: Request) {
content: dataBlock("PAYMENT DETAILS", JSON.stringify(receiptFields, null, 2)), content: dataBlock("PAYMENT DETAILS", JSON.stringify(receiptFields, null, 2)),
}, },
], ],
maxTokens: 1024,
json: true,
}) })
const text = completion.choices[0].message.content ?? ""
let receipt let receipt
try { try {
receipt = JSON.parse(text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim()) receipt = JSON.parse(text)
} catch { } catch {
return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 }) return NextResponse.json({ error: "Failed to parse AI response" }, { status: 500 })
} }
+63 -1
View File
@@ -1,4 +1,66 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth" import { auth } from "@/lib/auth"
import { toNextJsHandler } from "better-auth/next-js" import { toNextJsHandler } from "better-auth/next-js"
import { verifyTurnstile } from "@/lib/turnstile"
export const { GET, POST } = toNextJsHandler(auth) const handlers = toNextJsHandler(auth)
// The auth pages post to server actions, which call `auth.api.*` in-process and
// run their own verifyTurnstile() check. This route is the *other* door into the
// same endpoints — a direct HTTP POST — and without this gate it accepts
// unlimited credential guesses and email sends with no bot protection at all.
//
// Only credential-bearing / email-triggering POSTs are gated. GET is untouched
// (OAuth callbacks, verify-email links, get-session), and `/sign-in/social` is
// left open because it only starts a redirect to the provider.
const CAPTCHA_PROTECTED = new Set([
"/sign-in/email",
"/sign-up/email",
"/request-password-reset",
"/reset-password",
"/send-verification-email",
])
/**
* Turnstile token from a header (preferred — leaves the body stream untouched)
* or, for clients that submit it inline, from a cloned JSON body.
*/
async function captchaToken(request: Request): Promise<string | null> {
const header =
request.headers.get("x-captcha-response") ??
request.headers.get("cf-turnstile-response")
if (header) return header
try {
const body = (await request.clone().json()) as Record<string, unknown>
const inline = body?.["cf-turnstile-response"] ?? body?.captchaToken
return typeof inline === "string" ? inline : null
} catch {
// Not JSON, or no body — treated as a missing token, which fails closed.
return null
}
}
export const GET = handlers.GET
export async function POST(request: Request) {
const path = new URL(request.url).pathname.replace(/^\/api\/auth/, "")
if (CAPTCHA_PROTECTED.has(path)) {
const ok = await verifyTurnstile(
await captchaToken(request),
request.headers.get("x-forwarded-for")
)
if (!ok) {
return NextResponse.json(
{
message: "Verification challenge required.",
code: "CAPTCHA_VERIFICATION_FAILED",
},
{ status: 403 }
)
}
}
return handlers.POST(request)
}
+6 -1
View File
@@ -115,7 +115,12 @@ export async function GET(_req: Request, { params }: { params: Promise<{ token:
headers: { headers: {
"Content-Type": "text/calendar; charset=utf-8", "Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="property-management-network.ics"', "Content-Disposition": 'inline; filename="property-management-network.ics"',
"Cache-Control": "public, max-age=3600", // PRIVATE, never shared-cacheable: the only credential is the token in the
// URL, and the body carries tenant names, rent amounts, property addresses
// and lease dates. A `public` cache directive would let any intermediary
// or CDN retain that PII.
"Cache-Control": "private, max-age=3600",
"X-Robots-Tag": "noindex, nofollow",
}, },
}) })
} }
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server"
import { isAuthorizedCron } from "@/lib/cron-auth"
import { processDueDeletions } from "@/lib/gdpr/delete"
// GDPR deletion drain: hard-deletes accounts whose grace period
// (LEGAL.dataDeletionDays after the request) has elapsed. Scheduled daily via
// DigitalOcean Functions — see functions/project.yml.
export async function GET(request: Request) {
if (!isAuthorizedCron(request)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { processed, deleted } = await processDueDeletions(25)
return NextResponse.json({ processed, deleted })
}
+1 -1
View File
@@ -44,7 +44,7 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str
await db.delete(documents).where(and(eq(documents.id, id), eq(documents.user_id, ownerId))) await db.delete(documents).where(and(eq(documents.id, id), eq(documents.user_id, ownerId)))
if (doc.storage_path) { if (doc.storage_path) {
await deleteFile(doc.storage_path) await deleteFile(doc.storage_path, ownerId)
} }
return NextResponse.json({ success: true }) return NextResponse.json({ success: true })
+28 -3
View File
@@ -3,7 +3,14 @@ import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { documents, properties } from "@/lib/db/schema" import { documents, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage" import {
saveFile,
isAllowedUploadExt,
StorageNotConfiguredError,
keyBelongsToOwner,
contentMatchesExtension,
extOf,
} from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits" import { checkStorageLimit } from "@/lib/plan-limits"
import { ownsProperty, ownsTenant } from "@/lib/db/ownership" import { ownsProperty, ownsTenant } from "@/lib/db/ownership"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
@@ -57,6 +64,10 @@ export async function POST(request: Request) {
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 }) if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 })
if (file.size > 20 * 1024 * 1024) return NextResponse.json({ error: "File too large (max 20 MB)" }, { status: 400 }) if (file.size > 20 * 1024 * 1024) return NextResponse.json({ error: "File too large (max 20 MB)" }, { status: 400 })
if (!isAllowedUploadExt(file.name)) return NextResponse.json({ error: "File type not allowed" }, { status: 400 }) if (!isAllowedUploadExt(file.name)) return NextResponse.json({ error: "File type not allowed" }, { status: 400 })
const head = Buffer.from(await file.slice(0, 16).arrayBuffer())
if (!contentMatchesExtension(head, extOf(file.name))) {
return NextResponse.json({ error: "File content does not match its type" }, { status: 400 })
}
const storageError = await checkStorageLimit(ownerId, file.size) const storageError = await checkStorageLimit(ownerId, file.size)
if (storageError) return NextResponse.json({ error: storageError }, { status: 403 }) if (storageError) return NextResponse.json({ error: storageError }, { status: 403 })
@@ -113,6 +124,20 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Tenant not found" }, { status: 404 }) return NextResponse.json({ error: "Tenant not found" }, { status: 404 })
} }
// The file reference is client-supplied. Require it to be an /api/files URL
// inside the caller's OWN namespace, and derive storage_path from it — never
// trust a separate client storage_path (which could point at another tenant's
// object and later be deleted). Also blocks javascript:/external file_url values.
const FILES_PREFIX = "/api/files/"
const fileUrl = typeof body.file_url === "string" ? body.file_url : ""
if (!fileUrl.startsWith(FILES_PREFIX)) {
return NextResponse.json({ error: "file_url must reference an uploaded file" }, { status: 400 })
}
const storagePath = fileUrl.slice(FILES_PREFIX.length)
if (!keyBelongsToOwner(storagePath, ownerId)) {
return NextResponse.json({ error: "Invalid file reference" }, { status: 403 })
}
// Whitelist insertable columns — never trust client-supplied user_id/id/created_at. // Whitelist insertable columns — never trust client-supplied user_id/id/created_at.
const [data] = await db const [data] = await db
.insert(documents) .insert(documents)
@@ -122,8 +147,8 @@ export async function POST(request: Request) {
tenant_id: tenantId, tenant_id: tenantId,
name: body.name as string, name: body.name as string,
category: (body.category as typeof documents.$inferInsert.category) ?? "other", category: (body.category as typeof documents.$inferInsert.category) ?? "other",
file_url: body.file_url as string, file_url: fileUrl,
storage_path: body.storage_path as string | undefined, storage_path: storagePath,
file_type: body.file_type as string | undefined, file_type: body.file_type as string | undefined,
file_size: body.file_size as number | undefined, file_size: body.file_size as number | undefined,
}) })
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { getAdapter, saveEsignConnection, type ESignProvider } from "@/lib/esign"
import { verifyState, ESIGN_NONCE_COOKIE } from "@/lib/esign/state"
// OAuth callback — exchanges the code for tokens and stores the connection.
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider } = await params
const adapter = getAdapter(provider)
const settings = new URL("/settings/integrations", request.url)
const cookieStore = await cookies()
const nonceCookie = cookieStore.get(ESIGN_NONCE_COOKIE)?.value
const done = (p: Record<string, string>) => {
for (const [k, v] of Object.entries(p)) settings.searchParams.set(k, v)
const res = NextResponse.redirect(settings)
res.cookies.set(ESIGN_NONCE_COOKIE, "", { path: "/", maxAge: 0 })
return res
}
const url = new URL(request.url)
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const oauthError = url.searchParams.get("error")
if (oauthError || !adapter || adapter.kind !== "oauth") return done({ error: "connect_failed" })
const st = state ? verifyState(state) : null
// CSRF: state nonce must match the cookie, and the session must be the same owner.
if (!code || !st || st.provider !== provider || !nonceCookie || nonceCookie !== st.nonce) {
return done({ error: "invalid_state" })
}
const user = await getSessionUser()
if (!user) return done({ error: "invalid_state" })
const ctx = await getAccountContext(user.id)
if (ctx.ownerId !== st.ownerId) return done({ error: "invalid_state" })
try {
const tokens = await adapter.exchangeCode(code)
if (!tokens.accountId || !tokens.baseUri) throw new Error("No account returned from provider")
await saveEsignConnection(st.ownerId, provider as ESignProvider, tokens)
return done({ connected: provider })
} catch {
return done({ error: "connect_failed" })
}
}
+49
View File
@@ -0,0 +1,49 @@
import crypto from "crypto"
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { getAdapter } from "@/lib/esign"
import { signState, ESIGN_NONCE_COOKIE } from "@/lib/esign/state"
// Starts the OAuth connect flow for an e-signature provider (owner-only).
// API-key providers (Dropbox Sign) don't use this — they connect via a form.
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider } = await params
const adapter = getAdapter(provider)
const settings = new URL("/settings/integrations", request.url)
if (!adapter) {
settings.searchParams.set("error", "unknown_provider")
return NextResponse.redirect(settings)
}
const user = await getSessionUser()
if (!user) return NextResponse.redirect(new URL("/login", request.url))
const ctx = await getAccountContext(user.id)
if (!ctx.isOwner) {
settings.searchParams.set("error", "owner_only")
return NextResponse.redirect(settings)
}
if (adapter.kind !== "oauth") {
settings.searchParams.set("error", "use_api_key")
return NextResponse.redirect(settings)
}
if (!adapter.available()) {
settings.searchParams.set("error", "not_configured")
return NextResponse.redirect(settings)
}
// Bind the round-trip to this browser: nonce in the signed state AND a cookie.
const nonce = crypto.randomUUID()
const state = signState({ ownerId: ctx.ownerId, provider, nonce })
const res = NextResponse.redirect(adapter.getAuthUrl(state))
res.cookies.set(ESIGN_NONCE_COOKIE, nonce, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
})
return res
}
+5 -3
View File
@@ -1,15 +1,17 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getEffectiveOwnerId } from "@/lib/account" import { getAccountContext } from "@/lib/account"
import { runFollowUpsForUser } from "@/lib/follow-ups" import { runFollowUpsForUser } from "@/lib/follow-ups"
export async function POST() { export async function POST() {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const ownerId = await getEffectiveOwnerId(user.id) // Sends real outbound follow-ups — a mutating action, so viewers are blocked.
const ctx = await getAccountContext(user.id)
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const result = await runFollowUpsForUser(ownerId) const result = await runFollowUpsForUser(ctx.ownerId)
// Preserve the original response shape ({ sent, results }). The detailed // Preserve the original response shape ({ sent, results }). The detailed
// per-follow-up rows now live only in follow_up_log; the client re-fetches // per-follow-up rows now live only in follow_up_log; the client re-fetches
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server"
import { headers } from "next/headers"
import { db } from "@/lib/db"
import { consent_log } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { LEGAL } from "@/lib/legal"
// Records a cookie-consent choice from the banner. Only signed-in users are
// logged — anonymous visitors keep their choice in localStorage only, so this
// endpoint can't be used to spam the consent table.
export async function POST(request: Request) {
const user = await getSessionUser()
if (!user) return new NextResponse(null, { status: 204 })
let analytics = false
try {
const body = await request.json()
analytics = body?.analytics === true
} catch {
return NextResponse.json({ error: "Invalid body" }, { status: 400 })
}
const h = await headers()
await db.insert(consent_log).values({
user_id: user.id,
email: user.email,
kind: "cookies",
granted: analytics,
policy_version: LEGAL.lastUpdated,
source: "cookie-banner",
ip_address: h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip"),
user_agent: h.get("user-agent"),
})
return NextResponse.json({ ok: true })
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server"
import { db } from "@/lib/db"
import { usage_events } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { buildUserDataExport } from "@/lib/gdpr/export"
// GDPR data export (Articles 15/20) — downloads everything the platform stores
// about the signed-in user as a single JSON file. Sensitive credentials are
// excluded by the builder (see lib/gdpr/export.ts).
export async function GET() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const data = await buildUserDataExport(user.id)
// DSAR evidence: record that the export was served.
await db.insert(usage_events).values({ user_id: user.id, event_type: "gdpr_data_export" })
const filename = `pmn-data-export-${new Date().toISOString().slice(0, 10)}.json`
return new NextResponse(JSON.stringify(data, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "no-store",
},
})
}
@@ -1,37 +1,51 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { cookies } from "next/headers"
import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account"
import { getProvider, saveConnection, type Provider } from "@/lib/accounting" import { getProvider, saveConnection, type Provider } from "@/lib/accounting"
import { verifyState } from "@/lib/accounting/state" import { verifyState, OAUTH_NONCE_COOKIE } from "@/lib/accounting/state"
// OAuth callback — exchanges the code for tokens and stores the connection. // OAuth callback — exchanges the code for tokens and stores the connection.
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) { export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
const { provider: pid } = await params const { provider: pid } = await params
const prov = getProvider(pid) const prov = getProvider(pid)
const url = new URL(request.url)
const settings = new URL("/settings/integrations", request.url) const settings = new URL("/settings/integrations", request.url)
// Always clear the one-shot nonce cookie on the way out.
const cookieStore = await cookies()
const nonceCookie = cookieStore.get(OAUTH_NONCE_COOKIE)?.value
const done = (params: Record<string, string>) => {
for (const [k, v] of Object.entries(params)) settings.searchParams.set(k, v)
const res = NextResponse.redirect(settings)
res.cookies.set(OAUTH_NONCE_COOKIE, "", { path: "/", maxAge: 0 })
return res
}
const url = new URL(request.url)
const code = url.searchParams.get("code") const code = url.searchParams.get("code")
const state = url.searchParams.get("state") const state = url.searchParams.get("state")
const realmId = url.searchParams.get("realmId") // QuickBooks includes this const realmId = url.searchParams.get("realmId") // QuickBooks includes this
const oauthError = url.searchParams.get("error") const oauthError = url.searchParams.get("error")
if (oauthError || !prov) { if (oauthError || !prov) return done({ error: "connect_failed" })
settings.searchParams.set("error", "connect_failed")
return NextResponse.redirect(settings)
}
const st = state ? verifyState(state) : null const st = state ? verifyState(state) : null
if (!code || !st || st.provider !== pid) { // CSRF: the state's nonce must match the cookie set at connect time, and the
settings.searchParams.set("error", "invalid_state") // current session must be the same owner that initiated the connect.
return NextResponse.redirect(settings) if (!code || !st || st.provider !== pid || !nonceCookie || nonceCookie !== st.nonce) {
return done({ error: "invalid_state" })
} }
const user = await getSessionUser()
if (!user) return done({ error: "invalid_state" })
const ctx = await getAccountContext(user.id)
if (ctx.ownerId !== st.ownerId) return done({ error: "invalid_state" })
try { try {
const tokens = await prov.exchangeCode(code, realmId) const tokens = await prov.exchangeCode(code, realmId)
if (!tokens.realmId) throw new Error("No organisation returned from provider") if (!tokens.realmId) throw new Error("No organisation returned from provider")
await saveConnection(st.ownerId, pid as Provider, tokens) await saveConnection(st.ownerId, pid as Provider, tokens)
settings.searchParams.set("connected", pid) return done({ connected: pid })
} catch { } catch {
settings.searchParams.set("error", "connect_failed") return done({ error: "connect_failed" })
} }
return NextResponse.redirect(settings)
} }
@@ -1,8 +1,9 @@
import crypto from "crypto"
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account" import { getAccountContext } from "@/lib/account"
import { getProvider } from "@/lib/accounting" import { getProvider } from "@/lib/accounting"
import { signState } from "@/lib/accounting/state" import { signState, OAUTH_NONCE_COOKIE } from "@/lib/accounting/state"
// Starts the OAuth connect flow for an accounting provider (owner-only). // Starts the OAuth connect flow for an accounting provider (owner-only).
export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) { export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) {
@@ -28,6 +29,17 @@ export async function GET(request: Request, { params }: { params: Promise<{ prov
return NextResponse.redirect(settings) return NextResponse.redirect(settings)
} }
const state = signState({ ownerId: ctx.ownerId, provider: pid }) // Bind the OAuth round-trip to this browser: a random nonce goes into the
return NextResponse.redirect(prov.getAuthUrl(state)) // signed state AND an httpOnly cookie; the callback requires them to match.
const nonce = crypto.randomUUID()
const state = signState({ ownerId: ctx.ownerId, provider: pid, nonce })
const res = NextResponse.redirect(prov.getAuthUrl(state))
res.cookies.set(OAUTH_NONCE_COOKIE, nonce, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
})
return res
} }
+12 -1
View File
@@ -8,6 +8,7 @@ import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { logActivity } from "@/lib/activity" import { logActivity } from "@/lib/activity"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit" import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { enforceRateLimit, clientIp } from "@/lib/rate-limit"
const VALID_STATUSES = ["open", "in_progress", "resolved", "closed"] const VALID_STATUSES = ["open", "in_progress", "resolved", "closed"]
const VALID_PRIORITIES = ["low", "medium", "high", "emergency"] const VALID_PRIORITIES = ["low", "medium", "high", "emergency"]
@@ -62,12 +63,22 @@ export async function POST(request: Request) {
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 }) if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
userId = ctx.ownerId userId = ctx.ownerId
} else { } else {
// Tenant portal submission — verify portal_token // Tenant portal submission — verify portal_token.
//
// This is the one unauthenticated write path in the app, so it carries its
// own limits: a per-IP budget that also caps portal-token guessing, and a
// tighter per-token budget so a leaked token cannot flood a landlord's queue.
const ipLimited = enforceRateLimit(`portal-maintenance-ip:${clientIp(request)}`, 20, 3600)
if (ipLimited) return ipLimited
const portalToken = body.portal_token as string | undefined const portalToken = body.portal_token as string | undefined
if (!portalToken) { if (!portalToken) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
} }
const tokenLimited = enforceRateLimit(`portal-maintenance:${portalToken}`, 10, 3600)
if (tokenLimited) return tokenLimited
const tenant = await db.query.tenants.findFirst({ const tenant = await db.query.tenants.findFirst({
where: eq(tenants.portal_token, portalToken), where: eq(tenants.portal_token, portalToken),
columns: { id: true, user_id: true, property_id: true, unit_id: true }, columns: { id: true, user_id: true, property_id: true, unit_id: true },
-32
View File
@@ -1,32 +0,0 @@
import { NextResponse } from "next/server"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { cancelSubscription } from "@/lib/paypal/checkout"
// Cancel the signed-in user's PayPal subscription. The account keeps access
// until the paid period ends; the BILLING.SUBSCRIPTION.CANCELLED webhook does
// the final downgrade to starter.
export async function POST() {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { paypal_subscription_id: true },
})
if (!profile?.paypal_subscription_id) {
return NextResponse.json({ error: "No PayPal subscription to cancel" }, { status: 400 })
}
const ok = await cancelSubscription(profile.paypal_subscription_id)
if (!ok) return NextResponse.json({ error: "PayPal cancellation failed" }, { status: 502 })
await db
.update(profiles)
.set({ subscription_status: "canceled" })
.where(eq(profiles.id, user.id))
return NextResponse.json({ ok: true })
}
-73
View File
@@ -1,73 +0,0 @@
import { NextResponse } from "next/server"
import { eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { profiles } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { paypalConfigured } from "@/lib/paypal/client"
import { getPaypalPlanId } from "@/lib/paypal/plans"
import { createSubscription, createOrder } from "@/lib/paypal/checkout"
import { PLAN_AMOUNTS } from "@/lib/stripe/plans"
const RECURRING = new Set(["pro", "landlord"])
// Start a PayPal checkout for a plan upgrade and return the approval URL.
// Recurring plans → Subscriptions API; lifetime → one-time Orders API.
export async function POST(request: Request) {
if (!paypalConfigured()) {
return NextResponse.json({ error: "PayPal is not configured" }, { status: 400 })
}
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { plan, interval } = (await request.json().catch(() => ({}))) as {
plan?: string
interval?: "month" | "year"
}
if (!plan || (plan !== "lifetime" && !RECURRING.has(plan))) {
return NextResponse.json({ error: "Invalid plan" }, { status: 400 })
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL!
const cancelUrl = `${appUrl}/settings/billing?canceled=true`
try {
if (plan === "lifetime") {
const { approveUrl } = await createOrder({
amount: PLAN_AMOUNTS.lifetime,
userId: user.id,
plan: "lifetime",
returnUrl: `${appUrl}/api/paypal/return?type=order`,
cancelUrl,
})
if (!approveUrl) throw new Error("PayPal did not return an approval URL")
return NextResponse.json({ url: approveUrl })
}
const billingInterval = interval === "year" ? "year" : "month"
const planId = getPaypalPlanId(plan as "pro" | "landlord", billingInterval)
if (!planId) {
return NextResponse.json({ error: "That plan isn't available on PayPal yet." }, { status: 400 })
}
const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id),
columns: { email: true },
})
const { approveUrl } = await createSubscription({
planId,
userId: user.id,
plan,
email: profile?.email ?? user.email,
returnUrl: `${appUrl}/api/paypal/return?type=subscription`,
cancelUrl,
})
if (!approveUrl) throw new Error("PayPal did not return an approval URL")
return NextResponse.json({ url: approveUrl })
} catch (e) {
return NextResponse.json(
{ error: (e as Error).message || "PayPal checkout failed" },
{ status: 502 }
)
}
}
-52
View File
@@ -1,52 +0,0 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session"
import { captureOrder, getSubscription, decodeCustomId } from "@/lib/paypal/checkout"
import { fulfillSubscription, fulfillLifetime } from "@/lib/paypal/fulfill"
// PayPal redirects the approver back here. We finalize synchronously (capture
// the order / confirm the subscription) so the plan is live the moment they
// land on the billing page — the webhook is a backstop, not the only path.
export async function GET(request: Request) {
const url = new URL(request.url)
const type = url.searchParams.get("type")
const appUrl = process.env.NEXT_PUBLIC_APP_URL!
const ok = NextResponse.redirect(`${appUrl}/settings/billing?success=true`)
const fail = NextResponse.redirect(`${appUrl}/settings/billing?error=paypal`)
const user = await getSessionUser()
if (!user) return NextResponse.redirect(`${appUrl}/login`)
try {
if (type === "order") {
const orderId = url.searchParams.get("token")
if (!orderId) return fail
const captured = await captureOrder(orderId)
if (!captured || captured.status !== "COMPLETED") return fail
const decoded = decodeCustomId(captured.custom_id)
if (!decoded || decoded.userId !== user.id) return fail
await fulfillLifetime(user.id)
return ok
}
// Subscription approval.
const subId = url.searchParams.get("subscription_id")
if (!subId) return fail
const sub = await getSubscription(subId)
if (!sub) return fail
const decoded = decodeCustomId(sub.custom_id)
// Only accept a subscription whose custom_id matches the signed-in user.
if (!decoded || decoded.userId !== user.id) return fail
const active = sub.status === "ACTIVE" || sub.status === "APPROVED"
await fulfillSubscription(
user.id,
decoded.plan,
sub.id,
sub.billing_info?.next_billing_time,
active ? "active" : sub.status.toLowerCase()
)
return ok
} catch {
return fail
}
}
-89
View File
@@ -1,89 +0,0 @@
import { NextResponse } from "next/server"
import { verifyPaypalWebhook } from "@/lib/paypal/webhook"
import { decodeCustomId, getSubscription } from "@/lib/paypal/checkout"
import { fulfillSubscription, fulfillLifetime, markPaypalSubscriptionInactive } from "@/lib/paypal/fulfill"
// Inbound PayPal webhook. Signature is verified via PayPal's API using
// PAYPAL_WEBHOOK_ID; unverified events are rejected.
export async function POST(request: Request) {
const body = await request.text()
const valid = await verifyPaypalWebhook(request.headers, body)
if (!valid) return NextResponse.json({ error: "invalid signature" }, { status: 400 })
let event: { event_type?: string; resource?: Record<string, unknown> }
try {
event = JSON.parse(body)
} catch {
return NextResponse.json({ ok: true })
}
const type = event.event_type ?? ""
const resource = (event.resource ?? {}) as Record<string, any>
try {
switch (type) {
case "BILLING.SUBSCRIPTION.ACTIVATED":
case "BILLING.SUBSCRIPTION.UPDATED": {
const decoded = decodeCustomId(resource.custom_id)
if (decoded && resource.id) {
await fulfillSubscription(
decoded.userId,
decoded.plan,
resource.id,
resource.billing_info?.next_billing_time,
"active"
)
}
break
}
case "PAYMENT.SALE.COMPLETED": {
// A recurring payment cleared — refresh status + next billing date.
const subId = resource.billing_agreement_id as string | undefined
if (subId) {
const sub = await getSubscription(subId)
const decoded = decodeCustomId(sub?.custom_id)
if (sub && decoded) {
await fulfillSubscription(
decoded.userId,
decoded.plan,
subId,
sub.billing_info?.next_billing_time,
"active"
)
}
}
break
}
case "BILLING.SUBSCRIPTION.CANCELLED":
case "BILLING.SUBSCRIPTION.EXPIRED": {
if (resource.id) {
await markPaypalSubscriptionInactive(
resource.id,
type.endsWith("CANCELLED") ? "canceled" : "expired",
true
)
}
break
}
case "BILLING.SUBSCRIPTION.SUSPENDED": {
if (resource.id) await markPaypalSubscriptionInactive(resource.id, "suspended", false)
break
}
case "PAYMENT.CAPTURE.COMPLETED": {
// Lifetime order capture (backup to the return handler).
const decoded = decodeCustomId(resource.custom_id)
if (decoded && decoded.plan === "lifetime") await fulfillLifetime(decoded.userId)
break
}
}
} catch {
// Never loop forever on a handler bug — PayPal retries non-2xx.
}
return NextResponse.json({ received: true })
}
+9
View File
@@ -8,8 +8,17 @@ export async function GET() {
const user = await getSessionUser() const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Exclude bearer/secret + billing-id columns from the client payload. The
// calendar feed token and Stripe/PayPal ids are used server-side only.
const profile = await db.query.profiles.findFirst({ const profile = await db.query.profiles.findFirst({
where: eq(profiles.id, user.id), where: eq(profiles.id, user.id),
columns: {
calendar_token: false,
stripe_customer_id: false,
stripe_subscription_id: false,
paypal_subscription_id: false,
billing_provider: false,
},
}) })
return NextResponse.json({ profile: profile ?? null }) return NextResponse.json({ profile: profile ?? null })
+4 -13
View File
@@ -1,11 +1,10 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { desc, eq, sql } from "drizzle-orm" import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { properties } from "@/lib/db/schema" import { properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { propertySchema } from "@/lib/validations" import { propertySchema } from "@/lib/validations"
import { getUserPlan } from "@/lib/plan-limits" import { checkPropertyLimit } from "@/lib/plan-limits"
import { checkLimit } from "@/lib/stripe/plans"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit" import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { geocodeAddress } from "@/lib/geocoding" import { geocodeAddress } from "@/lib/geocoding"
@@ -37,16 +36,8 @@ export async function POST(request: Request) {
const parsed = propertySchema.safeParse(body) const parsed = propertySchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
// Check plan limit const limitError = await checkPropertyLimit(ownerId)
const [{ count }] = await db if (limitError) return NextResponse.json({ error: limitError }, { status: 403 })
.select({ count: sql<number>`count(*)::int` })
.from(properties)
.where(eq(properties.user_id, ownerId))
const plan = await getUserPlan(ownerId)
if (!checkLimit(plan, "maxProperties", count)) {
return NextResponse.json({ error: "Plan limit reached. Upgrade to add more properties." }, { status: 403 })
}
// Best-effort geocode so the property shows up on the map (never blocks save). // Best-effort geocode so the property shows up on the map (never blocks save).
const coords = await geocodeAddress(parsed.data) const coords = await geocodeAddress(parsed.data)
+12 -2
View File
@@ -5,6 +5,16 @@ import { db } from "@/lib/db"
import { profiles, rent_payments } from "@/lib/db/schema" import { profiles, rent_payments } from "@/lib/db/schema"
import type Stripe from "stripe" import type Stripe from "stripe"
/**
* `current_period_end` is present on the webhook payload but absent from the
* Subscription type in this pinned API version, so it is read through a narrow
* accessor rather than casting the whole object to `any`.
*/
function subscriptionPeriodEnd(sub: Stripe.Subscription): number | null {
const v = (sub as unknown as { current_period_end?: unknown }).current_period_end
return typeof v === "number" ? v : null
}
export async function POST(request: Request) { export async function POST(request: Request) {
const body = await request.text() const body = await request.text()
const sig = request.headers.get("stripe-signature")! const sig = request.headers.get("stripe-signature")!
@@ -54,8 +64,8 @@ export async function POST(request: Request) {
plan: (plan ?? "starter") as typeof profiles.$inferSelect.plan, plan: (plan ?? "starter") as typeof profiles.$inferSelect.plan,
stripe_subscription_id: subscription.id, stripe_subscription_id: subscription.id,
subscription_status: subscription.status, subscription_status: subscription.status,
plan_expires_at: (subscription as any).current_period_end plan_expires_at: subscriptionPeriodEnd(subscription)
? new Date((subscription as any).current_period_end * 1000).toISOString() ? new Date(subscriptionPeriodEnd(subscription)! * 1000).toISOString()
: null, : null,
}) })
.where(eq(profiles.id, userId)) .where(eq(profiles.id, userId))
+3 -14
View File
@@ -5,8 +5,7 @@ import { tenants, units } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { tenantSchema } from "@/lib/validations" import { tenantSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit } from "@/lib/db/ownership" import { ownsProperty, ownsUnit } from "@/lib/db/ownership"
import { getUserPlan } from "@/lib/plan-limits" import { checkTenantLimit } from "@/lib/plan-limits"
import { checkLimit } from "@/lib/stripe/plans"
import { logActivity } from "@/lib/activity" import { logActivity } from "@/lib/activity"
import { getEffectiveOwnerId, getAccountContext } from "@/lib/account" import { getEffectiveOwnerId, getAccountContext } from "@/lib/account"
import { emitWebhookEvent } from "@/lib/webhooks/emit" import { emitWebhookEvent } from "@/lib/webhooks/emit"
@@ -64,18 +63,8 @@ export async function POST(request: Request) {
const parsed = tenantSchema.safeParse(body) const parsed = tenantSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
// Enforce per-plan tenant limit (Starter = 3). const limitError = await checkTenantLimit(ownerId)
const plan = await getUserPlan(ownerId) if (limitError) return NextResponse.json({ error: limitError }, { status: 403 })
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(tenants)
.where(eq(tenants.user_id, ownerId))
if (!checkLimit(plan, "maxTenants", count)) {
return NextResponse.json(
{ error: "Plan limit reached. Upgrade to add more tenants." },
{ status: 403 }
)
}
if ( if (
!(await ownsProperty(ownerId, parsed.data.property_id)) || !(await ownsProperty(ownerId, parsed.data.property_id)) ||
+19 -1
View File
@@ -1,8 +1,15 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/session" import { getSessionUser } from "@/lib/session"
import { getAccountContext } from "@/lib/account" import { getAccountContext } from "@/lib/account"
import { saveFile, isAllowedUploadExt, StorageNotConfiguredError } from "@/lib/storage" import {
saveFile,
isAllowedUploadExt,
StorageNotConfiguredError,
contentMatchesExtension,
extOf,
} from "@/lib/storage"
import { checkStorageLimit } from "@/lib/plan-limits" import { checkStorageLimit } from "@/lib/plan-limits"
import { enforceRateLimit } from "@/lib/rate-limit"
const ALLOWED_SCOPES = ["property-images", "maintenance", "documents", "misc"] const ALLOWED_SCOPES = ["property-images", "maintenance", "documents", "misc"]
@@ -18,6 +25,11 @@ export async function POST(request: Request) {
if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 }) if (!ctx.canWrite) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
const ownerId = ctx.ownerId const ownerId = ctx.ownerId
// Storage quota caps total bytes, but not the rate of writes — throttle so a
// single account cannot hammer Spaces (or fill a plan's quota) in one burst.
const limited = enforceRateLimit(`upload:${ownerId}`, 60, 60)
if (limited) return limited
const fd = await request.formData() const fd = await request.formData()
const file = fd.get("file") as File | null const file = fd.get("file") as File | null
const scopeRaw = (fd.get("scope") as string) || "misc" const scopeRaw = (fd.get("scope") as string) || "misc"
@@ -33,6 +45,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "File type not allowed" }, { status: 400 }) return NextResponse.json({ error: "File type not allowed" }, { status: 400 })
} }
// Reject files whose real content doesn't match the claimed extension.
const head = Buffer.from(await file.slice(0, 16).arrayBuffer())
if (!contentMatchesExtension(head, extOf(file.name))) {
return NextResponse.json({ error: "File content does not match its type" }, { status: 400 })
}
// Enforce per-plan storage quota (accounts for everything already stored in // Enforce per-plan storage quota (accounts for everything already stored in
// the owner's portfolio namespace). // the owner's portfolio namespace).
const storageError = await checkStorageLimit(ownerId, file.size) const storageError = await checkStorageLimit(ownerId, file.size)
+5
View File
@@ -6,6 +6,7 @@ import { maintenance_requests } from "@/lib/db/schema"
import { maintenanceSchema } from "@/lib/validations" import { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership" import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth" import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit" import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — update a single maintenance request. Bearer API-key // Public REST API (v1) — update a single maintenance request. Bearer API-key
@@ -26,6 +27,10 @@ const maintenancePatchSchema = maintenanceSchema.partial().extend({
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden() if (!ctx.canWrite) return forbidden()
const { id } = await params const { id } = await params
+9
View File
@@ -5,6 +5,7 @@ import { maintenance_requests } from "@/lib/db/schema"
import { maintenanceSchema } from "@/lib/validations" import { maintenanceSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership" import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth" import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit" import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — maintenance requests. Bearer API-key auth. // Public REST API (v1) — maintenance requests. Bearer API-key auth.
@@ -22,6 +23,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const status = searchParams.get("status") const status = searchParams.get("status")
const priority = searchParams.get("priority") const priority = searchParams.get("priority")
@@ -55,6 +60,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden() if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null) const body = await request.json().catch(() => null)
+9
View File
@@ -5,6 +5,7 @@ import { rent_payments } from "@/lib/db/schema"
import { rentPaymentSchema } from "@/lib/validations" import { rentPaymentSchema } from "@/lib/validations"
import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership" import { ownsProperty, ownsUnit, ownsTenant } from "@/lib/db/ownership"
import { resolveApiRequest } from "@/lib/api-auth" import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { emitWebhookEvent } from "@/lib/webhooks/emit" import { emitWebhookEvent } from "@/lib/webhooks/emit"
// Public REST API (v1) — rent payments. Bearer API-key auth. Maps to the // Public REST API (v1) — rent payments. Bearer API-key auth. Maps to the
@@ -21,6 +22,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const status = searchParams.get("status") const status = searchParams.get("status")
const tenantId = searchParams.get("tenant_id") const tenantId = searchParams.get("tenant_id")
@@ -57,6 +62,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden() if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null) const body = await request.json().catch(() => null)
+18
View File
@@ -4,6 +4,8 @@ import { db } from "@/lib/db"
import { properties } from "@/lib/db/schema" import { properties } from "@/lib/db/schema"
import { propertySchema } from "@/lib/validations" import { propertySchema } from "@/lib/validations"
import { resolveApiRequest } from "@/lib/api-auth" import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { checkPropertyLimit } from "@/lib/plan-limits"
import { emitWebhookEvent } from "@/lib/webhooks/emit" import { emitWebhookEvent } from "@/lib/webhooks/emit"
import { geocodeAddress } from "@/lib/geocoding" import { geocodeAddress } from "@/lib/geocoding"
@@ -19,6 +21,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const data = await db.query.properties.findMany({ const data = await db.query.properties.findMany({
where: eq(properties.user_id, ctx.ownerId), where: eq(properties.user_id, ctx.ownerId),
with: { units: { columns: { id: true, status: true } } }, with: { units: { columns: { id: true, status: true } } },
@@ -31,6 +37,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden() if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null) const body = await request.json().catch(() => null)
@@ -42,6 +52,14 @@ export async function POST(request: Request) {
) )
} }
// Same plan cap the session route enforces — the public API is a creation
// entry point too, and skipping this here let a Starter key create unlimited
// properties.
const limitError = await checkPropertyLimit(ctx.ownerId)
if (limitError) {
return NextResponse.json({ error: { code: 403, message: limitError } }, { status: 403 })
}
const coords = await geocodeAddress(parsed.data) const coords = await geocodeAddress(parsed.data)
const [data] = await db const [data] = await db
+5
View File
@@ -3,6 +3,7 @@ import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { tenants } from "@/lib/db/schema" import { tenants } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth" import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
// Public REST API (v1) — Bearer API-key auth. Scoped by the resolved owner id. // Public REST API (v1) — Bearer API-key auth. Scoped by the resolved owner id.
@@ -13,6 +14,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const propertyId = searchParams.get("property_id") const propertyId = searchParams.get("property_id")
const status = searchParams.get("status") const status = searchParams.get("status")
+13
View File
@@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { webhook_endpoints } from "@/lib/db/schema" import { webhook_endpoints } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth" import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { webhookEndpointSchema } from "@/lib/validations" import { webhookEndpointSchema } from "@/lib/validations"
import { isWebhookEvent } from "@/lib/webhooks/events" import { isWebhookEvent } from "@/lib/webhooks/events"
import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf" import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
@@ -32,6 +33,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const { id } = await params const { id } = await params
const data = await db.query.webhook_endpoints.findFirst({ const data = await db.query.webhook_endpoints.findFirst({
where: and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ctx.ownerId)), where: and(eq(webhook_endpoints.id, id), eq(webhook_endpoints.user_id, ctx.ownerId)),
@@ -44,6 +49,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden() if (!ctx.canWrite) return forbidden()
const { id } = await params const { id } = await params
@@ -93,6 +102,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden() if (!ctx.canWrite) return forbidden()
const { id } = await params const { id } = await params
+9
View File
@@ -3,6 +3,7 @@ import { desc, eq } from "drizzle-orm"
import { db } from "@/lib/db" import { db } from "@/lib/db"
import { webhook_endpoints } from "@/lib/db/schema" import { webhook_endpoints } from "@/lib/db/schema"
import { resolveApiRequest } from "@/lib/api-auth" import { resolveApiRequest } from "@/lib/api-auth"
import { enforceRateLimit } from "@/lib/rate-limit"
import { webhookEndpointSchema } from "@/lib/validations" import { webhookEndpointSchema } from "@/lib/validations"
import { isWebhookEvent } from "@/lib/webhooks/events" import { isWebhookEvent } from "@/lib/webhooks/events"
import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf" import { assertSafeWebhookUrl, WebhookUrlError } from "@/lib/webhooks/ssrf"
@@ -37,6 +38,10 @@ export async function GET(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
const data = await db const data = await db
.select(PUBLIC_COLUMNS) .select(PUBLIC_COLUMNS)
.from(webhook_endpoints) .from(webhook_endpoints)
@@ -49,6 +54,10 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
const ctx = await resolveApiRequest(request) const ctx = await resolveApiRequest(request)
if (!ctx) return unauthorized() if (!ctx) return unauthorized()
// Public API budget: 120 requests/minute per key owner.
const limited = enforceRateLimit(`v1:${ctx.userId}`, 120, 60)
if (limited) return limited
if (!ctx.canWrite) return forbidden() if (!ctx.canWrite) return forbidden()
const body = await request.json().catch(() => null) const body = await request.json().catch(() => null)
+2
View File
@@ -1,5 +1,6 @@
"use client" "use client"
import * as Sentry from "@sentry/nextjs"
import { useEffect } from "react" import { useEffect } from "react"
export default function GlobalError({ export default function GlobalError({
@@ -10,6 +11,7 @@ export default function GlobalError({
reset: () => void reset: () => void
}) { }) {
useEffect(() => { useEffect(() => {
Sentry.captureException(error)
console.error(error) console.error(error)
}, [error]) }, [error])
+2
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google" import { Geist, Geist_Mono } from "next/font/google"
import { Toaster } from "@/components/ui/toaster" import { Toaster } from "@/components/ui/toaster"
import { UmamiAnalytics } from "@/components/analytics/umami" import { UmamiAnalytics } from "@/components/analytics/umami"
import { CookieConsent } from "@/components/shared/cookie-consent"
import "./globals.css" import "./globals.css"
const geistSans = Geist({ const geistSans = Geist({
@@ -70,6 +71,7 @@ export default function RootLayout({
<body suppressHydrationWarning className="min-h-full flex flex-col bg-[#09090b] text-white"> <body suppressHydrationWarning className="min-h-full flex flex-col bg-[#09090b] text-white">
{children} {children}
<Toaster /> <Toaster />
<CookieConsent />
<UmamiAnalytics /> <UmamiAnalytics />
</body> </body>
</html> </html>
+14 -2
View File
@@ -12,11 +12,23 @@ export default function manifest(): MetadataRoute.Manifest {
theme_color: "#09090b", theme_color: "#09090b",
icons: [ icons: [
{ {
src: "/logo-mark.png", src: "/icon-192.png",
type: "image/png", type: "image/png",
sizes: "100x100", sizes: "192x192",
purpose: "any", purpose: "any",
}, },
{
src: "/icon-512.png",
type: "image/png",
sizes: "512x512",
purpose: "any",
},
{
src: "/icon-maskable-512.png",
type: "image/png",
sizes: "512x512",
purpose: "maskable",
},
], ],
} }
} }
+9
View File
@@ -1,5 +1,14 @@
import type { Metadata } from "next"
import Link from "next/link" import Link from "next/link"
// A 404 already returns the right status code, but without its own title it
// would surface the site-wide default title in tabs, share previews and logs.
// Next.js emits its own `noindex` for not-found, so no robots field here —
// adding one only produces a second, redundant <meta name="robots">.
export const metadata: Metadata = {
title: "Page not found",
}
export default function NotFound() { export default function NotFound() {
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center bg-[#09090b] text-white"> <div className="flex min-h-screen flex-col items-center justify-center bg-[#09090b] text-white">
+44
View File
@@ -0,0 +1,44 @@
import type { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots {
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
return {
rules: {
userAgent: "*",
allow: "/",
// Private/app areas. Mirrors PROTECTED_PATHS in proxy.ts, plus the API
// surface and the token-gated tenant portal (both private but enforced
// outside the cookie proxy). `/tenant-portal/` keeps the trailing slash so
// it doesn't also block the indexable `/tenant-portal-info` marketing page.
disallow: [
"/dashboard",
"/admin",
"/api/",
"/settings",
"/onboarding",
"/team",
"/tenant-portal/",
"/calendar",
"/inspections",
"/vendors",
"/reports",
"/activity",
"/ai",
"/ai-dashboard",
"/predictions",
"/recommendations",
"/impact",
"/follow-ups",
"/properties",
"/tenants",
"/rent",
"/maintenance",
"/leases",
"/expenses",
],
},
sitemap: `${base}/sitemap.xml`,
host: base,
}
}
-21
View File
@@ -1,21 +0,0 @@
export async function GET() {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
const body = `User-agent: *
Allow: /
Disallow: /dashboard
Disallow: /properties
Disallow: /tenants
Disallow: /rent
Disallow: /maintenance
Disallow: /leases
Disallow: /expenses
Disallow: /settings
Disallow: /tenant-portal/
Disallow: /api/
Sitemap: ${appUrl}/sitemap.xml`
return new Response(body, {
headers: { "Content-Type": "text/plain" },
})
}
+33 -12
View File
@@ -1,17 +1,38 @@
import type { MetadataRoute } from "next" import type { MetadataRoute } from "next"
import { LEGAL_PAGES } from "@/lib/legal"
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
type ChangeFrequency = MetadataRoute.Sitemap[number]["changeFrequency"]
// Single source of truth for the public, indexable URL surface. Every entry
// must resolve to a real 200 page that is NOT noindex'd. Private/app routes are
// blocked in app/robots.ts, and the /login and /forgot-password auth pages are
// noindex, so all three are intentionally omitted here. /signup is kept as a
// conversion landing page.
const PAGES: { path: string; changeFrequency: ChangeFrequency; priority: number }[] = [
{ path: "/", changeFrequency: "weekly", priority: 1 },
{ path: "/tenant-portal-info", changeFrequency: "monthly", priority: 0.8 },
{ path: "/api-docs", changeFrequency: "monthly", priority: 0.8 },
{ path: "/signup", changeFrequency: "monthly", priority: 0.7 },
// Legal pages are derived from the shared LEGAL_PAGES constant (the same list
// the footer renders) so the sitemap can never drift from the real routes.
...LEGAL_PAGES.map((page) => ({
path: page.href,
changeFrequency: "yearly" as const,
priority: 0.3,
})),
]
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network" // Build-time timestamp, refreshed on every deploy. We don't track per-page
const lastModified = new Date("2026-07-01") // modification dates, so a single honest "last built" date is used throughout.
const lastModified = new Date()
return [ return PAGES.map(({ path, changeFrequency, priority }) => ({
{ url: base, lastModified, changeFrequency: "weekly", priority: 1 }, url: path === "/" ? base : `${base}${path}`,
{ url: `${base}/tenant-portal-info`, lastModified, changeFrequency: "monthly", priority: 0.8 }, lastModified,
{ url: `${base}/api-docs`, lastModified, changeFrequency: "monthly", priority: 0.6 }, changeFrequency,
{ url: `${base}/signup`, lastModified, changeFrequency: "monthly", priority: 0.7 }, priority,
{ url: `${base}/privacy`, lastModified, changeFrequency: "yearly", priority: 0.3 }, }))
{ url: `${base}/terms`, lastModified, changeFrequency: "yearly", priority: 0.3 },
{ url: `${base}/cookie-policy`, lastModified, changeFrequency: "yearly", priority: 0.3 },
{ url: `${base}/gdpr`, lastModified, changeFrequency: "yearly", priority: 0.3 },
]
} }
+6 -1
View File
@@ -5,7 +5,12 @@ import { acceptInvite } from "@/app/actions/team"
import { Logo } from "@/components/shared/logo" import { Logo } from "@/components/shared/logo"
import { XCircle } from "lucide-react" import { XCircle } from "lucide-react"
export const metadata = { title: "Accept Team Invite" } // The URL carries a single-use invite token, so this page is noindex/nofollow
// to keep tokens out of search results.
export const metadata = {
title: "Accept team invite",
robots: { index: false, follow: false },
}
export default async function AcceptInvitePage({ export default async function AcceptInvitePage({
params, params,
+15 -9
View File
@@ -19,15 +19,21 @@ export function PlanDonut({ data }: { data: Record<string, number> }) {
const radius = (size - stroke) / 2 const radius = (size - stroke) / 2
const circumference = 2 * Math.PI * radius const circumference = 2 * Math.PI * radius
// Build cumulative arc segments // Build cumulative arc segments. The running offset is derived per segment
let cumulative = 0 // from the slices before it rather than mutated across the map callback —
const segments = PLAN_META.map((p) => { // reassigning a closed-over local during render is what react-hooks
const value = data[p.key] ?? 0 // /immutability flags, and it misbehaves under re-render.
const fraction = total > 0 ? value / total : 0 const fractions = PLAN_META.map((p) => (total > 0 ? (data[p.key] ?? 0) / total : 0))
const dash = fraction * circumference const segments = PLAN_META.map((p, i) => {
const offset = cumulative * circumference const fraction = fractions[i]
cumulative += fraction const precedingFraction = fractions.slice(0, i).reduce((sum, f) => sum + f, 0)
return { ...p, value, fraction, dash, offset } return {
...p,
value: data[p.key] ?? 0,
fraction,
dash: fraction * circumference,
offset: precedingFraction * circumference,
}
}) })
return ( return (
+119
View File
@@ -0,0 +1,119 @@
"use client"
import { useState, useTransition } from "react"
import { toast } from "sonner"
import { Sparkles, Check, AlertTriangle } from "lucide-react"
import { setAiProviderAction } from "@/app/actions/admin"
type Provider = "openai" | "anthropic"
const LABELS: Record<Provider, string> = { openai: "OpenAI", anthropic: "Anthropic (Claude)" }
export function AiProviderToggle({
selected,
effective,
openaiConfigured,
anthropicConfigured,
openaiModel,
anthropicModel,
}: {
selected: Provider
effective: Provider
openaiConfigured: boolean
anthropicConfigured: boolean
openaiModel: string
anthropicModel: string
}) {
const [current, setCurrent] = useState<Provider>(selected)
const [pending, startTransition] = useTransition()
const configured: Record<Provider, boolean> = { openai: openaiConfigured, anthropic: anthropicConfigured }
const models: Record<Provider, string> = { openai: openaiModel, anthropic: anthropicModel }
function choose(next: Provider) {
if (next === current || pending) return
const prev = current
setCurrent(next)
startTransition(async () => {
try {
await setAiProviderAction(next)
toast.success(`AI provider set to ${LABELS[next]}`)
} catch {
setCurrent(prev) // revert optimistic change
toast.error("Couldn't switch the AI provider. Try again.")
}
})
}
// When the selected provider has no key on the server, AI falls back to the
// other configured provider (see lib/ai/provider). Surface that clearly.
const fallbackActive = effective !== current
const noneConfigured = !openaiConfigured && !anthropicConfigured
const options: Provider[] = ["openai", "anthropic"]
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<Sparkles className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">AI provider</h2>
</div>
<div className="px-5 py-4 space-y-3">
<p className="text-xs text-white/40">
Choose which LLM powers all AI features (assistant, recommendations, predictions, summaries,
receipts). Applies to everyone immediately.
</p>
<div className="grid gap-2 sm:grid-cols-2">
{options.map((p) => {
const active = current === p
return (
<button
key={p}
type="button"
onClick={() => choose(p)}
disabled={pending}
aria-pressed={active}
className={`flex items-start justify-between gap-3 rounded-xl border px-4 py-3 text-left transition disabled:opacity-60 ${
active
? "border-rose-500/40 bg-rose-500/[0.08]"
: "border-white/10 bg-white/[0.02] hover:bg-white/[0.05]"
}`}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white">{LABELS[p]}</span>
{active && <Check className="h-3.5 w-3.5 text-rose-400" />}
</div>
<p className="mt-0.5 font-mono text-[11px] text-white/40 truncate">{models[p]}</p>
<p className="mt-1 text-[11px]">
{configured[p] ? (
<span className="text-emerald-400">API key configured</span>
) : (
<span className="text-amber-400">No API key on server</span>
)}
</p>
</div>
</button>
)
})}
</div>
{noneConfigured ? (
<p className="flex items-start gap-1.5 rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-300/90">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
No AI provider key is set on the server AI features return a 503 until{" "}
<code className="font-mono">OPENAI_API_KEY</code> or <code className="font-mono">ANTHROPIC_API_KEY</code> is configured.
</p>
) : fallbackActive ? (
<p className="flex items-start gap-1.5 rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-300/90">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
{LABELS[current]} has no API key on this server, so AI is temporarily running on{" "}
<span className="font-semibold">{LABELS[effective]}</span>. Add the key to use {LABELS[current]}.
</p>
) : null}
</div>
</div>
)
}
+256
View File
@@ -0,0 +1,256 @@
"use client"
import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Receipt, RotateCcw, ExternalLink } from "lucide-react"
import { refundUserCharge } from "@/app/actions/admin"
import { Select } from "@/components/ui/select"
import { cn } from "@/lib/utils"
export type ChargeRow = {
id: string
amount: number
amountRefunded: number
currency: string
created: number
status: string
refunded: boolean
description: string | null
receiptUrl: string | null
}
const REASON_OPTIONS = [
{ value: "requested_by_customer", label: "Requested by customer" },
{ value: "duplicate", label: "Duplicate charge" },
{ value: "fraudulent", label: "Fraudulent" },
]
function money(cents: number, currency: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(cents / 100)
}
/**
* Recent Stripe charges with a refund control per row.
*
* Partial refunds are entered in DOLLARS and converted to integer cents here;
* the server re-validates the amount against what is actually still refundable
* on the charge, so a stale page cannot over-refund.
*/
export function BillingActions({ userId, charges }: { userId: string; charges: ChargeRow[] }) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [target, setTarget] = useState<ChargeRow | null>(null)
const [amount, setAmount] = useState("")
const [reason, setReason] = useState("requested_by_customer")
function openRefund(c: ChargeRow) {
setTarget(c)
// Default to the full remaining amount, which is the common case.
setAmount(((c.amount - c.amountRefunded) / 100).toFixed(2))
setReason("requested_by_customer")
}
function submitRefund() {
if (!target) return
const remaining = target.amount - target.amountRefunded
const parsed = Math.round(parseFloat(amount) * 100)
if (!Number.isFinite(parsed) || parsed <= 0) {
toast.error("Enter a refund amount greater than zero.")
return
}
if (parsed > remaining) {
toast.error(`Only ${money(remaining, target.currency)} is still refundable on that charge.`)
return
}
// A full refund sends no amount so Stripe refunds the exact remainder —
// avoids a rounding mismatch on odd amounts.
const amountCents = parsed === remaining ? undefined : parsed
startTransition(async () => {
try {
const res = await refundUserCharge(
userId,
target.id,
amountCents,
reason as "duplicate" | "fraudulent" | "requested_by_customer"
)
if (res.ok === false) {
toast.error(res.error ?? "Refund failed")
return
}
toast.success(res.detail ?? "Refund issued")
setTarget(null)
router.refresh()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Refund failed")
}
})
}
if (!charges.length) {
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white">
<Receipt className="h-4 w-4 text-emerald-400" />
<h2 className="text-sm font-semibold">Payments</h2>
</div>
<p className="mt-3 text-xs text-white/40">
No Stripe charges found for this user.
</p>
</div>
)
}
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white">
<Receipt className="h-4 w-4 text-emerald-400" />
<h2 className="text-sm font-semibold">Payments</h2>
</div>
<p className="mt-1 text-xs text-white/40">Most recent charges from Stripe.</p>
<div className="mt-4 overflow-x-auto">
<table className="w-full min-w-[520px] text-left text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-xs text-white/40">
<th className="pb-2 font-medium">Date</th>
<th className="pb-2 font-medium">Amount</th>
<th className="pb-2 font-medium">Status</th>
<th className="pb-2 text-right font-medium">Action</th>
</tr>
</thead>
<tbody>
{charges.map((c) => {
const remaining = c.amount - c.amountRefunded
const fullyRefunded = c.refunded || remaining <= 0
return (
<tr key={c.id} className="border-b border-white/[0.04] last:border-0">
<td className="py-3 text-white/70">
{new Date(c.created * 1000).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</td>
<td className="py-3 text-white">
{money(c.amount, c.currency)}
{c.amountRefunded > 0 && (
<span className="ml-1.5 text-xs text-amber-300/80">
{money(c.amountRefunded, c.currency)} refunded
</span>
)}
</td>
<td className="py-3">
<span
className={cn(
"rounded-md px-1.5 py-0.5 text-xs font-medium",
fullyRefunded
? "bg-amber-500/10 text-amber-300"
: c.status === "succeeded"
? "bg-emerald-500/10 text-emerald-300"
: "bg-white/[0.06] text-white/50"
)}
>
{fullyRefunded ? "refunded" : c.status}
</span>
</td>
<td className="py-3 text-right">
<div className="inline-flex items-center gap-2">
{c.receiptUrl && (
<a
href={c.receiptUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-2 py-1 text-xs text-white/50 transition hover:text-white"
>
Receipt <ExternalLink className="h-3 w-3" />
</a>
)}
<button
onClick={() => openRefund(c)}
disabled={isPending || fullyRefunded || c.status !== "succeeded"}
className="inline-flex items-center gap-1 rounded-lg border border-red-500/25 bg-red-500/10 px-2 py-1 text-xs font-medium text-red-300 transition hover:bg-red-500/20 disabled:cursor-not-allowed disabled:opacity-40"
>
<RotateCcw className="h-3 w-3" /> Refund
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Refund modal — amount + reason + confirm in ONE step, so the amount
field is never hidden behind a confirmation dialog. */}
{target && (
<div className="fixed inset-0 z-[300] flex items-center justify-center px-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => !isPending && setTarget(null)}
/>
<div className="relative w-full max-w-sm overflow-hidden rounded-2xl border border-white/[0.08] bg-[#16161f] p-6 shadow-2xl shadow-black/80">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border border-red-500/20 bg-red-500/10 text-red-400">
<RotateCcw className="h-6 w-6" />
</div>
<h2 className="text-center text-base font-bold text-white">Refund payment</h2>
<p className="mt-2 text-center text-sm text-white/50">
{money(target.amount, target.currency)} charged on{" "}
{new Date(target.created * 1000).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
. Up to {money(target.amount - target.amountRefunded, target.currency)} can be
refunded. This cannot be undone from here.
</p>
<div className="mt-4 space-y-3">
<div>
<label className="mb-1.5 block text-xs font-medium text-white/40">
Amount ({target.currency.toUpperCase()})
</label>
<input
value={amount}
onChange={(e) => setAmount(e.target.value)}
inputMode="decimal"
autoFocus
className="w-full rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-2.5 text-sm text-white outline-none transition focus:border-red-500/50 focus:ring-1 focus:ring-red-500/30"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-white/40">Reason</label>
<Select value={reason} onChange={setReason} options={REASON_OPTIONS} />
</div>
</div>
<div className="mt-6 flex gap-3">
<button
onClick={() => setTarget(null)}
disabled={isPending}
className="flex-1 rounded-xl border border-white/10 py-2.5 text-sm font-medium text-white/50 transition hover:border-white/20 hover:text-white disabled:opacity-50"
>
Cancel
</button>
<button
onClick={submitRefund}
disabled={isPending}
className="flex-1 rounded-xl bg-red-600 py-2.5 text-sm font-semibold text-white transition hover:bg-red-500 disabled:opacity-50"
>
{isPending ? "Refunding…" : "Issue refund"}
</button>
</div>
</div>
</div>
)}
</div>
)
}
+308 -65
View File
@@ -3,7 +3,17 @@
import { useState, useTransition } from "react" import { useState, useTransition } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
import { Ban, ShieldCheck, UserCog, MailCheck, Trash2, Crown } from "lucide-react" import {
Ban,
ShieldCheck,
UserCog,
MailCheck,
Trash2,
Crown,
CreditCard,
PlayCircle,
ShieldAlert,
} from "lucide-react"
import { import {
changeUserPlan, changeUserPlan,
banUser, banUser,
@@ -11,17 +21,31 @@ import {
impersonateUser, impersonateUser,
markEmailVerified, markEmailVerified,
deleteUser, deleteUser,
cancelUserSubscription,
resumeUserSubscription,
setUserRole,
} from "@/app/actions/admin" } from "@/app/actions/admin"
import { Select } from "@/components/ui/select" import { Select } from "@/components/ui/select"
import { ConfirmModal } from "@/components/ui/confirm-modal" import { ConfirmModal } from "@/components/ui/confirm-modal"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export type SubscriptionInfo = {
id: string
status: string
cancelAtPeriodEnd: boolean
currentPeriodEnd: number | null
amount: number | null
interval: string | null
} | null
interface UserActionsProps { interface UserActionsProps {
userId: string userId: string
email: string email: string
currentPlan: string currentPlan: string
banned: boolean banned: boolean
isSelf: boolean isSelf: boolean
isAdminRole: boolean
subscription: SubscriptionInfo
} }
const PLAN_OPTIONS = [ const PLAN_OPTIONS = [
@@ -31,25 +55,60 @@ const PLAN_OPTIONS = [
{ value: "lifetime", label: "Lifetime" }, { value: "lifetime", label: "Lifetime" },
] ]
// "comp" writes the entitlement only; "stripe" moves real billing. Keeping these
// as an explicit choice is the whole point — see changeUserPlan in
// app/actions/admin.ts.
const MODE_OPTIONS = [
{ value: "comp", label: "Comp — entitlement only, no billing change" },
{ value: "stripe", label: "Sync to Stripe — charges/credits the customer" },
]
const INTERVAL_OPTIONS = [
{ value: "month", label: "Monthly" },
{ value: "year", label: "Yearly" },
]
function errMessage(e: unknown) { function errMessage(e: unknown) {
return e instanceof Error ? e.message : "Something went wrong" return e instanceof Error ? e.message : "Something went wrong"
} }
export function UserActions({ userId, email, currentPlan, banned, isSelf }: UserActionsProps) { /** Server actions here return either a thrown Error or `{ ok: false, error }`. */
type ActionResult = { ok?: boolean; error?: string; detail?: string } | void | unknown
export function UserActions({
userId,
email,
currentPlan,
banned,
isSelf,
isAdminRole,
subscription,
}: UserActionsProps) {
const router = useRouter() const router = useRouter()
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [plan, setPlan] = useState(currentPlan) const [plan, setPlan] = useState(currentPlan)
const [planMode, setPlanMode] = useState<"comp" | "stripe">("comp")
const [interval, setInterval] = useState<"month" | "year">("month")
const [showBan, setShowBan] = useState(false) const [showBan, setShowBan] = useState(false)
const [banReason, setBanReason] = useState("") const [banReason, setBanReason] = useState("")
const [showImpersonate, setShowImpersonate] = useState(false) const [showImpersonate, setShowImpersonate] = useState(false)
const [showDelete, setShowDelete] = useState(false) const [showDelete, setShowDelete] = useState(false)
const [showStripePlan, setShowStripePlan] = useState(false)
const [showCancelNow, setShowCancelNow] = useState(false)
const [showRole, setShowRole] = useState(false)
// Run a server action inside a transition; toast on success/error, then refresh. // Run a server action inside a transition. Handles BOTH failure shapes: a
function run(fn: () => Promise<unknown>, successMsg: string, after?: () => void) { // thrown Error (guard/validation) and a returned { ok: false, error } (an
// expected Stripe condition, e.g. "no subscription to modify").
function run(fn: () => Promise<ActionResult>, fallbackMsg: string, after?: () => void) {
startTransition(async () => { startTransition(async () => {
try { try {
await fn() const res = (await fn()) as { ok?: boolean; error?: string; detail?: string } | undefined
toast.success(successMsg) if (res && res.ok === false) {
toast.error(res.error ?? "Action failed")
return
}
toast.success(res?.detail ?? fallbackMsg)
after?.() after?.()
router.refresh() router.refresh()
} catch (e) { } catch (e) {
@@ -58,67 +117,71 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
}) })
} }
function onApplyPlan() { function applyPlan(mode: "comp" | "stripe") {
if (plan === currentPlan) { if (plan === currentPlan && mode === "comp") {
toast.message("Plan unchanged") toast.message("Plan unchanged")
return return
} }
run(() => changeUserPlan(userId, plan), "Plan updated") run(
() => changeUserPlan(userId, plan, mode, interval),
mode === "stripe" ? "Stripe subscription updated" : "Plan comped",
() => setShowStripePlan(false)
)
} }
function onBan() { function onApplyPlan() {
run(() => banUser(userId, banReason.trim() || undefined), "User banned", () => { // Moving real money always gets a confirmation step.
setShowBan(false) if (planMode === "stripe") {
setBanReason("") setShowStripePlan(true)
}) return
} }
applyPlan("comp")
function onUnban() {
run(() => unbanUser(userId), "User unbanned")
}
function onImpersonate() {
// impersonateUser redirects to /dashboard on success — no toast needed.
startTransition(async () => {
try {
await impersonateUser(userId)
} catch (e) {
toast.error(errMessage(e))
setShowImpersonate(false)
}
})
}
function onVerify() {
run(() => markEmailVerified(userId), "Email marked as verified")
}
function onDelete() {
// deleteUser redirects to /admin/users on success.
startTransition(async () => {
try {
await deleteUser(userId)
} catch (e) {
toast.error(errMessage(e))
setShowDelete(false)
}
})
} }
const btnBase = const btnBase =
"w-full inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50" "w-full inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50"
const btnGhost =
"border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:text-white"
const periodEnd = subscription?.currentPeriodEnd
? new Date(subscription.currentPeriodEnd * 1000).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
: null
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Plan */} {/* ── Plan ───────────────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5"> <div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white"> <div className="flex items-center gap-2 text-white">
<Crown className="h-4 w-4 text-amber-400" /> <Crown className="h-4 w-4 text-amber-400" />
<h3 className="text-sm font-semibold">Change plan</h3> <h3 className="text-sm font-semibold">Change plan</h3>
</div> </div>
<p className="mt-1 text-xs text-white/40">Override the user&apos;s subscription tier.</p> <p className="mt-1 text-xs text-white/40">
A comp grants access without touching billing. Syncing to Stripe changes what the
customer actually pays.
</p>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<Select value={plan} onChange={setPlan} options={PLAN_OPTIONS} /> <Select value={plan} onChange={setPlan} options={PLAN_OPTIONS} />
<Select
value={planMode}
onChange={(v) => setPlanMode(v as "comp" | "stripe")}
options={MODE_OPTIONS}
/>
{planMode === "stripe" && (
<Select
value={interval}
onChange={(v) => setInterval(v as "month" | "year")}
options={INTERVAL_OPTIONS}
/>
)}
{planMode === "stripe" && (
<p className="rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-xs text-amber-300/90">
This charges or credits the customer immediately via proration.
</p>
)}
<button <button
onClick={onApplyPlan} onClick={onApplyPlan}
disabled={isPending} disabled={isPending}
@@ -129,19 +192,125 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
</div> </div>
</div> </div>
{/* Account actions */} {/* ── Subscription ───────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white">
<CreditCard className="h-4 w-4 text-sky-400" />
<h3 className="text-sm font-semibold">Subscription</h3>
</div>
{subscription ? (
<>
<div className="mt-3 space-y-1 text-xs text-white/50">
<div>
Status:{" "}
<span
className={cn(
"font-medium",
subscription.status === "active" ? "text-emerald-300" : "text-amber-300"
)}
>
{subscription.status}
</span>
</div>
{subscription.amount !== null && (
<div>
${(subscription.amount / 100).toFixed(2)}
{subscription.interval ? ` / ${subscription.interval}` : ""}
</div>
)}
{periodEnd && (
<div>
{subscription.cancelAtPeriodEnd ? "Cancels" : "Renews"} {periodEnd}
</div>
)}
</div>
<div className="mt-4 space-y-2.5">
{subscription.cancelAtPeriodEnd ? (
<button
onClick={() => run(() => resumeUserSubscription(userId), "Subscription resumed")}
disabled={isPending}
className={cn(
btnBase,
"border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20"
)}
>
<PlayCircle className="h-4 w-4" /> Resume subscription
</button>
) : (
<button
onClick={() =>
run(() => cancelUserSubscription(userId, false), "Cancellation scheduled")
}
disabled={isPending}
className={cn(btnBase, btnGhost)}
>
Cancel at period end
</button>
)}
<button
onClick={() => setShowCancelNow(true)}
disabled={isPending}
className={cn(
btnBase,
"border border-red-500/30 bg-red-500/10 text-red-300 hover:bg-red-500/20"
)}
>
Cancel immediately
</button>
</div>
</>
) : (
<p className="mt-3 text-xs text-white/40">
No active Stripe subscription. Plan changes for this user must be comps.
</p>
)}
</div>
{/* ── Admin role ─────────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white">
<ShieldAlert className="h-4 w-4 text-violet-400" />
<h3 className="text-sm font-semibold">Admin access</h3>
</div>
<p className="mt-1 text-xs text-white/40">
{isAdminRole
? "This user has full admin access to the platform."
: "Grant full access to the admin dashboard and every account."}
</p>
<button
onClick={() => setShowRole(true)}
disabled={isPending || isSelf}
title={isSelf ? "You cannot change your own role" : undefined}
className={cn(
btnBase,
"mt-4",
isAdminRole
? "border border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
: "border border-violet-500/30 bg-violet-500/10 text-violet-300 hover:bg-violet-500/20"
)}
>
<ShieldAlert className="h-4 w-4" />
{isAdminRole ? "Revoke admin access" : "Make admin"}
</button>
</div>
{/* ── Account actions ────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5"> <div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center gap-2 text-white"> <div className="flex items-center gap-2 text-white">
<UserCog className="h-4 w-4 text-rose-400" /> <UserCog className="h-4 w-4 text-rose-400" />
<h3 className="text-sm font-semibold">Account</h3> <h3 className="text-sm font-semibold">Account</h3>
</div> </div>
<div className="mt-4 space-y-2.5"> <div className="mt-4 space-y-2.5">
{/* Ban / Unban */}
{banned ? ( {banned ? (
<button <button
onClick={onUnban} onClick={() => run(() => unbanUser(userId), "User unbanned")}
disabled={isPending} disabled={isPending}
className={cn(btnBase, "border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20")} className={cn(
btnBase,
"border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20"
)}
> >
<ShieldCheck className="h-4 w-4" /> Unban user <ShieldCheck className="h-4 w-4" /> Unban user
</button> </button>
@@ -150,34 +319,35 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
onClick={() => setShowBan(true)} onClick={() => setShowBan(true)}
disabled={isPending || isSelf} disabled={isPending || isSelf}
title={isSelf ? "You cannot ban yourself" : undefined} title={isSelf ? "You cannot ban yourself" : undefined}
className={cn(btnBase, "border border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20")} className={cn(
btnBase,
"border border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
)}
> >
<Ban className="h-4 w-4" /> Ban user <Ban className="h-4 w-4" /> Ban user
</button> </button>
)} )}
{/* Impersonate */}
<button <button
onClick={() => setShowImpersonate(true)} onClick={() => setShowImpersonate(true)}
disabled={isPending || isSelf} disabled={isPending || isSelf}
title={isSelf ? "You cannot impersonate yourself" : undefined} title={isSelf ? "You cannot impersonate yourself" : undefined}
className={cn(btnBase, "border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:text-white")} className={cn(btnBase, btnGhost)}
> >
<UserCog className="h-4 w-4" /> Impersonate <UserCog className="h-4 w-4" /> Impersonate
</button> </button>
{/* Verify email */}
<button <button
onClick={onVerify} onClick={() => run(() => markEmailVerified(userId), "Email marked as verified")}
disabled={isPending} disabled={isPending}
className={cn(btnBase, "border border-white/10 bg-white/[0.03] text-white/70 hover:bg-white/[0.06] hover:text-white")} className={cn(btnBase, btnGhost)}
> >
<MailCheck className="h-4 w-4" /> Mark email verified <MailCheck className="h-4 w-4" /> Mark email verified
</button> </button>
</div> </div>
</div> </div>
{/* Danger zone */} {/* ── Danger zone ────────────────────────────────────────────────────── */}
<div className="rounded-2xl border border-red-500/20 bg-red-500/[0.03] p-5"> <div className="rounded-2xl border border-red-500/20 bg-red-500/[0.03] p-5">
<div className="flex items-center gap-2 text-red-400"> <div className="flex items-center gap-2 text-red-400">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@@ -199,7 +369,10 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
{/* Ban modal (with reason input) */} {/* Ban modal (with reason input) */}
{showBan && ( {showBan && (
<div className="fixed inset-0 z-[300] flex items-center justify-center px-4"> <div className="fixed inset-0 z-[300] flex items-center justify-center px-4">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => !isPending && setShowBan(false)} /> <div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => !isPending && setShowBan(false)}
/>
<div className="relative w-full max-w-sm overflow-hidden rounded-2xl border border-white/[0.08] bg-[#16161f] p-6 shadow-2xl shadow-black/80"> <div className="relative w-full max-w-sm overflow-hidden rounded-2xl border border-white/[0.08] bg-[#16161f] p-6 shadow-2xl shadow-black/80">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border border-amber-500/20 bg-amber-500/10 text-amber-400"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl border border-amber-500/20 bg-amber-500/10 text-amber-400">
<Ban className="h-6 w-6" /> <Ban className="h-6 w-6" />
@@ -209,7 +382,9 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
The user will be signed out and blocked from signing in until unbanned. The user will be signed out and blocked from signing in until unbanned.
</p> </p>
<div className="mt-4"> <div className="mt-4">
<label className="mb-1.5 block text-xs font-medium text-white/40">Reason (optional)</label> <label className="mb-1.5 block text-xs font-medium text-white/40">
Reason (optional)
</label>
<input <input
value={banReason} value={banReason}
onChange={(e) => setBanReason(e.target.value)} onChange={(e) => setBanReason(e.target.value)}
@@ -226,7 +401,12 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
Cancel Cancel
</button> </button>
<button <button
onClick={onBan} onClick={() =>
run(() => banUser(userId, banReason.trim() || undefined), "User banned", () => {
setShowBan(false)
setBanReason("")
})
}
disabled={isPending} disabled={isPending}
className="flex-1 rounded-xl bg-amber-600 py-2.5 text-sm font-semibold text-white transition hover:bg-amber-500 disabled:opacity-50" className="flex-1 rounded-xl bg-amber-600 py-2.5 text-sm font-semibold text-white transition hover:bg-amber-500 disabled:opacity-50"
> >
@@ -237,7 +417,53 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
</div> </div>
)} )}
{/* Impersonate confirm */} <ConfirmModal
open={showStripePlan}
variant="warning"
title={`Move ${email} to ${plan} in Stripe?`}
description="This updates their live Stripe subscription with proration — the customer will be charged or credited the difference immediately. Choose the Comp option instead to grant access without billing them."
confirmLabel="Update billing"
loading={isPending}
onConfirm={() => applyPlan("stripe")}
onCancel={() => setShowStripePlan(false)}
/>
<ConfirmModal
open={showCancelNow}
variant="danger"
title="Cancel immediately?"
description="Access ends right now and the plan resets to Starter. No refund is issued automatically — refund the charge separately if that's intended. To let them keep what they paid for, cancel at period end instead."
confirmLabel="Cancel now"
loading={isPending}
onConfirm={() =>
run(() => cancelUserSubscription(userId, true), "Subscription canceled", () =>
setShowCancelNow(false)
)
}
onCancel={() => setShowCancelNow(false)}
/>
<ConfirmModal
open={showRole}
variant={isAdminRole ? "warning" : "danger"}
title={isAdminRole ? `Revoke admin from ${email}?` : `Make ${email} an admin?`}
description={
isAdminRole
? "They will lose access to the admin dashboard immediately."
: "They will gain full access to every account on the platform, including billing actions and user deletion. Grant this only to staff you trust completely."
}
confirmLabel={isAdminRole ? "Revoke access" : "Make admin"}
loading={isPending}
onConfirm={() =>
run(
() => setUserRole(userId, isAdminRole ? "user" : "admin"),
isAdminRole ? "Admin access revoked" : "User promoted to admin",
() => setShowRole(false)
)
}
onCancel={() => setShowRole(false)}
/>
<ConfirmModal <ConfirmModal
open={showImpersonate} open={showImpersonate}
variant="warning" variant="warning"
@@ -245,11 +471,19 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
description="You will be signed in as this user and redirected to their dashboard. Your admin session can be restored from the impersonation banner." description="You will be signed in as this user and redirected to their dashboard. Your admin session can be restored from the impersonation banner."
confirmLabel="Impersonate" confirmLabel="Impersonate"
loading={isPending} loading={isPending}
onConfirm={onImpersonate} onConfirm={() => {
startTransition(async () => {
try {
await impersonateUser(userId)
} catch (e) {
toast.error(errMessage(e))
setShowImpersonate(false)
}
})
}}
onCancel={() => setShowImpersonate(false)} onCancel={() => setShowImpersonate(false)}
/> />
{/* Delete confirm */}
<ConfirmModal <ConfirmModal
open={showDelete} open={showDelete}
variant="danger" variant="danger"
@@ -257,7 +491,16 @@ export function UserActions({ userId, email, currentPlan, banned, isSelf }: User
description="This permanently deletes the user and ALL their data — properties, units, tenants, leases, payments and more. This action cannot be undone." description="This permanently deletes the user and ALL their data — properties, units, tenants, leases, payments and more. This action cannot be undone."
confirmLabel="Delete user" confirmLabel="Delete user"
loading={isPending} loading={isPending}
onConfirm={onDelete} onConfirm={() => {
startTransition(async () => {
try {
await deleteUser(userId)
} catch (e) {
toast.error(errMessage(e))
setShowDelete(false)
}
})
}}
onCancel={() => setShowDelete(false)} onCancel={() => setShowDelete(false)}
/> />
</div> </div>
+268
View File
@@ -0,0 +1,268 @@
"use client"
import { useEffect, useState, useTransition } from "react"
import { toast } from "sonner"
import {
PenLine,
Link2,
CheckCircle2,
AlertTriangle,
KeyRound,
ChevronDown,
ExternalLink,
Loader2,
} from "lucide-react"
import { connectDropboxSign, disconnectEsignAction } from "@/app/actions/esign"
type Adapter = { id: string; label: string; kind: "oauth" | "apikey"; available: boolean }
type Conn = { provider: string; accountName: string | null; status: string; lastError: string | null }
const ERR_MSG: Record<string, string> = {
connect_failed: "Connection failed — please try again.",
invalid_state: "The connection link expired or was invalid. Please retry.",
owner_only: "Only the account owner can manage integrations.",
not_configured: "E-signature isn't enabled on this server yet.",
unknown_provider: "Unknown provider.",
use_api_key: "That provider connects with an API key, not a redirect.",
}
const LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_sign: "Dropbox Sign" }
function StatusPill({ status }: { status: string }) {
const error = status === "error"
return (
<span
className={`flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium ${
error ? "border-red-500/20 bg-red-500/10 text-red-400" : "border-emerald-500/20 bg-emerald-500/10 text-emerald-400"
}`}
>
{error ? <AlertTriangle className="h-3 w-3" /> : <CheckCircle2 className="h-3 w-3" />}
{error ? "Error" : "Connected"}
</span>
)
}
export function EsignIntegrations({
adapters,
connections,
isOwner,
flash,
webhookUrl,
}: {
adapters: Adapter[]
connections: Conn[]
isOwner: boolean
flash: { connected?: string; error?: string }
webhookUrl: string
}) {
const connByProvider: Record<string, Conn> = Object.fromEntries(connections.map((c) => [c.provider, c]))
const [pending, start] = useTransition()
const [busy, setBusy] = useState<string | null>(null)
const [open, setOpen] = useState<string | null>(null) // which provider's instructions are expanded
const [apiKey, setApiKey] = useState("")
const [showKeyForm, setShowKeyForm] = useState(false)
useEffect(() => {
if (flash.connected) toast.success(`Connected to ${LABEL[flash.connected] ?? flash.connected}`)
if (flash.error) toast.error(ERR_MSG[flash.error] ?? "Something went wrong")
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function connectDbx() {
const key = apiKey.trim()
if (!key) return
setBusy("dropbox_sign")
start(async () => {
try {
const r = await connectDropboxSign(key)
toast.success(`Connected ${r.accountName ?? "Dropbox Sign"}`)
setApiKey("")
setShowKeyForm(false)
} catch (e) {
toast.error((e as Error).message || "Couldn't connect")
} finally {
setBusy(null)
}
})
}
function remove(id: string) {
if (!confirm(`Disconnect ${LABEL[id] ?? id}? You won't be able to send leases through it until you reconnect.`)) return
setBusy(id)
start(async () => {
try {
await disconnectEsignAction(id)
toast.success("Disconnected")
} catch {
toast.error("Couldn't disconnect")
} finally {
setBusy(null)
}
})
}
if (!isOwner) {
return (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-6 text-sm text-white/50">
Only the account owner can connect e-signature providers.
</div>
)
}
return (
<div className="space-y-3">
{/* Intro / how it works */}
<div className="rounded-2xl border border-indigo-500/15 bg-indigo-500/[0.04] p-5">
<div className="flex items-center gap-2">
<PenLine className="h-4 w-4 text-indigo-400" />
<h3 className="text-sm font-semibold text-white">Send leases for e-signature</h3>
</div>
<p className="mt-1.5 text-xs leading-relaxed text-white/50">
Connect <span className="text-white/80">your own</span> DocuSign or Dropbox Sign account so signed leases carry
your brand and audit trail and the signing costs stay on your provider plan, not ours. Once connected, a
<span className="text-white/80"> Send for signature </span> button appears on every lease that has a document
and a tenant email.
</p>
<ol className="mt-3 space-y-1.5 text-xs text-white/50">
<li>1. Connect your provider below (one-time).</li>
<li>2. Open a lease upload the lease PDF.</li>
<li>3. Click Send via DocuSign / Dropbox Sign. The tenant signs; the status updates here automatically and the signed copy is saved back to the lease.</li>
</ol>
</div>
{adapters.map((a) => {
const conn = connByProvider[a.id]
const isBusy = pending && busy === a.id
const instructionsOpen = open === a.id
return (
<div key={a.id} className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/[0.04] text-white/70">
<PenLine className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold text-white">{a.label}</p>
{conn ? (
<p className="text-xs text-white/40">{conn.accountName ?? "Connected"}</p>
) : (
<p className="text-xs text-white/40">
{a.kind === "oauth" ? "Connect with your DocuSign login" : "Connect with your API key"}
</p>
)}
</div>
</div>
{conn ? (
<StatusPill status={conn.status} />
) : !a.available ? (
<span className="shrink-0 rounded-full border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] font-medium text-amber-400">
Not available
</span>
) : null}
</div>
{conn?.status === "error" && conn.lastError && (
<p className="mt-3 rounded-lg border border-red-500/15 bg-red-500/[0.06] px-3 py-2 text-xs text-red-300/90">{conn.lastError}</p>
)}
{/* Actions */}
<div className="mt-4 flex flex-wrap items-center gap-2">
{conn ? (
<button
onClick={() => remove(a.id)}
disabled={isBusy}
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60 transition hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
>
{isBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Disconnect"}
</button>
) : !a.available ? (
<p className="text-xs text-white/30">
Ask your administrator to enable {a.label} (server credentials aren&apos;t configured).
</p>
) : a.kind === "oauth" ? (
<a
href={`/api/esign/${a.id}/connect`}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500"
>
<Link2 className="h-3.5 w-3.5" /> Connect {a.label}
</a>
) : showKeyForm ? (
<div className="flex w-full flex-col gap-2 sm:flex-row">
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Paste your Dropbox Sign API key"
className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-white placeholder-white/30 outline-none focus:border-indigo-500/50"
/>
<button
onClick={connectDbx}
disabled={isBusy || !apiKey.trim()}
className="flex items-center justify-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50"
>
{isBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <KeyRound className="h-3.5 w-3.5" />} Connect
</button>
</div>
) : (
<button
onClick={() => setShowKeyForm(true)}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500"
>
<KeyRound className="h-3.5 w-3.5" /> Connect {a.label}
</button>
)}
{a.available && (
<button
onClick={() => setOpen(instructionsOpen ? null : a.id)}
className="ml-auto flex items-center gap-1 text-[11px] text-white/40 transition hover:text-white/70"
>
How to connect <ChevronDown className={`h-3 w-3 transition ${instructionsOpen ? "rotate-180" : ""}`} />
</button>
)}
</div>
{/* Instructions */}
{instructionsOpen && (
<div className="mt-3 rounded-lg border border-white/[0.06] bg-white/[0.02] p-4 text-xs leading-relaxed text-white/55">
{a.id === "docusign" ? (
<ol className="space-y-1.5">
<li>
1. You need an active{" "}
<a href="https://www.docusign.com/products/electronic-signature" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 text-indigo-400 hover:text-indigo-300">
DocuSign eSignature plan <ExternalLink className="h-3 w-3" />
</a>.
</li>
<li>2. Click <span className="text-white/80">Connect DocuSign</span> above.</li>
<li>3. Log in to <span className="text-white/80">your</span> DocuSign account and click <span className="text-white/80">Allow</span> to grant access.</li>
<li>4. You&apos;ll return here connected no webhook setup needed. Signed-status updates and the completed PDF flow back automatically.</li>
</ol>
) : (
<ol className="space-y-1.5">
<li>
1. In Dropbox Sign, open{" "}
<a href="https://app.hellosign.com/account/settings/api" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 text-indigo-400 hover:text-indigo-300">
Settings API <ExternalLink className="h-3 w-3" />
</a>{" "}
and copy your <span className="text-white/80">API key</span>.
</li>
<li>2. Paste it above and click <span className="text-white/80">Connect</span>.</li>
<li>
3. In the same API settings, set your <span className="text-white/80">account callback URL</span> to:
<code className="mt-1 block overflow-x-auto rounded bg-black/30 px-2 py-1 font-mono text-[11px] text-emerald-300">{webhookUrl}</code>
This lets us receive signed-status updates.
</li>
</ol>
)}
</div>
)}
</div>
)
})}
<p className="px-1 text-[11px] text-white/30">
Your credentials are encrypted at rest and never leave the server. We only send the leases you explicitly submit.
</p>
</div>
)
}
+1
View File
@@ -16,6 +16,7 @@ const pageTitles: Record<string, string> = {
"/expenses": "Expenses", "/expenses": "Expenses",
"/settings/profile": "Settings", "/settings/profile": "Settings",
"/settings/billing": "Billing", "/settings/billing": "Billing",
"/settings/privacy": "Privacy & Data",
"/settings/demo": "Demo Data", "/settings/demo": "Demo Data",
"/ai": "AI Assistant", "/ai": "AI Assistant",
"/reports": "Reports", "/reports": "Reports",
+210
View File
@@ -0,0 +1,210 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Download, Loader2, ShieldCheck, Trash2, TriangleAlert } from "lucide-react"
import { requestAccountDeletion, cancelAccountDeletion } from "@/app/actions/gdpr"
const inputClass =
"w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
function formatDate(value: string): string {
const d = new Date(value)
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })
}
function daysUntil(value: string): number {
return Math.max(0, Math.ceil((new Date(value).getTime() - Date.now()) / 86_400_000))
}
export function PrivacyManager({
accountEmail,
graceDays,
pendingDeletion,
}: {
accountEmail: string
graceDays: number
pendingDeletion: { scheduled_for: string; created_at: string } | null
}) {
const router = useRouter()
const [confirmOpen, setConfirmOpen] = useState(false)
const [confirmEmail, setConfirmEmail] = useState("")
const [reason, setReason] = useState("")
const [busy, setBusy] = useState(false)
async function handleRequestDeletion(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setBusy(true)
try {
await requestAccountDeletion({ confirmEmail, reason })
toast.success("Account deletion scheduled. Check your email for confirmation.")
setConfirmOpen(false)
setConfirmEmail("")
setReason("")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to schedule deletion")
} finally {
setBusy(false)
}
}
async function handleCancelDeletion() {
setBusy(true)
try {
await cancelAccountDeletion()
toast.success("Deletion cancelled — your account is safe.")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to cancel deletion")
} finally {
setBusy(false)
}
}
return (
<div className="space-y-6">
{/* ── Export ────────────────────────────────────────────────────────── */}
<div className="rounded-xl border border-white/10 bg-[#111118] p-6">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-indigo-500/10 p-2">
<ShieldCheck className="h-5 w-5 text-indigo-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">Export your data</p>
<p className="mt-0.5 text-xs text-white/40">
Download a machine-readable JSON file with everything we store about you and your
portfolio profile, properties, tenants, payments, documents metadata, activity, and
consent history. Passwords and connected-service credentials are never included.
</p>
<a
href="/api/gdpr/export"
className="mt-3 inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3.5 py-2 text-xs font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98]"
>
<Download className="h-3.5 w-3.5" />
Download my data
</a>
</div>
</div>
</div>
{/* ── Delete account ───────────────────────────────────────────────── */}
<div className="rounded-xl border border-red-500/25 bg-red-500/[0.04] p-6">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-red-500/10 p-2">
<Trash2 className="h-5 w-5 text-red-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-white">Delete your account</p>
{pendingDeletion ? (
<>
<div className="mt-3 rounded-lg border border-red-500/25 bg-red-500/10 px-4 py-3">
<p className="flex items-center gap-2 text-sm font-semibold text-red-300">
<TriangleAlert className="h-4 w-4 shrink-0" />
Deletion scheduled for {formatDate(pendingDeletion.scheduled_for)}
</p>
<p className="mt-1 text-xs text-red-200/70">
{daysUntil(pendingDeletion.scheduled_for)} days left. Your account stays fully
usable until then. After that date, all data and files are permanently erased.
</p>
</div>
<button
type="button"
onClick={handleCancelDeletion}
disabled={busy}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 px-3.5 py-2 text-xs font-semibold text-white transition hover:bg-white/10 disabled:opacity-50"
>
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Cancel deletion keep my account
</button>
</>
) : (
<>
<p className="mt-0.5 text-xs text-white/40">
Permanently deletes your account, all properties, tenants, payments, documents,
and uploaded files, and cancels any active subscription. There is a{" "}
{graceDays}-day grace period during which you can change your mind after that,
deletion is irreversible.
</p>
{!confirmOpen ? (
<button
type="button"
onClick={() => setConfirmOpen(true)}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/10 px-3.5 py-2 text-xs font-semibold text-red-300 transition hover:bg-red-500/20"
>
<Trash2 className="h-3.5 w-3.5" />
Delete my account
</button>
) : (
<form onSubmit={handleRequestDeletion} className="mt-4 space-y-3">
<div>
<label
htmlFor="confirm-email"
className="mb-1.5 block text-xs font-medium text-white/70"
>
Type your account email (<span className="text-white/40">{accountEmail}</span>)
to confirm
</label>
<input
id="confirm-email"
type="email"
required
value={confirmEmail}
onChange={(e) => setConfirmEmail(e.target.value)}
placeholder={accountEmail}
className={inputClass}
autoComplete="off"
/>
</div>
<div>
<label
htmlFor="deletion-reason"
className="mb-1.5 block text-xs font-medium text-white/70"
>
Reason <span className="text-white/30">(optional helps us improve)</span>
</label>
<textarea
id="deletion-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={2}
maxLength={500}
className={inputClass}
/>
</div>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={busy || confirmEmail.trim().toLowerCase() !== accountEmail.toLowerCase()}
className="inline-flex items-center gap-1.5 rounded-lg bg-red-600 px-3.5 py-2 text-xs font-semibold text-white transition hover:bg-red-500 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Schedule permanent deletion
</button>
<button
type="button"
onClick={() => setConfirmOpen(false)}
className="rounded-lg px-3.5 py-2 text-xs font-medium text-white/40 transition hover:text-white/70"
>
Never mind
</button>
</div>
</form>
)}
</>
)}
</div>
</div>
</div>
</div>
)
}
+23 -1
View File
@@ -7,7 +7,7 @@ import {
LayoutDashboard, Building2, Users, CreditCard, LayoutDashboard, Building2, Users, CreditCard,
Wrench, FileText, Receipt, Settings, LogOut, Wrench, FileText, Receipt, Settings, LogOut,
X, Menu, ChevronRight, Zap, Sparkles, Bot, BarChart3, Hammer, ClipboardList, X, Menu, ChevronRight, Zap, Sparkles, Bot, BarChart3, Hammer, ClipboardList,
PanelLeftClose, PanelLeftOpen, CalendarDays, Activity, Brain, Bell, Palette, KeyRound, Plug, Webhook, PanelLeftClose, PanelLeftOpen, CalendarDays, Activity, Brain, Bell, Palette, KeyRound, Plug, Webhook, ShieldCheck,
} from "lucide-react" } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Logo, LogoMark } from "@/components/shared/logo" import { Logo, LogoMark } from "@/components/shared/logo"
@@ -245,6 +245,28 @@ function NavContent({
{!collapsed && "Webhooks"} {!collapsed && "Webhooks"}
</Link> </Link>
<Link
href="/settings/privacy"
onClick={onClose}
title={collapsed ? "Privacy & Data" : undefined}
className={cn(
"group relative flex items-center rounded-xl transition-all duration-200",
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5",
pathname === "/settings/privacy"
? "bg-indigo-600/15 text-indigo-300"
: "text-white/50 hover:bg-white/[0.05] hover:text-white/90"
)}
>
{pathname === "/settings/privacy" && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full bg-indigo-500" />
)}
<ShieldCheck className={cn(
"h-4 w-4 shrink-0 transition-colors",
pathname === "/settings/privacy" ? "text-indigo-400" : "text-white/30 group-hover:text-white/60"
)} />
{!collapsed && "Privacy & Data"}
</Link>
{(plan === "landlord" || plan === "lifetime") && ( {(plan === "landlord" || plan === "lifetime") && (
<> <>
<Link <Link
+1 -36
View File
@@ -9,7 +9,6 @@ export function CheckoutButton({
highlight, highlight,
interval = "month", interval = "month",
annualAvailable = false, annualAvailable = false,
paypalEnabled = false,
}: { }: {
plan: string plan: string
label: string label: string
@@ -18,11 +17,8 @@ export function CheckoutButton({
// When true, show a monthly/annual choice. Only pass this for subscription // When true, show a monthly/annual choice. Only pass this for subscription
// plans and only when annual billing is actually configured server-side. // plans and only when annual billing is actually configured server-side.
annualAvailable?: boolean annualAvailable?: boolean
// When true, also offer "Pay with PayPal" using the same interval choice.
paypalEnabled?: boolean
}) { }) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [paypalLoading, setPaypalLoading] = useState(false)
const [chosenInterval, setChosenInterval] = useState<"month" | "year">(interval) const [chosenInterval, setChosenInterval] = useState<"month" | "year">(interval)
const effectiveInterval = annualAvailable ? chosenInterval : interval const effectiveInterval = annualAvailable ? chosenInterval : interval
@@ -39,21 +35,6 @@ export function CheckoutButton({
else setLoading(false) else setLoading(false)
} }
async function handlePaypal() {
setPaypalLoading(true)
const res = await fetch("/api/paypal/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan, interval: effectiveInterval }),
})
const data = await res.json()
if (data.url) window.location.href = data.url
else {
setPaypalLoading(false)
if (data.error) alert(data.error)
}
}
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{annualAvailable && ( {annualAvailable && (
@@ -82,7 +63,7 @@ export function CheckoutButton({
)} )}
<button <button
onClick={handleClick} onClick={handleClick}
disabled={loading || paypalLoading} disabled={loading}
className={cn( className={cn(
"w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50", "w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50",
highlight highlight
@@ -92,22 +73,6 @@ export function CheckoutButton({
> >
{loading ? "Loading..." : label} {loading ? "Loading..." : label}
</button> </button>
{paypalEnabled && (
<button
onClick={handlePaypal}
disabled={loading || paypalLoading}
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-[#ffc439] py-2 text-xs font-bold text-[#003087] transition hover:bg-[#f0b90b] disabled:opacity-50"
>
{paypalLoading ? (
"Loading..."
) : (
<>
Pay with <span className="font-extrabold italic">Pay<span className="text-[#009cde]">Pal</span></span>
</>
)}
</button>
)}
</div> </div>
) )
} }
+24 -8
View File
@@ -1,8 +1,9 @@
"use client" "use client"
import { useTransition } from "react" import { useTransition } from "react"
import Link from "next/link"
import { toast } from "sonner" import { toast } from "sonner"
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle } from "lucide-react" import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle, Download } from "lucide-react"
import { formatDate } from "@/lib/utils" import { formatDate } from "@/lib/utils"
import { sendLeaseForSignatureAction } from "@/app/actions/esign" import { sendLeaseForSignatureAction } from "@/app/actions/esign"
@@ -12,11 +13,12 @@ type Req = {
status: string status: string
signer_email: string signer_email: string
document_name: string | null document_name: string | null
signed_document_url: string | null
sent_at: string | null sent_at: string | null
completed_at: string | null completed_at: string | null
last_error: string | null last_error: string | null
} }
type Prov = { id: string; label: string; configured: boolean } type Prov = { id: string; label: string }
const STATUS: Record<string, { label: string; cls: string; Icon: typeof Clock }> = { const STATUS: Record<string, { label: string; cls: string; Icon: typeof Clock }> = {
sent: { label: "Awaiting signature", cls: "text-amber-400 bg-amber-500/10 border-amber-500/20", Icon: Clock }, sent: { label: "Awaiting signature", cls: "text-amber-400 bg-amber-500/10 border-amber-500/20", Icon: Clock },
@@ -29,18 +31,17 @@ const PROVIDER_LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_s
export function EsignLease({ export function EsignLease({
leaseId, leaseId,
providers, connected,
requests, requests,
canSend, canSend,
disabledReason, disabledReason,
}: { }: {
leaseId: string leaseId: string
providers: Prov[] connected: Prov[]
requests: Req[] requests: Req[]
canSend: boolean canSend: boolean
disabledReason: string disabledReason: string
}) { }) {
const configured = providers.filter((p) => p.configured)
const [pending, start] = useTransition() const [pending, start] = useTransition()
function send(provider: string) { function send(provider: string) {
@@ -74,6 +75,16 @@ export function EsignLease({
{r.completed_at ? ` · Signed ${formatDate(r.completed_at)}` : ""} {r.completed_at ? ` · Signed ${formatDate(r.completed_at)}` : ""}
{r.status === "error" && r.last_error ? ` · ${r.last_error}` : ""} {r.status === "error" && r.last_error ? ` · ${r.last_error}` : ""}
</p> </p>
{r.signed_document_url && (
<a
href={r.signed_document_url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-[11px] text-indigo-400 transition hover:text-indigo-300"
>
<Download className="h-3 w-3" /> Signed document
</a>
)}
</div> </div>
<span className={`flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${s.cls}`}> <span className={`flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${s.cls}`}>
<s.Icon className="h-3 w-3" /> {s.label} <s.Icon className="h-3 w-3" /> {s.label}
@@ -84,11 +95,16 @@ export function EsignLease({
</ul> </ul>
)} )}
{configured.length === 0 ? ( {connected.length === 0 ? (
<p className="text-xs text-white/30">Configure DocuSign or Dropbox Sign on the server to send leases for e-signature.</p> <p className="text-xs text-white/30">
<Link href="/settings/integrations" className="text-indigo-400 hover:text-indigo-300">
Connect DocuSign or Dropbox Sign
</Link>{" "}
in Settings Integrations to send leases for signature.
</p>
) : canSend ? ( ) : canSend ? (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{configured.map((p) => ( {connected.map((p) => (
<button <button
key={p.id} key={p.id}
onClick={() => send(p.id)} onClick={() => send(p.id)}
+99
View File
@@ -0,0 +1,99 @@
"use client"
import { useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { FileText, Upload, ExternalLink, Loader2 } from "lucide-react"
import { setLeaseDocument } from "@/app/actions/esign"
export function LeaseDocument({
leaseId,
documentUrl,
canWrite,
}: {
leaseId: string
documentUrl: string | null
canWrite: boolean
}) {
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (!/\.(pdf|docx?)$/i.test(file.name)) {
toast.error("Upload a PDF or Word document")
if (inputRef.current) inputRef.current.value = ""
return
}
setUploading(true)
try {
const fd = new FormData()
fd.append("file", file)
fd.append("scope", "documents")
const res = await fetch("/api/upload", { method: "POST", body: fd })
if (!res.ok) {
const j = await res.json().catch(() => ({}))
throw new Error(j?.error || "Upload failed")
}
const { url } = (await res.json()) as { url: string }
await setLeaseDocument(leaseId, url)
toast.success(documentUrl ? "Lease document replaced" : "Lease document attached")
router.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed")
} finally {
setUploading(false)
if (inputRef.current) inputRef.current.value = ""
}
}
return (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium text-white">
<FileText className="h-4 w-4 shrink-0 text-indigo-400" />
{documentUrl ? "Lease document" : "No lease document yet"}
</div>
{documentUrl && (
<a
href={documentUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-indigo-400 transition hover:text-indigo-300"
>
View <ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
{!documentUrl && (
<p className="mt-1.5 text-xs text-white/40">Upload the lease PDF to enable sending it for e-signature.</p>
)}
{canWrite && (
<div className="mt-3">
<input
ref={inputRef}
type="file"
accept=".pdf,.doc,.docx"
onChange={onPick}
disabled={uploading}
className="hidden"
id={`lease-doc-${leaseId}`}
/>
<label
htmlFor={`lease-doc-${leaseId}`}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 transition hover:bg-white/[0.06] hover:text-white ${
uploading ? "pointer-events-none opacity-50" : ""
}`}
>
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
{documentUrl ? "Replace document" : "Upload lease document"}
</label>
</div>
)}
</div>
)
}
-32
View File
@@ -1,32 +0,0 @@
"use client"
import { useState } from "react"
export function PaypalCancelButton() {
const [loading, setLoading] = useState(false)
async function handleClick() {
if (!confirm("Cancel your PayPal subscription? You'll keep access until the end of the current billing period.")) {
return
}
setLoading(true)
const res = await fetch("/api/paypal/cancel", { method: "POST" })
const data = await res.json().catch(() => ({}))
if (res.ok) {
window.location.href = "/settings/billing?canceled=true"
} else {
setLoading(false)
alert(data.error || "Could not cancel the subscription.")
}
}
return (
<button
onClick={handleClick}
disabled={loading}
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition disabled:opacity-50"
>
{loading ? "Canceling..." : "Cancel Subscription"}
</button>
)
}
+38 -53
View File
@@ -1,43 +1,9 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { motion, AnimatePresence } from "framer-motion" import { motion } from "framer-motion"
import { Plus, Minus } from "lucide-react" import { Plus, Minus } from "lucide-react"
import { FAQS } from "@/lib/marketing/faqs"
const FAQS = [
{
q: "Is there really a free plan?",
a: "Yes — the Starter plan is free forever. You get 1 property, up to 3 tenants, rent tracking, maintenance requests, and lease management. No credit card needed to sign up.",
},
{
q: "What happens when my trial ends?",
a: "There's no trial — paid plans start immediately when you upgrade. If you're on the free Starter plan, it never expires. You upgrade when you outgrow it.",
},
{
q: "Can I cancel anytime?",
a: "Yes. Cancel any time from your billing portal — no questions, no fees. Your data stays accessible for 30 days after cancellation so you can export everything.",
},
{
q: "Do tenants need to create an account?",
a: "No. Each tenant gets a unique private link to their portal — no account creation, no password. They can view rent history and submit maintenance requests instantly.",
},
{
q: "Does Property Management Network handle actual rent collection?",
a: "Yes — the Pro and Landlord plans include Stripe payment link generation. You can create a payment link for each tenant and they pay directly via card or bank transfer.",
},
{
q: "Is my data secure?",
a: "Yes. Every request is authenticated and scoped to your account, so landlords only ever see their own data and tenants only see their own records. All data is encrypted at rest and in transit.",
},
{
q: "Can I manage multiple properties?",
a: "The Starter plan supports 1 property. Pro supports up to 10. Landlord and Lifetime plans support unlimited properties and units.",
},
{
q: "What's included in the Lifetime deal?",
a: "The Lifetime plan is a one-time payment of $199. You get everything in the Landlord plan — unlimited properties, tenants, 25GB storage, AI calls, team access — with no recurring fees, ever. All future updates included.",
},
]
export function FAQ() { export function FAQ() {
const [open, setOpen] = useState<number | null>(null) const [open, setOpen] = useState<number | null>(null)
@@ -56,7 +22,9 @@ export function FAQ() {
</motion.div> </motion.div>
<div className="space-y-3"> <div className="space-y-3">
{FAQS.map((faq, i) => ( {FAQS.map((faq, i) => {
const isOpen = open === i
return (
<motion.div <motion.div
key={i} key={i}
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
@@ -65,36 +33,53 @@ export function FAQ() {
transition={{ delay: i * 0.05 }} transition={{ delay: i * 0.05 }}
className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden" className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden"
> >
<h3>
<button <button
onClick={() => setOpen(open === i ? null : i)} type="button"
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
aria-controls={`faq-answer-${i}`}
id={`faq-question-${i}`}
className="flex w-full items-center justify-between px-5 py-4 text-left" className="flex w-full items-center justify-between px-5 py-4 text-left"
> >
<span className="text-sm font-medium text-white pr-4">{faq.q}</span> <span className="text-sm font-medium text-white pr-4">{faq.q}</span>
<div className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition-colors ${ <div
open === i ? "border-indigo-500/40 bg-indigo-500/10" : "border-white/10" className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition-colors ${
}`}> isOpen ? "border-indigo-500/40 bg-indigo-500/10" : "border-white/10"
{open === i }`}
? <Minus className="h-3 w-3 text-indigo-400" /> >
: <Plus className="h-3 w-3 text-white/50" /> {isOpen ? (
} <Minus className="h-3 w-3 text-indigo-400" />
) : (
<Plus className="h-3 w-3 text-white/50" />
)}
</div> </div>
</button> </button>
<AnimatePresence initial={false}> </h3>
{open === i && ( {/*
The answer stays mounted and is collapsed by animating its height
rather than being conditionally rendered. Google requires the
answer text behind an FAQ accordion to be present in the served
HTML — unmounting it when closed would leave the FAQPage JSON-LD
in components/marketing/structured-data.tsx describing content no
crawler can see.
*/}
<motion.div <motion.div
initial={{ height: 0, opacity: 0 }} id={`faq-answer-${i}`}
animate={{ height: "auto", opacity: 1 }} role="region"
exit={{ height: 0, opacity: 0 }} aria-labelledby={`faq-question-${i}`}
initial={false}
animate={{ height: isOpen ? "auto" : 0, opacity: isOpen ? 1 : 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }} transition={{ duration: 0.25, ease: "easeInOut" }}
className="overflow-hidden"
> >
<p className="px-5 pb-5 text-sm text-white/50 leading-relaxed border-t border-white/[0.04] pt-3"> <p className="px-5 pb-5 text-sm text-white/50 leading-relaxed border-t border-white/[0.04] pt-3">
{faq.a} {faq.a}
</p> </p>
</motion.div> </motion.div>
)}
</AnimatePresence>
</motion.div> </motion.div>
))} )
})}
</div> </div>
</section> </section>
) )
+4 -4
View File
@@ -5,10 +5,10 @@ import { LEGAL_PAGES } from "@/lib/legal"
const LINKS = { const LINKS = {
Product: [ Product: [
{ label: "Features", href: "#features" }, { label: "Features", href: "/#features" },
{ label: "Pricing", href: "#pricing" }, { label: "Pricing", href: "/#pricing" },
{ label: "How it works", href: "#how-it-works" }, { label: "How it works", href: "/#how-it-works" },
{ label: "FAQ", href: "#faq" }, { label: "FAQ", href: "/#faq" },
], ],
Platform: [ Platform: [
{ label: "Dashboard", href: "/login" }, { label: "Dashboard", href: "/login" },
+4 -4
View File
@@ -7,10 +7,10 @@ import { Menu, X, ArrowRight } from "lucide-react"
import { Logo } from "@/components/shared/logo" import { Logo } from "@/components/shared/logo"
const NAV_LINKS = [ const NAV_LINKS = [
{ label: "Features", href: "#features" }, { label: "Features", href: "/#features" },
{ label: "How it works", href: "#how-it-works" }, { label: "How it works", href: "/#how-it-works" },
{ label: "Pricing", href: "#pricing" }, { label: "Pricing", href: "/#pricing" },
{ label: "FAQ", href: "#faq" }, { label: "FAQ", href: "/#faq" },
] ]
export function Navbar() { export function Navbar() {
+81 -56
View File
@@ -1,48 +1,36 @@
import { PLAN_AMOUNTS, getPlanLabel } from "@/lib/stripe/plans"
import { FAQS } from "@/lib/marketing/faqs"
import type { Plan } from "@/types"
const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network" const base = process.env.NEXT_PUBLIC_APP_URL ?? "https://propertymanagement.network"
// Mirrors the visible FAQ content in components/marketing/faq.tsx. function JsonLd({ data }: { data: Record<string, unknown> }) {
// Keep these in sync with that source so the JSON-LD matches what users see. return (
const faqs = [ <script
{ type="application/ld+json"
q: "Is there really a free plan?", // JSON.stringify output is escaped for the closing-tag sequence so a value
a: "Yes — the Starter plan is free forever. You get 1 property, up to 3 tenants, rent tracking, maintenance requests, and lease management. No credit card needed to sign up.", // containing "</script>" can't break out of the block.
}, dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, "\u003c") }}
{ />
q: "What happens when my trial ends?", )
a: "There's no trial — paid plans start immediately when you upgrade. If you're on the free Starter plan, it never expires. You upgrade when you outgrow it.", }
},
{ // ── Site-wide entities ───────────────────────────────────────────
q: "Can I cancel anytime?", // Organization and WebSite describe the publisher and the site itself, so they
a: "Yes. Cancel any time from your billing portal — no questions, no fees. Your data stays accessible for 30 days after cancellation so you can export everything.", // are valid on every page of the marketing surface.
},
{
q: "Do tenants need to create an account?",
a: "No. Each tenant gets a unique private link to their portal — no account creation, no password. They can view rent history and submit maintenance requests instantly.",
},
{
q: "Does Property Management Network handle actual rent collection?",
a: "Yes — the Pro and Landlord plans include Stripe payment link generation. You can create a payment link for each tenant and they pay directly via card or bank transfer.",
},
{
q: "Is my data secure?",
a: "Yes. Every request is authenticated and scoped to your account, so landlords only ever see their own data and tenants only see their own records. All data is encrypted at rest and in transit.",
},
{
q: "Can I manage multiple properties?",
a: "The Starter plan supports 1 property. Pro supports up to 10. Landlord and Lifetime plans support unlimited properties and units.",
},
{
q: "What's included in the Lifetime deal?",
a: "The Lifetime plan is a one-time payment of $199. You get everything in the Landlord plan — unlimited properties, tenants, 25GB storage, AI calls, team access — with no recurring fees, ever. All future updates included.",
},
]
const organization: Record<string, unknown> = { const organization: Record<string, unknown> = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Organization", "@type": "Organization",
"@id": `${base}/#organization`,
name: "Property Management Network", name: "Property Management Network",
url: base, url: base,
logo: `${base}/logo-mark.png`, logo: `${base}/logo-mark.png`,
contactPoint: {
"@type": "ContactPoint",
contactType: "customer support",
email: "support@propertymanagement.network",
},
sameAs: [ sameAs: [
"https://twitter.com/propertymgmtnet", "https://twitter.com/propertymgmtnet",
"https://github.com/propertymanagement-network", "https://github.com/propertymanagement-network",
@@ -52,29 +40,76 @@ const organization: Record<string, unknown> = {
const website: Record<string, unknown> = { const website: Record<string, unknown> = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebSite", "@type": "WebSite",
"@id": `${base}/#website`,
name: "Property Management Network", name: "Property Management Network",
url: base, url: base,
publisher: { "@id": `${base}/#organization` },
} }
/**
* Organization + WebSite JSON-LD. Safe to render on every marketing page —
* both describe the site as a whole rather than the content of one page.
*/
export function SiteStructuredData() {
return (
<>
<JsonLd data={organization} />
<JsonLd data={website} />
</>
)
}
// ── Home-page-only entities ──────────────────────────────────────
// SoftwareApplication describes the product presented on the landing page, and
// FAQPage MUST only be emitted where the same questions and answers are visible
// to the user (Google's FAQ structured data policy). Both therefore belong to
// `/` alone and must NOT be moved into the shared marketing layout.
// Real plans + displayed prices from lib/stripe/plans.ts (single source of truth).
// Annual billing is auto-provisioned at checkout and has no fixed amount here, so
// we advertise only the monthly / one-time base prices that actually exist.
const planOrder: Plan[] = ["starter", "pro", "landlord", "lifetime"]
const planPrices = planOrder.map((plan) => PLAN_AMOUNTS[plan])
const planOffers = planOrder.map((plan) => ({
"@type": "Offer",
name: getPlanLabel(plan),
price: String(PLAN_AMOUNTS[plan]),
priceCurrency: "USD",
url: `${base}/#pricing`,
availability: "https://schema.org/InStock",
}))
const softwareApplication: Record<string, unknown> = { const softwareApplication: Record<string, unknown> = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "SoftwareApplication", "@type": "SoftwareApplication",
"@id": `${base}/#software`,
name: "Property Management Network", name: "Property Management Network",
url: base,
applicationCategory: "BusinessApplication", applicationCategory: "BusinessApplication",
operatingSystem: "Web", operatingSystem: "Web",
description: description:
"Property management software for independent landlords — track rent, maintenance requests, leases and expenses in one place. Free to start.", "Property management software for independent landlords — track rent, maintenance requests, leases and expenses in one place. Free to start.",
publisher: { "@id": `${base}/#organization` },
// AggregateOffer is the correct wrapper for a product sold at several price
// points; the individual plan Offers are nested inside it.
offers: { offers: {
"@type": "Offer", "@type": "AggregateOffer",
price: "0",
priceCurrency: "USD", priceCurrency: "USD",
lowPrice: String(Math.min(...planPrices)),
highPrice: String(Math.max(...planPrices)),
offerCount: planOffers.length,
offers: planOffers,
}, },
} }
// Mirrors the visible FAQ rendered by components/marketing/faq.tsx — both read
// the same lib/marketing/faqs.ts list, so the markup can never drift from the
// copy on the page.
const faqPage: Record<string, unknown> = { const faqPage: Record<string, unknown> = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "FAQPage", "@type": "FAQPage",
mainEntity: faqs.map((faq) => ({ "@id": `${base}/#faq`,
mainEntity: FAQS.map((faq) => ({
"@type": "Question", "@type": "Question",
name: faq.q, name: faq.q,
acceptedAnswer: { acceptedAnswer: {
@@ -84,25 +119,15 @@ const faqPage: Record<string, unknown> = {
})), })),
} }
export function StructuredData() { /**
* SoftwareApplication + FAQPage JSON-LD. Render this ONLY on `/`, which is the
* page that actually shows the pricing table and the FAQ accordion.
*/
export function HomeStructuredData() {
return ( return (
<> <>
<script <JsonLd data={softwareApplication} />
type="application/ld+json" <JsonLd data={faqPage} />
dangerouslySetInnerHTML={{ __html: JSON.stringify(organization) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(website) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareApplication) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqPage) }}
/>
</> </>
) )
} }
+137
View File
@@ -0,0 +1,137 @@
"use client"
import { useEffect, useSyncExternalStore } from "react"
import Link from "next/link"
import { Cookie } from "lucide-react"
// Cookie/privacy consent banner.
//
// The platform only sets strictly-necessary cookies (auth session, CSRF) and
// uses cookieless Umami analytics — so this banner is disclosure plus an
// analytics opt-out, not a tracking gate. "Essential only" sets the
// `umami.disabled` localStorage flag, which the Umami script honors, so the
// choice takes effect without a reload for subsequent page views.
//
// The choice is stored locally for everyone; signed-in users also get a row in
// consent_log via /api/gdpr/consent (anonymous visitors are a 204 no-op).
const STORAGE_KEY = "pmn-cookie-consent"
const CONSENT_VERSION = 1
type StoredConsent = { v: number; analytics: boolean; ts: string }
// ── localStorage as an external store (SSR-safe, lint-clean) ────────────────
let listeners: Array<() => void> = []
function subscribe(listener: () => void) {
listeners.push(listener)
return () => {
listeners = listeners.filter((l) => l !== listener)
}
}
function notify() {
for (const l of listeners) l()
}
function readStored(): string | null {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
// Storage unavailable (private mode) — treat as "answered" so the banner
// doesn't nag on every render; the choice just can't persist.
return "unavailable"
}
}
function hasValidConsent(raw: string | null): boolean {
if (raw === null) return false
if (raw === "unavailable") return true
try {
return (JSON.parse(raw) as StoredConsent).v === CONSENT_VERSION
} catch {
return false
}
}
function applyAnalyticsChoice(analytics: boolean) {
try {
if (analytics) localStorage.removeItem("umami.disabled")
else localStorage.setItem("umami.disabled", "1")
} catch {
// Storage unavailable — nothing to apply.
}
}
export function CookieConsent() {
// Server snapshot says "answered" so nothing renders during SSR/hydration.
const raw = useSyncExternalStore(subscribe, readStored, () => "unavailable")
const visible = !hasValidConsent(raw)
// Re-apply a returning visitor's analytics opt-out (external system only).
useEffect(() => {
if (raw && raw !== "unavailable") {
try {
applyAnalyticsChoice((JSON.parse(raw) as StoredConsent).analytics)
} catch {
// Corrupt value — banner is showing anyway.
}
}
}, [raw])
function choose(analytics: boolean) {
const stored: StoredConsent = { v: CONSENT_VERSION, analytics, ts: new Date().toISOString() }
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored))
} catch {
// Private mode — still honor the choice for this page view.
}
applyAnalyticsChoice(analytics)
// Record the choice server-side for signed-in users (fire-and-forget).
fetch("/api/gdpr/consent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ analytics }),
}).catch(() => {})
notify()
}
if (!visible) return null
return (
<div className="fixed inset-x-0 bottom-0 z-50 p-4 sm:p-6" role="dialog" aria-label="Cookie consent">
<div className="mx-auto flex max-w-3xl flex-col gap-4 rounded-2xl border border-white/10 bg-[#111118]/95 p-5 shadow-2xl shadow-black/50 backdrop-blur sm:flex-row sm:items-center">
<div className="flex items-start gap-3">
<div className="rounded-lg bg-indigo-500/10 p-2">
<Cookie className="h-5 w-5 text-indigo-400" />
</div>
<p className="text-xs leading-relaxed text-white/60">
We only use strictly-necessary cookies (sign-in and security) plus cookieless,
privacy-friendly analytics. Choose &ldquo;Essential only&rdquo; to opt out of analytics.
Details in our{" "}
<Link href="/cookie-policy" className="text-indigo-400 underline underline-offset-2 hover:text-indigo-300">
Cookie Policy
</Link>
.
</p>
</div>
<div className="flex shrink-0 items-center gap-2 sm:flex-col md:flex-row">
<button
type="button"
onClick={() => choose(true)}
className="flex-1 whitespace-nowrap rounded-lg bg-indigo-600 px-4 py-2 text-xs font-semibold text-white transition hover:bg-indigo-500 active:scale-[0.98] sm:w-full"
>
Accept all
</button>
<button
type="button"
onClick={() => choose(false)}
className="flex-1 whitespace-nowrap rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-xs font-semibold text-white/70 transition hover:bg-white/10 sm:w-full"
>
Essential only
</button>
</div>
</div>
</div>
)
}
+17
View File
@@ -38,6 +38,23 @@ export function TurnstileWidget({ className }: { className?: string }) {
widgetIdRef.current = window.turnstile.render(containerRef.current, { widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey, sitekey: siteKey,
theme: "dark", theme: "dark",
// A Turnstile token is only valid for ~5 minutes. Without these the
// widget goes quietly stale on a form left open, and the submit fails
// server-side with "complete the verification challenge" even though
// the challenge visibly passed. Re-running it keeps the hidden
// cf-turnstile-response input fresh.
"refresh-expired": "auto",
"expired-callback": () => {
if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current)
},
"timeout-callback": () => {
if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current)
},
"error-callback": () => {
// Returning false lets Turnstile surface its own error UI rather than
// leaving an empty box the user can't act on.
return false
},
}) })
} }
+2 -2
View File
@@ -1,12 +1,12 @@
// DigitalOcean Function invoked by scheduler triggers (see functions/project.yml). // DigitalOcean Function invoked by scheduler triggers (see functions/project.yml).
// Calls the app's protected cron endpoint (`daily`, `late-fees`, `follow-ups`, // Calls the app's protected cron endpoint (`daily`, `late-fees`, `follow-ups`,
// or `webhooks`, chosen by the trigger body) with the CRON_SECRET bearer token. // `webhooks`, or `gdpr`, chosen by the trigger body) with the CRON_SECRET bearer token.
// nodejs:18 has global fetch. // nodejs:18 has global fetch.
async function main(args) { async function main(args) {
const base = (process.env.APP_BASE_URL || "").replace(/\/+$/, "") const base = (process.env.APP_BASE_URL || "").replace(/\/+$/, "")
const secret = process.env.CRON_SECRET const secret = process.env.CRON_SECRET
const requested = args && args.job const requested = args && args.job
const allowed = ["daily", "late-fees", "follow-ups", "webhooks"] const allowed = ["daily", "late-fees", "follow-ups", "webhooks", "gdpr"]
const job = allowed.includes(requested) ? requested : "daily" const job = allowed.includes(requested) ? requested : "daily"
if (!base || !secret) { if (!base || !secret) {

Some files were not shown because too many files have changed in this diff Show More