Compare commits
8
Commits
c9968531e4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d02598786 | ||
|
|
8f90347659 | ||
|
|
595a5e3e04 | ||
|
|
e5e987eb4c | ||
|
|
5a555c715e | ||
|
|
0d11018019 | ||
|
|
5495b94924 | ||
|
|
917a06ee85 |
+91
-47
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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 -->
|
||||||
+12
-1
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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)
|
└── tenant-portal/[token]/ # Public tenant portal (no login)
|
||||||
│ ├── 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)
|
|
||||||
|
|
||||||
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).
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
+29
-18
@@ -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,25 +29,29 @@ 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">
|
||||||
{/* Google OAuth */}
|
{isGoogleConfigured() && (
|
||||||
<form action={signInWithGoogle}>
|
<>
|
||||||
<button
|
{/* Google OAuth */}
|
||||||
type="submit"
|
<form action={signInWithGoogle}>
|
||||||
className="flex w-full items-center justify-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-white/10"
|
<button
|
||||||
>
|
type="submit"
|
||||||
<GoogleIcon />
|
className="flex w-full items-center justify-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-white/10"
|
||||||
Continue with Google
|
>
|
||||||
</button>
|
<GoogleIcon />
|
||||||
</form>
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div className="relative my-6">
|
<div className="relative my-6">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-white/10" />
|
<div className="w-full border-t border-white/10" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-xs">
|
<div className="relative flex justify-center text-xs">
|
||||||
<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 && (
|
||||||
|
|||||||
+35
-18
@@ -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,25 +60,29 @@ 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">
|
||||||
{/* Google OAuth */}
|
{isGoogleConfigured() && (
|
||||||
<form action={signInWithGoogle}>
|
<>
|
||||||
<button
|
{/* Google OAuth */}
|
||||||
type="submit"
|
<form action={signInWithGoogle}>
|
||||||
className="flex w-full items-center justify-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-white/10"
|
<button
|
||||||
>
|
type="submit"
|
||||||
<GoogleIcon />
|
className="flex w-full items-center justify-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-white/10"
|
||||||
Continue with Google
|
>
|
||||||
</button>
|
<GoogleIcon />
|
||||||
</form>
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div className="relative my-6">
|
<div className="relative my-6">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="w-full border-t border-white/10" />
|
<div className="w-full border-t border-white/10" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-xs">
|
<div className="relative flex justify-center text-xs">
|
||||||
<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">
|
||||||
|
|||||||
@@ -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]"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'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 ? (
|
<PortalButton />
|
||||||
<PaypalCancelButton />
|
)}
|
||||||
) : hasStripeAccount ? (
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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,20 +22,45 @@ 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>
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-bold text-white">Integrations</h2>
|
<div>
|
||||||
<p className="text-sm text-white/40 mt-0.5">
|
<h2 className="text-lg font-bold text-white">E-signature</h2>
|
||||||
Connect your accounting software to automatically push rent income and expenses into your books.
|
<p className="text-sm text-white/40 mt-0.5">
|
||||||
</p>
|
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">
|
||||||
|
Connect your accounting software to automatically push rent income and expenses into your books.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AccountingIntegrations
|
||||||
|
providers={providers}
|
||||||
|
connections={connections}
|
||||||
|
isOwner={ctx.isOwner}
|
||||||
|
flash={{ connected: sp.connected, error: sp.error }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AccountingIntegrations
|
|
||||||
providers={providers}
|
|
||||||
connections={connections}
|
|
||||||
isOwner={ctx.isOwner}
|
|
||||||
flash={{ connected: sp.connected, error: sp.error }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & 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 & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 }[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 → Privacy & Data</strong> in your dashboard:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Access & portability</strong> — download a complete,
|
||||||
|
machine-readable JSON export of your personal data and portfolio records.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Erasure</strong> — 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> — correct your details at any time in{" "}
|
||||||
|
<strong>Settings → 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
|
||||||
|
|||||||
@@ -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
@@ -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 />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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." },
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+21
-11
@@ -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,
|
||||||
|
})
|
||||||
const answer = completion.choices[0].message.content ?? ""
|
} catch (err) {
|
||||||
|
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 } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 })
|
|
||||||
}
|
|
||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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
@@ -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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 },
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
|||||||
@@ -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'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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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,45 +22,64 @@ 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) => {
|
||||||
<motion.div
|
const isOpen = open === i
|
||||||
key={i}
|
return (
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<motion.div
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
key={i}
|
||||||
viewport={{ once: true }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
transition={{ delay: i * 0.05 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden"
|
viewport={{ once: true }}
|
||||||
>
|
transition={{ delay: i * 0.05 }}
|
||||||
<button
|
className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden"
|
||||||
onClick={() => setOpen(open === i ? null : i)}
|
|
||||||
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>
|
<h3>
|
||||||
<div className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition-colors ${
|
<button
|
||||||
open === i ? "border-indigo-500/40 bg-indigo-500/10" : "border-white/10"
|
type="button"
|
||||||
}`}>
|
onClick={() => setOpen(isOpen ? null : i)}
|
||||||
{open === i
|
aria-expanded={isOpen}
|
||||||
? <Minus className="h-3 w-3 text-indigo-400" />
|
aria-controls={`faq-answer-${i}`}
|
||||||
: <Plus className="h-3 w-3 text-white/50" />
|
id={`faq-question-${i}`}
|
||||||
}
|
className="flex w-full items-center justify-between px-5 py-4 text-left"
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{open === i && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: "auto", opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
transition={{ duration: 0.25, ease: "easeInOut" }}
|
|
||||||
>
|
>
|
||||||
<p className="px-5 pb-5 text-sm text-white/50 leading-relaxed border-t border-white/[0.04] pt-3">
|
<span className="text-sm font-medium text-white pr-4">{faq.q}</span>
|
||||||
{faq.a}
|
<div
|
||||||
</p>
|
className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition-colors ${
|
||||||
</motion.div>
|
isOpen ? "border-indigo-500/40 bg-indigo-500/10" : "border-white/10"
|
||||||
)}
|
}`}
|
||||||
</AnimatePresence>
|
>
|
||||||
</motion.div>
|
{isOpen ? (
|
||||||
))}
|
<Minus className="h-3 w-3 text-indigo-400" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-3 w-3 text-white/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
{/*
|
||||||
|
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
|
||||||
|
id={`faq-answer-${i}`}
|
||||||
|
role="region"
|
||||||
|
aria-labelledby={`faq-question-${i}`}
|
||||||
|
initial={false}
|
||||||
|
animate={{ height: isOpen ? "auto" : 0, opacity: isOpen ? 1 : 0 }}
|
||||||
|
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">
|
||||||
|
{faq.a}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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) }}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 “Essential only” 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user