Compare commits
1 Commits
main
...
fb20c917e1
| Author | SHA1 | Date | |
|---|---|---|---|
| fb20c917e1 |
24
.env.example
24
.env.example
@@ -13,30 +13,6 @@ AI_MODEL=qwen/qwen3.5-397b-a17b
|
|||||||
# Set to 'true' to enable detailed AI prompt/response logging
|
# Set to 'true' to enable detailed AI prompt/response logging
|
||||||
AI_DEBUG=false
|
AI_DEBUG=false
|
||||||
|
|
||||||
# Stripe Billing
|
|
||||||
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
|
||||||
|
|
||||||
# Stripe Price IDs (Monthly)
|
|
||||||
STRIPE_STARTER_MONTHLY_PRICE_ID=price_starter_monthly
|
|
||||||
STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=price_professional_monthly
|
|
||||||
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_enterprise_monthly
|
|
||||||
|
|
||||||
# Stripe Price IDs (Annual — 25% discount)
|
|
||||||
STRIPE_STARTER_ANNUAL_PRICE_ID=price_starter_annual
|
|
||||||
STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=price_professional_annual
|
|
||||||
STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=price_enterprise_annual
|
|
||||||
|
|
||||||
# Trial configuration
|
|
||||||
REQUIRE_PAYMENT_METHOD_FOR_TRIAL=false
|
|
||||||
|
|
||||||
# Email (Resend)
|
|
||||||
RESEND_API_KEY=re_your_resend_api_key
|
|
||||||
|
|
||||||
# Application
|
|
||||||
APP_URL=http://localhost
|
|
||||||
INVITE_TOKEN_SECRET=dev-invite-secret
|
|
||||||
|
|
||||||
# New Relic APM — set ENABLED=true and provide your license key to activate
|
# New Relic APM — set ENABLED=true and provide your license key to activate
|
||||||
NEW_RELIC_ENABLED=false
|
NEW_RELIC_ENABLED=false
|
||||||
NEW_RELIC_LICENSE_KEY=your_new_relic_license_key_here
|
NEW_RELIC_LICENSE_KEY=your_new_relic_license_key_here
|
||||||
|
|||||||
@@ -1,587 +0,0 @@
|
|||||||
# HOA LedgerIQ -- Payment, Onboarding & Authentication Guide
|
|
||||||
|
|
||||||
> **Version:** 2026.03.18
|
|
||||||
> **Last updated:** March 18, 2026
|
|
||||||
> **Migrations:** `db/migrations/015-saas-onboarding-auth.sql`, `db/migrations/017-billing-enhancements.sql`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
1. [High-Level Flow](#1-high-level-flow)
|
|
||||||
2. [Stripe Billing & Checkout](#2-stripe-billing--checkout)
|
|
||||||
3. [14-Day Free Trial](#3-14-day-free-trial)
|
|
||||||
4. [Monthly / Annual Billing](#4-monthly--annual-billing)
|
|
||||||
5. [Provisioning Pipeline](#5-provisioning-pipeline)
|
|
||||||
6. [Account Activation (Magic Link)](#6-account-activation-magic-link)
|
|
||||||
7. [Guided Onboarding Checklist](#7-guided-onboarding-checklist)
|
|
||||||
8. [Subscription Management & Upgrade/Downgrade](#8-subscription-management--upgradedowngrade)
|
|
||||||
9. [ACH / Invoice Billing](#9-ach--invoice-billing)
|
|
||||||
10. [Access Control & Grace Periods](#10-access-control--grace-periods)
|
|
||||||
11. [Authentication & Sessions](#11-authentication--sessions)
|
|
||||||
12. [Multi-Factor Authentication (TOTP)](#12-multi-factor-authentication-totp)
|
|
||||||
13. [Single Sign-On (SSO)](#13-single-sign-on-sso)
|
|
||||||
14. [Passkeys (WebAuthn)](#14-passkeys-webauthn)
|
|
||||||
15. [Environment Variables Reference](#15-environment-variables-reference)
|
|
||||||
16. [Manual Intervention & Ops Tasks](#16-manual-intervention--ops-tasks)
|
|
||||||
17. [What's Stubbed vs. Production-Ready](#17-whats-stubbed-vs-production-ready)
|
|
||||||
18. [API Endpoint Reference](#18-api-endpoint-reference)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. High-Level Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
Visitor hits /pricing
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Selects plan (Starter / Professional / Enterprise)
|
|
||||||
Chooses billing frequency (Monthly / Annual — 25% discount)
|
|
||||||
Enters email + business name
|
|
||||||
|
|
|
||||||
v
|
|
||||||
POST /api/billing/start-trial (no card required)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Backend creates Stripe customer + subscription with trial_period_days=14
|
|
||||||
Backend provisions: org -> schema -> user -> invite token -> email
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Frontend navigates to /onboarding/pending?session_id=xxx
|
|
||||||
(polls GET /api/billing/status every 3s)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Status returns "active" -> user is redirected to /login
|
|
||||||
|
|
|
||||||
v
|
|
||||||
User clicks activation link from email
|
|
||||||
|
|
|
||||||
v
|
|
||||||
GET /activate?token=xxx -> validates token
|
|
||||||
POST /activate -> sets password + name, issues session
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Redirect to /onboarding (4-step guided wizard)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Dashboard (14-day trial active)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Day 11: Stripe fires customer.subscription.trial_will_end webhook
|
|
||||||
Backend sends trial-ending reminder email
|
|
||||||
|
|
|
||||||
v
|
|
||||||
User adds payment method via Stripe Portal (Settings > Manage Billing)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Trial ends -> Stripe charges card -> subscription becomes 'active'
|
|
||||||
OR: No card -> subscription cancelled -> org archived
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Stripe Billing & Checkout
|
|
||||||
|
|
||||||
### Plans & Pricing
|
|
||||||
|
|
||||||
| Plan | Monthly | Annual (25% off) | Unit Limit |
|
|
||||||
|------|---------|-------------------|------------|
|
|
||||||
| Starter | $29/mo | $261/yr ($21.75/mo) | 50 units |
|
|
||||||
| Professional | $79/mo | $711/yr ($59.25/mo) | 200 units |
|
|
||||||
| Enterprise | Custom | Custom | Unlimited |
|
|
||||||
|
|
||||||
### Stripe Products & Prices
|
|
||||||
|
|
||||||
Each plan has **two Stripe Prices** (monthly and annual):
|
|
||||||
|
|
||||||
| Env Variable | Description |
|
|
||||||
|-------------|-------------|
|
|
||||||
| `STRIPE_STARTER_MONTHLY_PRICE_ID` | Starter monthly recurring price |
|
|
||||||
| `STRIPE_STARTER_ANNUAL_PRICE_ID` | Starter annual recurring price |
|
|
||||||
| `STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID` | Professional monthly recurring price |
|
|
||||||
| `STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID` | Professional annual recurring price |
|
|
||||||
| `STRIPE_ENTERPRISE_MONTHLY_PRICE_ID` | Enterprise monthly recurring price |
|
|
||||||
| `STRIPE_ENTERPRISE_ANNUAL_PRICE_ID` | Enterprise annual recurring price |
|
|
||||||
|
|
||||||
Backward compatibility: `STRIPE_STARTER_PRICE_ID` (old single var) maps to monthly if the new `_MONTHLY_` var is not set.
|
|
||||||
|
|
||||||
### Two Billing Paths
|
|
||||||
|
|
||||||
| Path | Audience | Payment | Trial |
|
|
||||||
|------|----------|---------|-------|
|
|
||||||
| **Path A: Self-serve (Card)** | Starter & Professional | Automatic card charge | 14-day no-card trial |
|
|
||||||
| **Path B: Invoice / ACH** | Enterprise (admin-set) | Invoice with Net-30 terms | Admin configures |
|
|
||||||
|
|
||||||
### Webhook Events Handled
|
|
||||||
|
|
||||||
| Event | Action |
|
|
||||||
|-------|--------|
|
|
||||||
| `checkout.session.completed` | Triggers full provisioning pipeline (card-required flow) |
|
|
||||||
| `invoice.payment_succeeded` | Sets org status to `active` (reactivation after trial/past_due) |
|
|
||||||
| `invoice.payment_failed` | Sets org to `past_due`, sends payment-failed email |
|
|
||||||
| `customer.subscription.deleted` | Sets org status to `archived` |
|
|
||||||
| `customer.subscription.trial_will_end` | Sends trial-ending reminder email (3 days before) |
|
|
||||||
| `customer.subscription.updated` | Syncs plan, interval, status, and collection_method to DB |
|
|
||||||
|
|
||||||
All webhook events are deduplicated via the `shared.stripe_events` table (idempotency by Stripe event ID).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 14-Day Free Trial
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
1. User visits `/pricing`, selects a plan and billing frequency
|
|
||||||
2. User enters email + business name (required)
|
|
||||||
3. Clicks "Start Free Trial"
|
|
||||||
4. Backend creates Stripe customer (no payment method)
|
|
||||||
5. Backend creates subscription with `trial_period_days: 14`
|
|
||||||
6. Backend provisions org with `status = 'trial'` immediately
|
|
||||||
7. User receives activation email, sets password, starts using the app
|
|
||||||
|
|
||||||
### Trial Configuration
|
|
||||||
|
|
||||||
| Setting | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `REQUIRE_PAYMENT_METHOD_FOR_TRIAL` | `false` (default): no-card trial. `true`: uses Stripe Checkout (card required upfront). |
|
|
||||||
|
|
||||||
### Trial Lifecycle
|
|
||||||
|
|
||||||
| Day | Event |
|
|
||||||
|-----|-------|
|
|
||||||
| 0 | Trial starts, full access granted |
|
|
||||||
| 11 | `customer.subscription.trial_will_end` webhook fires |
|
|
||||||
| 11 | Trial-ending email sent ("Your trial ends in 3 days") |
|
|
||||||
| 14 | Trial ends |
|
|
||||||
| 14 | If card on file: Stripe charges, subscription becomes `active` |
|
|
||||||
| 14 | If no card: subscription cancelled, org set to `archived` |
|
|
||||||
|
|
||||||
### Trial Behavior by Plan Frequency
|
|
||||||
|
|
||||||
- **Monthly trial**: Trial ends, charge monthly price
|
|
||||||
- **Annual trial**: Trial ends, charge full annual amount
|
|
||||||
|
|
||||||
### Trial End Behavior
|
|
||||||
|
|
||||||
Configured in Stripe subscription: `trial_settings.end_behavior.missing_payment_method: 'cancel'`
|
|
||||||
|
|
||||||
When trial ends without a payment method, the subscription is cancelled and the org is archived. Users can resubscribe at any time.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Monthly / Annual Billing
|
|
||||||
|
|
||||||
### Pricing Page Toggle
|
|
||||||
|
|
||||||
The pricing page (`PricingPage.tsx`) features a segmented control toggle:
|
|
||||||
- **Monthly**: Shows monthly prices ($29/mo, $79/mo)
|
|
||||||
- **Annual (Save 25%)**: Shows effective monthly rate + annual total ($21.75/mo billed annually at $261/yr)
|
|
||||||
|
|
||||||
The selected billing frequency is passed to the backend when starting a trial or creating a checkout session.
|
|
||||||
|
|
||||||
### Annual Discount
|
|
||||||
|
|
||||||
Annual pricing = Monthly price x 12 x 0.75 (25% discount):
|
|
||||||
- Starter: $29 x 12 x 0.75 = **$261/yr**
|
|
||||||
- Professional: $79 x 12 x 0.75 = **$711/yr**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Provisioning Pipeline
|
|
||||||
|
|
||||||
When a trial starts or `checkout.session.completed` fires, the backend runs **inline provisioning**:
|
|
||||||
|
|
||||||
1. **Create organization** in `shared.organizations` with:
|
|
||||||
- `name` = business name from signup
|
|
||||||
- `schema_name` = `tenant_{random_12_chars}`
|
|
||||||
- `status` = `trial` (for trial) or `active` (for card checkout)
|
|
||||||
- `plan_level` = selected plan
|
|
||||||
- `billing_interval` = `month` or `year`
|
|
||||||
- `stripe_customer_id` + `stripe_subscription_id`
|
|
||||||
- `trial_ends_at` (if trial)
|
|
||||||
- Uses `ON CONFLICT (stripe_customer_id)` for idempotency
|
|
||||||
|
|
||||||
2. **Create tenant schema** via `TenantSchemaService.createTenantSchema()`
|
|
||||||
3. **Create or find user** in `shared.users` by email
|
|
||||||
4. **Create membership** in `shared.user_organizations` (role: `president`)
|
|
||||||
5. **Generate invite token** (JWT, 72-hour expiry)
|
|
||||||
6. **Send activation email** with link to set password
|
|
||||||
7. **Initialize onboarding** progress row
|
|
||||||
|
|
||||||
### Provisioning Status Polling
|
|
||||||
|
|
||||||
`GET /api/billing/status?session_id=xxx` (no auth required)
|
|
||||||
|
|
||||||
Accepts both Stripe checkout session IDs and subscription IDs. Returns: `{ status }` where status is:
|
|
||||||
- `not_configured` -- Stripe not set up
|
|
||||||
- `pending` -- no customer ID yet
|
|
||||||
- `provisioning` -- org exists but not ready
|
|
||||||
- `active` -- ready (includes `trial` status)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Account Activation (Magic Link)
|
|
||||||
|
|
||||||
### Validate Token
|
|
||||||
|
|
||||||
`GET /api/auth/activate?token=xxx` -- returns `{ valid, email, orgName, orgId, userId }`
|
|
||||||
|
|
||||||
### Activate Account
|
|
||||||
|
|
||||||
`POST /api/auth/activate` -- body `{ token, password, fullName }` -- sets password, issues session
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Guided Onboarding Checklist
|
|
||||||
|
|
||||||
| Step Key | UI Label | Description |
|
|
||||||
|----------|----------|-------------|
|
|
||||||
| `profile` | Profile | Set up user profile |
|
|
||||||
| `workspace` | Workspace | Configure organization settings |
|
|
||||||
| `invite_member` | Invite Member | Invite at least one team member |
|
|
||||||
| `first_workflow` | First Account | Create the first chart-of-accounts entry |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Subscription Management & Upgrade/Downgrade
|
|
||||||
|
|
||||||
### Stripe Customer Portal
|
|
||||||
|
|
||||||
Users manage their subscription through the **Stripe Customer Portal**, accessed via:
|
|
||||||
- Settings page > Billing card > "Manage Billing" button
|
|
||||||
- Calls `POST /api/billing/portal` which creates a portal session and returns the URL
|
|
||||||
|
|
||||||
### What Users Can Do in the Portal
|
|
||||||
|
|
||||||
- **Switch plans**: Change between Starter and Professional
|
|
||||||
- **Switch billing frequency**: Monthly to Annual (and vice versa)
|
|
||||||
- **Update payment method**: Add/change credit card
|
|
||||||
- **Cancel subscription**: Cancels at end of current period
|
|
||||||
- **View invoices**: See billing history
|
|
||||||
|
|
||||||
### Upgrade/Downgrade Behavior
|
|
||||||
|
|
||||||
| Change | Behavior |
|
|
||||||
|--------|----------|
|
|
||||||
| Monthly to Annual | Immediate. Prorate remaining monthly time, start annual cycle now. |
|
|
||||||
| Annual to Monthly | Scheduled at end of current annual period. |
|
|
||||||
| Starter to Professional | Immediate. Prorate price difference. |
|
|
||||||
| Professional to Starter | Scheduled at end of current period. |
|
|
||||||
|
|
||||||
Stripe handles proration automatically when configured with `proration_behavior: 'create_prorations'`.
|
|
||||||
|
|
||||||
### Subscription Info Endpoint
|
|
||||||
|
|
||||||
`GET /api/billing/subscription` (auth required) returns:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"plan": "professional",
|
|
||||||
"planName": "Professional",
|
|
||||||
"billingInterval": "month",
|
|
||||||
"status": "active",
|
|
||||||
"collectionMethod": "charge_automatically",
|
|
||||||
"trialEndsAt": null,
|
|
||||||
"currentPeriodEnd": "2026-04-18T00:00:00.000Z",
|
|
||||||
"cancelAtPeriodEnd": false
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. ACH / Invoice Billing
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
|
|
||||||
For enterprise customers who need to pay via ACH bank transfer or wire, an admin can switch the subscription's collection method from automatic card charge to invoice billing.
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
1. **Admin** calls `PUT /api/admin/organizations/:id/billing` with:
|
|
||||||
```json
|
|
||||||
{ "collectionMethod": "send_invoice", "daysUntilDue": 30 }
|
|
||||||
```
|
|
||||||
2. Stripe subscription is updated: `collection_method = 'send_invoice'`, `days_until_due = 30`
|
|
||||||
3. At each billing cycle, Stripe generates an invoice and emails it to the customer
|
|
||||||
4. Customer pays via ACH / wire / bank transfer
|
|
||||||
5. When payment is received, Stripe marks invoice paid and org remains active
|
|
||||||
|
|
||||||
### Access Rules for Invoice Customers
|
|
||||||
|
|
||||||
| Stage | Access |
|
|
||||||
|-------|--------|
|
|
||||||
| Trial | Full |
|
|
||||||
| Invoice issued | Full |
|
|
||||||
| Due date passed | Read-only (past_due) |
|
|
||||||
| 15+ days overdue | Admin may archive |
|
|
||||||
|
|
||||||
### Switching Back
|
|
||||||
|
|
||||||
To switch back to automatic card billing:
|
|
||||||
```json
|
|
||||||
{ "collectionMethod": "charge_automatically" }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Access Control & Grace Periods
|
|
||||||
|
|
||||||
### Organization Status Access Rules
|
|
||||||
|
|
||||||
| Status | Access | Description |
|
|
||||||
|--------|--------|-------------|
|
|
||||||
| `trial` | **Full** | 14-day trial, all features available |
|
|
||||||
| `active` | **Full** | Paid subscription, all features available |
|
|
||||||
| `past_due` | **Read-only** | Payment failed or invoice overdue. Users can view data but cannot create/edit/delete. |
|
|
||||||
| `suspended` | **Blocked** | Admin suspended. 403 on all org-scoped endpoints. |
|
|
||||||
| `archived` | **Blocked** | Subscription cancelled. 403 on all org-scoped endpoints. Data preserved. |
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- **Tenant Middleware** (`tenant.middleware.ts`): Blocks `suspended` and `archived` with 403. Sets `req.orgPastDue = true` for `past_due`.
|
|
||||||
- **WriteAccessGuard** (`write-access.guard.ts`): Blocks POST/PUT/PATCH/DELETE for `past_due` orgs with message: "Your subscription is past due. Please update your payment method."
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Authentication & Sessions
|
|
||||||
|
|
||||||
### Token Architecture
|
|
||||||
|
|
||||||
| Token | Type | Lifetime | Storage |
|
|
||||||
|-------|------|----------|---------|
|
|
||||||
| Access token | JWT | 1 hour | Frontend Zustand store |
|
|
||||||
| Refresh token | Opaque (64 bytes) | 30 days | httpOnly cookie (`ledgeriq_rt`) |
|
|
||||||
| MFA challenge | JWT | 5 minutes | Frontend state |
|
|
||||||
| Invite/activation | JWT | 72 hours | URL query parameter |
|
|
||||||
|
|
||||||
### Session Endpoints
|
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
|
||||||
|--------|------|------|-------------|
|
|
||||||
| `POST` | `/api/auth/login` | No | Email + password login |
|
|
||||||
| `POST` | `/api/auth/register` | No | Create account |
|
|
||||||
| `POST` | `/api/auth/refresh` | Cookie | Refresh access token |
|
|
||||||
| `POST` | `/api/auth/logout` | Cookie | Revoke current session |
|
|
||||||
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all sessions |
|
|
||||||
| `POST` | `/api/auth/switch-org` | JWT | Switch organization |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Multi-Factor Authentication (TOTP)
|
|
||||||
|
|
||||||
### MFA Endpoints
|
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
|
||||||
|--------|------|------|-------------|
|
|
||||||
| `POST` | `/api/auth/mfa/setup` | JWT | Generate QR code + secret |
|
|
||||||
| `POST` | `/api/auth/mfa/enable` | JWT | Enable MFA with TOTP code |
|
|
||||||
| `POST` | `/api/auth/mfa/verify` | mfaToken | Verify during login |
|
|
||||||
| `POST` | `/api/auth/mfa/disable` | JWT | Disable (requires password) |
|
|
||||||
| `GET` | `/api/auth/mfa/status` | JWT | Check MFA status |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Single Sign-On (SSO)
|
|
||||||
|
|
||||||
| Provider | Env Vars Required |
|
|
||||||
|----------|-------------------|
|
|
||||||
| Google | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_CALLBACK_URL` |
|
|
||||||
| Microsoft/Azure AD | `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`, `AZURE_CALLBACK_URL` |
|
|
||||||
|
|
||||||
SSO providers are conditionally loaded based on env vars.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Passkeys (WebAuthn)
|
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
|
||||||
|--------|------|------|-------------|
|
|
||||||
| `POST` | `/api/auth/passkeys/register-options` | JWT | Get registration options |
|
|
||||||
| `POST` | `/api/auth/passkeys/register` | JWT | Complete registration |
|
|
||||||
| `POST` | `/api/auth/passkeys/login-options` | No | Get authentication options |
|
|
||||||
| `POST` | `/api/auth/passkeys/login` | No | Authenticate with passkey |
|
|
||||||
| `GET` | `/api/auth/passkeys` | JWT | List user's passkeys |
|
|
||||||
| `DELETE` | `/api/auth/passkeys/:id` | JWT | Remove a passkey |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Environment Variables Reference
|
|
||||||
|
|
||||||
### Stripe (Required for billing)
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `STRIPE_SECRET_KEY` | Stripe secret key. Must NOT contain "placeholder" to activate. |
|
|
||||||
| `STRIPE_WEBHOOK_SECRET` | Webhook endpoint signing secret |
|
|
||||||
| `STRIPE_STARTER_MONTHLY_PRICE_ID` | Stripe Price ID for Starter monthly |
|
|
||||||
| `STRIPE_STARTER_ANNUAL_PRICE_ID` | Stripe Price ID for Starter annual |
|
|
||||||
| `STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID` | Stripe Price ID for Professional monthly |
|
|
||||||
| `STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID` | Stripe Price ID for Professional annual |
|
|
||||||
| `STRIPE_ENTERPRISE_MONTHLY_PRICE_ID` | Stripe Price ID for Enterprise monthly |
|
|
||||||
| `STRIPE_ENTERPRISE_ANNUAL_PRICE_ID` | Stripe Price ID for Enterprise annual |
|
|
||||||
|
|
||||||
Legacy single-price vars (`STRIPE_STARTER_PRICE_ID`, etc.) are still supported as fallback for monthly prices.
|
|
||||||
|
|
||||||
### Trial Configuration
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `REQUIRE_PAYMENT_METHOD_FOR_TRIAL` | `false` | Set to `true` to require card upfront via Stripe Checkout |
|
|
||||||
|
|
||||||
### SSO (Optional)
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
|
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
|
||||||
| `GOOGLE_CALLBACK_URL` | OAuth redirect URI |
|
|
||||||
| `AZURE_CLIENT_ID` | Azure AD application (client) ID |
|
|
||||||
| `AZURE_CLIENT_SECRET` | Azure AD client secret |
|
|
||||||
| `AZURE_TENANT_ID` | Azure AD tenant ID |
|
|
||||||
| `AZURE_CALLBACK_URL` | OAuth redirect URI |
|
|
||||||
|
|
||||||
### WebAuthn / Passkeys
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `WEBAUTHN_RP_ID` | `localhost` | Relying party identifier |
|
|
||||||
| `WEBAUTHN_RP_ORIGIN` | `http://localhost` | Expected browser origin |
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `INVITE_TOKEN_SECRET` | `dev-invite-secret` | Secret for invite/activation JWTs |
|
|
||||||
| `APP_URL` | `http://localhost` | Base URL for generated links |
|
|
||||||
| `RESEND_API_KEY` | -- | Resend email provider API key |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Manual Intervention & Ops Tasks
|
|
||||||
|
|
||||||
### Stripe Dashboard Setup
|
|
||||||
|
|
||||||
1. **Create Products and Prices** for each plan:
|
|
||||||
- Starter: monthly ($29/mo recurring) + annual ($261/yr recurring)
|
|
||||||
- Professional: monthly ($79/mo recurring) + annual ($711/yr recurring)
|
|
||||||
- Enterprise: monthly + annual (custom pricing)
|
|
||||||
- Copy all Price IDs to env vars
|
|
||||||
|
|
||||||
2. **Configure Stripe Webhook** endpoint:
|
|
||||||
- URL: `https://yourdomain.com/api/webhooks/stripe`
|
|
||||||
- Events: `checkout.session.completed`, `invoice.payment_succeeded`, `invoice.payment_failed`, `customer.subscription.deleted`, `customer.subscription.trial_will_end`, `customer.subscription.updated`
|
|
||||||
|
|
||||||
3. **Configure Stripe Customer Portal**:
|
|
||||||
- Enable plan switching (allow switching between monthly and annual prices)
|
|
||||||
- Enable payment method updates
|
|
||||||
- Enable cancellation
|
|
||||||
- Enable invoice history
|
|
||||||
|
|
||||||
4. **Set production secrets**: `INVITE_TOKEN_SECRET`, `JWT_SECRET`, `WEBAUTHN_RP_ID`, `WEBAUTHN_RP_ORIGIN`
|
|
||||||
|
|
||||||
5. **Configure SSO providers** (optional)
|
|
||||||
|
|
||||||
### Ongoing Ops
|
|
||||||
|
|
||||||
- **Refresh token cleanup**: Schedule `RefreshTokenService.cleanupExpired()` periodically
|
|
||||||
- **Monitor `shared.email_log`**: Check for failed email deliveries
|
|
||||||
- **ACH/Invoice customers**: Admin sets up via `PUT /api/admin/organizations/:id/billing`
|
|
||||||
|
|
||||||
### Finding activation URLs (dev/testing)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT to_email, metadata->>'activationUrl' AS url, sent_at
|
|
||||||
FROM shared.email_log
|
|
||||||
WHERE template = 'activation'
|
|
||||||
ORDER BY sent_at DESC
|
|
||||||
LIMIT 10;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. What's Stubbed vs. Production-Ready
|
|
||||||
|
|
||||||
| Component | Status | Notes |
|
|
||||||
|-----------|--------|-------|
|
|
||||||
| Stripe Checkout (card-required flow) | **Ready** (test mode) | Switch to live keys for production |
|
|
||||||
| Stripe Trial (no-card flow) | **Ready** (test mode) | Creates customer + subscription server-side |
|
|
||||||
| Stripe Webhooks | **Ready** | All 6 events handled with idempotency |
|
|
||||||
| Stripe Customer Portal | **Ready** | Full org-context customer ID lookup implemented |
|
|
||||||
| Monthly/Annual Pricing | **Ready** | Toggle on pricing page, 6 Stripe Price IDs |
|
|
||||||
| ACH/Invoice Billing | **Ready** | Admin endpoint switches collection method |
|
|
||||||
| Provisioning | **Ready** | Inline, supports both trial and active status |
|
|
||||||
| Email service | **Ready** (with Resend) | Falls back to stub logging if not configured |
|
|
||||||
| Trial emails | **Ready** | Trial-ending and trial-expired templates |
|
|
||||||
| Access control (past_due) | **Ready** | Read-only grace period for failed payments |
|
|
||||||
| Activation (magic link) | **Ready** | Full end-to-end flow |
|
|
||||||
| Onboarding checklist | **Ready** | Server-side progress tracking |
|
|
||||||
| Refresh tokens | **Ready** | Needs scheduled cleanup |
|
|
||||||
| TOTP MFA | **Ready** | Full setup, enable, verify, recovery |
|
|
||||||
| SSO (Google/Azure) | **Ready** (needs keys) | Conditional loading |
|
|
||||||
| Passkeys (WebAuthn) | **Ready** | Registration, authentication, removal |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 18. API Endpoint Reference
|
|
||||||
|
|
||||||
### Billing
|
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
|
||||||
|--------|------|------|-------------|
|
|
||||||
| `POST` | `/api/billing/start-trial` | No | Start 14-day no-card trial |
|
|
||||||
| `POST` | `/api/billing/create-checkout-session` | No | Create Stripe Checkout (card-required flow) |
|
|
||||||
| `POST` | `/api/webhooks/stripe` | Stripe sig | Webhook receiver |
|
|
||||||
| `GET` | `/api/billing/status?session_id=` | No | Poll provisioning status |
|
|
||||||
| `GET` | `/api/billing/subscription` | JWT | Get current subscription info |
|
|
||||||
| `POST` | `/api/billing/portal` | JWT | Create Stripe Customer Portal session |
|
|
||||||
| `PUT` | `/api/admin/organizations/:id/billing` | JWT (superadmin) | Switch billing method (card/invoice) |
|
|
||||||
|
|
||||||
### Auth
|
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
|
||||||
|--------|------|------|-------------|
|
|
||||||
| `POST` | `/api/auth/register` | No | Register new user |
|
|
||||||
| `POST` | `/api/auth/login` | No | Login (may return MFA challenge) |
|
|
||||||
| `POST` | `/api/auth/refresh` | Cookie | Refresh access token |
|
|
||||||
| `POST` | `/api/auth/logout` | Cookie | Logout current session |
|
|
||||||
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all sessions |
|
|
||||||
| `GET` | `/api/auth/activate?token=` | No | Validate activation token |
|
|
||||||
| `POST` | `/api/auth/activate` | No | Set password + activate |
|
|
||||||
| `POST` | `/api/auth/resend-activation` | No | Resend activation email |
|
|
||||||
| `GET` | `/api/auth/profile` | JWT | Get user profile |
|
|
||||||
| `POST` | `/api/auth/switch-org` | JWT | Switch organization |
|
|
||||||
|
|
||||||
### Onboarding
|
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
|
||||||
|--------|------|------|-------------|
|
|
||||||
| `GET` | `/api/onboarding/progress` | JWT | Get onboarding progress |
|
|
||||||
| `PATCH` | `/api/onboarding/progress` | JWT | Mark step complete |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database Tables & Columns
|
|
||||||
|
|
||||||
### Tables Added (Migration 015)
|
|
||||||
|
|
||||||
| Table | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `shared.refresh_tokens` | Hashed refresh tokens with expiry/revocation |
|
|
||||||
| `shared.stripe_events` | Idempotency ledger for Stripe webhooks |
|
|
||||||
| `shared.invite_tokens` | Activation/invite magic links |
|
|
||||||
| `shared.onboarding_progress` | Per-org onboarding step completion |
|
|
||||||
| `shared.user_passkeys` | WebAuthn credentials |
|
|
||||||
| `shared.email_log` | Email audit trail |
|
|
||||||
|
|
||||||
### Columns Added to `shared.organizations`
|
|
||||||
|
|
||||||
| Column | Type | Migration | Description |
|
|
||||||
|--------|------|-----------|-------------|
|
|
||||||
| `stripe_customer_id` | VARCHAR(255) UNIQUE | 015 | Stripe customer ID |
|
|
||||||
| `stripe_subscription_id` | VARCHAR(255) UNIQUE | 015 | Stripe subscription ID |
|
|
||||||
| `trial_ends_at` | TIMESTAMPTZ | 015 | Trial expiration date |
|
|
||||||
| `billing_interval` | VARCHAR(20) | 017 | `month` or `year` |
|
|
||||||
| `collection_method` | VARCHAR(20) | 017 | `charge_automatically` or `send_invoice` |
|
|
||||||
|
|
||||||
### Organization Status Values
|
|
||||||
|
|
||||||
`active`, `trial`, `past_due`, `suspended`, `archived`
|
|
||||||
1190
backend/package-lock.json
generated
1190
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.03.16",
|
||||||
"description": "HOA LedgerIQ - Backend API",
|
"description": "HOA LedgerIQ - Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -27,27 +27,18 @@
|
|||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"@simplewebauthn/server": "^13.3.0",
|
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"bullmq": "^5.71.0",
|
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"cookie-parser": "^1.4.7",
|
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"newrelic": "latest",
|
"newrelic": "latest",
|
||||||
"otplib": "^13.3.0",
|
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-azure-ad": "^4.3.5",
|
|
||||||
"passport-google-oauth20": "^2.0.0",
|
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"resend": "^6.9.4",
|
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"stripe": "^20.4.1",
|
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
@@ -56,15 +47,12 @@
|
|||||||
"@nestjs/schematics": "^10.2.3",
|
"@nestjs/schematics": "^10.2.3",
|
||||||
"@nestjs/testing": "^10.4.15",
|
"@nestjs/testing": "^10.4.15",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^20.17.12",
|
"@types/node": "^20.17.12",
|
||||||
"@types/passport-google-oauth20": "^2.0.17",
|
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
"@types/qrcode": "^1.5.6",
|
|
||||||
"@types/uuid": "^9.0.8",
|
"@types/uuid": "^9.0.8",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ import { AttachmentsModule } from './modules/attachments/attachments.module';
|
|||||||
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
||||||
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
||||||
import { BoardPlanningModule } from './modules/board-planning/board-planning.module';
|
import { BoardPlanningModule } from './modules/board-planning/board-planning.module';
|
||||||
import { BillingModule } from './modules/billing/billing.module';
|
|
||||||
import { EmailModule } from './modules/email/email.module';
|
import { EmailModule } from './modules/email/email.module';
|
||||||
import { OnboardingModule } from './modules/onboarding/onboarding.module';
|
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -85,9 +83,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
InvestmentPlanningModule,
|
InvestmentPlanningModule,
|
||||||
HealthScoresModule,
|
HealthScoresModule,
|
||||||
BoardPlanningModule,
|
BoardPlanningModule,
|
||||||
BillingModule,
|
|
||||||
EmailModule,
|
EmailModule,
|
||||||
OnboardingModule,
|
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -30,13 +30,6 @@ export class WriteAccessGuard implements CanActivate {
|
|||||||
throw new ForbiddenException('Read-only users cannot modify data');
|
throw new ForbiddenException('Read-only users cannot modify data');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block writes for past_due organizations (grace period: read-only access)
|
|
||||||
if (request.orgPastDue) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'Your subscription is past due. Please update your payment method to continue making changes.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export interface TenantRequest extends Request {
|
|||||||
orgId?: string;
|
orgId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
userRole?: string;
|
userRole?: string;
|
||||||
orgPastDue?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -42,10 +41,6 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// past_due: allow through with read-only flag (WriteAccessGuard enforces)
|
|
||||||
if (orgInfo.status === 'past_due') {
|
|
||||||
req.orgPastDue = true;
|
|
||||||
}
|
|
||||||
req.tenantSchema = orgInfo.schemaName;
|
req.tenantSchema = orgInfo.schemaName;
|
||||||
}
|
}
|
||||||
req.orgId = decoded.orgId;
|
req.orgId = decoded.orgId;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import * as cookieParser from 'cookie-parser';
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
|
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
|
||||||
@@ -39,15 +38,10 @@ if (WORKERS > 1 && cluster.isPrimary) {
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
logger: isProduction ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'],
|
logger: isProduction ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||||
// Enable raw body for Stripe webhook signature verification
|
|
||||||
rawBody: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
|
|
||||||
// Cookie parser — needed for refresh token httpOnly cookies
|
|
||||||
app.use(cookieParser());
|
|
||||||
|
|
||||||
// Security headers — Helmet sets CSP, X-Frame-Options, X-Content-Type-Options,
|
// Security headers — Helmet sets CSP, X-Frame-Options, X-Content-Type-Options,
|
||||||
// Referrer-Policy, Permissions-Policy, and removes X-Powered-By
|
// Referrer-Policy, Permissions-Policy, and removes X-Powered-By
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ export class AccountsService {
|
|||||||
|
|
||||||
// Create opening balance journal entry if initialBalance is provided and non-zero
|
// Create opening balance journal entry if initialBalance is provided and non-zero
|
||||||
if (dto.initialBalance && dto.initialBalance !== 0) {
|
if (dto.initialBalance && dto.initialBalance !== 0) {
|
||||||
const balanceDate = dto.initialBalanceDate ? new Date(dto.initialBalanceDate) : new Date();
|
const now = new Date();
|
||||||
const year = balanceDate.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = balanceDate.getMonth() + 1;
|
const month = now.getMonth() + 1;
|
||||||
|
|
||||||
// Find the current fiscal period
|
// Find the current fiscal period
|
||||||
const periods = await this.tenant.query(
|
const periods = await this.tenant.query(
|
||||||
@@ -111,14 +111,12 @@ export class AccountsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the journal entry (use provided balance date or today)
|
// Create the journal entry
|
||||||
const entryDate = dto.initialBalanceDate || new Date().toISOString().split('T')[0];
|
|
||||||
const jeInsert = await this.tenant.query(
|
const jeInsert = await this.tenant.query(
|
||||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||||
VALUES ($1::date, $2, 'opening_balance', $3, true, NOW(), $4)
|
VALUES (CURRENT_DATE, $1, 'opening_balance', $2, true, NOW(), $3)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[
|
[
|
||||||
entryDate,
|
|
||||||
`Opening balance for ${dto.name}`,
|
`Opening balance for ${dto.name}`,
|
||||||
fiscalPeriodId,
|
fiscalPeriodId,
|
||||||
'00000000-0000-0000-0000-000000000000',
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
|||||||
@@ -37,11 +37,6 @@ export class CreateAccountDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
initialBalance?: number;
|
initialBalance?: number;
|
||||||
|
|
||||||
@ApiProperty({ required: false, description: 'ISO date string (YYYY-MM-DD) for when the initial balance was accurate' })
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
initialBalanceDate?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ required: false, description: 'Annual interest rate as a percentage' })
|
@ApiProperty({ required: false, description: 'Annual interest rate as a percentage' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
interestRate?: number;
|
interestRate?: number;
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
Get,
|
Get,
|
||||||
Res,
|
|
||||||
Query,
|
|
||||||
HttpCode,
|
HttpCode,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -15,7 +13,6 @@ import {
|
|||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { Response } from 'express';
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
@@ -23,29 +20,8 @@ import { SwitchOrgDto } from './dto/switch-org.dto';
|
|||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
|
||||||
const COOKIE_NAME = 'ledgeriq_rt';
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true';
|
const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true';
|
||||||
|
|
||||||
function setRefreshCookie(res: Response, token: string) {
|
|
||||||
res.cookie(COOKIE_NAME, token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: isProduction,
|
|
||||||
sameSite: 'strict',
|
|
||||||
path: '/api/auth',
|
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearRefreshCookie(res: Response) {
|
|
||||||
res.clearCookie(COOKIE_NAME, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: isProduction,
|
|
||||||
sameSite: 'strict',
|
|
||||||
path: '/api/auth',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@ApiTags('auth')
|
@ApiTags('auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -54,71 +30,32 @@ export class AuthController {
|
|||||||
@Post('register')
|
@Post('register')
|
||||||
@ApiOperation({ summary: 'Register a new user (disabled unless ALLOW_OPEN_REGISTRATION=true)' })
|
@ApiOperation({ summary: 'Register a new user (disabled unless ALLOW_OPEN_REGISTRATION=true)' })
|
||||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
async register(@Body() dto: RegisterDto) {
|
||||||
if (!isOpenRegistration) {
|
if (!isOpenRegistration) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
'Open registration is disabled. Please use an invitation link to create your account.',
|
'Open registration is disabled. Please use an invitation link to create your account.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const result = await this.authService.register(dto);
|
return this.authService.register(dto);
|
||||||
if (result.refreshToken) {
|
|
||||||
setRefreshCookie(res, result.refreshToken);
|
|
||||||
}
|
|
||||||
const { refreshToken, ...response } = result;
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@ApiOperation({ summary: 'Login with email and password' })
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
@UseGuards(AuthGuard('local'))
|
@UseGuards(AuthGuard('local'))
|
||||||
async login(@Request() req: any, @Body() _dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
||||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||||
const ua = req.headers['user-agent'];
|
const ua = req.headers['user-agent'];
|
||||||
const result = await this.authService.login(req.user, ip, ua);
|
return this.authService.login(req.user, ip, ua);
|
||||||
|
|
||||||
// MFA challenge — no cookie, just return the challenge token
|
|
||||||
if ('mfaRequired' in result) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('refreshToken' in result && result.refreshToken) {
|
|
||||||
setRefreshCookie(res, result.refreshToken);
|
|
||||||
}
|
|
||||||
const { refreshToken: _rt, ...response } = result as any;
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('refresh')
|
|
||||||
@ApiOperation({ summary: 'Refresh access token using httpOnly cookie' })
|
|
||||||
async refresh(@Request() req: any, @Res({ passthrough: true }) res: Response) {
|
|
||||||
const rawToken = req.cookies?.[COOKIE_NAME];
|
|
||||||
if (!rawToken) {
|
|
||||||
throw new BadRequestException('No refresh token');
|
|
||||||
}
|
|
||||||
return this.authService.refreshAccessToken(rawToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@ApiOperation({ summary: 'Logout and revoke refresh token' })
|
@ApiOperation({ summary: 'Logout (invalidate current session)' })
|
||||||
@HttpCode(200)
|
|
||||||
async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) {
|
|
||||||
const rawToken = req.cookies?.[COOKIE_NAME];
|
|
||||||
if (rawToken) {
|
|
||||||
await this.authService.logout(rawToken);
|
|
||||||
}
|
|
||||||
clearRefreshCookie(res);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('logout-everywhere')
|
|
||||||
@ApiOperation({ summary: 'Revoke all sessions' })
|
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) {
|
async logout(@Request() req: any) {
|
||||||
await this.authService.logoutEverywhere(req.user.sub);
|
await this.authService.logout(req.user.sub);
|
||||||
clearRefreshCookie(res);
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,53 +82,10 @@ export class AuthController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto, @Res({ passthrough: true }) res: Response) {
|
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
||||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||||
const ua = req.headers['user-agent'];
|
const ua = req.headers['user-agent'];
|
||||||
const result = await this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
|
return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
|
||||||
if (result.refreshToken) {
|
|
||||||
setRefreshCookie(res, result.refreshToken);
|
|
||||||
}
|
|
||||||
const { refreshToken, ...response } = result;
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Activation Endpoints ─────────────────────────────────────────
|
|
||||||
|
|
||||||
@Get('activate')
|
|
||||||
@ApiOperation({ summary: 'Validate an activation token' })
|
|
||||||
async validateActivation(@Query('token') token: string) {
|
|
||||||
if (!token) throw new BadRequestException('Token required');
|
|
||||||
return this.authService.validateInviteToken(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('activate')
|
|
||||||
@ApiOperation({ summary: 'Activate user account with password' })
|
|
||||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
|
||||||
async activate(
|
|
||||||
@Body() body: { token: string; password: string; fullName: string },
|
|
||||||
@Res({ passthrough: true }) res: Response,
|
|
||||||
) {
|
|
||||||
if (!body.token || !body.password || !body.fullName) {
|
|
||||||
throw new BadRequestException('Token, password, and fullName are required');
|
|
||||||
}
|
|
||||||
if (body.password.length < 8) {
|
|
||||||
throw new BadRequestException('Password must be at least 8 characters');
|
|
||||||
}
|
|
||||||
const result = await this.authService.activateUser(body.token, body.password, body.fullName);
|
|
||||||
if (result.refreshToken) {
|
|
||||||
setRefreshCookie(res, result.refreshToken);
|
|
||||||
}
|
|
||||||
const { refreshToken, ...response } = result;
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('resend-activation')
|
|
||||||
@ApiOperation({ summary: 'Resend activation email' })
|
|
||||||
@Throttle({ default: { limit: 2, ttl: 60000 } })
|
|
||||||
async resendActivation(@Body() body: { email: string }) {
|
|
||||||
// Stubbed — will be implemented when email service is ready
|
|
||||||
return { success: true, message: 'If an account exists, a new activation link has been sent.' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Password Reset Flow ──────────────────────────────────────────
|
// ─── Password Reset Flow ──────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,15 +4,8 @@ import { PassportModule } from '@nestjs/passport';
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
import { MfaController } from './mfa.controller';
|
|
||||||
import { SsoController } from './sso.controller';
|
|
||||||
import { PasskeyController } from './passkey.controller';
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||||
import { RefreshTokenService } from './refresh-token.service';
|
|
||||||
import { MfaService } from './mfa.service';
|
|
||||||
import { SsoService } from './sso.service';
|
|
||||||
import { PasskeyService } from './passkey.service';
|
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { LocalStrategy } from './strategies/local.strategy';
|
import { LocalStrategy } from './strategies/local.strategy';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
@@ -32,23 +25,8 @@ import { OrganizationsModule } from '../organizations/organizations.module';
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [AuthController, AdminController],
|
||||||
AuthController,
|
providers: [AuthService, AdminAnalyticsService, JwtStrategy, LocalStrategy],
|
||||||
AdminController,
|
exports: [AuthService],
|
||||||
MfaController,
|
|
||||||
SsoController,
|
|
||||||
PasskeyController,
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
AuthService,
|
|
||||||
AdminAnalyticsService,
|
|
||||||
RefreshTokenService,
|
|
||||||
MfaService,
|
|
||||||
SsoService,
|
|
||||||
PasskeyService,
|
|
||||||
JwtStrategy,
|
|
||||||
LocalStrategy,
|
|
||||||
],
|
|
||||||
exports: [AuthService, RefreshTokenService, JwtModule],
|
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -16,12 +16,10 @@ import { UsersService } from '../users/users.service';
|
|||||||
import { EmailService } from '../email/email.service';
|
import { EmailService } from '../email/email.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
import { RefreshTokenService } from './refresh-token.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly logger = new Logger(AuthService.name);
|
private readonly logger = new Logger(AuthService.name);
|
||||||
private readonly inviteSecret: string;
|
|
||||||
private readonly appUrl: string;
|
private readonly appUrl: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -29,10 +27,8 @@ export class AuthService {
|
|||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
private refreshTokenService: RefreshTokenService,
|
|
||||||
private emailService: EmailService,
|
private emailService: EmailService,
|
||||||
) {
|
) {
|
||||||
this.inviteSecret = this.configService.get<string>('INVITE_TOKEN_SECRET') || 'dev-invite-secret';
|
|
||||||
this.appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:5173';
|
this.appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:5173';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,25 +84,15 @@ export class AuthService {
|
|||||||
// Record login in history (org_id is null at initial login)
|
// Record login in history (org_id is null at initial login)
|
||||||
this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {});
|
this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {});
|
||||||
|
|
||||||
// If MFA is enabled, return a challenge token instead of full session
|
|
||||||
if (u.mfaEnabled && u.mfaSecret) {
|
|
||||||
const mfaToken = this.jwtService.sign(
|
|
||||||
{ sub: u.id, type: 'mfa_challenge' },
|
|
||||||
{ expiresIn: '5m' },
|
|
||||||
);
|
|
||||||
return { mfaRequired: true, mfaToken };
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.generateTokenResponse(u);
|
return this.generateTokenResponse(u);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete login after MFA verification — generate full session tokens.
|
* Logout — currently a no-op on the server since JWT is stateless.
|
||||||
|
* When refresh tokens are added, this should revoke the refresh token.
|
||||||
*/
|
*/
|
||||||
async completeMfaLogin(userId: string): Promise<any> {
|
async logout(_userId: string): Promise<void> {
|
||||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
// Placeholder for refresh token revocation
|
||||||
if (!user) throw new UnauthorizedException('User not found');
|
|
||||||
return this.generateTokenResponse(user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProfile(userId: string) {
|
async getProfile(userId: string) {
|
||||||
@@ -119,7 +105,6 @@ export class AuthService {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
mfaEnabled: user.mfaEnabled || false,
|
|
||||||
organizations: user.userOrganizations?.map((uo) => ({
|
organizations: user.userOrganizations?.map((uo) => ({
|
||||||
id: uo.organization.id,
|
id: uo.organization.id,
|
||||||
name: uo.organization.name,
|
name: uo.organization.name,
|
||||||
@@ -159,12 +144,8 @@ export class AuthService {
|
|||||||
// Record org switch in login history
|
// Record org switch in login history
|
||||||
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
|
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
|
||||||
|
|
||||||
// Generate new refresh token for org switch
|
|
||||||
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: this.jwtService.sign(payload),
|
accessToken: this.jwtService.sign(payload),
|
||||||
refreshToken,
|
|
||||||
organization: {
|
organization: {
|
||||||
id: membership.organization.id,
|
id: membership.organization.id,
|
||||||
name: membership.organization.name,
|
name: membership.organization.name,
|
||||||
@@ -174,145 +155,10 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh an access token using a valid refresh token.
|
|
||||||
*/
|
|
||||||
async refreshAccessToken(rawRefreshToken: string) {
|
|
||||||
const userId = await this.refreshTokenService.validateRefreshToken(rawRefreshToken);
|
|
||||||
if (!userId) {
|
|
||||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException('User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a new access token (keep same org context if available)
|
|
||||||
const orgs = (user.userOrganizations || []).filter(
|
|
||||||
(uo) => !uo.organization?.status || !['suspended', 'archived'].includes(uo.organization.status),
|
|
||||||
);
|
|
||||||
const defaultOrg = orgs[0];
|
|
||||||
|
|
||||||
const payload: Record<string, any> = {
|
|
||||||
sub: user.id,
|
|
||||||
email: user.email,
|
|
||||||
isSuperadmin: user.isSuperadmin || false,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (defaultOrg) {
|
|
||||||
payload.orgId = defaultOrg.organizationId;
|
|
||||||
payload.role = defaultOrg.role;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessToken: this.jwtService.sign(payload),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logout: revoke the refresh token.
|
|
||||||
*/
|
|
||||||
async logout(rawRefreshToken: string): Promise<void> {
|
|
||||||
if (rawRefreshToken) {
|
|
||||||
await this.refreshTokenService.revokeToken(rawRefreshToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logout everywhere: revoke all refresh tokens for a user.
|
|
||||||
*/
|
|
||||||
async logoutEverywhere(userId: string): Promise<void> {
|
|
||||||
await this.refreshTokenService.revokeAllUserTokens(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async markIntroSeen(userId: string): Promise<void> {
|
async markIntroSeen(userId: string): Promise<void> {
|
||||||
await this.usersService.markIntroSeen(userId);
|
await this.usersService.markIntroSeen(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Invite Token (Activation) Methods ──────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate an invite/activation token.
|
|
||||||
*/
|
|
||||||
async validateInviteToken(token: string) {
|
|
||||||
try {
|
|
||||||
const payload = this.jwtService.verify(token, { secret: this.inviteSecret });
|
|
||||||
if (payload.type !== 'invite') throw new Error('Not an invite token');
|
|
||||||
|
|
||||||
const tokenHash = createHash('sha256').update(token).digest('hex');
|
|
||||||
const rows = await this.dataSource.query(
|
|
||||||
`SELECT it.*, o.name as org_name FROM shared.invite_tokens it
|
|
||||||
JOIN shared.organizations o ON o.id = it.organization_id
|
|
||||||
WHERE it.token_hash = $1`,
|
|
||||||
[tokenHash],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length === 0) throw new Error('Token not found');
|
|
||||||
const row = rows[0];
|
|
||||||
if (row.used_at) throw new BadRequestException('This activation link has already been used');
|
|
||||||
if (new Date(row.expires_at) < new Date()) throw new BadRequestException('This activation link has expired');
|
|
||||||
|
|
||||||
return { valid: true, email: payload.email, orgName: row.org_name, orgId: payload.orgId, userId: payload.userId };
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof BadRequestException) throw err;
|
|
||||||
throw new BadRequestException('Invalid or expired activation link');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Activate a user from an invite token (set password, activate, issue session).
|
|
||||||
*/
|
|
||||||
async activateUser(token: string, password: string, fullName: string) {
|
|
||||||
const info = await this.validateInviteToken(token);
|
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
|
||||||
const [firstName, ...rest] = fullName.trim().split(' ');
|
|
||||||
const lastName = rest.join(' ') || '';
|
|
||||||
|
|
||||||
// Update user record
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.users SET password_hash = $1, first_name = $2, last_name = $3,
|
|
||||||
is_email_verified = true, updated_at = NOW()
|
|
||||||
WHERE id = $4`,
|
|
||||||
[passwordHash, firstName, lastName, info.userId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mark invite token as used
|
|
||||||
const tokenHash = createHash('sha256').update(token).digest('hex');
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.invite_tokens SET used_at = NOW() WHERE token_hash = $1`,
|
|
||||||
[tokenHash],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Issue session
|
|
||||||
const user = await this.usersService.findByIdWithOrgs(info.userId);
|
|
||||||
if (!user) throw new NotFoundException('User not found after activation');
|
|
||||||
|
|
||||||
return this.generateTokenResponse(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a signed invite token for a user/org pair.
|
|
||||||
*/
|
|
||||||
async generateInviteToken(userId: string, orgId: string, email: string): Promise<string> {
|
|
||||||
const token = this.jwtService.sign(
|
|
||||||
{ type: 'invite', userId, orgId, email },
|
|
||||||
{ secret: this.inviteSecret, expiresIn: '72h' },
|
|
||||||
);
|
|
||||||
|
|
||||||
const tokenHash = createHash('sha256').update(token).digest('hex');
|
|
||||||
const expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
await this.dataSource.query(
|
|
||||||
`INSERT INTO shared.invite_tokens (organization_id, user_id, token_hash, expires_at)
|
|
||||||
VALUES ($1, $2, $3, $4)`,
|
|
||||||
[orgId, userId, tokenHash, expiresAt],
|
|
||||||
);
|
|
||||||
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Password Reset Flow ──────────────────────────────────────────
|
// ─── Password Reset Flow ──────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -429,7 +275,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateTokenResponse(user: User, impersonatedBy?: string) {
|
private generateTokenResponse(user: User, impersonatedBy?: string) {
|
||||||
const allOrgs = user.userOrganizations || [];
|
const allOrgs = user.userOrganizations || [];
|
||||||
// Filter out suspended/archived organizations
|
// Filter out suspended/archived organizations
|
||||||
const orgs = allOrgs.filter(
|
const orgs = allOrgs.filter(
|
||||||
@@ -452,12 +298,8 @@ export class AuthService {
|
|||||||
payload.role = defaultOrg.role;
|
payload.role = defaultOrg.role;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create refresh token
|
|
||||||
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: this.jwtService.sign(payload),
|
accessToken: this.jwtService.sign(payload),
|
||||||
refreshToken,
|
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -466,7 +308,6 @@ export class AuthService {
|
|||||||
isSuperadmin: user.isSuperadmin || false,
|
isSuperadmin: user.isSuperadmin || false,
|
||||||
isPlatformOwner: user.isPlatformOwner || false,
|
isPlatformOwner: user.isPlatformOwner || false,
|
||||||
hasSeenIntro: user.hasSeenIntro || false,
|
hasSeenIntro: user.hasSeenIntro || false,
|
||||||
mfaEnabled: user.mfaEnabled || false,
|
|
||||||
},
|
},
|
||||||
organizations: orgs.map((uo) => ({
|
organizations: orgs.map((uo) => ({
|
||||||
id: uo.organizationId,
|
id: uo.organizationId,
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Get,
|
|
||||||
Body,
|
|
||||||
UseGuards,
|
|
||||||
Request,
|
|
||||||
Res,
|
|
||||||
BadRequestException,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
||||||
import { Throttle } from '@nestjs/throttler';
|
|
||||||
import { JwtService } from '@nestjs/jwt';
|
|
||||||
import { Response } from 'express';
|
|
||||||
import { MfaService } from './mfa.service';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
|
||||||
|
|
||||||
const COOKIE_NAME = 'ledgeriq_rt';
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
|
|
||||||
@ApiTags('auth')
|
|
||||||
@Controller('auth/mfa')
|
|
||||||
export class MfaController {
|
|
||||||
constructor(
|
|
||||||
private mfaService: MfaService,
|
|
||||||
private authService: AuthService,
|
|
||||||
private jwtService: JwtService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post('setup')
|
|
||||||
@ApiOperation({ summary: 'Generate MFA setup (QR code + secret)' })
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async setup(@Request() req: any) {
|
|
||||||
return this.mfaService.generateSetup(req.user.sub);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('enable')
|
|
||||||
@ApiOperation({ summary: 'Enable MFA after verifying TOTP code' })
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async enable(@Request() req: any, @Body() body: { token: string }) {
|
|
||||||
if (!body.token) throw new BadRequestException('TOTP code required');
|
|
||||||
return this.mfaService.enableMfa(req.user.sub, body.token);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('verify')
|
|
||||||
@ApiOperation({ summary: 'Verify MFA during login flow' })
|
|
||||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
|
||||||
async verify(
|
|
||||||
@Body() body: { mfaToken: string; token: string; useRecovery?: boolean },
|
|
||||||
@Res({ passthrough: true }) res: Response,
|
|
||||||
) {
|
|
||||||
if (!body.mfaToken || !body.token) {
|
|
||||||
throw new BadRequestException('mfaToken and verification code required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode the MFA challenge token
|
|
||||||
let payload: any;
|
|
||||||
try {
|
|
||||||
payload = this.jwtService.verify(body.mfaToken);
|
|
||||||
if (payload.type !== 'mfa_challenge') throw new Error('Wrong token type');
|
|
||||||
} catch {
|
|
||||||
throw new UnauthorizedException('Invalid or expired MFA challenge');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = payload.sub;
|
|
||||||
let verified = false;
|
|
||||||
|
|
||||||
if (body.useRecovery) {
|
|
||||||
verified = await this.mfaService.verifyRecoveryCode(userId, body.token);
|
|
||||||
} else {
|
|
||||||
verified = await this.mfaService.verifyMfa(userId, body.token);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verified) {
|
|
||||||
throw new UnauthorizedException('Invalid verification code');
|
|
||||||
}
|
|
||||||
|
|
||||||
// MFA passed — issue full session
|
|
||||||
const result = await this.authService.completeMfaLogin(userId);
|
|
||||||
if (result.refreshToken) {
|
|
||||||
res.cookie(COOKIE_NAME, result.refreshToken, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: isProduction,
|
|
||||||
sameSite: 'strict',
|
|
||||||
path: '/api/auth',
|
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const { refreshToken: _rt, ...response } = result;
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('disable')
|
|
||||||
@ApiOperation({ summary: 'Disable MFA (requires password)' })
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async disable(@Request() req: any, @Body() body: { password: string }) {
|
|
||||||
if (!body.password) throw new BadRequestException('Password required to disable MFA');
|
|
||||||
|
|
||||||
// Verify password first
|
|
||||||
const user = await this.authService.validateUser(req.user.email, body.password);
|
|
||||||
if (!user) throw new UnauthorizedException('Invalid password');
|
|
||||||
|
|
||||||
await this.mfaService.disableMfa(req.user.sub);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('status')
|
|
||||||
@ApiOperation({ summary: 'Get MFA status' })
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@AllowViewer()
|
|
||||||
async status(@Request() req: any) {
|
|
||||||
return this.mfaService.getStatus(req.user.sub);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import { Injectable, Logger, BadRequestException, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
import * as bcrypt from 'bcryptjs';
|
|
||||||
import { generateSecret, generateURI, verifySync } from 'otplib';
|
|
||||||
import * as QRCode from 'qrcode';
|
|
||||||
import { randomBytes } from 'crypto';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MfaService {
|
|
||||||
private readonly logger = new Logger(MfaService.name);
|
|
||||||
|
|
||||||
constructor(private dataSource: DataSource) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate MFA setup data (secret + QR code) for a user.
|
|
||||||
*/
|
|
||||||
async generateSetup(userId: string): Promise<{ secret: string; qrDataUrl: string; otpauthUrl: string }> {
|
|
||||||
const userRows = await this.dataSource.query(
|
|
||||||
`SELECT email, mfa_enabled FROM shared.users WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
if (userRows.length === 0) throw new BadRequestException('User not found');
|
|
||||||
|
|
||||||
const secret = generateSecret();
|
|
||||||
const otpauthUrl = generateURI({ secret, issuer: 'HOA LedgerIQ', label: userRows[0].email });
|
|
||||||
const qrDataUrl = await QRCode.toDataURL(otpauthUrl);
|
|
||||||
|
|
||||||
// Store the secret temporarily (not verified yet)
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.users SET mfa_secret = $1, updated_at = NOW() WHERE id = $2`,
|
|
||||||
[secret, userId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { secret, qrDataUrl, otpauthUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable MFA after verifying the initial TOTP code.
|
|
||||||
* Returns recovery codes.
|
|
||||||
*/
|
|
||||||
async enableMfa(userId: string, token: string): Promise<{ recoveryCodes: string[] }> {
|
|
||||||
const userRows = await this.dataSource.query(
|
|
||||||
`SELECT mfa_secret, mfa_enabled FROM shared.users WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
if (userRows.length === 0) throw new BadRequestException('User not found');
|
|
||||||
if (!userRows[0].mfa_secret) throw new BadRequestException('MFA setup not initiated');
|
|
||||||
if (userRows[0].mfa_enabled) throw new BadRequestException('MFA is already enabled');
|
|
||||||
|
|
||||||
// Verify the token
|
|
||||||
const result = verifySync({ token, secret: userRows[0].mfa_secret });
|
|
||||||
if (!result.valid) throw new BadRequestException('Invalid verification code');
|
|
||||||
|
|
||||||
// Generate recovery codes
|
|
||||||
const recoveryCodes = Array.from({ length: 10 }, () =>
|
|
||||||
randomBytes(4).toString('hex').toUpperCase(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Hash recovery codes for storage
|
|
||||||
const hashedCodes = await Promise.all(
|
|
||||||
recoveryCodes.map((code) => bcrypt.hash(code, 10)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Enable MFA
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.users SET
|
|
||||||
mfa_enabled = true,
|
|
||||||
totp_verified_at = NOW(),
|
|
||||||
recovery_codes = $1,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $2`,
|
|
||||||
[JSON.stringify(hashedCodes), userId],
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`MFA enabled for user ${userId}`);
|
|
||||||
return { recoveryCodes };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a TOTP code during login.
|
|
||||||
*/
|
|
||||||
async verifyMfa(userId: string, token: string): Promise<boolean> {
|
|
||||||
const userRows = await this.dataSource.query(
|
|
||||||
`SELECT mfa_secret, mfa_enabled FROM shared.users WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
if (userRows.length === 0 || !userRows[0].mfa_enabled) return false;
|
|
||||||
|
|
||||||
const result = verifySync({ token, secret: userRows[0].mfa_secret });
|
|
||||||
return result.valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify a recovery code (consumes it on success).
|
|
||||||
*/
|
|
||||||
async verifyRecoveryCode(userId: string, code: string): Promise<boolean> {
|
|
||||||
const userRows = await this.dataSource.query(
|
|
||||||
`SELECT recovery_codes FROM shared.users WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
if (userRows.length === 0 || !userRows[0].recovery_codes) return false;
|
|
||||||
|
|
||||||
const hashedCodes: string[] = JSON.parse(userRows[0].recovery_codes);
|
|
||||||
|
|
||||||
for (let i = 0; i < hashedCodes.length; i++) {
|
|
||||||
const match = await bcrypt.compare(code.toUpperCase(), hashedCodes[i]);
|
|
||||||
if (match) {
|
|
||||||
// Remove the used code
|
|
||||||
hashedCodes.splice(i, 1);
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.users SET recovery_codes = $1, updated_at = NOW() WHERE id = $2`,
|
|
||||||
[JSON.stringify(hashedCodes), userId],
|
|
||||||
);
|
|
||||||
this.logger.log(`Recovery code used for user ${userId}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disable MFA (requires password verification done by caller).
|
|
||||||
*/
|
|
||||||
async disableMfa(userId: string): Promise<void> {
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.users SET
|
|
||||||
mfa_enabled = false,
|
|
||||||
mfa_secret = NULL,
|
|
||||||
totp_verified_at = NULL,
|
|
||||||
recovery_codes = NULL,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
this.logger.log(`MFA disabled for user ${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get MFA status for a user.
|
|
||||||
*/
|
|
||||||
async getStatus(userId: string): Promise<{ enabled: boolean; hasRecoveryCodes: boolean }> {
|
|
||||||
const rows = await this.dataSource.query(
|
|
||||||
`SELECT mfa_enabled, recovery_codes FROM shared.users WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
if (rows.length === 0) return { enabled: false, hasRecoveryCodes: false };
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled: rows[0].mfa_enabled || false,
|
|
||||||
hasRecoveryCodes: !!rows[0].recovery_codes && JSON.parse(rows[0].recovery_codes || '[]').length > 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Get,
|
|
||||||
Delete,
|
|
||||||
Param,
|
|
||||||
Body,
|
|
||||||
UseGuards,
|
|
||||||
Request,
|
|
||||||
Res,
|
|
||||||
BadRequestException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
||||||
import { Throttle } from '@nestjs/throttler';
|
|
||||||
import { Response } from 'express';
|
|
||||||
import { PasskeyService } from './passkey.service';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
|
||||||
|
|
||||||
const COOKIE_NAME = 'ledgeriq_rt';
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
|
|
||||||
@ApiTags('auth')
|
|
||||||
@Controller('auth/passkeys')
|
|
||||||
export class PasskeyController {
|
|
||||||
constructor(
|
|
||||||
private passkeyService: PasskeyService,
|
|
||||||
private authService: AuthService,
|
|
||||||
private usersService: UsersService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post('register-options')
|
|
||||||
@ApiOperation({ summary: 'Get passkey registration options' })
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async getRegistrationOptions(@Request() req: any) {
|
|
||||||
return this.passkeyService.generateRegistrationOptions(req.user.sub);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('register')
|
|
||||||
@ApiOperation({ summary: 'Register a new passkey' })
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async register(
|
|
||||||
@Request() req: any,
|
|
||||||
@Body() body: { response: any; deviceName?: string },
|
|
||||||
) {
|
|
||||||
if (!body.response) throw new BadRequestException('Attestation response required');
|
|
||||||
return this.passkeyService.verifyRegistration(req.user.sub, body.response, body.deviceName);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('login-options')
|
|
||||||
@ApiOperation({ summary: 'Get passkey login options' })
|
|
||||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
|
||||||
async getLoginOptions(@Body() body: { email?: string }) {
|
|
||||||
return this.passkeyService.generateAuthenticationOptions(body.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('login')
|
|
||||||
@ApiOperation({ summary: 'Authenticate with passkey' })
|
|
||||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
|
||||||
async login(
|
|
||||||
@Body() body: { response: any; challenge: string },
|
|
||||||
@Res({ passthrough: true }) res: Response,
|
|
||||||
) {
|
|
||||||
if (!body.response || !body.challenge) {
|
|
||||||
throw new BadRequestException('Assertion response and challenge required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { userId } = await this.passkeyService.verifyAuthentication(body.response, body.challenge);
|
|
||||||
|
|
||||||
// Get user with orgs and generate session
|
|
||||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
|
||||||
if (!user) throw new BadRequestException('User not found');
|
|
||||||
|
|
||||||
await this.usersService.updateLastLogin(userId);
|
|
||||||
const result = await this.authService.generateTokenResponse(user);
|
|
||||||
|
|
||||||
if (result.refreshToken) {
|
|
||||||
res.cookie(COOKIE_NAME, result.refreshToken, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: isProduction,
|
|
||||||
sameSite: 'strict',
|
|
||||||
path: '/api/auth',
|
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { refreshToken: _rt, ...response } = result;
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
@ApiOperation({ summary: 'List registered passkeys' })
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@AllowViewer()
|
|
||||||
async list(@Request() req: any) {
|
|
||||||
return this.passkeyService.listPasskeys(req.user.sub);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete(':id')
|
|
||||||
@ApiOperation({ summary: 'Remove a passkey' })
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async remove(@Request() req: any, @Param('id') passkeyId: string) {
|
|
||||||
await this.passkeyService.removePasskey(req.user.sub, passkeyId);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import { Injectable, Logger, BadRequestException, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
import {
|
|
||||||
generateRegistrationOptions,
|
|
||||||
verifyRegistrationResponse,
|
|
||||||
generateAuthenticationOptions,
|
|
||||||
verifyAuthenticationResponse,
|
|
||||||
} from '@simplewebauthn/server';
|
|
||||||
|
|
||||||
// Use inline type aliases to avoid ESM-only @simplewebauthn/types import issue
|
|
||||||
type RegistrationResponseJSON = any;
|
|
||||||
type AuthenticationResponseJSON = any;
|
|
||||||
type AuthenticatorTransportFuture = any;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PasskeyService {
|
|
||||||
private readonly logger = new Logger(PasskeyService.name);
|
|
||||||
private rpID: string;
|
|
||||||
private rpName: string;
|
|
||||||
private origin: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private configService: ConfigService,
|
|
||||||
private dataSource: DataSource,
|
|
||||||
) {
|
|
||||||
this.rpID = this.configService.get<string>('WEBAUTHN_RP_ID') || 'localhost';
|
|
||||||
this.rpName = 'HOA LedgerIQ';
|
|
||||||
this.origin = this.configService.get<string>('WEBAUTHN_RP_ORIGIN') || 'http://localhost';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate registration options for navigator.credentials.create().
|
|
||||||
*/
|
|
||||||
async generateRegistrationOptions(userId: string) {
|
|
||||||
const userRows = await this.dataSource.query(
|
|
||||||
`SELECT id, email, first_name, last_name FROM shared.users WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
if (userRows.length === 0) throw new BadRequestException('User not found');
|
|
||||||
const user = userRows[0];
|
|
||||||
|
|
||||||
// Get existing passkeys for exclusion
|
|
||||||
const existingKeys = await this.dataSource.query(
|
|
||||||
`SELECT credential_id, transports FROM shared.user_passkeys WHERE user_id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const options = await generateRegistrationOptions({
|
|
||||||
rpName: this.rpName,
|
|
||||||
rpID: this.rpID,
|
|
||||||
userID: new TextEncoder().encode(userId),
|
|
||||||
userName: user.email,
|
|
||||||
userDisplayName: `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email,
|
|
||||||
attestationType: 'none',
|
|
||||||
excludeCredentials: existingKeys.map((k: any) => ({
|
|
||||||
id: k.credential_id,
|
|
||||||
type: 'public-key' as const,
|
|
||||||
transports: k.transports || [],
|
|
||||||
})),
|
|
||||||
authenticatorSelection: {
|
|
||||||
residentKey: 'preferred',
|
|
||||||
userVerification: 'preferred',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store challenge temporarily
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.users SET webauthn_challenge = $1, updated_at = NOW() WHERE id = $2`,
|
|
||||||
[options.challenge, userId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify and store a passkey registration.
|
|
||||||
*/
|
|
||||||
async verifyRegistration(userId: string, response: RegistrationResponseJSON, deviceName?: string) {
|
|
||||||
const userRows = await this.dataSource.query(
|
|
||||||
`SELECT webauthn_challenge FROM shared.users WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
if (userRows.length === 0) throw new BadRequestException('User not found');
|
|
||||||
const expectedChallenge = userRows[0].webauthn_challenge;
|
|
||||||
if (!expectedChallenge) throw new BadRequestException('No registration challenge found');
|
|
||||||
|
|
||||||
const verification = await verifyRegistrationResponse({
|
|
||||||
response,
|
|
||||||
expectedChallenge,
|
|
||||||
expectedOrigin: this.origin,
|
|
||||||
expectedRPID: this.rpID,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!verification.verified || !verification.registrationInfo) {
|
|
||||||
throw new BadRequestException('Passkey registration verification failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { credential } = verification.registrationInfo;
|
|
||||||
|
|
||||||
// Store the passkey
|
|
||||||
await this.dataSource.query(
|
|
||||||
`INSERT INTO shared.user_passkeys (user_id, credential_id, public_key, counter, device_name, transports)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
||||||
[
|
|
||||||
userId,
|
|
||||||
Buffer.from(credential.id).toString('base64url'),
|
|
||||||
Buffer.from(credential.publicKey).toString('base64url'),
|
|
||||||
credential.counter,
|
|
||||||
deviceName || 'Passkey',
|
|
||||||
credential.transports || [],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clear challenge
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.users SET webauthn_challenge = NULL WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Passkey registered for user ${userId}`);
|
|
||||||
return { verified: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate authentication options for navigator.credentials.get().
|
|
||||||
*/
|
|
||||||
async generateAuthenticationOptions(email?: string) {
|
|
||||||
let allowCredentials: any[] | undefined;
|
|
||||||
|
|
||||||
if (email) {
|
|
||||||
const userRows = await this.dataSource.query(
|
|
||||||
`SELECT u.id FROM shared.users u WHERE u.email = $1`,
|
|
||||||
[email],
|
|
||||||
);
|
|
||||||
if (userRows.length > 0) {
|
|
||||||
const passkeys = await this.dataSource.query(
|
|
||||||
`SELECT credential_id, transports FROM shared.user_passkeys WHERE user_id = $1`,
|
|
||||||
[userRows[0].id],
|
|
||||||
);
|
|
||||||
allowCredentials = passkeys.map((k: any) => ({
|
|
||||||
id: k.credential_id,
|
|
||||||
type: 'public-key' as const,
|
|
||||||
transports: k.transports || [],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = await generateAuthenticationOptions({
|
|
||||||
rpID: this.rpID,
|
|
||||||
allowCredentials,
|
|
||||||
userVerification: 'preferred',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store challenge — for passkey login we need a temporary storage
|
|
||||||
// Since we don't know the user yet, store in a shared way
|
|
||||||
// In production, use Redis/session. For now, we'll pass it back and verify client-side.
|
|
||||||
return { ...options, challenge: options.challenge };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify authentication and return the user.
|
|
||||||
*/
|
|
||||||
async verifyAuthentication(response: AuthenticationResponseJSON, expectedChallenge: string) {
|
|
||||||
// Find the credential
|
|
||||||
const credId = response.id;
|
|
||||||
const passkeys = await this.dataSource.query(
|
|
||||||
`SELECT p.*, u.id as user_id, u.email
|
|
||||||
FROM shared.user_passkeys p
|
|
||||||
JOIN shared.users u ON u.id = p.user_id
|
|
||||||
WHERE p.credential_id = $1`,
|
|
||||||
[credId],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (passkeys.length === 0) {
|
|
||||||
throw new UnauthorizedException('Passkey not recognized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const passkey = passkeys[0];
|
|
||||||
|
|
||||||
const verification = await verifyAuthenticationResponse({
|
|
||||||
response,
|
|
||||||
expectedChallenge,
|
|
||||||
expectedOrigin: this.origin,
|
|
||||||
expectedRPID: this.rpID,
|
|
||||||
credential: {
|
|
||||||
id: passkey.credential_id,
|
|
||||||
publicKey: Buffer.from(passkey.public_key, 'base64url'),
|
|
||||||
counter: Number(passkey.counter),
|
|
||||||
transports: (passkey.transports || []) as AuthenticatorTransportFuture[],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!verification.verified) {
|
|
||||||
throw new UnauthorizedException('Passkey authentication failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update counter and last_used_at
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.user_passkeys SET counter = $1, last_used_at = NOW() WHERE id = $2`,
|
|
||||||
[verification.authenticationInfo.newCounter, passkey.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { userId: passkey.user_id };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List user's registered passkeys.
|
|
||||||
*/
|
|
||||||
async listPasskeys(userId: string) {
|
|
||||||
const rows = await this.dataSource.query(
|
|
||||||
`SELECT id, device_name, created_at, last_used_at
|
|
||||||
FROM shared.user_passkeys
|
|
||||||
WHERE user_id = $1
|
|
||||||
ORDER BY created_at DESC`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a passkey.
|
|
||||||
*/
|
|
||||||
async removePasskey(userId: string, passkeyId: string): Promise<void> {
|
|
||||||
// Check that user has password or other passkeys
|
|
||||||
const [userRows, passkeyCount] = await Promise.all([
|
|
||||||
this.dataSource.query(`SELECT password_hash FROM shared.users WHERE id = $1`, [userId]),
|
|
||||||
this.dataSource.query(
|
|
||||||
`SELECT COUNT(*) as cnt FROM shared.user_passkeys WHERE user_id = $1`,
|
|
||||||
[userId],
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hasPassword = !!userRows[0]?.password_hash;
|
|
||||||
const count = parseInt(passkeyCount[0]?.cnt || '0', 10);
|
|
||||||
|
|
||||||
if (!hasPassword && count <= 1) {
|
|
||||||
throw new BadRequestException('Cannot remove your only passkey without a password set');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.dataSource.query(
|
|
||||||
`DELETE FROM shared.user_passkeys WHERE id = $1 AND user_id = $2`,
|
|
||||||
[passkeyId, userId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { randomBytes, createHash } from 'crypto';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RefreshTokenService {
|
|
||||||
private readonly logger = new Logger(RefreshTokenService.name);
|
|
||||||
|
|
||||||
constructor(private dataSource: DataSource) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new refresh token for a user.
|
|
||||||
* Returns the raw (unhashed) token to be sent as an httpOnly cookie.
|
|
||||||
*/
|
|
||||||
async createRefreshToken(userId: string): Promise<string> {
|
|
||||||
const rawToken = randomBytes(64).toString('base64url');
|
|
||||||
const tokenHash = this.hashToken(rawToken);
|
|
||||||
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
|
||||||
|
|
||||||
await this.dataSource.query(
|
|
||||||
`INSERT INTO shared.refresh_tokens (user_id, token_hash, expires_at)
|
|
||||||
VALUES ($1, $2, $3)`,
|
|
||||||
[userId, tokenHash, expiresAt],
|
|
||||||
);
|
|
||||||
|
|
||||||
return rawToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate a refresh token. Returns the user_id if valid, null otherwise.
|
|
||||||
*/
|
|
||||||
async validateRefreshToken(rawToken: string): Promise<string | null> {
|
|
||||||
const tokenHash = this.hashToken(rawToken);
|
|
||||||
|
|
||||||
const rows = await this.dataSource.query(
|
|
||||||
`SELECT user_id, expires_at, revoked_at
|
|
||||||
FROM shared.refresh_tokens
|
|
||||||
WHERE token_hash = $1`,
|
|
||||||
[tokenHash],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length === 0) return null;
|
|
||||||
|
|
||||||
const { user_id, expires_at, revoked_at } = rows[0];
|
|
||||||
|
|
||||||
// Check if revoked
|
|
||||||
if (revoked_at) return null;
|
|
||||||
|
|
||||||
// Check if expired
|
|
||||||
if (new Date(expires_at) < new Date()) return null;
|
|
||||||
|
|
||||||
return user_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke a single refresh token.
|
|
||||||
*/
|
|
||||||
async revokeToken(rawToken: string): Promise<void> {
|
|
||||||
const tokenHash = this.hashToken(rawToken);
|
|
||||||
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`,
|
|
||||||
[tokenHash],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke all refresh tokens for a user ("log out everywhere").
|
|
||||||
*/
|
|
||||||
async revokeAllUserTokens(userId: string): Promise<void> {
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.refresh_tokens SET revoked_at = NOW()
|
|
||||||
WHERE user_id = $1 AND revoked_at IS NULL`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove expired / revoked tokens older than 7 days.
|
|
||||||
* Called periodically to keep the table clean.
|
|
||||||
*/
|
|
||||||
async cleanupExpired(): Promise<number> {
|
|
||||||
const result = await this.dataSource.query(
|
|
||||||
`DELETE FROM shared.refresh_tokens
|
|
||||||
WHERE (expires_at < NOW() - INTERVAL '7 days')
|
|
||||||
OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days')`,
|
|
||||||
);
|
|
||||||
const deleted = result?.[1] ?? 0;
|
|
||||||
if (deleted > 0) {
|
|
||||||
this.logger.log(`Cleaned up ${deleted} expired/revoked refresh tokens`);
|
|
||||||
}
|
|
||||||
return deleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
private hashToken(rawToken: string): string {
|
|
||||||
return createHash('sha256').update(rawToken).digest('hex');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
Delete,
|
|
||||||
Param,
|
|
||||||
UseGuards,
|
|
||||||
Request,
|
|
||||||
Res,
|
|
||||||
BadRequestException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
||||||
import { Response } from 'express';
|
|
||||||
import { SsoService } from './sso.service';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
|
||||||
|
|
||||||
const COOKIE_NAME = 'ledgeriq_rt';
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
|
||||||
|
|
||||||
@ApiTags('auth')
|
|
||||||
@Controller('auth')
|
|
||||||
export class SsoController {
|
|
||||||
constructor(
|
|
||||||
private ssoService: SsoService,
|
|
||||||
private authService: AuthService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('sso/providers')
|
|
||||||
@ApiOperation({ summary: 'Get available SSO providers' })
|
|
||||||
getProviders() {
|
|
||||||
return this.ssoService.getAvailableProviders();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Google OAuth routes would be:
|
|
||||||
// GET /auth/google → passport.authenticate('google')
|
|
||||||
// GET /auth/google/callback → passport callback
|
|
||||||
// These are registered conditionally in auth.module.ts if env vars are set.
|
|
||||||
// For now, we'll add the callback handler:
|
|
||||||
|
|
||||||
@Get('google/callback')
|
|
||||||
@ApiOperation({ summary: 'Google OAuth callback' })
|
|
||||||
async googleCallback(@Request() req: any, @Res() res: Response) {
|
|
||||||
if (!req.user) {
|
|
||||||
return res.redirect('/login?error=sso_failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.authService.generateTokenResponse(req.user);
|
|
||||||
|
|
||||||
// Set refresh token cookie
|
|
||||||
if (result.refreshToken) {
|
|
||||||
res.cookie(COOKIE_NAME, result.refreshToken, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: isProduction,
|
|
||||||
sameSite: 'strict',
|
|
||||||
path: '/api/auth',
|
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to app with access token in URL fragment (for SPA to pick up)
|
|
||||||
return res.redirect(`/sso-callback?token=${result.accessToken}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('azure/callback')
|
|
||||||
@ApiOperation({ summary: 'Azure AD OAuth callback' })
|
|
||||||
async azureCallback(@Request() req: any, @Res() res: Response) {
|
|
||||||
if (!req.user) {
|
|
||||||
return res.redirect('/login?error=sso_failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.authService.generateTokenResponse(req.user);
|
|
||||||
|
|
||||||
if (result.refreshToken) {
|
|
||||||
res.cookie(COOKIE_NAME, result.refreshToken, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: isProduction,
|
|
||||||
sameSite: 'strict',
|
|
||||||
path: '/api/auth',
|
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.redirect(`/sso-callback?token=${result.accessToken}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('sso/link')
|
|
||||||
@ApiOperation({ summary: 'Link SSO provider to current user' })
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async linkAccount(@Request() req: any) {
|
|
||||||
// This would typically be done via the OAuth redirect flow
|
|
||||||
// For now, it's a placeholder
|
|
||||||
throw new BadRequestException('Use the OAuth redirect flow to link accounts');
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('sso/unlink/:provider')
|
|
||||||
@ApiOperation({ summary: 'Unlink SSO provider from current user' })
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async unlinkAccount(@Request() req: any, @Param('provider') provider: string) {
|
|
||||||
await this.ssoService.unlinkSsoAccount(req.user.sub, provider);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { UsersService } from '../users/users.service';
|
|
||||||
|
|
||||||
interface SsoProfile {
|
|
||||||
provider: string;
|
|
||||||
providerId: string;
|
|
||||||
email: string;
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SsoService {
|
|
||||||
private readonly logger = new Logger(SsoService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private dataSource: DataSource,
|
|
||||||
private usersService: UsersService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find existing user by SSO provider+id, or by email match, or create new.
|
|
||||||
*/
|
|
||||||
async findOrCreateSsoUser(profile: SsoProfile) {
|
|
||||||
// 1. Try to find by provider + provider ID
|
|
||||||
const byProvider = await this.dataSource.query(
|
|
||||||
`SELECT * FROM shared.users WHERE oauth_provider = $1 AND oauth_provider_id = $2`,
|
|
||||||
[profile.provider, profile.providerId],
|
|
||||||
);
|
|
||||||
if (byProvider.length > 0) {
|
|
||||||
return this.usersService.findByIdWithOrgs(byProvider[0].id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try to find by email match (link accounts)
|
|
||||||
const byEmail = await this.usersService.findByEmail(profile.email);
|
|
||||||
if (byEmail) {
|
|
||||||
// Link the SSO provider to existing account
|
|
||||||
await this.linkSsoAccount(byEmail.id, profile.provider, profile.providerId);
|
|
||||||
return this.usersService.findByIdWithOrgs(byEmail.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Create new user
|
|
||||||
const newUser = await this.dataSource.query(
|
|
||||||
`INSERT INTO shared.users (email, first_name, last_name, oauth_provider, oauth_provider_id, is_email_verified)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, true)
|
|
||||||
RETURNING id`,
|
|
||||||
[profile.email, profile.firstName || '', profile.lastName || '', profile.provider, profile.providerId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.usersService.findByIdWithOrgs(newUser[0].id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Link an SSO provider to an existing user.
|
|
||||||
*/
|
|
||||||
async linkSsoAccount(userId: string, provider: string, providerId: string): Promise<void> {
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.users SET oauth_provider = $1, oauth_provider_id = $2, updated_at = NOW() WHERE id = $3`,
|
|
||||||
[provider, providerId, userId],
|
|
||||||
);
|
|
||||||
this.logger.log(`Linked ${provider} SSO to user ${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unlink SSO from a user (only if they have a password set).
|
|
||||||
*/
|
|
||||||
async unlinkSsoAccount(userId: string, provider: string): Promise<void> {
|
|
||||||
const rows = await this.dataSource.query(
|
|
||||||
`SELECT password_hash, oauth_provider FROM shared.users WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
if (rows.length === 0) throw new BadRequestException('User not found');
|
|
||||||
if (!rows[0].password_hash) {
|
|
||||||
throw new BadRequestException('Cannot unlink SSO — you must set a password first');
|
|
||||||
}
|
|
||||||
if (rows[0].oauth_provider !== provider) {
|
|
||||||
throw new BadRequestException('SSO provider mismatch');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.users SET oauth_provider = NULL, oauth_provider_id = NULL, updated_at = NOW() WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
this.logger.log(`Unlinked ${provider} SSO from user ${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get which SSO providers are configured.
|
|
||||||
*/
|
|
||||||
getAvailableProviders(): { google: boolean; azure: boolean } {
|
|
||||||
return {
|
|
||||||
google: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
|
|
||||||
azure: !!(process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
Put,
|
|
||||||
Get,
|
|
||||||
Body,
|
|
||||||
Param,
|
|
||||||
Query,
|
|
||||||
Req,
|
|
||||||
UseGuards,
|
|
||||||
RawBodyRequest,
|
|
||||||
BadRequestException,
|
|
||||||
ForbiddenException,
|
|
||||||
Request,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
||||||
import { Throttle } from '@nestjs/throttler';
|
|
||||||
import { Request as ExpressRequest } from 'express';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { BillingService } from './billing.service';
|
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
||||||
|
|
||||||
@ApiTags('billing')
|
|
||||||
@Controller()
|
|
||||||
export class BillingController {
|
|
||||||
constructor(
|
|
||||||
private billingService: BillingService,
|
|
||||||
private dataSource: DataSource,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post('billing/start-trial')
|
|
||||||
@ApiOperation({ summary: 'Start a free trial (no card required)' })
|
|
||||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
|
||||||
async startTrial(
|
|
||||||
@Body() body: { planId: string; billingInterval?: 'month' | 'year'; email: string; businessName: string },
|
|
||||||
) {
|
|
||||||
if (!body.planId) throw new BadRequestException('planId is required');
|
|
||||||
if (!body.email) throw new BadRequestException('email is required');
|
|
||||||
if (!body.businessName) throw new BadRequestException('businessName is required');
|
|
||||||
return this.billingService.startTrial(
|
|
||||||
body.planId,
|
|
||||||
body.billingInterval || 'month',
|
|
||||||
body.email,
|
|
||||||
body.businessName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('billing/create-checkout-session')
|
|
||||||
@ApiOperation({ summary: 'Create a Stripe Checkout Session' })
|
|
||||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
|
||||||
async createCheckout(
|
|
||||||
@Body() body: { planId: string; billingInterval?: 'month' | 'year'; email?: string; businessName?: string },
|
|
||||||
) {
|
|
||||||
if (!body.planId) throw new BadRequestException('planId is required');
|
|
||||||
return this.billingService.createCheckoutSession(
|
|
||||||
body.planId,
|
|
||||||
body.billingInterval || 'month',
|
|
||||||
body.email,
|
|
||||||
body.businessName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('webhooks/stripe')
|
|
||||||
@ApiOperation({ summary: 'Stripe webhook endpoint' })
|
|
||||||
async handleWebhook(@Req() req: RawBodyRequest<ExpressRequest>) {
|
|
||||||
const signature = req.headers['stripe-signature'] as string;
|
|
||||||
if (!signature) throw new BadRequestException('Missing Stripe signature');
|
|
||||||
if (!req.rawBody) throw new BadRequestException('Missing raw body');
|
|
||||||
await this.billingService.handleWebhook(req.rawBody, signature);
|
|
||||||
return { received: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('billing/status')
|
|
||||||
@ApiOperation({ summary: 'Check provisioning status for a checkout session or subscription' })
|
|
||||||
async getStatus(@Query('session_id') sessionId: string) {
|
|
||||||
if (!sessionId) throw new BadRequestException('session_id required');
|
|
||||||
return this.billingService.getProvisioningStatus(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('billing/subscription')
|
|
||||||
@ApiOperation({ summary: 'Get current subscription info' })
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async getSubscription(@Request() req: any) {
|
|
||||||
const orgId = req.user.orgId;
|
|
||||||
if (!orgId) throw new BadRequestException('No organization context');
|
|
||||||
return this.billingService.getSubscriptionInfo(orgId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('billing/portal')
|
|
||||||
@ApiOperation({ summary: 'Create Stripe Customer Portal session' })
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async createPortal(@Request() req: any) {
|
|
||||||
const orgId = req.user.orgId;
|
|
||||||
if (!orgId) throw new BadRequestException('No organization context');
|
|
||||||
return this.billingService.createPortalSession(orgId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Admin: Switch Billing Method (ACH / Invoice) ──────────
|
|
||||||
|
|
||||||
@Put('admin/organizations/:id/billing')
|
|
||||||
@ApiOperation({ summary: 'Switch organization billing method (superadmin only)' })
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async updateBillingMethod(
|
|
||||||
@Request() req: any,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() body: { collectionMethod: 'charge_automatically' | 'send_invoice'; daysUntilDue?: number },
|
|
||||||
) {
|
|
||||||
// Require superadmin
|
|
||||||
const userId = req.user.userId || req.user.sub;
|
|
||||||
const userRows = await this.dataSource.query(
|
|
||||||
`SELECT is_superadmin FROM shared.users WHERE id = $1`,
|
|
||||||
[userId],
|
|
||||||
);
|
|
||||||
if (!userRows.length || !userRows[0].is_superadmin) {
|
|
||||||
throw new ForbiddenException('Superadmin access required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!['charge_automatically', 'send_invoice'].includes(body.collectionMethod)) {
|
|
||||||
throw new BadRequestException('collectionMethod must be "charge_automatically" or "send_invoice"');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.billingService.switchToInvoiceBilling(
|
|
||||||
id,
|
|
||||||
body.collectionMethod,
|
|
||||||
body.daysUntilDue || 30,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { BillingService } from './billing.service';
|
|
||||||
import { BillingController } from './billing.controller';
|
|
||||||
import { AuthModule } from '../auth/auth.module';
|
|
||||||
import { DatabaseModule } from '../../database/database.module';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [AuthModule, DatabaseModule],
|
|
||||||
controllers: [BillingController],
|
|
||||||
providers: [BillingService],
|
|
||||||
exports: [BillingService],
|
|
||||||
})
|
|
||||||
export class BillingModule {}
|
|
||||||
@@ -1,678 +0,0 @@
|
|||||||
import { Injectable, Logger, BadRequestException, RawBodyRequest } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
import Stripe from 'stripe';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import * as bcrypt from 'bcryptjs';
|
|
||||||
import { TenantSchemaService } from '../../database/tenant-schema.service';
|
|
||||||
import { AuthService } from '../auth/auth.service';
|
|
||||||
import { EmailService } from '../email/email.service';
|
|
||||||
|
|
||||||
const PLAN_FEATURES: Record<string, { name: string; unitLimit: number }> = {
|
|
||||||
starter: { name: 'Starter', unitLimit: 50 },
|
|
||||||
professional: { name: 'Professional', unitLimit: 200 },
|
|
||||||
enterprise: { name: 'Enterprise', unitLimit: 999999 },
|
|
||||||
};
|
|
||||||
|
|
||||||
type BillingInterval = 'month' | 'year';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BillingService {
|
|
||||||
private readonly logger = new Logger(BillingService.name);
|
|
||||||
private stripe: Stripe | null = null;
|
|
||||||
private webhookSecret: string;
|
|
||||||
private priceMap: Record<string, { monthly: string; annual: string }>;
|
|
||||||
private requirePaymentForTrial: boolean;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private configService: ConfigService,
|
|
||||||
private dataSource: DataSource,
|
|
||||||
private tenantSchemaService: TenantSchemaService,
|
|
||||||
private authService: AuthService,
|
|
||||||
private emailService: EmailService,
|
|
||||||
) {
|
|
||||||
const secretKey = this.configService.get<string>('STRIPE_SECRET_KEY');
|
|
||||||
if (secretKey && !secretKey.includes('placeholder')) {
|
|
||||||
this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' as any });
|
|
||||||
this.logger.log('Stripe initialized');
|
|
||||||
} else {
|
|
||||||
this.logger.warn('Stripe not configured — billing endpoints will return stubs');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET') || '';
|
|
||||||
this.requirePaymentForTrial =
|
|
||||||
this.configService.get<string>('REQUIRE_PAYMENT_METHOD_FOR_TRIAL') === 'true';
|
|
||||||
|
|
||||||
// Build price map with backward-compat: new monthly vars fall back to old single vars
|
|
||||||
this.priceMap = {
|
|
||||||
starter: {
|
|
||||||
monthly: this.configService.get<string>('STRIPE_STARTER_MONTHLY_PRICE_ID')
|
|
||||||
|| this.configService.get<string>('STRIPE_STARTER_PRICE_ID') || '',
|
|
||||||
annual: this.configService.get<string>('STRIPE_STARTER_ANNUAL_PRICE_ID') || '',
|
|
||||||
},
|
|
||||||
professional: {
|
|
||||||
monthly: this.configService.get<string>('STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID')
|
|
||||||
|| this.configService.get<string>('STRIPE_PROFESSIONAL_PRICE_ID') || '',
|
|
||||||
annual: this.configService.get<string>('STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID') || '',
|
|
||||||
},
|
|
||||||
enterprise: {
|
|
||||||
monthly: this.configService.get<string>('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID')
|
|
||||||
|| this.configService.get<string>('STRIPE_ENTERPRISE_PRICE_ID') || '',
|
|
||||||
annual: this.configService.get<string>('STRIPE_ENTERPRISE_ANNUAL_PRICE_ID') || '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Price Resolution ────────────────────────────────────────
|
|
||||||
|
|
||||||
private getPriceId(planId: string, interval: BillingInterval): string {
|
|
||||||
const plan = this.priceMap[planId];
|
|
||||||
if (!plan) throw new BadRequestException(`Invalid plan: ${planId}`);
|
|
||||||
const priceId = interval === 'year' ? plan.annual : plan.monthly;
|
|
||||||
if (!priceId || priceId.includes('placeholder')) {
|
|
||||||
throw new BadRequestException(`Price not configured for ${planId} (${interval})`);
|
|
||||||
}
|
|
||||||
return priceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Trial Signup (No Card Required) ────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a free trial without collecting payment.
|
|
||||||
* Creates a Stripe customer + subscription with trial_period_days,
|
|
||||||
* then provisions the organization immediately.
|
|
||||||
*/
|
|
||||||
async startTrial(
|
|
||||||
planId: string,
|
|
||||||
billingInterval: BillingInterval,
|
|
||||||
email: string,
|
|
||||||
businessName: string,
|
|
||||||
): Promise<{ success: boolean; subscriptionId: string }> {
|
|
||||||
if (!this.stripe) throw new BadRequestException('Stripe not configured');
|
|
||||||
if (!email) throw new BadRequestException('Email is required');
|
|
||||||
if (!businessName) throw new BadRequestException('Business name is required');
|
|
||||||
|
|
||||||
const priceId = this.getPriceId(planId, billingInterval);
|
|
||||||
|
|
||||||
// 1. Create Stripe customer
|
|
||||||
const customer = await this.stripe.customers.create({
|
|
||||||
email,
|
|
||||||
metadata: { plan_id: planId, business_name: businessName, billing_interval: billingInterval },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Create subscription with 14-day trial (no payment method)
|
|
||||||
const subscription = await this.stripe.subscriptions.create({
|
|
||||||
customer: customer.id,
|
|
||||||
items: [{ price: priceId }],
|
|
||||||
trial_period_days: 14,
|
|
||||||
payment_settings: {
|
|
||||||
save_default_payment_method: 'on_subscription',
|
|
||||||
},
|
|
||||||
trial_settings: {
|
|
||||||
end_behavior: { missing_payment_method: 'cancel' },
|
|
||||||
},
|
|
||||||
metadata: { plan_id: planId, business_name: businessName, billing_interval: billingInterval },
|
|
||||||
});
|
|
||||||
|
|
||||||
const trialEnd = subscription.trial_end
|
|
||||||
? new Date(subscription.trial_end * 1000)
|
|
||||||
: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
// 3. Provision organization immediately with trial status
|
|
||||||
await this.provisionOrganization(
|
|
||||||
customer.id,
|
|
||||||
subscription.id,
|
|
||||||
email,
|
|
||||||
planId,
|
|
||||||
businessName,
|
|
||||||
'trial',
|
|
||||||
billingInterval,
|
|
||||||
trialEnd,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Trial started for ${email}, plan=${planId}, interval=${billingInterval}`);
|
|
||||||
return { success: true, subscriptionId: subscription.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Checkout Session (Card-required flow / post-trial) ─────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Stripe Checkout Session for a new subscription.
|
|
||||||
* Used when REQUIRE_PAYMENT_METHOD_FOR_TRIAL=true, or for
|
|
||||||
* post-trial conversion where the user adds a payment method.
|
|
||||||
*/
|
|
||||||
async createCheckoutSession(
|
|
||||||
planId: string,
|
|
||||||
billingInterval: BillingInterval = 'month',
|
|
||||||
email?: string,
|
|
||||||
businessName?: string,
|
|
||||||
): Promise<{ url: string }> {
|
|
||||||
if (!this.stripe) throw new BadRequestException('Stripe not configured');
|
|
||||||
|
|
||||||
const priceId = this.getPriceId(planId, billingInterval);
|
|
||||||
|
|
||||||
const sessionConfig: Stripe.Checkout.SessionCreateParams = {
|
|
||||||
mode: 'subscription',
|
|
||||||
payment_method_types: ['card'],
|
|
||||||
line_items: [{ price: priceId, quantity: 1 }],
|
|
||||||
success_url: `${this.getAppUrl()}/onboarding/pending?session_id={CHECKOUT_SESSION_ID}`,
|
|
||||||
cancel_url: `${this.getAppUrl()}/pricing`,
|
|
||||||
customer_email: email || undefined,
|
|
||||||
metadata: {
|
|
||||||
plan_id: planId,
|
|
||||||
business_name: businessName || '',
|
|
||||||
billing_interval: billingInterval,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// If trial is card-required, add trial period to checkout
|
|
||||||
if (this.requirePaymentForTrial) {
|
|
||||||
sessionConfig.subscription_data = {
|
|
||||||
trial_period_days: 14,
|
|
||||||
metadata: {
|
|
||||||
plan_id: planId,
|
|
||||||
business_name: businessName || '',
|
|
||||||
billing_interval: billingInterval,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await this.stripe.checkout.sessions.create(sessionConfig);
|
|
||||||
return { url: session.url! };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Webhook Handling ───────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a Stripe webhook event.
|
|
||||||
*/
|
|
||||||
async handleWebhook(rawBody: Buffer, signature: string): Promise<void> {
|
|
||||||
if (!this.stripe) throw new BadRequestException('Stripe not configured');
|
|
||||||
|
|
||||||
let event: Stripe.Event;
|
|
||||||
try {
|
|
||||||
event = this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret);
|
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.error(`Webhook signature verification failed: ${err.message}`);
|
|
||||||
throw new BadRequestException('Invalid webhook signature');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idempotency check
|
|
||||||
const existing = await this.dataSource.query(
|
|
||||||
`SELECT id FROM shared.stripe_events WHERE id = $1`,
|
|
||||||
[event.id],
|
|
||||||
);
|
|
||||||
if (existing.length > 0) {
|
|
||||||
this.logger.log(`Duplicate Stripe event ${event.id}, skipping`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record event
|
|
||||||
await this.dataSource.query(
|
|
||||||
`INSERT INTO shared.stripe_events (id, type, payload) VALUES ($1, $2, $3)`,
|
|
||||||
[event.id, event.type, JSON.stringify(event.data)],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Dispatch
|
|
||||||
switch (event.type) {
|
|
||||||
case 'checkout.session.completed':
|
|
||||||
await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
|
|
||||||
break;
|
|
||||||
case 'invoice.payment_succeeded':
|
|
||||||
await this.handlePaymentSucceeded(event.data.object as Stripe.Invoice);
|
|
||||||
break;
|
|
||||||
case 'invoice.payment_failed':
|
|
||||||
await this.handlePaymentFailed(event.data.object as Stripe.Invoice);
|
|
||||||
break;
|
|
||||||
case 'customer.subscription.deleted':
|
|
||||||
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
|
||||||
break;
|
|
||||||
case 'customer.subscription.trial_will_end':
|
|
||||||
await this.handleTrialWillEnd(event.data.object as Stripe.Subscription);
|
|
||||||
break;
|
|
||||||
case 'customer.subscription.updated':
|
|
||||||
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.logger.log(`Unhandled Stripe event: ${event.type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Provisioning Status ────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get provisioning status for a checkout session OR subscription ID.
|
|
||||||
*/
|
|
||||||
async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> {
|
|
||||||
if (!this.stripe) return { status: 'not_configured' };
|
|
||||||
|
|
||||||
// Try as checkout session first
|
|
||||||
let customerId: string | null = null;
|
|
||||||
try {
|
|
||||||
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
|
|
||||||
customerId = session.customer as string;
|
|
||||||
} catch {
|
|
||||||
// Not a checkout session — try looking up by subscription ID
|
|
||||||
try {
|
|
||||||
const subscription = await this.stripe.subscriptions.retrieve(sessionId);
|
|
||||||
customerId = subscription.customer as string;
|
|
||||||
} catch {
|
|
||||||
return { status: 'pending' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!customerId) return { status: 'pending' };
|
|
||||||
|
|
||||||
const rows = await this.dataSource.query(
|
|
||||||
`SELECT id, status FROM shared.organizations WHERE stripe_customer_id = $1`,
|
|
||||||
[customerId],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length === 0) return { status: 'provisioning' };
|
|
||||||
if (['active', 'trial'].includes(rows[0].status)) return { status: 'active' };
|
|
||||||
return { status: 'provisioning' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Stripe Customer Portal ─────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a Stripe Customer Portal session for managing subscription.
|
|
||||||
*/
|
|
||||||
async createPortalSession(orgId: string): Promise<{ url: string }> {
|
|
||||||
if (!this.stripe) throw new BadRequestException('Stripe is not configured');
|
|
||||||
|
|
||||||
const rows = await this.dataSource.query(
|
|
||||||
`SELECT stripe_customer_id, stripe_subscription_id, status
|
|
||||||
FROM shared.organizations WHERE id = $1`,
|
|
||||||
[orgId],
|
|
||||||
);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
throw new BadRequestException('Organization not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
let customerId = rows[0].stripe_customer_id;
|
|
||||||
|
|
||||||
// Fallback: if customer ID is missing but subscription exists, retrieve customer from subscription
|
|
||||||
if (!customerId && rows[0].stripe_subscription_id) {
|
|
||||||
try {
|
|
||||||
const sub = await this.stripe.subscriptions.retrieve(rows[0].stripe_subscription_id) as Stripe.Subscription;
|
|
||||||
customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id;
|
|
||||||
if (customerId) {
|
|
||||||
// Backfill the customer ID for future calls
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.organizations SET stripe_customer_id = $1 WHERE id = $2`,
|
|
||||||
[customerId, orgId],
|
|
||||||
);
|
|
||||||
this.logger.log(`Backfilled stripe_customer_id=${customerId} for org=${orgId}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to retrieve customer from subscription: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!customerId) {
|
|
||||||
const status = rows[0].status;
|
|
||||||
if (status === 'trial') {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Billing portal is not available during your free trial. Add a payment method when your trial ends to manage your subscription.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new BadRequestException('No Stripe customer found for this organization. Please contact support.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await this.stripe.billingPortal.sessions.create({
|
|
||||||
customer: customerId,
|
|
||||||
return_url: `${this.getAppUrl()}/settings`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { url: session.url };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Subscription Info ──────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current subscription details for the Settings billing tab.
|
|
||||||
*/
|
|
||||||
async getSubscriptionInfo(orgId: string): Promise<{
|
|
||||||
plan: string;
|
|
||||||
planName: string;
|
|
||||||
billingInterval: string;
|
|
||||||
status: string;
|
|
||||||
collectionMethod: string;
|
|
||||||
trialEndsAt: string | null;
|
|
||||||
currentPeriodEnd: string | null;
|
|
||||||
cancelAtPeriodEnd: boolean;
|
|
||||||
hasStripeCustomer: boolean;
|
|
||||||
}> {
|
|
||||||
const rows = await this.dataSource.query(
|
|
||||||
`SELECT plan_level, billing_interval, status, collection_method,
|
|
||||||
trial_ends_at, stripe_subscription_id, stripe_customer_id
|
|
||||||
FROM shared.organizations WHERE id = $1`,
|
|
||||||
[orgId],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length === 0) throw new BadRequestException('Organization not found');
|
|
||||||
|
|
||||||
const org = rows[0];
|
|
||||||
let currentPeriodEnd: string | null = null;
|
|
||||||
let cancelAtPeriodEnd = false;
|
|
||||||
|
|
||||||
// Fetch live data from Stripe if available
|
|
||||||
if (this.stripe && org.stripe_subscription_id) {
|
|
||||||
try {
|
|
||||||
const sub = await this.stripe.subscriptions.retrieve(org.stripe_subscription_id, {
|
|
||||||
expand: ['items.data'],
|
|
||||||
}) as Stripe.Subscription;
|
|
||||||
// current_period_end is on the subscription item in newer Stripe API versions
|
|
||||||
const firstItem = sub.items?.data?.[0];
|
|
||||||
if (firstItem?.current_period_end) {
|
|
||||||
currentPeriodEnd = new Date(firstItem.current_period_end * 1000).toISOString();
|
|
||||||
}
|
|
||||||
cancelAtPeriodEnd = sub.cancel_at_period_end;
|
|
||||||
} catch {
|
|
||||||
// Non-critical — use DB data only
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
plan: org.plan_level || 'starter',
|
|
||||||
planName: PLAN_FEATURES[org.plan_level]?.name || org.plan_level || 'Starter',
|
|
||||||
billingInterval: org.billing_interval || 'month',
|
|
||||||
status: org.status || 'active',
|
|
||||||
collectionMethod: org.collection_method || 'charge_automatically',
|
|
||||||
trialEndsAt: org.trial_ends_at ? new Date(org.trial_ends_at).toISOString() : null,
|
|
||||||
currentPeriodEnd,
|
|
||||||
cancelAtPeriodEnd,
|
|
||||||
hasStripeCustomer: !!org.stripe_customer_id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Invoice / ACH Billing (Admin) ──────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch a customer's subscription to invoice collection (ACH/wire).
|
|
||||||
* Admin-only operation for enterprise customers.
|
|
||||||
*/
|
|
||||||
async switchToInvoiceBilling(
|
|
||||||
orgId: string,
|
|
||||||
collectionMethod: 'charge_automatically' | 'send_invoice',
|
|
||||||
daysUntilDue: number = 30,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.stripe) throw new BadRequestException('Stripe not configured');
|
|
||||||
|
|
||||||
const rows = await this.dataSource.query(
|
|
||||||
`SELECT stripe_subscription_id, stripe_customer_id FROM shared.organizations WHERE id = $1`,
|
|
||||||
[orgId],
|
|
||||||
);
|
|
||||||
if (rows.length === 0 || !rows[0].stripe_subscription_id) {
|
|
||||||
throw new BadRequestException('No Stripe subscription found for this organization');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateParams: Stripe.SubscriptionUpdateParams = {
|
|
||||||
collection_method: collectionMethod,
|
|
||||||
};
|
|
||||||
if (collectionMethod === 'send_invoice') {
|
|
||||||
updateParams.days_until_due = daysUntilDue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.stripe.subscriptions.update(rows[0].stripe_subscription_id, updateParams);
|
|
||||||
|
|
||||||
// Update DB
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.organizations SET collection_method = $1, updated_at = NOW() WHERE id = $2`,
|
|
||||||
[collectionMethod, orgId],
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Billing method updated for org ${orgId}: ${collectionMethod}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Webhook Handlers ──────────────────────────────────────
|
|
||||||
|
|
||||||
private async handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
|
|
||||||
const customerId = session.customer as string;
|
|
||||||
const subscriptionId = session.subscription as string;
|
|
||||||
const email = session.customer_email || session.customer_details?.email || '';
|
|
||||||
const planId = session.metadata?.plan_id || 'starter';
|
|
||||||
const businessName = session.metadata?.business_name || 'My HOA';
|
|
||||||
const billingInterval = (session.metadata?.billing_interval || 'month') as BillingInterval;
|
|
||||||
|
|
||||||
this.logger.log(`Provisioning org for ${email}, plan=${planId}, customer=${customerId}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Determine if this is a trial checkout (card required for trial)
|
|
||||||
let status: 'active' | 'trial' = 'active';
|
|
||||||
let trialEnd: Date | undefined;
|
|
||||||
|
|
||||||
if (this.stripe && subscriptionId) {
|
|
||||||
const sub = await this.stripe.subscriptions.retrieve(subscriptionId);
|
|
||||||
if (sub.status === 'trialing' && sub.trial_end) {
|
|
||||||
status = 'trial';
|
|
||||||
trialEnd = new Date(sub.trial_end * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.provisionOrganization(
|
|
||||||
customerId, subscriptionId, email, planId, businessName,
|
|
||||||
status, billingInterval, trialEnd,
|
|
||||||
);
|
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.error(`Provisioning failed: ${err.message}`, err.stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
|
|
||||||
const customerId = invoice.customer as string;
|
|
||||||
// Activate tenant if it was pending/trial
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.organizations SET status = 'active', updated_at = NOW()
|
|
||||||
WHERE stripe_customer_id = $1 AND status IN ('trial', 'past_due')`,
|
|
||||||
[customerId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
|
|
||||||
const customerId = invoice.customer as string;
|
|
||||||
const rows = await this.dataSource.query(
|
|
||||||
`SELECT email, name FROM shared.organizations WHERE stripe_customer_id = $1`,
|
|
||||||
[customerId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set org to past_due for grace period (read-only access)
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.organizations SET status = 'past_due', updated_at = NOW()
|
|
||||||
WHERE stripe_customer_id = $1 AND status = 'active'`,
|
|
||||||
[customerId],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length > 0 && rows[0].email) {
|
|
||||||
await this.emailService.sendPaymentFailedEmail(rows[0].email, rows[0].name || 'Your organization');
|
|
||||||
}
|
|
||||||
this.logger.warn(`Payment failed for customer ${customerId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
|
|
||||||
const customerId = subscription.customer as string;
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.organizations SET status = 'archived', updated_at = NOW()
|
|
||||||
WHERE stripe_customer_id = $1`,
|
|
||||||
[customerId],
|
|
||||||
);
|
|
||||||
this.logger.log(`Subscription cancelled for customer ${customerId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleTrialWillEnd(subscription: Stripe.Subscription): Promise<void> {
|
|
||||||
const customerId = subscription.customer as string;
|
|
||||||
const rows = await this.dataSource.query(
|
|
||||||
`SELECT id, email, name FROM shared.organizations WHERE stripe_customer_id = $1`,
|
|
||||||
[customerId],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
const org = rows[0];
|
|
||||||
const daysRemaining = 3; // This webhook fires 3 days before trial end
|
|
||||||
const settingsUrl = `${this.getAppUrl()}/settings`;
|
|
||||||
|
|
||||||
if (org.email) {
|
|
||||||
await this.emailService.sendTrialEndingEmail(
|
|
||||||
org.email,
|
|
||||||
org.name || 'Your organization',
|
|
||||||
daysRemaining,
|
|
||||||
settingsUrl,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Trial ending soon for customer ${customerId}, org ${org.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
|
|
||||||
const customerId = subscription.customer as string;
|
|
||||||
|
|
||||||
// Determine new status
|
|
||||||
let newStatus: string;
|
|
||||||
switch (subscription.status) {
|
|
||||||
case 'trialing':
|
|
||||||
newStatus = 'trial';
|
|
||||||
break;
|
|
||||||
case 'active':
|
|
||||||
newStatus = 'active';
|
|
||||||
break;
|
|
||||||
case 'past_due':
|
|
||||||
newStatus = 'past_due';
|
|
||||||
break;
|
|
||||||
case 'canceled':
|
|
||||||
case 'unpaid':
|
|
||||||
newStatus = 'archived';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return; // Don't update for other statuses
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine billing interval from the subscription items
|
|
||||||
let billingInterval: BillingInterval = 'month';
|
|
||||||
if (subscription.items?.data?.[0]?.price?.recurring?.interval === 'year') {
|
|
||||||
billingInterval = 'year';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine plan from price metadata or existing mapping
|
|
||||||
let planId: string | null = null;
|
|
||||||
const activePriceId = subscription.items?.data?.[0]?.price?.id;
|
|
||||||
if (activePriceId) {
|
|
||||||
for (const [plan, prices] of Object.entries(this.priceMap)) {
|
|
||||||
if (prices.monthly === activePriceId || prices.annual === activePriceId) {
|
|
||||||
planId = plan;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build update query dynamically
|
|
||||||
const updates: string[] = [`status = '${newStatus}'`, `billing_interval = '${billingInterval}'`, `updated_at = NOW()`];
|
|
||||||
if (planId) {
|
|
||||||
updates.push(`plan_level = '${planId}'`);
|
|
||||||
}
|
|
||||||
if (subscription.collection_method) {
|
|
||||||
updates.push(`collection_method = '${subscription.collection_method}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.organizations SET ${updates.join(', ')} WHERE stripe_customer_id = $1`,
|
|
||||||
[customerId],
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Subscription updated for customer ${customerId}: status=${newStatus}, interval=${billingInterval}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Provisioning ──────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full provisioning flow: create org, schema, user, invite token, email.
|
|
||||||
*/
|
|
||||||
async provisionOrganization(
|
|
||||||
customerId: string,
|
|
||||||
subscriptionId: string,
|
|
||||||
email: string,
|
|
||||||
planId: string,
|
|
||||||
businessName: string,
|
|
||||||
status: 'active' | 'trial' = 'active',
|
|
||||||
billingInterval: BillingInterval = 'month',
|
|
||||||
trialEndsAt?: Date,
|
|
||||||
): Promise<void> {
|
|
||||||
// 1. Create or upsert organization
|
|
||||||
const schemaName = `tenant_${uuid().replace(/-/g, '').substring(0, 12)}`;
|
|
||||||
|
|
||||||
const orgRows = await this.dataSource.query(
|
|
||||||
`INSERT INTO shared.organizations
|
|
||||||
(name, schema_name, status, plan_level, stripe_customer_id, stripe_subscription_id, email, billing_interval, trial_ends_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
ON CONFLICT (stripe_customer_id) DO UPDATE SET
|
|
||||||
stripe_subscription_id = EXCLUDED.stripe_subscription_id,
|
|
||||||
plan_level = EXCLUDED.plan_level,
|
|
||||||
status = EXCLUDED.status,
|
|
||||||
billing_interval = EXCLUDED.billing_interval,
|
|
||||||
trial_ends_at = EXCLUDED.trial_ends_at,
|
|
||||||
updated_at = NOW()
|
|
||||||
RETURNING id, schema_name`,
|
|
||||||
[businessName, schemaName, status, planId, customerId, subscriptionId, email, billingInterval, trialEndsAt || null],
|
|
||||||
);
|
|
||||||
|
|
||||||
const orgId = orgRows[0].id;
|
|
||||||
const actualSchema = orgRows[0].schema_name;
|
|
||||||
|
|
||||||
// 2. Create tenant schema
|
|
||||||
try {
|
|
||||||
await this.tenantSchemaService.createTenantSchema(actualSchema);
|
|
||||||
this.logger.log(`Created tenant schema: ${actualSchema}`);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.message?.includes('already exists')) {
|
|
||||||
this.logger.log(`Schema ${actualSchema} already exists, skipping creation`);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Create or find user
|
|
||||||
let userRows = await this.dataSource.query(
|
|
||||||
`SELECT id FROM shared.users WHERE email = $1`,
|
|
||||||
[email],
|
|
||||||
);
|
|
||||||
|
|
||||||
let userId: string;
|
|
||||||
if (userRows.length === 0) {
|
|
||||||
const newUser = await this.dataSource.query(
|
|
||||||
`INSERT INTO shared.users (email, is_email_verified)
|
|
||||||
VALUES ($1, false)
|
|
||||||
RETURNING id`,
|
|
||||||
[email],
|
|
||||||
);
|
|
||||||
userId = newUser[0].id;
|
|
||||||
} else {
|
|
||||||
userId = userRows[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Create membership (president role)
|
|
||||||
await this.dataSource.query(
|
|
||||||
`INSERT INTO shared.user_organizations (user_id, organization_id, role)
|
|
||||||
VALUES ($1, $2, 'president')
|
|
||||||
ON CONFLICT (user_id, organization_id) DO NOTHING`,
|
|
||||||
[userId, orgId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 5. Generate invite token and "send" activation email
|
|
||||||
const inviteToken = await this.authService.generateInviteToken(userId, orgId, email);
|
|
||||||
const activationUrl = `${this.getAppUrl()}/activate?token=${inviteToken}`;
|
|
||||||
await this.emailService.sendActivationEmail(email, businessName, activationUrl);
|
|
||||||
|
|
||||||
// 6. Initialize onboarding progress
|
|
||||||
await this.dataSource.query(
|
|
||||||
`INSERT INTO shared.onboarding_progress (organization_id) VALUES ($1) ON CONFLICT DO NOTHING`,
|
|
||||||
[orgId],
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Provisioning complete for org=${orgId}, user=${userId}, status=${status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getAppUrl(): string {
|
|
||||||
return this.configService.get<string>('APP_URL') || 'http://localhost';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,195 +1,30 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stubbed email service — logs to console and stores in shared.email_log.
|
||||||
|
* Replace internals with Resend/SendGrid when ready for production.
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailService {
|
export class EmailService {
|
||||||
private readonly logger = new Logger(EmailService.name);
|
private readonly logger = new Logger(EmailService.name);
|
||||||
private resend: Resend | null = null;
|
|
||||||
private fromAddress: string;
|
|
||||||
private replyToAddress: string;
|
|
||||||
|
|
||||||
constructor(
|
constructor(private dataSource: DataSource) {}
|
||||||
private configService: ConfigService,
|
|
||||||
private dataSource: DataSource,
|
|
||||||
) {
|
|
||||||
const apiKey = this.configService.get<string>('RESEND_API_KEY');
|
|
||||||
if (apiKey && !apiKey.includes('placeholder')) {
|
|
||||||
this.resend = new Resend(apiKey);
|
|
||||||
this.logger.log('Resend email service initialized');
|
|
||||||
} else {
|
|
||||||
this.logger.warn('Resend not configured — emails will be logged only (stub mode)');
|
|
||||||
}
|
|
||||||
this.fromAddress = this.configService.get<string>('RESEND_FROM_ADDRESS') || 'noreply@hoaledgeriq.com';
|
|
||||||
this.replyToAddress = this.configService.get<string>('RESEND_REPLY_TO') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Public API ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
|
|
||||||
const subject = `Activate your ${businessName} account on HOA LedgerIQ`;
|
|
||||||
const html = this.buildTemplate({
|
|
||||||
preheader: 'Your HOA LedgerIQ account is ready to activate.',
|
|
||||||
heading: 'Welcome to HOA LedgerIQ!',
|
|
||||||
body: `
|
|
||||||
<p>Your organization <strong>${this.esc(businessName)}</strong> has been created and is ready to go.</p>
|
|
||||||
<p>Click the button below to set your password and activate your account:</p>
|
|
||||||
`,
|
|
||||||
ctaText: 'Activate My Account',
|
|
||||||
ctaUrl: activationUrl,
|
|
||||||
footer: 'This activation link expires in 72 hours. If you did not sign up for HOA LedgerIQ, please ignore this email.',
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.send(email, subject, html, 'activation', { businessName, activationUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendWelcomeEmail(email: string, businessName: string): Promise<void> {
|
|
||||||
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
|
|
||||||
const subject = `Welcome to HOA LedgerIQ — ${businessName}`;
|
|
||||||
const html = this.buildTemplate({
|
|
||||||
preheader: `${businessName} is all set up on HOA LedgerIQ.`,
|
|
||||||
heading: `You're all set!`,
|
|
||||||
body: `
|
|
||||||
<p>Your account for <strong>${this.esc(businessName)}</strong> is now active.</p>
|
|
||||||
<p>Log in to start managing your HOA's finances, assessments, and investments — all in one place.</p>
|
|
||||||
`,
|
|
||||||
ctaText: 'Go to Dashboard',
|
|
||||||
ctaUrl: `${appUrl}/dashboard`,
|
|
||||||
footer: 'If you have any questions, just reply to this email and we\'ll help you get started.',
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.send(email, subject, html, 'welcome', { businessName });
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendPaymentFailedEmail(email: string, businessName: string): Promise<void> {
|
|
||||||
const subject = `Action required: Payment failed for ${businessName}`;
|
|
||||||
const html = this.buildTemplate({
|
|
||||||
preheader: 'We were unable to process your payment.',
|
|
||||||
heading: 'Payment Failed',
|
|
||||||
body: `
|
|
||||||
<p>We were unable to process the latest payment for <strong>${this.esc(businessName)}</strong>.</p>
|
|
||||||
<p>Please update your payment method to avoid any interruption to your service.</p>
|
|
||||||
`,
|
|
||||||
ctaText: 'Update Payment Method',
|
|
||||||
ctaUrl: `${this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com'}/settings`,
|
|
||||||
footer: 'If you believe this is an error, please reply to this email and we\'ll look into it.',
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.send(email, subject, html, 'payment_failed', { businessName });
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise<void> {
|
|
||||||
const subject = `You've been invited to ${orgName} on HOA LedgerIQ`;
|
|
||||||
const html = this.buildTemplate({
|
|
||||||
preheader: `Join ${orgName} on HOA LedgerIQ.`,
|
|
||||||
heading: 'You\'re Invited!',
|
|
||||||
body: `
|
|
||||||
<p>You've been invited to join <strong>${this.esc(orgName)}</strong> on HOA LedgerIQ.</p>
|
|
||||||
<p>Click below to accept the invitation and set up your account:</p>
|
|
||||||
`,
|
|
||||||
ctaText: 'Accept Invitation',
|
|
||||||
ctaUrl: inviteUrl,
|
|
||||||
footer: 'This invitation link expires in 7 days. If you were not expecting this, please ignore this email.',
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendTrialEndingEmail(email: string, businessName: string, daysRemaining: number, settingsUrl: string): Promise<void> {
|
|
||||||
const subject = `Your free trial ends in ${daysRemaining} days — ${businessName}`;
|
|
||||||
const html = this.buildTemplate({
|
|
||||||
preheader: `Your HOA LedgerIQ trial for ${businessName} is ending soon.`,
|
|
||||||
heading: `Your Trial Ends in ${daysRemaining} Days`,
|
|
||||||
body: `
|
|
||||||
<p>Your free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ ends in <strong>${daysRemaining} days</strong>.</p>
|
|
||||||
<p>To continue using all features without interruption, add a payment method before your trial expires.</p>
|
|
||||||
<p>If you don't add a payment method, your account will become read-only and you won't be able to make changes to your data.</p>
|
|
||||||
`,
|
|
||||||
ctaText: 'Add Payment Method',
|
|
||||||
ctaUrl: settingsUrl,
|
|
||||||
footer: 'If you have any questions about plans or pricing, just reply to this email.',
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.send(email, subject, html, 'trial_ending', { businessName, daysRemaining, settingsUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendTrialExpiredEmail(email: string, businessName: string): Promise<void> {
|
|
||||||
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
|
|
||||||
const subject = `Your free trial has ended — ${businessName}`;
|
|
||||||
const html = this.buildTemplate({
|
|
||||||
preheader: `Your HOA LedgerIQ trial for ${businessName} has ended.`,
|
|
||||||
heading: 'Your Trial Has Ended',
|
|
||||||
body: `
|
|
||||||
<p>The free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ has ended.</p>
|
|
||||||
<p>Your data is safe and your account is preserved. Subscribe to a plan to regain full access to your HOA financial management tools.</p>
|
|
||||||
`,
|
|
||||||
ctaText: 'Choose a Plan',
|
|
||||||
ctaUrl: `${appUrl}/pricing`,
|
|
||||||
footer: 'Your data will be preserved. You can reactivate your account at any time by subscribing to a plan.',
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.send(email, subject, html, 'trial_expired', { businessName });
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
|
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
|
||||||
const subject = 'Reset your HOA LedgerIQ password';
|
const subject = 'Reset your HOA LedgerIQ password';
|
||||||
const html = this.buildTemplate({
|
const body = [
|
||||||
preheader: 'Password reset requested for your HOA LedgerIQ account.',
|
`You requested a password reset for your HOA LedgerIQ account.`,
|
||||||
heading: 'Password Reset',
|
``,
|
||||||
body: `
|
`Click the link below to reset your password:`,
|
||||||
<p>We received a request to reset your password. Click the button below to choose a new one:</p>
|
resetUrl,
|
||||||
`,
|
``,
|
||||||
ctaText: 'Reset Password',
|
`This link expires in 15 minutes. If you didn't request this, ignore this email.`,
|
||||||
ctaUrl: resetUrl,
|
].join('\n');
|
||||||
footer: 'This link expires in 1 hour. If you did not request a password reset, please ignore this email — your password will remain unchanged.',
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.send(email, subject, html, 'password_reset', { resetUrl });
|
await this.log(email, subject, body, 'password_reset', { resetUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Core send logic ────────────────────────────────────────
|
|
||||||
|
|
||||||
private async send(
|
|
||||||
toEmail: string,
|
|
||||||
subject: string,
|
|
||||||
html: string,
|
|
||||||
template: string,
|
|
||||||
metadata: Record<string, any>,
|
|
||||||
): Promise<void> {
|
|
||||||
// Always log to the database
|
|
||||||
await this.log(toEmail, subject, html, template, metadata);
|
|
||||||
|
|
||||||
if (!this.resend) {
|
|
||||||
this.logger.log(`📧 EMAIL STUB → ${toEmail}`);
|
|
||||||
this.logger.log(` Subject: ${subject}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await this.resend.emails.send({
|
|
||||||
from: this.fromAddress,
|
|
||||||
to: [toEmail],
|
|
||||||
replyTo: this.replyToAddress || undefined,
|
|
||||||
subject,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
this.logger.error(`Resend error for ${toEmail}: ${JSON.stringify(result.error)}`);
|
|
||||||
await this.updateLogStatus(toEmail, template, 'failed', result.error.message);
|
|
||||||
} else {
|
|
||||||
this.logger.log(`✅ Email sent to ${toEmail} (id: ${result.data?.id})`);
|
|
||||||
await this.updateLogStatus(toEmail, template, 'sent', result.data?.id);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.error(`Failed to send email to ${toEmail}: ${err.message}`);
|
|
||||||
await this.updateLogStatus(toEmail, template, 'failed', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Database logging ───────────────────────────────────────
|
|
||||||
|
|
||||||
private async log(
|
private async log(
|
||||||
toEmail: string,
|
toEmail: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
@@ -197,6 +32,10 @@ export class EmailService {
|
|||||||
template: string,
|
template: string,
|
||||||
metadata: Record<string, any>,
|
metadata: Record<string, any>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
this.logger.log(`EMAIL STUB -> ${toEmail}`);
|
||||||
|
this.logger.log(` Subject: ${subject}`);
|
||||||
|
this.logger.log(` Body:\n${body}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
|
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
|
||||||
@@ -207,119 +46,4 @@ export class EmailService {
|
|||||||
this.logger.warn(`Failed to log email: ${err}`);
|
this.logger.warn(`Failed to log email: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateLogStatus(toEmail: string, template: string, status: string, detail?: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.email_log
|
|
||||||
SET metadata = metadata || $1::jsonb
|
|
||||||
WHERE to_email = $2 AND template = $3
|
|
||||||
AND created_at = (
|
|
||||||
SELECT MAX(created_at) FROM shared.email_log
|
|
||||||
WHERE to_email = $2 AND template = $3
|
|
||||||
)`,
|
|
||||||
[JSON.stringify({ send_status: status, send_detail: detail || '' }), toEmail, template],
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Best effort — don't block the flow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── HTML email template ────────────────────────────────────
|
|
||||||
|
|
||||||
private esc(text: string): string {
|
|
||||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildTemplate(opts: {
|
|
||||||
preheader: string;
|
|
||||||
heading: string;
|
|
||||||
body: string;
|
|
||||||
ctaText: string;
|
|
||||||
ctaUrl: string;
|
|
||||||
footer: string;
|
|
||||||
}): string {
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${this.esc(opts.heading)}</title>
|
|
||||||
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
|
||||||
</head>
|
|
||||||
<body style="margin:0;padding:0;background-color:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
|
||||||
<!-- Preheader (hidden preview text) -->
|
|
||||||
<div style="display:none;max-height:0;overflow:hidden;">${this.esc(opts.preheader)}</div>
|
|
||||||
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7;padding:24px 0;">
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
|
||||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;">
|
|
||||||
|
|
||||||
<!-- Logo bar -->
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:24px 0 16px;">
|
|
||||||
<span style="font-size:22px;font-weight:700;color:#1a73e8;letter-spacing:-0.5px;">
|
|
||||||
HOA LedgerIQ
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Main card -->
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
|
||||||
style="background-color:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:40px 32px;">
|
|
||||||
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#1a1a2e;">
|
|
||||||
${this.esc(opts.heading)}
|
|
||||||
</h1>
|
|
||||||
<div style="font-size:15px;line-height:1.6;color:#4a4a68;">
|
|
||||||
${opts.body}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CTA Button -->
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:28px 0 8px;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="background-color:#1a73e8;border-radius:6px;">
|
|
||||||
<a href="${opts.ctaUrl}"
|
|
||||||
target="_blank"
|
|
||||||
style="display:inline-block;padding:14px 32px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;border-radius:6px;">
|
|
||||||
${this.esc(opts.ctaText)}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Fallback URL -->
|
|
||||||
<p style="font-size:12px;color:#999;word-break:break-all;margin-top:16px;">
|
|
||||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
|
||||||
<a href="${opts.ctaUrl}" style="color:#1a73e8;">${opts.ctaUrl}</a>
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding:24px 32px;text-align:center;">
|
|
||||||
<p style="font-size:12px;color:#999;line-height:1.5;margin:0;">
|
|
||||||
${this.esc(opts.footer)}
|
|
||||||
</p>
|
|
||||||
<p style="font-size:12px;color:#bbb;margin:12px 0 0;">
|
|
||||||
© ${new Date().getFullYear()} HOA LedgerIQ — Smart Financial Management for HOAs
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Controller, Get, Patch, Body, UseGuards, Request, BadRequestException } from '@nestjs/common';
|
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
|
||||||
import { OnboardingService } from './onboarding.service';
|
|
||||||
|
|
||||||
@ApiTags('onboarding')
|
|
||||||
@Controller('onboarding')
|
|
||||||
@ApiBearerAuth()
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
export class OnboardingController {
|
|
||||||
constructor(private onboardingService: OnboardingService) {}
|
|
||||||
|
|
||||||
@Get('progress')
|
|
||||||
@ApiOperation({ summary: 'Get onboarding progress for current org' })
|
|
||||||
@AllowViewer()
|
|
||||||
async getProgress(@Request() req: any) {
|
|
||||||
const orgId = req.user.orgId;
|
|
||||||
if (!orgId) throw new BadRequestException('No organization context');
|
|
||||||
return this.onboardingService.getProgress(orgId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch('progress')
|
|
||||||
@ApiOperation({ summary: 'Mark an onboarding step as complete' })
|
|
||||||
async markStep(@Request() req: any, @Body() body: { step: string }) {
|
|
||||||
const orgId = req.user.orgId;
|
|
||||||
if (!orgId) throw new BadRequestException('No organization context');
|
|
||||||
if (!body.step) throw new BadRequestException('step is required');
|
|
||||||
return this.onboardingService.markStepComplete(orgId, body.step);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { OnboardingService } from './onboarding.service';
|
|
||||||
import { OnboardingController } from './onboarding.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [OnboardingController],
|
|
||||||
providers: [OnboardingService],
|
|
||||||
exports: [OnboardingService],
|
|
||||||
})
|
|
||||||
export class OnboardingModule {}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
|
|
||||||
const REQUIRED_STEPS = ['profile', 'workspace', 'invite_member', 'first_workflow'];
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OnboardingService {
|
|
||||||
private readonly logger = new Logger(OnboardingService.name);
|
|
||||||
|
|
||||||
constructor(private dataSource: DataSource) {}
|
|
||||||
|
|
||||||
async getProgress(orgId: string) {
|
|
||||||
const rows = await this.dataSource.query(
|
|
||||||
`SELECT completed_steps, completed_at, updated_at
|
|
||||||
FROM shared.onboarding_progress
|
|
||||||
WHERE organization_id = $1`,
|
|
||||||
[orgId],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
// Create a fresh record
|
|
||||||
await this.dataSource.query(
|
|
||||||
`INSERT INTO shared.onboarding_progress (organization_id)
|
|
||||||
VALUES ($1) ON CONFLICT DO NOTHING`,
|
|
||||||
[orgId],
|
|
||||||
);
|
|
||||||
return { completedSteps: [], completedAt: null, requiredSteps: REQUIRED_STEPS };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
completedSteps: rows[0].completed_steps || [],
|
|
||||||
completedAt: rows[0].completed_at,
|
|
||||||
requiredSteps: REQUIRED_STEPS,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async markStepComplete(orgId: string, step: string) {
|
|
||||||
// Add step to array (using array_append with dedup)
|
|
||||||
await this.dataSource.query(
|
|
||||||
`INSERT INTO shared.onboarding_progress (organization_id, completed_steps, updated_at)
|
|
||||||
VALUES ($1, ARRAY[$2::text], NOW())
|
|
||||||
ON CONFLICT (organization_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
completed_steps = CASE
|
|
||||||
WHEN $2 = ANY(onboarding_progress.completed_steps) THEN onboarding_progress.completed_steps
|
|
||||||
ELSE array_append(onboarding_progress.completed_steps, $2::text)
|
|
||||||
END,
|
|
||||||
updated_at = NOW()`,
|
|
||||||
[orgId, step],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if all required steps are done
|
|
||||||
const rows = await this.dataSource.query(
|
|
||||||
`SELECT completed_steps FROM shared.onboarding_progress WHERE organization_id = $1`,
|
|
||||||
[orgId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const completedSteps = rows[0]?.completed_steps || [];
|
|
||||||
const allDone = REQUIRED_STEPS.every((s) => completedSteps.includes(s));
|
|
||||||
|
|
||||||
if (allDone) {
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.onboarding_progress SET completed_at = NOW() WHERE organization_id = $1 AND completed_at IS NULL`,
|
|
||||||
[orgId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.getProgress(orgId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async resetProgress(orgId: string) {
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.onboarding_progress SET completed_steps = '{}', completed_at = NULL, updated_at = NOW()
|
|
||||||
WHERE organization_id = $1`,
|
|
||||||
[orgId],
|
|
||||||
);
|
|
||||||
return this.getProgress(orgId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
-- Migration 015: SaaS Onboarding + Auth (Stripe, Refresh Tokens, MFA, SSO, Passkeys)
|
|
||||||
-- Adds tables for refresh tokens, stripe event tracking, invite tokens,
|
|
||||||
-- onboarding progress, and WebAuthn passkeys.
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 1. Modify shared.organizations — add Stripe billing columns
|
|
||||||
-- ============================================================================
|
|
||||||
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255) UNIQUE;
|
|
||||||
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255) UNIQUE;
|
|
||||||
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ;
|
|
||||||
|
|
||||||
-- Update plan_level CHECK constraint to include new SaaS plan tiers
|
|
||||||
-- (Drop and re-add since ALTER CHECK is not supported in PG)
|
|
||||||
ALTER TABLE shared.organizations DROP CONSTRAINT IF EXISTS organizations_plan_level_check;
|
|
||||||
ALTER TABLE shared.organizations ADD CONSTRAINT organizations_plan_level_check
|
|
||||||
CHECK (plan_level IN ('standard', 'premium', 'enterprise', 'starter', 'professional'));
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 2. New table: shared.refresh_tokens
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS shared.refresh_tokens (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
|
||||||
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
revoked_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON shared.refresh_tokens(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON shared.refresh_tokens(token_hash);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON shared.refresh_tokens(expires_at);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 3. New table: shared.stripe_events (idempotency for webhook processing)
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS shared.stripe_events (
|
|
||||||
id VARCHAR(255) PRIMARY KEY,
|
|
||||||
type VARCHAR(100) NOT NULL,
|
|
||||||
processed_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
payload JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 4. New table: shared.invite_tokens (magic link activation)
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS shared.invite_tokens (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
|
||||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
|
||||||
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
|
||||||
used_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_invite_tokens_hash ON shared.invite_tokens(token_hash);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_invite_tokens_user ON shared.invite_tokens(user_id);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 5. New table: shared.onboarding_progress
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS shared.onboarding_progress (
|
|
||||||
organization_id UUID PRIMARY KEY REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
|
||||||
completed_steps TEXT[] DEFAULT '{}',
|
|
||||||
completed_at TIMESTAMPTZ,
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 6. New table: shared.user_passkeys (WebAuthn)
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS shared.user_passkeys (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
|
||||||
credential_id TEXT UNIQUE NOT NULL,
|
|
||||||
public_key TEXT NOT NULL,
|
|
||||||
counter BIGINT DEFAULT 0,
|
|
||||||
device_name VARCHAR(255),
|
|
||||||
transports TEXT[],
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
last_used_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user ON shared.user_passkeys(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_passkeys_cred ON shared.user_passkeys(credential_id);
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 7. Modify shared.users — add MFA/WebAuthn columns
|
|
||||||
-- ============================================================================
|
|
||||||
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS totp_verified_at TIMESTAMPTZ;
|
|
||||||
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS recovery_codes TEXT;
|
|
||||||
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS webauthn_challenge TEXT;
|
|
||||||
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS has_seen_intro BOOLEAN DEFAULT FALSE;
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 8. Stubbed email log table (for development — replaces real email sends)
|
|
||||||
-- ============================================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS shared.email_log (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
||||||
to_email VARCHAR(255) NOT NULL,
|
|
||||||
subject VARCHAR(500) NOT NULL,
|
|
||||||
body TEXT,
|
|
||||||
template VARCHAR(100),
|
|
||||||
metadata JSONB,
|
|
||||||
sent_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
-- Migration 017: Billing Enhancements
|
|
||||||
-- Adds support for annual billing, free trials, ACH/invoice billing,
|
|
||||||
-- and past_due grace period status.
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 1. Add billing_interval column (month or year)
|
|
||||||
-- ============================================================================
|
|
||||||
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS billing_interval VARCHAR(20) DEFAULT 'month';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 2. Add collection_method column (charge_automatically or send_invoice)
|
|
||||||
-- ============================================================================
|
|
||||||
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS collection_method VARCHAR(20) DEFAULT 'charge_automatically';
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 3. Update status CHECK to include 'past_due'
|
|
||||||
-- ============================================================================
|
|
||||||
ALTER TABLE shared.organizations DROP CONSTRAINT IF EXISTS organizations_status_check;
|
|
||||||
ALTER TABLE shared.organizations ADD CONSTRAINT organizations_status_check
|
|
||||||
CHECK (status IN ('active', 'suspended', 'trial', 'archived', 'past_due'));
|
|
||||||
|
|
||||||
-- ============================================================================
|
|
||||||
-- 4. Ensure plan_level CHECK includes SaaS tiers (idempotent with 015)
|
|
||||||
-- ============================================================================
|
|
||||||
ALTER TABLE shared.organizations DROP CONSTRAINT IF EXISTS organizations_plan_level_check;
|
|
||||||
ALTER TABLE shared.organizations ADD CONSTRAINT organizations_plan_level_check
|
|
||||||
CHECK (plan_level IN ('standard', 'premium', 'enterprise', 'starter', 'professional'));
|
|
||||||
@@ -40,32 +40,6 @@ services:
|
|||||||
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
||||||
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
||||||
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
||||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
|
||||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
|
||||||
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
|
|
||||||
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
|
|
||||||
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
|
|
||||||
- STRIPE_STARTER_MONTHLY_PRICE_ID=${STRIPE_STARTER_MONTHLY_PRICE_ID:-}
|
|
||||||
- STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=${STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID:-}
|
|
||||||
- STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-}
|
|
||||||
- STRIPE_STARTER_ANNUAL_PRICE_ID=${STRIPE_STARTER_ANNUAL_PRICE_ID:-}
|
|
||||||
- STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=${STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID:-}
|
|
||||||
- STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=${STRIPE_ENTERPRISE_ANNUAL_PRICE_ID:-}
|
|
||||||
- REQUIRE_PAYMENT_METHOD_FOR_TRIAL=${REQUIRE_PAYMENT_METHOD_FOR_TRIAL:-false}
|
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
|
||||||
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback}
|
|
||||||
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-}
|
|
||||||
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-}
|
|
||||||
- AZURE_TENANT_ID=${AZURE_TENANT_ID:-}
|
|
||||||
- AZURE_CALLBACK_URL=${AZURE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/azure/callback}
|
|
||||||
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-app.hoaledgeriq.com}
|
|
||||||
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-https://app.hoaledgeriq.com}
|
|
||||||
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-}
|
|
||||||
- APP_URL=${APP_URL:-https://app.hoaledgeriq.com}
|
|
||||||
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
|
||||||
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
|
|
||||||
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-sales@hoaledgeriq.com}
|
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|||||||
@@ -29,32 +29,6 @@ services:
|
|||||||
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
||||||
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
||||||
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
||||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
|
||||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
|
||||||
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
|
|
||||||
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
|
|
||||||
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
|
|
||||||
- STRIPE_STARTER_MONTHLY_PRICE_ID=${STRIPE_STARTER_MONTHLY_PRICE_ID:-}
|
|
||||||
- STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID=${STRIPE_PROFESSIONAL_MONTHLY_PRICE_ID:-}
|
|
||||||
- STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=${STRIPE_ENTERPRISE_MONTHLY_PRICE_ID:-}
|
|
||||||
- STRIPE_STARTER_ANNUAL_PRICE_ID=${STRIPE_STARTER_ANNUAL_PRICE_ID:-}
|
|
||||||
- STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID=${STRIPE_PROFESSIONAL_ANNUAL_PRICE_ID:-}
|
|
||||||
- STRIPE_ENTERPRISE_ANNUAL_PRICE_ID=${STRIPE_ENTERPRISE_ANNUAL_PRICE_ID:-}
|
|
||||||
- REQUIRE_PAYMENT_METHOD_FOR_TRIAL=${REQUIRE_PAYMENT_METHOD_FOR_TRIAL:-false}
|
|
||||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
|
||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
|
||||||
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost/api/auth/google/callback}
|
|
||||||
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-}
|
|
||||||
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-}
|
|
||||||
- AZURE_TENANT_ID=${AZURE_TENANT_ID:-}
|
|
||||||
- AZURE_CALLBACK_URL=${AZURE_CALLBACK_URL:-http://localhost/api/auth/azure/callback}
|
|
||||||
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost}
|
|
||||||
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost}
|
|
||||||
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret}
|
|
||||||
- APP_URL=${APP_URL:-http://localhost}
|
|
||||||
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
|
||||||
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
|
|
||||||
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/src:/app/src
|
- ./backend/src:/app/src
|
||||||
- ./backend/nest-cli.json:/app/nest-cli.json
|
- ./backend/nest-cli.json:/app/nest-cli.json
|
||||||
|
|||||||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.03.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.03.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.15.3",
|
"@mantine/core": "^7.15.3",
|
||||||
"@mantine/dates": "^7.15.3",
|
"@mantine/dates": "^7.15.3",
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
"@mantine/hooks": "^7.15.3",
|
"@mantine/hooks": "^7.15.3",
|
||||||
"@mantine/modals": "^7.15.3",
|
"@mantine/modals": "^7.15.3",
|
||||||
"@mantine/notifications": "^7.15.3",
|
"@mantine/notifications": "^7.15.3",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
|
||||||
"@tabler/icons-react": "^3.28.1",
|
"@tabler/icons-react": "^3.28.1",
|
||||||
"@tanstack/react-query": "^5.64.2",
|
"@tanstack/react-query": "^5.64.2",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
@@ -1290,12 +1289,6 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@simplewebauthn/browser": {
|
|
||||||
"version": "13.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
|
|
||||||
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@tabler/icons": {
|
"node_modules/@tabler/icons": {
|
||||||
"version": "3.36.1",
|
"version": "3.36.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.03.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
"@mantine/hooks": "^7.15.3",
|
"@mantine/hooks": "^7.15.3",
|
||||||
"@mantine/modals": "^7.15.3",
|
"@mantine/modals": "^7.15.3",
|
||||||
"@mantine/notifications": "^7.15.3",
|
"@mantine/notifications": "^7.15.3",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
|
||||||
"@tabler/icons-react": "^3.28.1",
|
"@tabler/icons-react": "^3.28.1",
|
||||||
"@tanstack/react-query": "^5.64.2",
|
"@tanstack/react-query": "^5.64.2",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { AppLayout } from './components/layout/AppLayout';
|
|||||||
import { LoginPage } from './pages/auth/LoginPage';
|
import { LoginPage } from './pages/auth/LoginPage';
|
||||||
import { RegisterPage } from './pages/auth/RegisterPage';
|
import { RegisterPage } from './pages/auth/RegisterPage';
|
||||||
import { SelectOrgPage } from './pages/auth/SelectOrgPage';
|
import { SelectOrgPage } from './pages/auth/SelectOrgPage';
|
||||||
import { ActivatePage } from './pages/auth/ActivatePage';
|
|
||||||
import { DashboardPage } from './pages/dashboard/DashboardPage';
|
import { DashboardPage } from './pages/dashboard/DashboardPage';
|
||||||
import { AccountsPage } from './pages/accounts/AccountsPage';
|
import { AccountsPage } from './pages/accounts/AccountsPage';
|
||||||
import { TransactionsPage } from './pages/transactions/TransactionsPage';
|
import { TransactionsPage } from './pages/transactions/TransactionsPage';
|
||||||
@@ -38,9 +37,6 @@ import { AssessmentScenariosPage } from './pages/board-planning/AssessmentScenar
|
|||||||
import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage';
|
import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage';
|
||||||
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
|
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
|
||||||
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
|
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
|
||||||
import { PricingPage } from './pages/pricing/PricingPage';
|
|
||||||
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
|
|
||||||
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
|
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
@@ -81,12 +77,6 @@ function AuthRoute({ children }: { children: React.ReactNode }) {
|
|||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public routes (no auth required) */}
|
|
||||||
<Route path="/pricing" element={<PricingPage />} />
|
|
||||||
<Route path="/activate" element={<ActivatePage />} />
|
|
||||||
<Route path="/onboarding/pending" element={<OnboardingPendingPage />} />
|
|
||||||
|
|
||||||
{/* Auth routes (redirect if already logged in) */}
|
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={
|
element={
|
||||||
@@ -111,18 +101,6 @@ export function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Onboarding (requires auth but not org selection) */}
|
|
||||||
<Route
|
|
||||||
path="/onboarding"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<OnboardingPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Admin routes */}
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
@@ -133,8 +111,6 @@ export function App() {
|
|||||||
>
|
>
|
||||||
<Route index element={<AdminPage />} />
|
<Route index element={<AdminPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Main app routes (require auth + org) */}
|
|
||||||
<Route
|
<Route
|
||||||
path="/*"
|
path="/*"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
IconChartAreaLine,
|
IconChartAreaLine,
|
||||||
IconClipboardCheck,
|
IconClipboardCheck,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
|
IconHeartRateMonitor,
|
||||||
IconCalculator,
|
IconCalculator,
|
||||||
IconGitCompare,
|
IconGitCompare,
|
||||||
IconScale,
|
IconScale,
|
||||||
@@ -45,6 +46,14 @@ const navSections = [
|
|||||||
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
|
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Transactions',
|
||||||
|
items: [
|
||||||
|
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
||||||
|
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||||
|
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Board Planning',
|
label: 'Board Planning',
|
||||||
items: [
|
items: [
|
||||||
@@ -58,8 +67,12 @@ const navSections = [
|
|||||||
{
|
{
|
||||||
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
|
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
|
||||||
},
|
},
|
||||||
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
|
{
|
||||||
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
|
label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning',
|
||||||
|
children: [
|
||||||
|
{ label: 'Investment Scenarios', path: '/board-planning/investments' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -69,14 +82,6 @@ const navSections = [
|
|||||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Transactions',
|
|
||||||
items: [
|
|
||||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
|
||||||
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
|
||||||
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Reports',
|
label: 'Reports',
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
|
Modal, Stepper, Button, Group, TextInput, NumberInput, Textarea,
|
||||||
Select, Stack, Text, Title, Alert, ActionIcon, Table,
|
Select, Stack, Text, Title, Alert, ActionIcon, Table, FileInput,
|
||||||
Card, ThemeIcon, Divider, Badge, SimpleGrid, Box,
|
Card, ThemeIcon, Divider, Loader, Badge, SimpleGrid, Box,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { DateInput } from '@mantine/dates';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconBuildingBank, IconUsers,
|
IconBuildingBank, IconUsers, IconFileSpreadsheet,
|
||||||
IconPlus, IconTrash, IconCheck, IconRocket,
|
IconPlus, IconTrash, IconDownload, IconCheck, IconRocket,
|
||||||
IconAlertCircle, IconFileSpreadsheet,
|
IconAlertCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
@@ -25,6 +24,27 @@ interface UnitRow {
|
|||||||
ownerEmail: string;
|
ownerEmail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CSV Parsing (reused from BudgetsPage pattern) ──
|
||||||
|
function parseCSV(text: string): Record<string, string>[] {
|
||||||
|
const lines = text.split('\n').filter((l) => l.trim());
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, ''));
|
||||||
|
return lines.slice(1).map((line) => {
|
||||||
|
const values: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (const char of line) {
|
||||||
|
if (char === '"') { inQuotes = !inQuotes; }
|
||||||
|
else if (char === ',' && !inQuotes) { values.push(current.trim()); current = ''; }
|
||||||
|
else { current += char; }
|
||||||
|
}
|
||||||
|
values.push(current.trim());
|
||||||
|
const row: Record<string, string> = {};
|
||||||
|
headers.forEach((h, i) => { row[h] = values[i] || ''; });
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
|
export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps) {
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -37,17 +57,22 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
const [accountNumber, setAccountNumber] = useState('1000');
|
const [accountNumber, setAccountNumber] = useState('1000');
|
||||||
const [accountDescription, setAccountDescription] = useState('');
|
const [accountDescription, setAccountDescription] = useState('');
|
||||||
const [initialBalance, setInitialBalance] = useState<number | string>(0);
|
const [initialBalance, setInitialBalance] = useState<number | string>(0);
|
||||||
const [balanceDate, setBalanceDate] = useState<Date | null>(new Date());
|
|
||||||
|
|
||||||
// ── Step 2: Assessment Group State ──
|
// ── Step 2: Assessment Group State ──
|
||||||
const [groupCreated, setGroupCreated] = useState(false);
|
const [groupCreated, setGroupCreated] = useState(false);
|
||||||
const [groupName, setGroupName] = useState('Standard Assessment');
|
const [groupName, setGroupName] = useState('Standard Assessment');
|
||||||
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
|
const [regularAssessment, setRegularAssessment] = useState<number | string>(0);
|
||||||
const [frequency, setFrequency] = useState('monthly');
|
const [frequency, setFrequency] = useState('monthly');
|
||||||
const [unitCount, setUnitCount] = useState<number | string>(0);
|
|
||||||
const [units, setUnits] = useState<UnitRow[]>([]);
|
const [units, setUnits] = useState<UnitRow[]>([]);
|
||||||
const [unitsCreated, setUnitsCreated] = useState(false);
|
const [unitsCreated, setUnitsCreated] = useState(false);
|
||||||
|
|
||||||
|
// ── Step 3: Budget State ──
|
||||||
|
const [budgetFile, setBudgetFile] = useState<File | null>(null);
|
||||||
|
const [budgetUploaded, setBudgetUploaded] = useState(false);
|
||||||
|
const [budgetImportResult, setBudgetImportResult] = useState<any>(null);
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
// ── Step 1: Create Account ──
|
// ── Step 1: Create Account ──
|
||||||
const handleCreateAccount = async () => {
|
const handleCreateAccount = async () => {
|
||||||
if (!accountName.trim()) {
|
if (!accountName.trim()) {
|
||||||
@@ -74,7 +99,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
accountType: 'asset',
|
accountType: 'asset',
|
||||||
fundType: 'operating',
|
fundType: 'operating',
|
||||||
initialBalance: balance,
|
initialBalance: balance,
|
||||||
initialBalanceDate: balanceDate ? balanceDate.toISOString().split('T')[0] : undefined,
|
|
||||||
});
|
});
|
||||||
setAccountCreated(true);
|
setAccountCreated(true);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -102,8 +126,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = typeof unitCount === 'string' ? parseInt(unitCount) : unitCount;
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -111,7 +133,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
name: groupName.trim(),
|
name: groupName.trim(),
|
||||||
regularAssessment: assessment,
|
regularAssessment: assessment,
|
||||||
frequency,
|
frequency,
|
||||||
unitCount: isNaN(count) ? 0 : count,
|
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
});
|
});
|
||||||
setGroupCreated(true);
|
setGroupCreated(true);
|
||||||
@@ -154,6 +175,62 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Step 3: Budget Import ──
|
||||||
|
const handleDownloadTemplate = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/budgets/${currentYear}/template`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `budget_template_${currentYear}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to download template',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadBudget = async () => {
|
||||||
|
if (!budgetFile) {
|
||||||
|
setError('Please select a CSV file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const text = await budgetFile.text();
|
||||||
|
const rows = parseCSV(text);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
setError('CSV file appears to be empty or invalid');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await api.post(`/budgets/${currentYear}/import`, { rows });
|
||||||
|
setBudgetUploaded(true);
|
||||||
|
setBudgetImportResult(data);
|
||||||
|
notifications.show({
|
||||||
|
title: 'Budget Imported',
|
||||||
|
message: `Imported ${data.imported || rows.length} budget line(s) for ${currentYear}`,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.message || 'Failed to import budget';
|
||||||
|
setError(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ── Finish Wizard ──
|
// ── Finish Wizard ──
|
||||||
const handleFinish = async () => {
|
const handleFinish = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -188,12 +265,13 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
const canGoNext = () => {
|
const canGoNext = () => {
|
||||||
if (active === 0) return accountCreated;
|
if (active === 0) return accountCreated;
|
||||||
if (active === 1) return groupCreated;
|
if (active === 1) return groupCreated;
|
||||||
|
if (active === 2) return true; // Budget is optional
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextStep = () => {
|
const nextStep = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (active < 2) setActive(active + 1);
|
if (active < 3) setActive(active + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -237,6 +315,12 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
icon={<IconUsers size={18} />}
|
icon={<IconUsers size={18} />}
|
||||||
completedIcon={<IconCheck size={18} />}
|
completedIcon={<IconCheck size={18} />}
|
||||||
/>
|
/>
|
||||||
|
<Stepper.Step
|
||||||
|
label="Budget"
|
||||||
|
description="Import your annual budget"
|
||||||
|
icon={<IconFileSpreadsheet size={18} />}
|
||||||
|
completedIcon={<IconCheck size={18} />}
|
||||||
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -259,7 +343,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
<Text fw={500}>{accountName} created successfully!</Text>
|
<Text fw={500}>{accountName} created successfully!</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
|
Initial balance: ${(typeof initialBalance === 'number' ? initialBalance : parseFloat(initialBalance as string) || 0).toLocaleString()}
|
||||||
{balanceDate && ` as of ${balanceDate.toLocaleDateString()}`}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
@@ -289,7 +372,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
autosize
|
autosize
|
||||||
minRows={2}
|
minRows={2}
|
||||||
/>
|
/>
|
||||||
<SimpleGrid cols={2} mb="md">
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Current Balance"
|
label="Current Balance"
|
||||||
description="Enter the current balance of this bank account"
|
description="Enter the current balance of this bank account"
|
||||||
@@ -299,16 +381,8 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
thousandSeparator=","
|
thousandSeparator=","
|
||||||
prefix="$"
|
prefix="$"
|
||||||
decimalScale={2}
|
decimalScale={2}
|
||||||
|
mb="md"
|
||||||
/>
|
/>
|
||||||
<DateInput
|
|
||||||
label="Balance As-Of Date"
|
|
||||||
description="Date this balance was accurate (e.g. last statement date)"
|
|
||||||
value={balanceDate}
|
|
||||||
onChange={setBalanceDate}
|
|
||||||
maxDate={new Date()}
|
|
||||||
clearable={false}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateAccount}
|
onClick={handleCreateAccount}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -341,7 +415,7 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SimpleGrid cols={2} mb="md">
|
<SimpleGrid cols={3} mb="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Group Name"
|
label="Group Name"
|
||||||
placeholder="e.g. Standard Assessment"
|
placeholder="e.g. Standard Assessment"
|
||||||
@@ -349,17 +423,6 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
onChange={(e) => setGroupName(e.currentTarget.value)}
|
onChange={(e) => setGroupName(e.currentTarget.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
|
||||||
label="Total Unit Count"
|
|
||||||
description="How many units/lots does your community have?"
|
|
||||||
placeholder="e.g. 50"
|
|
||||||
value={unitCount}
|
|
||||||
onChange={setUnitCount}
|
|
||||||
min={0}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
<SimpleGrid cols={2} mb="md">
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Assessment Amount"
|
label="Assessment Amount"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
@@ -457,16 +520,71 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Completion Screen ── */}
|
{/* ── Step 3: Budget Upload ── */}
|
||||||
{active === 2 && (
|
{active === 2 && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Text fw={600} mb="xs">Import Your {currentYear} Budget</Text>
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
Upload a CSV file with your annual budget. If you don't have one ready, you can download a template
|
||||||
|
or skip this step and set it up later from the Budgets page.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{budgetUploaded ? (
|
||||||
|
<Alert icon={<IconCheck size={16} />} color="green" variant="light">
|
||||||
|
<Text fw={500}>Budget imported successfully!</Text>
|
||||||
|
{budgetImportResult && (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{budgetImportResult.created || 0} new lines created, {budgetImportResult.updated || 0} updated
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Group mb="md">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconDownload size={16} />}
|
||||||
|
onClick={handleDownloadTemplate}
|
||||||
|
>
|
||||||
|
Download CSV Template
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<FileInput
|
||||||
|
label="Upload Budget CSV"
|
||||||
|
placeholder="Click to select a .csv file"
|
||||||
|
accept=".csv"
|
||||||
|
value={budgetFile}
|
||||||
|
onChange={setBudgetFile}
|
||||||
|
mb="md"
|
||||||
|
leftSection={<IconFileSpreadsheet size={16} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleUploadBudget}
|
||||||
|
loading={loading}
|
||||||
|
leftSection={<IconFileSpreadsheet size={16} />}
|
||||||
|
disabled={!budgetFile}
|
||||||
|
>
|
||||||
|
Import Budget
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Completion Screen ── */}
|
||||||
|
{active === 3 && (
|
||||||
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
|
<Card withBorder p="xl" style={{ textAlign: 'center' }}>
|
||||||
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
|
<ThemeIcon size={60} radius="xl" variant="gradient" gradient={{ from: 'green', to: 'teal' }} mx="auto" mb="md">
|
||||||
<IconCheck size={32} />
|
<IconCheck size={32} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Title order={3} mb="xs">You're All Set!</Title>
|
<Title order={3} mb="xs">You're All Set!</Title>
|
||||||
<Text c="dimmed" mb="lg" maw={400} mx="auto">
|
<Text c="dimmed" mb="lg" maw={400} mx="auto">
|
||||||
Your organization is configured and ready to go. You can always update your accounts
|
Your organization is configured and ready to go. You can always update your accounts,
|
||||||
and assessment groups from the sidebar navigation.
|
assessment groups, and budgets from the sidebar navigation.
|
||||||
</Text>
|
</Text>
|
||||||
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
|
<SimpleGrid cols={3} mb="xl" maw={500} mx="auto">
|
||||||
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
<Card withBorder p="sm" style={{ textAlign: 'center' }}>
|
||||||
@@ -487,17 +605,12 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
<ThemeIcon size={32} color="blue" variant="light" radius="xl" mx="auto" mb={4}>
|
||||||
<IconFileSpreadsheet size={16} />
|
<IconFileSpreadsheet size={16} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Badge color="cyan" size="sm">Up Next</Badge>
|
<Badge color={budgetUploaded ? 'green' : 'yellow'} size="sm">
|
||||||
|
{budgetUploaded ? 'Done' : 'Skipped'}
|
||||||
|
</Badge>
|
||||||
<Text size="xs" mt={4}>Budget</Text>
|
<Text size="xs" mt={4}>Budget</Text>
|
||||||
</Card>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
<Alert icon={<IconFileSpreadsheet size={16} />} color="blue" variant="light" mb="lg" ta="left">
|
|
||||||
<Text size="sm" fw={500} mb={4}>Set Up Your Budget</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Head to <Text span fw={600}>Budget Planning</Text> from the sidebar to download a CSV template,
|
|
||||||
fill in your monthly amounts, and upload your budget. You can do this at any time.
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={handleFinish}
|
onClick={handleFinish}
|
||||||
@@ -512,13 +625,18 @@ export function OnboardingWizard({ opened, onComplete }: OnboardingWizardProps)
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Navigation Buttons ── */}
|
{/* ── Navigation Buttons ── */}
|
||||||
{active < 2 && (
|
{active < 3 && (
|
||||||
<Group justify="flex-end" mt="xl">
|
<Group justify="flex-end" mt="xl">
|
||||||
|
{active === 2 && !budgetUploaded && (
|
||||||
|
<Button variant="subtle" onClick={nextStep}>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
disabled={!canGoNext()}
|
disabled={!canGoNext()}
|
||||||
>
|
>
|
||||||
Next Step
|
{active === 2 ? (budgetUploaded ? 'Continue' : '') : 'Next Step'}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import '@mantine/core/styles.css';
|
|||||||
import '@mantine/dates/styles.css';
|
import '@mantine/dates/styles.css';
|
||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { defaultTheme, compactTheme } from './theme/theme';
|
import { theme } from './theme/theme';
|
||||||
import { usePreferencesStore } from './stores/preferencesStore';
|
import { usePreferencesStore } from './stores/preferencesStore';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -24,11 +24,9 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
function Root() {
|
function Root() {
|
||||||
const colorScheme = usePreferencesStore((s) => s.colorScheme);
|
const colorScheme = usePreferencesStore((s) => s.colorScheme);
|
||||||
const compactView = usePreferencesStore((s) => s.compactView);
|
|
||||||
const activeTheme = compactView ? compactTheme : defaultTheme;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider theme={activeTheme} forceColorScheme={colorScheme}>
|
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
|
||||||
<Notifications position="top-right" />
|
<Notifications position="top-right" />
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Container, Paper, Title, Text, TextInput, PasswordInput,
|
|
||||||
Button, Stack, Alert, Center, Loader, Progress, Anchor,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useForm } from '@mantine/form';
|
|
||||||
import { IconAlertCircle, IconCheck, IconShieldCheck } from '@tabler/icons-react';
|
|
||||||
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
|
|
||||||
import api from '../../services/api';
|
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
|
||||||
import logoSrc from '../../assets/logo.png';
|
|
||||||
|
|
||||||
function getPasswordStrength(pw: string): number {
|
|
||||||
let score = 0;
|
|
||||||
if (pw.length >= 8) score += 25;
|
|
||||||
if (pw.length >= 12) score += 15;
|
|
||||||
if (/[A-Z]/.test(pw)) score += 20;
|
|
||||||
if (/[a-z]/.test(pw)) score += 10;
|
|
||||||
if (/[0-9]/.test(pw)) score += 15;
|
|
||||||
if (/[^A-Za-z0-9]/.test(pw)) score += 15;
|
|
||||||
return Math.min(score, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function strengthColor(s: number): string {
|
|
||||||
if (s < 40) return 'red';
|
|
||||||
if (s < 70) return 'orange';
|
|
||||||
return 'green';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActivatePage() {
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const setAuth = useAuthStore((s) => s.setAuth);
|
|
||||||
const token = searchParams.get('token');
|
|
||||||
|
|
||||||
const [validating, setValidating] = useState(true);
|
|
||||||
const [tokenInfo, setTokenInfo] = useState<any>(null);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
initialValues: { fullName: '', password: '', confirmPassword: '' },
|
|
||||||
validate: {
|
|
||||||
fullName: (v) => (v.trim().length >= 2 ? null : 'Name is required'),
|
|
||||||
password: (v) => (v.length >= 8 ? null : 'Password must be at least 8 characters'),
|
|
||||||
confirmPassword: (v, values) => (v === values.password ? null : 'Passwords do not match'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!token) {
|
|
||||||
setError('No activation token provided');
|
|
||||||
setValidating(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
api.get(`/auth/activate?token=${token}`)
|
|
||||||
.then(({ data }) => {
|
|
||||||
setTokenInfo(data);
|
|
||||||
setValidating(false);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
setError(err.response?.data?.message || 'Invalid or expired activation link');
|
|
||||||
setValidating(false);
|
|
||||||
});
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
const handleSubmit = async (values: typeof form.values) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const { data } = await api.post('/auth/activate', {
|
|
||||||
token,
|
|
||||||
password: values.password,
|
|
||||||
fullName: values.fullName,
|
|
||||||
});
|
|
||||||
setAuth(data.accessToken, data.user, data.organizations);
|
|
||||||
navigate('/onboarding');
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.response?.data?.message || 'Activation failed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const passwordStrength = getPasswordStrength(form.values.password);
|
|
||||||
|
|
||||||
if (validating) {
|
|
||||||
return (
|
|
||||||
<Container size={420} my={80}>
|
|
||||||
<Center><Loader size="lg" /></Center>
|
|
||||||
<Text ta="center" mt="md" c="dimmed">Validating activation link...</Text>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && !tokenInfo) {
|
|
||||||
return (
|
|
||||||
<Container size={420} my={80}>
|
|
||||||
<Center>
|
|
||||||
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
|
|
||||||
</Center>
|
|
||||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
|
||||||
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light" mb="md">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
<Stack>
|
|
||||||
<Anchor component={Link} to="/login" size="sm" ta="center">
|
|
||||||
Go to Login
|
|
||||||
</Anchor>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size={420} my={80}>
|
|
||||||
<Center>
|
|
||||||
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
|
|
||||||
</Center>
|
|
||||||
<Text ta="center" mt={5} c="dimmed" size="sm">
|
|
||||||
Activate your account for <strong>{tokenInfo?.orgName || 'your organization'}</strong>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
||||||
<Stack>
|
|
||||||
{error && (
|
|
||||||
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Full Name"
|
|
||||||
placeholder="John Doe"
|
|
||||||
required
|
|
||||||
{...form.getInputProps('fullName')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<PasswordInput
|
|
||||||
label="Password"
|
|
||||||
placeholder="Create a strong password"
|
|
||||||
required
|
|
||||||
{...form.getInputProps('password')}
|
|
||||||
/>
|
|
||||||
{form.values.password && (
|
|
||||||
<Progress
|
|
||||||
value={passwordStrength}
|
|
||||||
color={strengthColor(passwordStrength)}
|
|
||||||
size="xs"
|
|
||||||
mt={4}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PasswordInput
|
|
||||||
label="Confirm Password"
|
|
||||||
placeholder="Confirm your password"
|
|
||||||
required
|
|
||||||
{...form.getInputProps('confirmPassword')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
fullWidth
|
|
||||||
loading={loading}
|
|
||||||
leftSection={<IconShieldCheck size={16} />}
|
|
||||||
>
|
|
||||||
Activate Account
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
@@ -10,41 +10,18 @@ import {
|
|||||||
Anchor,
|
Anchor,
|
||||||
Stack,
|
Stack,
|
||||||
Alert,
|
Alert,
|
||||||
Divider,
|
|
||||||
Group,
|
|
||||||
PinInput,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from '@mantine/form';
|
||||||
import {
|
import { IconAlertCircle } from '@tabler/icons-react';
|
||||||
IconAlertCircle,
|
|
||||||
IconBrandGoogle,
|
|
||||||
IconBrandWindows,
|
|
||||||
IconFingerprint,
|
|
||||||
IconShieldLock,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
import logoSrc from '../../assets/logo.png';
|
import logoSrc from '../../assets/logo.png';
|
||||||
|
|
||||||
type LoginState = 'credentials' | 'mfa';
|
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loginState, setLoginState] = useState<LoginState>('credentials');
|
|
||||||
const [mfaToken, setMfaToken] = useState('');
|
|
||||||
const [mfaCode, setMfaCode] = useState('');
|
|
||||||
const [useRecovery, setUseRecovery] = useState(false);
|
|
||||||
const [recoveryCode, setRecoveryCode] = useState('');
|
|
||||||
const [ssoProviders, setSsoProviders] = useState<{ google: boolean; azure: boolean }>({
|
|
||||||
google: false,
|
|
||||||
azure: false,
|
|
||||||
});
|
|
||||||
const [passkeySupported, setPasskeySupported] = useState(false);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const setAuth = useAuthStore((s) => s.setAuth);
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
@@ -57,42 +34,20 @@ export function LoginPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch SSO providers & check passkey support on mount
|
|
||||||
useEffect(() => {
|
|
||||||
api
|
|
||||||
.get('/auth/sso/providers')
|
|
||||||
.then(({ data }) => setSsoProviders(data))
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
if (
|
|
||||||
window.PublicKeyCredential &&
|
|
||||||
typeof window.PublicKeyCredential === 'function'
|
|
||||||
) {
|
|
||||||
setPasskeySupported(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLoginSuccess = (data: any) => {
|
|
||||||
setAuth(data.accessToken, data.user, data.organizations);
|
|
||||||
if (data.user?.isSuperadmin && data.organizations.length === 0) {
|
|
||||||
navigate('/admin');
|
|
||||||
} else if (data.organizations.length >= 1) {
|
|
||||||
navigate('/select-org');
|
|
||||||
} else {
|
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (values: typeof form.values) => {
|
const handleSubmit = async (values: typeof form.values) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('/auth/login', values);
|
const { data } = await api.post('/auth/login', values);
|
||||||
if (data.mfaRequired) {
|
setAuth(data.accessToken, data.user, data.organizations);
|
||||||
setMfaToken(data.mfaToken);
|
// Platform owner / superadmin with no orgs → admin panel
|
||||||
setLoginState('mfa');
|
if (data.user?.isSuperadmin && data.organizations.length === 0) {
|
||||||
|
navigate('/admin');
|
||||||
|
} else if (data.organizations.length >= 1) {
|
||||||
|
// Always go through org selection to ensure correct JWT with orgSchema
|
||||||
|
navigate('/select-org');
|
||||||
} else {
|
} else {
|
||||||
handleLoginSuccess(data);
|
navigate('/');
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || 'Login failed');
|
setError(err.response?.data?.message || 'Login failed');
|
||||||
@@ -101,57 +56,6 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMfaVerify = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const token = useRecovery ? recoveryCode : mfaCode;
|
|
||||||
const { data } = await api.post('/auth/mfa/verify', {
|
|
||||||
mfaToken,
|
|
||||||
token,
|
|
||||||
isRecoveryCode: useRecovery,
|
|
||||||
});
|
|
||||||
handleLoginSuccess(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.response?.data?.message || 'MFA verification failed');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasskeyLogin = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
// Get authentication options
|
|
||||||
const { data: options } = await api.post('/auth/passkeys/login-options', {
|
|
||||||
email: form.values.email || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger browser WebAuthn prompt
|
|
||||||
const credential = await startAuthentication({ optionsJSON: options });
|
|
||||||
|
|
||||||
// Verify with server
|
|
||||||
const { data } = await api.post('/auth/passkeys/login', {
|
|
||||||
response: credential,
|
|
||||||
challenge: options.challenge,
|
|
||||||
});
|
|
||||||
handleLoginSuccess(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.name === 'NotAllowedError') {
|
|
||||||
setError('Passkey authentication was cancelled');
|
|
||||||
} else {
|
|
||||||
setError(err.response?.data?.message || err.message || 'Passkey login failed');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasSso = ssoProviders.google || ssoProviders.azure;
|
|
||||||
|
|
||||||
// MFA verification screen
|
|
||||||
if (loginState === 'mfa') {
|
|
||||||
return (
|
return (
|
||||||
<Container size={420} my={80}>
|
<Container size={420} my={80}>
|
||||||
<Center>
|
<Center>
|
||||||
@@ -160,136 +64,9 @@ export function LoginPage() {
|
|||||||
alt="HOA LedgerIQ"
|
alt="HOA LedgerIQ"
|
||||||
style={{
|
style={{
|
||||||
height: 60,
|
height: 60,
|
||||||
...(isDark
|
...(isDark ? {
|
||||||
? {
|
filter: 'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
||||||
filter:
|
} : {}),
|
||||||
'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
|
||||||
<Stack>
|
|
||||||
<Group gap="xs" justify="center">
|
|
||||||
<IconShieldLock size={24} />
|
|
||||||
<Text fw={600} size="lg">
|
|
||||||
Two-Factor Authentication
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!useRecovery ? (
|
|
||||||
<>
|
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
|
||||||
Enter the 6-digit code from your authenticator app
|
|
||||||
</Text>
|
|
||||||
<Center>
|
|
||||||
<PinInput
|
|
||||||
length={6}
|
|
||||||
type="number"
|
|
||||||
value={mfaCode}
|
|
||||||
onChange={setMfaCode}
|
|
||||||
oneTimeCode
|
|
||||||
autoFocus
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
</Center>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
loading={loading}
|
|
||||||
onClick={handleMfaVerify}
|
|
||||||
disabled={mfaCode.length !== 6}
|
|
||||||
>
|
|
||||||
Verify
|
|
||||||
</Button>
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
ta="center"
|
|
||||||
onClick={() => {
|
|
||||||
setUseRecovery(true);
|
|
||||||
setError('');
|
|
||||||
}}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
Use a recovery code instead
|
|
||||||
</Anchor>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
|
||||||
Enter one of your recovery codes
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
placeholder="xxxxxxxx"
|
|
||||||
value={recoveryCode}
|
|
||||||
onChange={(e) => setRecoveryCode(e.currentTarget.value)}
|
|
||||||
autoFocus
|
|
||||||
ff="monospace"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
loading={loading}
|
|
||||||
onClick={handleMfaVerify}
|
|
||||||
disabled={!recoveryCode.trim()}
|
|
||||||
>
|
|
||||||
Verify Recovery Code
|
|
||||||
</Button>
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
ta="center"
|
|
||||||
onClick={() => {
|
|
||||||
setUseRecovery(false);
|
|
||||||
setError('');
|
|
||||||
}}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
Use authenticator code instead
|
|
||||||
</Anchor>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Anchor
|
|
||||||
size="sm"
|
|
||||||
ta="center"
|
|
||||||
onClick={() => {
|
|
||||||
setLoginState('credentials');
|
|
||||||
setMfaToken('');
|
|
||||||
setMfaCode('');
|
|
||||||
setRecoveryCode('');
|
|
||||||
setError('');
|
|
||||||
}}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
← Back to login
|
|
||||||
</Anchor>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main login form
|
|
||||||
return (
|
|
||||||
<Container size={420} my={80}>
|
|
||||||
<Center>
|
|
||||||
<img
|
|
||||||
src={logoSrc}
|
|
||||||
alt="HOA LedgerIQ"
|
|
||||||
style={{
|
|
||||||
height: 60,
|
|
||||||
...(isDark
|
|
||||||
? {
|
|
||||||
filter:
|
|
||||||
'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Center>
|
</Center>
|
||||||
@@ -325,53 +102,6 @@ export function LoginPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Passkey login */}
|
|
||||||
{passkeySupported && (
|
|
||||||
<>
|
|
||||||
<Divider label="or" labelPosition="center" my="md" />
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
fullWidth
|
|
||||||
leftSection={<IconFingerprint size={18} />}
|
|
||||||
onClick={handlePasskeyLogin}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
Sign in with Passkey
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* SSO providers */}
|
|
||||||
{hasSso && (
|
|
||||||
<>
|
|
||||||
<Divider label="or continue with" labelPosition="center" my="md" />
|
|
||||||
<Group grow>
|
|
||||||
{ssoProviders.google && (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconBrandGoogle size={18} color="#4285F4" />}
|
|
||||||
onClick={() => {
|
|
||||||
window.location.href = '/api/auth/google';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Google
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{ssoProviders.azure && (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconBrandWindows size={18} color="#0078D4" />}
|
|
||||||
onClick={() => {
|
|
||||||
window.location.href = '/api/auth/azure';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Microsoft
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Paper>
|
</Paper>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Table, Group, Button, Stack, Text, NumberInput,
|
Title, Table, Group, Button, Stack, Text, NumberInput,
|
||||||
Select, Loader, Center, Badge, Card, Alert, Modal, ThemeIcon,
|
Select, Loader, Center, Badge, Card, Alert, Modal,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
IconDeviceFloppy, IconInfoCircle, IconPencil, IconX,
|
IconDeviceFloppy, IconInfoCircle, IconPencil, IconX,
|
||||||
IconCheck, IconArrowBack, IconTrash, IconRefresh,
|
IconCheck, IconArrowBack, IconTrash, IconRefresh,
|
||||||
IconUpload, IconDownload, IconFileSpreadsheet,
|
IconUpload, IconDownload,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
@@ -659,37 +659,7 @@ export function BudgetPlanningPage() {
|
|||||||
{lineData.length === 0 && (
|
{lineData.length === 0 && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={15}>
|
<Table.Td colSpan={15}>
|
||||||
<Card withBorder p="xl" mx="auto" maw={600} my="lg" style={{ textAlign: 'center' }}>
|
<Text ta="center" c="dimmed" py="lg">No budget plan lines.</Text>
|
||||||
<ThemeIcon size={60} radius="xl" variant="light" color="blue" mx="auto" mb="md">
|
|
||||||
<IconFileSpreadsheet size={28} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Title order={4} mb="xs">Get Started with Your {selectedYear} Budget</Title>
|
|
||||||
<Text c="dimmed" size="sm" mb="lg" maw={450} mx="auto">
|
|
||||||
Your budget plan is created but has no line items yet. Download the
|
|
||||||
CSV template pre-filled with your chart of accounts, fill in your
|
|
||||||
monthly amounts, then upload it here.
|
|
||||||
</Text>
|
|
||||||
<Group justify="center" gap="md">
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
leftSection={<IconDownload size={16} />}
|
|
||||||
onClick={handleDownloadTemplate}
|
|
||||||
>
|
|
||||||
Download Budget Template
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
leftSection={<IconUpload size={16} />}
|
|
||||||
onClick={handleImportCSV}
|
|
||||||
loading={importMutation.isPending}
|
|
||||||
>
|
|
||||||
Upload Budget CSV
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
<Text size="xs" c="dimmed" mt="md">
|
|
||||||
Tip: The template includes all your active accounts. Fill in the monthly
|
|
||||||
dollar amounts for each line, save as CSV, then upload.
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -89,20 +89,20 @@ export function ProjectionChart({ datapoints, title = 'Financial Projection', su
|
|||||||
<AreaChart data={chartData}>
|
<AreaChart data={chartData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#228be6" stopOpacity={0.6} />
|
<stop offset="5%" stopColor="#228be6" stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="#228be6" stopOpacity={0.15} />
|
<stop offset="95%" stopColor="#228be6" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
|
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
|
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
|
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
|
<stop offset="95%" stopColor="#7950f2" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
|
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.3} />
|
||||||
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
|
<stop offset="95%" stopColor="#b197fc" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Text, Stack, Card, Group,
|
Title, Text, Stack, Card, Group, SimpleGrid, ThemeIcon,
|
||||||
SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge,
|
SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
|
IconCash, IconBuildingBank, IconChartAreaLine,
|
||||||
IconArrowLeft, IconArrowRight, IconCalendar,
|
IconArrowLeft, IconArrowRight, IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -107,6 +108,30 @@ export function CashFlowForecastPage() {
|
|||||||
return datapoints.slice(viewStartIndex, viewStartIndex + 12);
|
return datapoints.slice(viewStartIndex, viewStartIndex + 12);
|
||||||
}, [datapoints, viewStartIndex]);
|
}, [datapoints, viewStartIndex]);
|
||||||
|
|
||||||
|
// Compute summary stats for the current view
|
||||||
|
const summaryStats = useMemo(() => {
|
||||||
|
if (!viewData.length) return null;
|
||||||
|
const last = viewData[viewData.length - 1];
|
||||||
|
const first = viewData[0];
|
||||||
|
|
||||||
|
const totalOperating = last.operating_cash + last.operating_investments;
|
||||||
|
const totalReserve = last.reserve_cash + last.reserve_investments;
|
||||||
|
const totalAll = totalOperating + totalReserve;
|
||||||
|
|
||||||
|
const firstTotal = first.operating_cash + first.operating_investments +
|
||||||
|
first.reserve_cash + first.reserve_investments;
|
||||||
|
const netChange = totalAll - firstTotal;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalOperating,
|
||||||
|
totalReserve,
|
||||||
|
totalAll,
|
||||||
|
netChange,
|
||||||
|
periodStart: first.month,
|
||||||
|
periodEnd: last.month,
|
||||||
|
};
|
||||||
|
}, [viewData]);
|
||||||
|
|
||||||
// Determine the first forecast month index within the view
|
// Determine the first forecast month index within the view
|
||||||
const forecastStartLabel = useMemo(() => {
|
const forecastStartLabel = useMemo(() => {
|
||||||
const idx = viewData.findIndex((d) => d.is_forecast);
|
const idx = viewData.findIndex((d) => d.is_forecast);
|
||||||
@@ -156,6 +181,65 @@ export function CashFlowForecastPage() {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{summaryStats && (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color="blue" size="sm">
|
||||||
|
<IconCash size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Total</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">
|
||||||
|
{fmt(summaryStats.totalOperating)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color="violet" size="sm">
|
||||||
|
<IconBuildingBank size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Total</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">
|
||||||
|
{fmt(summaryStats.totalReserve)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color="teal" size="sm">
|
||||||
|
<IconChartAreaLine size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Combined Total</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">
|
||||||
|
{fmt(summaryStats.totalAll)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={summaryStats.netChange >= 0 ? 'green' : 'red'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<IconCash size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
|
||||||
|
</Group>
|
||||||
|
<Text
|
||||||
|
fw={700}
|
||||||
|
size="xl"
|
||||||
|
ff="monospace"
|
||||||
|
c={summaryStats.netChange >= 0 ? 'green' : 'red'}
|
||||||
|
>
|
||||||
|
{fmt(summaryStats.netChange)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Chart Navigation */}
|
{/* Chart Navigation */}
|
||||||
<Card withBorder p="lg">
|
<Card withBorder p="lg">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
@@ -203,20 +287,20 @@ export function CashFlowForecastPage() {
|
|||||||
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#339af0" stopOpacity={0.6} />
|
<stop offset="5%" stopColor="#339af0" stopOpacity={0.4} />
|
||||||
<stop offset="95%" stopColor="#339af0" stopOpacity={0.15} />
|
<stop offset="95%" stopColor="#339af0" stopOpacity={0.05} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
|
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.4} />
|
||||||
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
|
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.05} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
|
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.4} />
|
||||||
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
|
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.05} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
|
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.4} />
|
||||||
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
|
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.05} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />
|
||||||
|
|||||||
@@ -494,6 +494,35 @@ export function DashboardPage() {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Title order={4} mb="sm">Recent Transactions</Title>
|
||||||
|
{(data?.recent_transactions || []).length === 0 ? (
|
||||||
|
<Text c="dimmed" size="sm">No transactions yet. Start by entering journal entries.</Text>
|
||||||
|
) : (
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(data?.recent_transactions || []).map((tx) => (
|
||||||
|
<Table.Tr key={tx.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" c="dimmed">{new Date(tx.entry_date).toLocaleDateString()}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" lineClamp={1}>{tx.description}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color={entryTypeColors[tx.entry_type] || 'gray'} variant="light">
|
||||||
|
{tx.entry_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace" fw={500}>
|
||||||
|
{fmt(tx.amount)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Title order={4}>Quick Stats</Title>
|
<Title order={4}>Quick Stats</Title>
|
||||||
<Stack mt="sm" gap="xs">
|
<Stack mt="sm" gap="xs">
|
||||||
@@ -554,35 +583,6 @@ export function DashboardPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
<Card withBorder padding="lg" radius="md">
|
|
||||||
<Title order={4} mb="sm">Recent Transactions</Title>
|
|
||||||
{(data?.recent_transactions || []).length === 0 ? (
|
|
||||||
<Text c="dimmed" size="sm">No transactions yet. Start by entering journal entries.</Text>
|
|
||||||
) : (
|
|
||||||
<Table striped highlightOnHover>
|
|
||||||
<Table.Tbody>
|
|
||||||
{(data?.recent_transactions || []).map((tx) => (
|
|
||||||
<Table.Tr key={tx.id}>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">{new Date(tx.entry_date).toLocaleDateString()}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm" lineClamp={1}>{tx.description}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge size="xs" color={entryTypeColors[tx.entry_type] || 'gray'} variant="light">
|
|
||||||
{tx.entry_type}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td ta="right" ff="monospace" fw={500}>
|
|
||||||
{fmt(tx.amount)}
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Container, Title, Text, Stack, Card, Group, Button, TextInput,
|
|
||||||
Select, Stepper, ThemeIcon, Progress, Alert, Loader, Center, Anchor,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useForm } from '@mantine/form';
|
|
||||||
import {
|
|
||||||
IconUser, IconBuilding, IconUserPlus, IconListDetails,
|
|
||||||
IconCheck, IconPlayerPlay, IconConfetti,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import api from '../../services/api';
|
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
|
||||||
|
|
||||||
const STEPS = [
|
|
||||||
{ slug: 'profile', label: 'Complete Your Profile', icon: IconUser, description: 'Set up your name and contact' },
|
|
||||||
{ slug: 'workspace', label: 'Configure Your HOA', icon: IconBuilding, description: 'Organization name and settings' },
|
|
||||||
{ slug: 'invite_member', label: 'Invite a Team Member', icon: IconUserPlus, description: 'Add a board member or manager' },
|
|
||||||
{ slug: 'first_workflow', label: 'Set Up First Account', icon: IconListDetails, description: 'Create your chart of accounts' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function OnboardingPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const user = useAuthStore((s) => s.user);
|
|
||||||
const [activeStep, setActiveStep] = useState(0);
|
|
||||||
|
|
||||||
const { data: progress, isLoading } = useQuery({
|
|
||||||
queryKey: ['onboarding-progress'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get('/onboarding/progress');
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const markStep = useMutation({
|
|
||||||
mutationFn: (step: string) => api.patch('/onboarding/progress', { step }),
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['onboarding-progress'] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const completedSteps = progress?.completedSteps || [];
|
|
||||||
const completedCount = completedSteps.length;
|
|
||||||
const allDone = progress?.completedAt != null;
|
|
||||||
|
|
||||||
// Profile form
|
|
||||||
const profileForm = useForm({
|
|
||||||
initialValues: {
|
|
||||||
firstName: user?.firstName || '',
|
|
||||||
lastName: user?.lastName || '',
|
|
||||||
phone: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Workspace form
|
|
||||||
const workspaceForm = useForm({
|
|
||||||
initialValues: { orgName: '', address: '', fiscalYearStart: '1' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invite form
|
|
||||||
const inviteForm = useForm({
|
|
||||||
initialValues: { email: '', role: 'treasurer' },
|
|
||||||
validate: { email: (v) => (/\S+@\S+/.test(v) ? null : 'Valid email required') },
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Auto-advance to first incomplete step
|
|
||||||
const firstIncomplete = STEPS.findIndex((s) => !completedSteps.includes(s.slug));
|
|
||||||
if (firstIncomplete >= 0) setActiveStep(firstIncomplete);
|
|
||||||
}, [completedSteps]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Center h={400}><Loader size="lg" /></Center>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allDone) {
|
|
||||||
return (
|
|
||||||
<Container size="sm" py={60}>
|
|
||||||
<Center>
|
|
||||||
<Stack align="center" gap="lg">
|
|
||||||
<ThemeIcon size={60} radius="xl" color="green" variant="light">
|
|
||||||
<IconConfetti size={30} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Title order={2}>You're all set!</Title>
|
|
||||||
<Text c="dimmed" ta="center">
|
|
||||||
Your workspace is ready. Let's get to work.
|
|
||||||
</Text>
|
|
||||||
<Button size="lg" onClick={() => navigate('/dashboard')}>
|
|
||||||
Go to Dashboard
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Center>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size="md" py={40}>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<div>
|
|
||||||
<Title order={2}>Welcome to HOA LedgerIQ</Title>
|
|
||||||
<Text c="dimmed" size="sm">Complete these steps to set up your workspace</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Progress value={(completedCount / STEPS.length) * 100} size="lg" color="teal" />
|
|
||||||
<Text size="sm" c="dimmed" ta="center">{completedCount} of {STEPS.length} steps complete</Text>
|
|
||||||
|
|
||||||
<Stepper
|
|
||||||
active={activeStep}
|
|
||||||
onStepClick={setActiveStep}
|
|
||||||
orientation="vertical"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{/* Step 1: Profile */}
|
|
||||||
<Stepper.Step
|
|
||||||
label={STEPS[0].label}
|
|
||||||
description={STEPS[0].description}
|
|
||||||
icon={completedSteps.includes('profile') ? <IconCheck size={16} /> : <IconUser size={16} />}
|
|
||||||
completedIcon={<IconCheck size={16} />}
|
|
||||||
color={completedSteps.includes('profile') ? 'green' : undefined}
|
|
||||||
>
|
|
||||||
<Card withBorder p="lg" mt="sm">
|
|
||||||
<form onSubmit={profileForm.onSubmit(() => markStep.mutate('profile'))}>
|
|
||||||
<Stack>
|
|
||||||
<Group grow>
|
|
||||||
<TextInput label="First Name" {...profileForm.getInputProps('firstName')} />
|
|
||||||
<TextInput label="Last Name" {...profileForm.getInputProps('lastName')} />
|
|
||||||
</Group>
|
|
||||||
<TextInput label="Phone (optional)" {...profileForm.getInputProps('phone')} />
|
|
||||||
<Button type="submit" loading={markStep.isPending}>Save & Continue</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
</Stepper.Step>
|
|
||||||
|
|
||||||
{/* Step 2: Workspace */}
|
|
||||||
<Stepper.Step
|
|
||||||
label={STEPS[1].label}
|
|
||||||
description={STEPS[1].description}
|
|
||||||
icon={completedSteps.includes('workspace') ? <IconCheck size={16} /> : <IconBuilding size={16} />}
|
|
||||||
completedIcon={<IconCheck size={16} />}
|
|
||||||
color={completedSteps.includes('workspace') ? 'green' : undefined}
|
|
||||||
>
|
|
||||||
<Card withBorder p="lg" mt="sm">
|
|
||||||
<form onSubmit={workspaceForm.onSubmit(() => markStep.mutate('workspace'))}>
|
|
||||||
<Stack>
|
|
||||||
<TextInput label="Organization Name" placeholder="Sunset Village HOA" {...workspaceForm.getInputProps('orgName')} />
|
|
||||||
<TextInput label="Address" placeholder="123 Main St" {...workspaceForm.getInputProps('address')} />
|
|
||||||
<Select
|
|
||||||
label="Fiscal Year Start Month"
|
|
||||||
data={[
|
|
||||||
{ value: '1', label: 'January' },
|
|
||||||
{ value: '4', label: 'April' },
|
|
||||||
{ value: '7', label: 'July' },
|
|
||||||
{ value: '10', label: 'October' },
|
|
||||||
]}
|
|
||||||
{...workspaceForm.getInputProps('fiscalYearStart')}
|
|
||||||
/>
|
|
||||||
<Button type="submit" loading={markStep.isPending}>Save & Continue</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
</Stepper.Step>
|
|
||||||
|
|
||||||
{/* Step 3: Invite */}
|
|
||||||
<Stepper.Step
|
|
||||||
label={STEPS[2].label}
|
|
||||||
description={STEPS[2].description}
|
|
||||||
icon={completedSteps.includes('invite_member') ? <IconCheck size={16} /> : <IconUserPlus size={16} />}
|
|
||||||
completedIcon={<IconCheck size={16} />}
|
|
||||||
color={completedSteps.includes('invite_member') ? 'green' : undefined}
|
|
||||||
>
|
|
||||||
<Card withBorder p="lg" mt="sm">
|
|
||||||
<form onSubmit={inviteForm.onSubmit(() => markStep.mutate('invite_member'))}>
|
|
||||||
<Stack>
|
|
||||||
<TextInput label="Email Address" placeholder="teammate@example.com" {...inviteForm.getInputProps('email')} />
|
|
||||||
<Select
|
|
||||||
label="Role"
|
|
||||||
data={[
|
|
||||||
{ value: 'president', label: 'President' },
|
|
||||||
{ value: 'treasurer', label: 'Treasurer' },
|
|
||||||
{ value: 'secretary', label: 'Secretary' },
|
|
||||||
{ value: 'member_at_large', label: 'Member at Large' },
|
|
||||||
{ value: 'manager', label: 'Manager' },
|
|
||||||
{ value: 'viewer', label: 'Viewer' },
|
|
||||||
]}
|
|
||||||
{...inviteForm.getInputProps('role')}
|
|
||||||
/>
|
|
||||||
<Group>
|
|
||||||
<Button type="submit" loading={markStep.isPending}>Send Invite & Continue</Button>
|
|
||||||
<Button variant="subtle" onClick={() => markStep.mutate('invite_member')}>
|
|
||||||
Skip for now
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
</Stepper.Step>
|
|
||||||
|
|
||||||
{/* Step 4: First Account */}
|
|
||||||
<Stepper.Step
|
|
||||||
label={STEPS[3].label}
|
|
||||||
description={STEPS[3].description}
|
|
||||||
icon={completedSteps.includes('first_workflow') ? <IconCheck size={16} /> : <IconListDetails size={16} />}
|
|
||||||
completedIcon={<IconCheck size={16} />}
|
|
||||||
color={completedSteps.includes('first_workflow') ? 'green' : undefined}
|
|
||||||
>
|
|
||||||
<Card withBorder p="lg" mt="sm">
|
|
||||||
<Stack>
|
|
||||||
<Text size="sm">
|
|
||||||
Your chart of accounts has been pre-configured with standard HOA accounts.
|
|
||||||
You can review and customize them now, or do it later.
|
|
||||||
</Text>
|
|
||||||
<Group>
|
|
||||||
<Button
|
|
||||||
leftSection={<IconListDetails size={16} />}
|
|
||||||
onClick={() => {
|
|
||||||
markStep.mutate('first_workflow');
|
|
||||||
navigate('/accounts');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Review Accounts
|
|
||||||
</Button>
|
|
||||||
<Button variant="subtle" onClick={() => markStep.mutate('first_workflow')}>
|
|
||||||
Use defaults & Continue
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</Stepper.Step>
|
|
||||||
</Stepper>
|
|
||||||
|
|
||||||
<Group justify="center" mt="md">
|
|
||||||
<Button variant="subtle" color="gray" onClick={() => navigate('/dashboard')}>
|
|
||||||
Finish Later
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Container, Center, Stack, Loader, Text, Title, Alert, Button } from '@mantine/core';
|
|
||||||
import { IconCheck, IconAlertCircle } from '@tabler/icons-react';
|
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
||||||
import api from '../../services/api';
|
|
||||||
|
|
||||||
export function OnboardingPendingPage() {
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const sessionId = searchParams.get('session_id');
|
|
||||||
const [status, setStatus] = useState<string>('polling');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sessionId) {
|
|
||||||
setError('No session ID provided');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await api.get(`/billing/status?session_id=${sessionId}`);
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
if (data.status === 'active') {
|
|
||||||
setStatus('complete');
|
|
||||||
// Redirect to login page — user will get activation email
|
|
||||||
setTimeout(() => navigate('/login'), 3000);
|
|
||||||
} else if (data.status === 'not_configured') {
|
|
||||||
setError('Payment system is not configured. Please contact support.');
|
|
||||||
} else {
|
|
||||||
// Still provisioning — poll again
|
|
||||||
setTimeout(poll, 3000);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(err.response?.data?.message || 'Failed to check status');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
poll();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [sessionId, navigate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size="sm" py={80}>
|
|
||||||
<Center>
|
|
||||||
<Stack align="center" gap="lg">
|
|
||||||
{error ? (
|
|
||||||
<>
|
|
||||||
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
<Button variant="light" onClick={() => navigate('/pricing')}>
|
|
||||||
Back to Pricing
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : status === 'complete' ? (
|
|
||||||
<>
|
|
||||||
<IconCheck size={48} color="var(--mantine-color-green-6)" />
|
|
||||||
<Title order={2}>Your account is ready!</Title>
|
|
||||||
<Text c="dimmed" ta="center">
|
|
||||||
Check your email for an activation link to set your password and get started.
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">Redirecting to login...</Text>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Loader size="xl" />
|
|
||||||
<Title order={2}>Setting up your account...</Title>
|
|
||||||
<Text c="dimmed" ta="center" maw={400}>
|
|
||||||
We're creating your HOA workspace. This usually takes just a few seconds.
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Center>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
|
|||||||
|
|
||||||
export function UserPreferencesPage() {
|
export function UserPreferencesPage() {
|
||||||
const { user, currentOrg } = useAuthStore();
|
const { user, currentOrg } = useAuthStore();
|
||||||
const { colorScheme, toggleColorScheme, compactView, toggleCompactView } = usePreferencesStore();
|
const { colorScheme, toggleColorScheme } = usePreferencesStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -78,7 +78,7 @@ export function UserPreferencesPage() {
|
|||||||
<Text size="sm">Compact View</Text>
|
<Text size="sm">Compact View</Text>
|
||||||
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
|
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={compactView} onChange={toggleCompactView} />
|
<Switch disabled />
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
|
<Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge,
|
|
||||||
Button, List, ThemeIcon, TextInput, Center, Alert, SegmentedControl, Box,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconCheck, IconX, IconRocket, IconStar, IconCrown, IconAlertCircle } from '@tabler/icons-react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import api from '../../services/api';
|
|
||||||
import logoSrc from '../../assets/logo.png';
|
|
||||||
|
|
||||||
type BillingInterval = 'month' | 'year';
|
|
||||||
|
|
||||||
const plans = [
|
|
||||||
{
|
|
||||||
id: 'starter',
|
|
||||||
name: 'Starter',
|
|
||||||
monthlyPrice: 29,
|
|
||||||
annualPrice: 261, // 29 * 12 * 0.75
|
|
||||||
description: 'For small communities getting started',
|
|
||||||
icon: IconRocket,
|
|
||||||
color: 'blue',
|
|
||||||
features: [
|
|
||||||
{ text: 'Up to 50 units', included: true },
|
|
||||||
{ text: 'Chart of Accounts', included: true },
|
|
||||||
{ text: 'Assessment Tracking', included: true },
|
|
||||||
{ text: 'Basic Reports', included: true },
|
|
||||||
{ text: 'Board Planning', included: false },
|
|
||||||
{ text: 'AI Investment Advisor', included: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'professional',
|
|
||||||
name: 'Professional',
|
|
||||||
monthlyPrice: 79,
|
|
||||||
annualPrice: 711, // 79 * 12 * 0.75
|
|
||||||
description: 'For growing HOAs that need full features',
|
|
||||||
icon: IconStar,
|
|
||||||
color: 'violet',
|
|
||||||
popular: true,
|
|
||||||
features: [
|
|
||||||
{ text: 'Up to 200 units', included: true },
|
|
||||||
{ text: 'Everything in Starter', included: true },
|
|
||||||
{ text: 'Board Planning & Scenarios', included: true },
|
|
||||||
{ text: 'AI Investment Advisor', included: true },
|
|
||||||
{ text: 'Advanced Reports', included: true },
|
|
||||||
{ text: 'Priority Support', included: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'enterprise',
|
|
||||||
name: 'Enterprise',
|
|
||||||
monthlyPrice: 0,
|
|
||||||
annualPrice: 0,
|
|
||||||
description: 'For large communities and management firms',
|
|
||||||
icon: IconCrown,
|
|
||||||
color: 'orange',
|
|
||||||
externalUrl: 'https://www.hoaledgeriq.com/#preview-signup',
|
|
||||||
features: [
|
|
||||||
{ text: 'Unlimited units', included: true },
|
|
||||||
{ text: 'Everything in Professional', included: true },
|
|
||||||
{ text: 'Priority Support', included: true },
|
|
||||||
{ text: 'Custom Integrations', included: true },
|
|
||||||
{ text: 'Dedicated Account Manager', included: true },
|
|
||||||
{ text: 'SLA Guarantee', included: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatPrice(plan: typeof plans[0], interval: BillingInterval) {
|
|
||||||
if (plan.externalUrl) return { display: 'Custom', sub: '' };
|
|
||||||
if (interval === 'year') {
|
|
||||||
const monthly = (plan.annualPrice / 12).toFixed(2);
|
|
||||||
return {
|
|
||||||
display: `$${monthly}`,
|
|
||||||
sub: `/mo billed annually ($${plan.annualPrice}/yr)`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { display: `$${plan.monthlyPrice}`, sub: '/month' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PricingPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [loading, setLoading] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [businessName, setBusinessName] = useState('');
|
|
||||||
const [billingInterval, setBillingInterval] = useState<BillingInterval>('month');
|
|
||||||
|
|
||||||
const handleStartTrial = async (planId: string) => {
|
|
||||||
if (!email.trim()) {
|
|
||||||
setError('Email address is required to start a trial');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!businessName.trim()) {
|
|
||||||
setError('HOA / Business name is required to start a trial');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(planId);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const { data } = await api.post('/billing/start-trial', {
|
|
||||||
planId,
|
|
||||||
billingInterval,
|
|
||||||
email: email.trim(),
|
|
||||||
businessName: businessName.trim(),
|
|
||||||
});
|
|
||||||
if (data.subscriptionId) {
|
|
||||||
// Navigate to pending page with subscription ID for polling
|
|
||||||
navigate(`/onboarding/pending?session_id=${data.subscriptionId}`);
|
|
||||||
} else {
|
|
||||||
setError('Unable to start trial');
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.response?.data?.message || 'Failed to start trial');
|
|
||||||
} finally {
|
|
||||||
setLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size="lg" py={60}>
|
|
||||||
<Stack align="center" mb={40}>
|
|
||||||
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
|
|
||||||
<Title order={1} ta="center">
|
|
||||||
Simple, transparent pricing
|
|
||||||
</Title>
|
|
||||||
<Text size="lg" c="dimmed" ta="center" maw={500}>
|
|
||||||
Choose the plan that fits your community. All plans include a 14-day free trial.
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Monthly / Annual Toggle */}
|
|
||||||
<Center mb="xl">
|
|
||||||
<Box pos="relative">
|
|
||||||
<SegmentedControl
|
|
||||||
value={billingInterval}
|
|
||||||
onChange={(val) => setBillingInterval(val as BillingInterval)}
|
|
||||||
data={[
|
|
||||||
{ label: 'Monthly', value: 'month' },
|
|
||||||
{ label: 'Annual', value: 'year' },
|
|
||||||
]}
|
|
||||||
size="md"
|
|
||||||
radius="xl"
|
|
||||||
/>
|
|
||||||
{billingInterval === 'year' && (
|
|
||||||
<Badge
|
|
||||||
color="green"
|
|
||||||
variant="filled"
|
|
||||||
size="sm"
|
|
||||||
style={{ position: 'absolute', top: -10, right: -40 }}
|
|
||||||
>
|
|
||||||
Save 25%
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
{/* Pre-capture fields (required for trial) */}
|
|
||||||
<Center mb="xl">
|
|
||||||
<Group>
|
|
||||||
<TextInput
|
|
||||||
placeholder="Email address *"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
|
||||||
style={{ width: 220 }}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
placeholder="HOA / Business name *"
|
|
||||||
value={businessName}
|
|
||||||
onChange={(e) => setBusinessName(e.currentTarget.value)}
|
|
||||||
style={{ width: 220 }}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Center>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="lg" variant="light">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
|
||||||
{plans.map((plan) => {
|
|
||||||
const price = formatPrice(plan, billingInterval);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={plan.id}
|
|
||||||
withBorder
|
|
||||||
shadow={plan.popular ? 'lg' : 'sm'}
|
|
||||||
radius="md"
|
|
||||||
p="xl"
|
|
||||||
style={plan.popular ? {
|
|
||||||
border: '2px solid var(--mantine-color-violet-5)',
|
|
||||||
position: 'relative',
|
|
||||||
} : undefined}
|
|
||||||
>
|
|
||||||
{plan.popular && (
|
|
||||||
<Badge
|
|
||||||
color="violet"
|
|
||||||
variant="filled"
|
|
||||||
style={{ position: 'absolute', top: -10, right: 20 }}
|
|
||||||
>
|
|
||||||
Most Popular
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group>
|
|
||||||
<ThemeIcon size="lg" color={plan.color} variant="light" radius="md">
|
|
||||||
<plan.icon size={20} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Text fw={700} size="lg">{plan.name}</Text>
|
|
||||||
<Text size="xs" c="dimmed">{plan.description}</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Group align="baseline" gap={4}>
|
|
||||||
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}>
|
|
||||||
{plan.externalUrl ? 'Request Quote' : price.display}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{price.sub && (
|
|
||||||
<Text size="xs" c="dimmed" mt={2}>
|
|
||||||
{price.sub}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{!plan.externalUrl && billingInterval === 'year' && (
|
|
||||||
<Text size="xs" c="dimmed" td="line-through" mt={2}>
|
|
||||||
${plan.monthlyPrice}/mo without annual discount
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<List spacing="xs" size="sm" center>
|
|
||||||
{plan.features.map((f, i) => (
|
|
||||||
<List.Item
|
|
||||||
key={i}
|
|
||||||
icon={
|
|
||||||
<ThemeIcon
|
|
||||||
size={20}
|
|
||||||
radius="xl"
|
|
||||||
color={f.included ? 'teal' : 'gray'}
|
|
||||||
variant={f.included ? 'filled' : 'light'}
|
|
||||||
>
|
|
||||||
{f.included ? <IconCheck size={12} /> : <IconX size={12} />}
|
|
||||||
</ThemeIcon>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text c={f.included ? undefined : 'dimmed'}>{f.text}</Text>
|
|
||||||
</List.Item>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
size="md"
|
|
||||||
color={plan.color}
|
|
||||||
variant={plan.popular ? 'filled' : 'light'}
|
|
||||||
loading={!plan.externalUrl ? loading === plan.id : false}
|
|
||||||
onClick={() =>
|
|
||||||
plan.externalUrl
|
|
||||||
? window.open(plan.externalUrl, '_blank', 'noopener')
|
|
||||||
: handleStartTrial(plan.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{plan.externalUrl ? 'Request Quote' : 'Start Free Trial'}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<Text ta="center" size="sm" c="dimmed" mt="xl">
|
|
||||||
All plans include a 14-day free trial. No credit card required.
|
|
||||||
</Text>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import {
|
|
||||||
Card, Title, Text, Stack, Group, Button, Badge, Alert,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconBrandGoogle, IconBrandAzure, IconLink, IconLinkOff, IconAlertCircle } from '@tabler/icons-react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import api from '../../services/api';
|
|
||||||
|
|
||||||
export function LinkedAccounts() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data: providers } = useQuery({
|
|
||||||
queryKey: ['sso-providers'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get('/auth/sso/providers');
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
|
||||||
queryKey: ['auth-profile'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get('/auth/profile');
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const unlinkMutation = useMutation({
|
|
||||||
mutationFn: (provider: string) => api.delete(`/auth/sso/unlink/${provider}`),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['auth-profile'] });
|
|
||||||
notifications.show({ message: 'Account unlinked', color: 'orange' });
|
|
||||||
},
|
|
||||||
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Failed to unlink', color: 'red' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const noProviders = !providers?.google && !providers?.azure;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder p="lg">
|
|
||||||
<Group justify="space-between" mb="md">
|
|
||||||
<div>
|
|
||||||
<Title order={4}>Linked Accounts</Title>
|
|
||||||
<Text size="sm" c="dimmed">Connect third-party accounts for single sign-on</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{noProviders && (
|
|
||||||
<Alert color="gray" variant="light" icon={<IconAlertCircle size={16} />}>
|
|
||||||
No SSO providers are configured. Contact your administrator to enable Google or Microsoft SSO.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack gap="md">
|
|
||||||
{providers?.google && (
|
|
||||||
<Group justify="space-between" p="sm" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}>
|
|
||||||
<Group>
|
|
||||||
<IconBrandGoogle size={24} color="#4285F4" />
|
|
||||||
<div>
|
|
||||||
<Text fw={500}>Google</Text>
|
|
||||||
<Text size="xs" c="dimmed">Sign in with your Google account</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
size="sm"
|
|
||||||
leftSection={<IconLink size={14} />}
|
|
||||||
onClick={() => window.location.href = '/api/auth/google'}
|
|
||||||
>
|
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{providers?.azure && (
|
|
||||||
<Group justify="space-between" p="sm" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}>
|
|
||||||
<Group>
|
|
||||||
<IconBrandAzure size={24} color="#0078D4" />
|
|
||||||
<div>
|
|
||||||
<Text fw={500}>Microsoft</Text>
|
|
||||||
<Text size="xs" c="dimmed">Sign in with your Microsoft account</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
size="sm"
|
|
||||||
leftSection={<IconLink size={14} />}
|
|
||||||
onClick={() => window.location.href = '/api/auth/azure'}
|
|
||||||
>
|
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Card, Title, Text, Stack, Group, Button, TextInput,
|
|
||||||
PasswordInput, Alert, Code, SimpleGrid, Badge, Image,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconShieldCheck, IconShieldOff, IconAlertCircle } from '@tabler/icons-react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import api from '../../services/api';
|
|
||||||
|
|
||||||
export function MfaSettings() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [setupData, setSetupData] = useState<any>(null);
|
|
||||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
|
||||||
const [verifyCode, setVerifyCode] = useState('');
|
|
||||||
const [disablePassword, setDisablePassword] = useState('');
|
|
||||||
const [showDisable, setShowDisable] = useState(false);
|
|
||||||
|
|
||||||
const { data: mfaStatus, isLoading } = useQuery({
|
|
||||||
queryKey: ['mfa-status'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get('/auth/mfa/status');
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const setupMutation = useMutation({
|
|
||||||
mutationFn: () => api.post('/auth/mfa/setup'),
|
|
||||||
onSuccess: ({ data }) => setSetupData(data),
|
|
||||||
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Setup failed', color: 'red' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const enableMutation = useMutation({
|
|
||||||
mutationFn: (token: string) => api.post('/auth/mfa/enable', { token }),
|
|
||||||
onSuccess: ({ data }) => {
|
|
||||||
setRecoveryCodes(data.recoveryCodes);
|
|
||||||
setSetupData(null);
|
|
||||||
setVerifyCode('');
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
|
||||||
notifications.show({ message: 'MFA enabled successfully', color: 'green' });
|
|
||||||
},
|
|
||||||
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Invalid code', color: 'red' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const disableMutation = useMutation({
|
|
||||||
mutationFn: (password: string) => api.post('/auth/mfa/disable', { password }),
|
|
||||||
onSuccess: () => {
|
|
||||||
setShowDisable(false);
|
|
||||||
setDisablePassword('');
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
|
||||||
notifications.show({ message: 'MFA disabled', color: 'orange' });
|
|
||||||
},
|
|
||||||
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Invalid password', color: 'red' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder p="lg">
|
|
||||||
<Group justify="space-between" mb="md">
|
|
||||||
<div>
|
|
||||||
<Title order={4}>Two-Factor Authentication (MFA)</Title>
|
|
||||||
<Text size="sm" c="dimmed">Add an extra layer of security to your account</Text>
|
|
||||||
</div>
|
|
||||||
<Badge color={mfaStatus?.enabled ? 'green' : 'gray'} variant="light" size="lg">
|
|
||||||
{mfaStatus?.enabled ? 'Enabled' : 'Disabled'}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{/* Recovery codes display (shown once after enable) */}
|
|
||||||
{recoveryCodes && (
|
|
||||||
<Alert color="orange" variant="light" mb="md" icon={<IconAlertCircle size={16} />} title="Save your recovery codes">
|
|
||||||
<Text size="sm" mb="sm">
|
|
||||||
These codes can be used to access your account if you lose your authenticator. Save them securely — they will not be shown again.
|
|
||||||
</Text>
|
|
||||||
<SimpleGrid cols={2} spacing="xs">
|
|
||||||
{recoveryCodes.map((code, i) => (
|
|
||||||
<Code key={i} block>{code}</Code>
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
<Button variant="subtle" size="xs" mt="sm" onClick={() => setRecoveryCodes(null)}>
|
|
||||||
I've saved my codes
|
|
||||||
</Button>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!mfaStatus?.enabled && !setupData && (
|
|
||||||
<Button
|
|
||||||
leftSection={<IconShieldCheck size={16} />}
|
|
||||||
onClick={() => setupMutation.mutate()}
|
|
||||||
loading={setupMutation.isPending}
|
|
||||||
>
|
|
||||||
Set Up MFA
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* QR Code Setup */}
|
|
||||||
{setupData && (
|
|
||||||
<Stack>
|
|
||||||
<Text size="sm">Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.):</Text>
|
|
||||||
<Group justify="center">
|
|
||||||
<Image src={setupData.qrDataUrl} w={200} h={200} />
|
|
||||||
</Group>
|
|
||||||
<Text size="xs" c="dimmed" ta="center">
|
|
||||||
Manual entry key: <Code>{setupData.secret}</Code>
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
label="Verification Code"
|
|
||||||
placeholder="Enter 6-digit code"
|
|
||||||
value={verifyCode}
|
|
||||||
onChange={(e) => setVerifyCode(e.currentTarget.value)}
|
|
||||||
maxLength={6}
|
|
||||||
/>
|
|
||||||
<Group>
|
|
||||||
<Button
|
|
||||||
onClick={() => enableMutation.mutate(verifyCode)}
|
|
||||||
loading={enableMutation.isPending}
|
|
||||||
disabled={verifyCode.length < 6}
|
|
||||||
>
|
|
||||||
Verify & Enable
|
|
||||||
</Button>
|
|
||||||
<Button variant="subtle" onClick={() => setSetupData(null)}>Cancel</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Disable MFA */}
|
|
||||||
{mfaStatus?.enabled && !showDisable && (
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
leftSection={<IconShieldOff size={16} />}
|
|
||||||
onClick={() => setShowDisable(true)}
|
|
||||||
>
|
|
||||||
Disable MFA
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showDisable && (
|
|
||||||
<Stack mt="md">
|
|
||||||
<Alert color="red" variant="light">
|
|
||||||
Disabling MFA will make your account less secure. Enter your password to confirm.
|
|
||||||
</Alert>
|
|
||||||
<PasswordInput
|
|
||||||
label="Current Password"
|
|
||||||
value={disablePassword}
|
|
||||||
onChange={(e) => setDisablePassword(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<Group>
|
|
||||||
<Button color="red" onClick={() => disableMutation.mutate(disablePassword)} loading={disableMutation.isPending}>
|
|
||||||
Disable MFA
|
|
||||||
</Button>
|
|
||||||
<Button variant="subtle" onClick={() => setShowDisable(false)}>Cancel</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Card, Title, Text, Stack, Group, Button, TextInput,
|
|
||||||
Table, Badge, ActionIcon, Tooltip, Alert,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconFingerprint, IconTrash, IconPlus, IconAlertCircle } from '@tabler/icons-react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import { startRegistration } from '@simplewebauthn/browser';
|
|
||||||
import api from '../../services/api';
|
|
||||||
|
|
||||||
export function PasskeySettings() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [deviceName, setDeviceName] = useState('');
|
|
||||||
const [registering, setRegistering] = useState(false);
|
|
||||||
|
|
||||||
const { data: passkeys = [], isLoading } = useQuery({
|
|
||||||
queryKey: ['passkeys'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get('/auth/passkeys');
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const removeMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => api.delete(`/auth/passkeys/${id}`),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
|
||||||
notifications.show({ message: 'Passkey removed', color: 'orange' });
|
|
||||||
},
|
|
||||||
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Failed to remove', color: 'red' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleRegister = async () => {
|
|
||||||
setRegistering(true);
|
|
||||||
try {
|
|
||||||
// 1. Get registration options from server
|
|
||||||
const { data: options } = await api.post('/auth/passkeys/register-options');
|
|
||||||
|
|
||||||
// 2. Create credential via browser WebAuthn API
|
|
||||||
const credential = await startRegistration({ optionsJSON: options });
|
|
||||||
|
|
||||||
// 3. Send attestation to server for verification
|
|
||||||
await api.post('/auth/passkeys/register', {
|
|
||||||
response: credential,
|
|
||||||
deviceName: deviceName || 'My Passkey',
|
|
||||||
});
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
|
||||||
setDeviceName('');
|
|
||||||
notifications.show({ message: 'Passkey registered successfully', color: 'green' });
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.name === 'NotAllowedError') {
|
|
||||||
notifications.show({ message: 'Registration was cancelled', color: 'yellow' });
|
|
||||||
} else {
|
|
||||||
notifications.show({ message: err.response?.data?.message || err.message || 'Registration failed', color: 'red' });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setRegistering(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const webauthnSupported = typeof window !== 'undefined' && !!window.PublicKeyCredential;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder p="lg">
|
|
||||||
<Group justify="space-between" mb="md">
|
|
||||||
<div>
|
|
||||||
<Title order={4}>Passkeys</Title>
|
|
||||||
<Text size="sm" c="dimmed">Sign in with your fingerprint, face, or security key</Text>
|
|
||||||
</div>
|
|
||||||
<Badge color={passkeys.length > 0 ? 'green' : 'gray'} variant="light" size="lg">
|
|
||||||
{passkeys.length} registered
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{!webauthnSupported && (
|
|
||||||
<Alert color="yellow" variant="light" icon={<IconAlertCircle size={16} />} mb="md">
|
|
||||||
Your browser doesn't support WebAuthn passkeys.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{passkeys.length > 0 && (
|
|
||||||
<Table striped mb="md">
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Device</Table.Th>
|
|
||||||
<Table.Th>Created</Table.Th>
|
|
||||||
<Table.Th>Last Used</Table.Th>
|
|
||||||
<Table.Th w={60}>Actions</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{passkeys.map((pk: any) => (
|
|
||||||
<Table.Tr key={pk.id}>
|
|
||||||
<Table.Td>
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconFingerprint size={16} />
|
|
||||||
<Text size="sm" fw={500}>{pk.device_name || 'Passkey'}</Text>
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td><Text size="sm">{new Date(pk.created_at).toLocaleDateString()}</Text></Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm" c={pk.last_used_at ? undefined : 'dimmed'}>
|
|
||||||
{pk.last_used_at ? new Date(pk.last_used_at).toLocaleDateString() : 'Never'}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Tooltip label="Remove">
|
|
||||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(pk.id)}>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{webauthnSupported && (
|
|
||||||
<Group>
|
|
||||||
<TextInput
|
|
||||||
placeholder="Device name (e.g., MacBook Pro)"
|
|
||||||
value={deviceName}
|
|
||||||
onChange={(e) => setDeviceName(e.currentTarget.value)}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
leftSection={<IconPlus size={16} />}
|
|
||||||
onClick={handleRegister}
|
|
||||||
loading={registering}
|
|
||||||
>
|
|
||||||
Register Passkey
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,92 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
|
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
|
||||||
Tabs, Button, Switch, Loader,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconBuilding, IconUser, IconSettings, IconShieldLock,
|
IconBuilding, IconUser, IconUsers, IconSettings, IconShieldLock,
|
||||||
IconFingerprint, IconLink, IconLogout, IconCreditCard,
|
IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
import { MfaSettings } from './MfaSettings';
|
|
||||||
import { PasskeySettings } from './PasskeySettings';
|
|
||||||
import { LinkedAccounts } from './LinkedAccounts';
|
|
||||||
import api from '../../services/api';
|
|
||||||
|
|
||||||
interface SubscriptionInfo {
|
|
||||||
plan: string;
|
|
||||||
planName: string;
|
|
||||||
billingInterval: string;
|
|
||||||
status: string;
|
|
||||||
collectionMethod: string;
|
|
||||||
trialEndsAt: string | null;
|
|
||||||
currentPeriodEnd: string | null;
|
|
||||||
cancelAtPeriodEnd: boolean;
|
|
||||||
hasStripeCustomer: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
active: 'green',
|
|
||||||
trial: 'blue',
|
|
||||||
past_due: 'orange',
|
|
||||||
archived: 'red',
|
|
||||||
suspended: 'red',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { user, currentOrg } = useAuthStore();
|
const { user, currentOrg } = useAuthStore();
|
||||||
const { compactView, toggleCompactView } = usePreferencesStore();
|
|
||||||
const [loggingOutAll, setLoggingOutAll] = useState(false);
|
|
||||||
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
|
|
||||||
const [subLoading, setSubLoading] = useState(true);
|
|
||||||
const [portalLoading, setPortalLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.get('/billing/subscription')
|
|
||||||
.then(({ data }) => setSubscription(data))
|
|
||||||
.catch(() => { /* billing not configured or no subscription */ })
|
|
||||||
.finally(() => setSubLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLogoutEverywhere = async () => {
|
|
||||||
setLoggingOutAll(true);
|
|
||||||
try {
|
|
||||||
await api.post('/auth/logout-everywhere');
|
|
||||||
notifications.show({ message: 'All other sessions have been logged out', color: 'green' });
|
|
||||||
} catch {
|
|
||||||
notifications.show({ message: 'Failed to log out other sessions', color: 'red' });
|
|
||||||
} finally {
|
|
||||||
setLoggingOutAll(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleManageBilling = async () => {
|
|
||||||
setPortalLoading(true);
|
|
||||||
try {
|
|
||||||
const { data } = await api.post('/billing/portal');
|
|
||||||
if (data.url) {
|
|
||||||
window.location.href = data.url;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
const msg = err.response?.data?.message || 'Unable to open billing portal';
|
|
||||||
notifications.show({ message: typeof msg === 'string' ? msg : 'Unable to open billing portal', color: 'red' });
|
|
||||||
} finally {
|
|
||||||
setPortalLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatInterval = (interval: string) => {
|
|
||||||
return interval === 'year' ? 'Annual' : 'Monthly';
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (iso: string | null) => {
|
|
||||||
if (!iso) return null;
|
|
||||||
return new Date(iso).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric', month: 'short', day: 'numeric',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -119,79 +41,6 @@ export function SettingsPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Billing / Subscription */}
|
|
||||||
<Card withBorder padding="lg">
|
|
||||||
<Group mb="md">
|
|
||||||
<ThemeIcon color="teal" variant="light" size={40} radius="md">
|
|
||||||
<IconCreditCard size={24} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Text fw={600} size="lg">Billing</Text>
|
|
||||||
<Text c="dimmed" size="sm">Subscription and payment</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
{subLoading ? (
|
|
||||||
<Group justify="center" py="md"><Loader size="sm" /></Group>
|
|
||||||
) : subscription ? (
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Plan</Text>
|
|
||||||
<Group gap={4}>
|
|
||||||
<Badge variant="light">{subscription.planName}</Badge>
|
|
||||||
<Badge variant="light" color="gray" size="sm">{formatInterval(subscription.billingInterval)}</Badge>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Status</Text>
|
|
||||||
<Badge
|
|
||||||
color={statusColors[subscription.status] || 'gray'}
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
{subscription.status === 'past_due' ? 'Past Due' : subscription.status}
|
|
||||||
{subscription.cancelAtPeriodEnd ? ' (Canceling)' : ''}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
{subscription.trialEndsAt && subscription.status === 'trial' && (
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Trial Ends</Text>
|
|
||||||
<Text size="sm" fw={500}>{formatDate(subscription.trialEndsAt)}</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{subscription.currentPeriodEnd && subscription.status !== 'trial' && (
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Current Period Ends</Text>
|
|
||||||
<Text size="sm" fw={500}>{formatDate(subscription.currentPeriodEnd)}</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{subscription.collectionMethod === 'send_invoice' && (
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Payment</Text>
|
|
||||||
<Badge variant="light" color="cyan" size="sm">Invoice / ACH</Badge>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{subscription.hasStripeCustomer ? (
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="teal"
|
|
||||||
size="sm"
|
|
||||||
leftSection={<IconCreditCard size={16} />}
|
|
||||||
onClick={handleManageBilling}
|
|
||||||
loading={portalLoading}
|
|
||||||
mt="xs"
|
|
||||||
>
|
|
||||||
Manage Billing
|
|
||||||
</Button>
|
|
||||||
) : subscription.status === 'trial' ? (
|
|
||||||
<Text size="xs" c="dimmed" mt="xs">
|
|
||||||
Billing portal will be available once you add a payment method.
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<Text size="sm" c="dimmed">No active subscription</Text>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* User Profile */}
|
{/* User Profile */}
|
||||||
<Card withBorder padding="lg">
|
<Card withBorder padding="lg">
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
@@ -219,6 +68,33 @@ export function SettingsPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Security */}
|
||||||
|
<Card withBorder padding="lg">
|
||||||
|
<Group mb="md">
|
||||||
|
<ThemeIcon color="red" variant="light" size={40} radius="md">
|
||||||
|
<IconShieldLock size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="lg">Security</Text>
|
||||||
|
<Text c="dimmed" size="sm">Authentication and access</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Authentication</Text>
|
||||||
|
<Badge color="green" variant="light">Active Session</Badge>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">Two-Factor Auth</Text>
|
||||||
|
<Badge color="gray" variant="light">Not Configured</Badge>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">OAuth Providers</Text>
|
||||||
|
<Badge color="gray" variant="light">None Linked</Badge>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* System Info */}
|
{/* System Info */}
|
||||||
<Card withBorder padding="lg">
|
<Card withBorder padding="lg">
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
@@ -237,87 +113,15 @@ export function SettingsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Version</Text>
|
<Text size="sm" c="dimmed">Version</Text>
|
||||||
<Badge variant="light">2026.03.18</Badge>
|
<Badge variant="light">2026.03.10</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">API</Text>
|
<Text size="sm" c="dimmed">API</Text>
|
||||||
<Text size="sm" ff="monospace" c="dimmed">/api/docs</Text>
|
<Text size="sm" ff="monospace" c="dimmed">/api/docs</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
|
||||||
<Group justify="space-between">
|
|
||||||
<div>
|
|
||||||
<Text size="sm">Compact View</Text>
|
|
||||||
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
|
|
||||||
</div>
|
|
||||||
<Switch checked={compactView} onChange={toggleCompactView} />
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Sessions */}
|
|
||||||
<Card withBorder padding="lg">
|
|
||||||
<Group mb="md">
|
|
||||||
<ThemeIcon color="orange" variant="light" size={40} radius="md">
|
|
||||||
<IconLogout size={24} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<div>
|
|
||||||
<Text fw={600} size="lg">Sessions</Text>
|
|
||||||
<Text c="dimmed" size="sm">Manage active sessions</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text size="sm" c="dimmed">Current Session</Text>
|
|
||||||
<Badge color="green" variant="light">Active</Badge>
|
|
||||||
</Group>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="orange"
|
|
||||||
size="sm"
|
|
||||||
leftSection={<IconLogout size={16} />}
|
|
||||||
onClick={handleLogoutEverywhere}
|
|
||||||
loading={loggingOutAll}
|
|
||||||
mt="xs"
|
|
||||||
>
|
|
||||||
Log Out All Other Sessions
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Divider my="md" />
|
|
||||||
|
|
||||||
{/* Security Settings */}
|
|
||||||
<div>
|
|
||||||
<Title order={3} mb="sm">Security</Title>
|
|
||||||
<Text c="dimmed" size="sm" mb="md">Manage authentication methods and security settings</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs defaultValue="mfa">
|
|
||||||
<Tabs.List>
|
|
||||||
<Tabs.Tab value="mfa" leftSection={<IconShieldLock size={16} />}>
|
|
||||||
Two-Factor Auth
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="passkeys" leftSection={<IconFingerprint size={16} />}>
|
|
||||||
Passkeys
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="linked" leftSection={<IconLink size={16} />}>
|
|
||||||
Linked Accounts
|
|
||||||
</Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Panel value="mfa" pt="md">
|
|
||||||
<MfaSettings />
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="passkeys" pt="md">
|
|
||||||
<PasskeySettings />
|
|
||||||
</Tabs.Panel>
|
|
||||||
|
|
||||||
<Tabs.Panel value="linked" pt="md">
|
|
||||||
<LinkedAccounts />
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
import axios from 'axios';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
withCredentials: true, // Send httpOnly cookies for refresh token
|
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
@@ -15,89 +14,23 @@ api.interceptors.request.use((config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Silent Refresh Logic ─────────────────────────────────────────
|
|
||||||
let isRefreshing = false;
|
|
||||||
let pendingQueue: Array<{
|
|
||||||
resolve: (token: string) => void;
|
|
||||||
reject: (err: any) => void;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
function processPendingQueue(error: any, token: string | null) {
|
|
||||||
pendingQueue.forEach((p) => {
|
|
||||||
if (error) {
|
|
||||||
p.reject(error);
|
|
||||||
} else {
|
|
||||||
p.resolve(token!);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
pendingQueue = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError) => {
|
(error) => {
|
||||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
if (error.response?.status === 401) {
|
||||||
|
|
||||||
// If 401 and we haven't retried yet, try refreshing the token
|
|
||||||
if (
|
|
||||||
error.response?.status === 401 &&
|
|
||||||
originalRequest &&
|
|
||||||
!originalRequest._retry &&
|
|
||||||
!originalRequest.url?.includes('/auth/refresh') &&
|
|
||||||
!originalRequest.url?.includes('/auth/login')
|
|
||||||
) {
|
|
||||||
originalRequest._retry = true;
|
|
||||||
|
|
||||||
if (isRefreshing) {
|
|
||||||
// Another request is already refreshing — queue this one
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
pendingQueue.push({
|
|
||||||
resolve: (token: string) => {
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
|
||||||
resolve(api(originalRequest));
|
|
||||||
},
|
|
||||||
reject: (err: any) => reject(err),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
isRefreshing = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true });
|
|
||||||
const newToken = data.accessToken;
|
|
||||||
useAuthStore.getState().setToken(newToken);
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
|
||||||
processPendingQueue(null, newToken);
|
|
||||||
return api(originalRequest);
|
|
||||||
} catch (refreshError) {
|
|
||||||
processPendingQueue(refreshError, null);
|
|
||||||
useAuthStore.getState().logout();
|
|
||||||
window.location.href = '/login';
|
|
||||||
return Promise.reject(refreshError);
|
|
||||||
} finally {
|
|
||||||
isRefreshing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-retryable 401 (e.g. refresh failed, login failed)
|
|
||||||
if (error.response?.status === 401 && originalRequest?.url?.includes('/auth/refresh')) {
|
|
||||||
useAuthStore.getState().logout();
|
useAuthStore.getState().logout();
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle org suspended/archived — redirect to org selection
|
// Handle org suspended/archived — redirect to org selection
|
||||||
const responseData = error.response?.data as any;
|
|
||||||
if (
|
if (
|
||||||
error.response?.status === 403 &&
|
error.response?.status === 403 &&
|
||||||
typeof responseData?.message === 'string' &&
|
typeof error.response?.data?.message === 'string' &&
|
||||||
responseData.message.includes('has been')
|
error.response.data.message.includes('has been')
|
||||||
) {
|
) {
|
||||||
const store = useAuthStore.getState();
|
const store = useAuthStore.getState();
|
||||||
store.setCurrentOrg({ id: '', name: '', role: '' }); // Clear current org
|
store.setCurrentOrg({ id: '', name: '', role: '' }); // Clear current org
|
||||||
window.location.href = '/select-org';
|
window.location.href = '/select-org';
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ interface AuthState {
|
|||||||
currentOrg: Organization | null;
|
currentOrg: Organization | null;
|
||||||
impersonationOriginal: ImpersonationOriginal | null;
|
impersonationOriginal: ImpersonationOriginal | null;
|
||||||
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
||||||
setToken: (token: string) => void;
|
|
||||||
setCurrentOrg: (org: Organization, token?: string) => void;
|
setCurrentOrg: (org: Organization, token?: string) => void;
|
||||||
setUserIntroSeen: () => void;
|
setUserIntroSeen: () => void;
|
||||||
setOrgSettings: (settings: Record<string, any>) => void;
|
setOrgSettings: (settings: Record<string, any>) => void;
|
||||||
@@ -61,7 +60,6 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
// Don't auto-select org — force user through SelectOrgPage
|
// Don't auto-select org — force user through SelectOrgPage
|
||||||
currentOrg: null,
|
currentOrg: null,
|
||||||
}),
|
}),
|
||||||
setToken: (token) => set({ token }),
|
|
||||||
setCurrentOrg: (org, token) =>
|
setCurrentOrg: (org, token) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
currentOrg: org,
|
currentOrg: org,
|
||||||
@@ -104,17 +102,14 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
logout: () => {
|
logout: () =>
|
||||||
// Fire-and-forget server-side logout to revoke refresh token cookie
|
|
||||||
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {});
|
|
||||||
set({
|
set({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
currentOrg: null,
|
currentOrg: null,
|
||||||
impersonationOriginal: null,
|
impersonationOriginal: null,
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ledgeriq-auth',
|
name: 'ledgeriq-auth',
|
||||||
|
|||||||
@@ -5,26 +5,19 @@ type ColorScheme = 'light' | 'dark';
|
|||||||
|
|
||||||
interface PreferencesState {
|
interface PreferencesState {
|
||||||
colorScheme: ColorScheme;
|
colorScheme: ColorScheme;
|
||||||
compactView: boolean;
|
|
||||||
toggleColorScheme: () => void;
|
toggleColorScheme: () => void;
|
||||||
setColorScheme: (scheme: ColorScheme) => void;
|
setColorScheme: (scheme: ColorScheme) => void;
|
||||||
toggleCompactView: () => void;
|
|
||||||
setCompactView: (compact: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePreferencesStore = create<PreferencesState>()(
|
export const usePreferencesStore = create<PreferencesState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
colorScheme: 'light',
|
colorScheme: 'light',
|
||||||
compactView: false,
|
|
||||||
toggleColorScheme: () =>
|
toggleColorScheme: () =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
|
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
|
||||||
})),
|
})),
|
||||||
setColorScheme: (scheme) => set({ colorScheme: scheme }),
|
setColorScheme: (scheme) => set({ colorScheme: scheme }),
|
||||||
toggleCompactView: () =>
|
|
||||||
set((state) => ({ compactView: !state.compactView })),
|
|
||||||
setCompactView: (compact) => set({ compactView: compact }),
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ledgeriq-preferences',
|
name: 'ledgeriq-preferences',
|
||||||
|
|||||||
@@ -1,57 +1,10 @@
|
|||||||
import { createTheme } from '@mantine/core';
|
import { createTheme } from '@mantine/core';
|
||||||
|
|
||||||
const baseFontFamily = '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif';
|
export const theme = createTheme({
|
||||||
|
|
||||||
export const defaultTheme = createTheme({
|
|
||||||
primaryColor: 'blue',
|
primaryColor: 'blue',
|
||||||
fontFamily: baseFontFamily,
|
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
|
||||||
headings: {
|
headings: {
|
||||||
fontFamily: baseFontFamily,
|
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
|
||||||
},
|
},
|
||||||
defaultRadius: 'md',
|
defaultRadius: 'md',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const compactTheme = createTheme({
|
|
||||||
primaryColor: 'blue',
|
|
||||||
fontFamily: baseFontFamily,
|
|
||||||
headings: {
|
|
||||||
fontFamily: baseFontFamily,
|
|
||||||
},
|
|
||||||
defaultRadius: 'md',
|
|
||||||
spacing: {
|
|
||||||
xs: '4px',
|
|
||||||
sm: '6px',
|
|
||||||
md: '10px',
|
|
||||||
lg: '12px',
|
|
||||||
xl: '16px',
|
|
||||||
},
|
|
||||||
fontSizes: {
|
|
||||||
xs: '11px',
|
|
||||||
sm: '12px',
|
|
||||||
md: '13px',
|
|
||||||
lg: '15px',
|
|
||||||
xl: '18px',
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
Table: {
|
|
||||||
defaultProps: {
|
|
||||||
verticalSpacing: 'xs',
|
|
||||||
horizontalSpacing: 'xs',
|
|
||||||
fz: 'sm',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Card: {
|
|
||||||
defaultProps: {
|
|
||||||
padding: 'sm',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppShell: {
|
|
||||||
defaultProps: {
|
|
||||||
padding: 'xs',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/** @deprecated Use `defaultTheme` or `compactTheme` instead */
|
|
||||||
export const theme = defaultTheme;
|
|
||||||
|
|||||||
@@ -1,788 +0,0 @@
|
|||||||
#!/usr/bin/env tsx
|
|
||||||
/**
|
|
||||||
* Test Data Cleanup Utility
|
|
||||||
*
|
|
||||||
* Interactive CLI for managing test organizations, users, and tenant data.
|
|
||||||
* Supports listing, selective deletion, full purge, and re-seeding.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* cd scripts
|
|
||||||
* npx tsx cleanup-test-data.ts <command> [options]
|
|
||||||
*
|
|
||||||
* Commands:
|
|
||||||
* list Show all organizations and users
|
|
||||||
* delete-org <name-or-id> Delete an organization (drops tenant schema + shared data)
|
|
||||||
* delete-user <email-or-id> Delete a user (cascades through all related tables)
|
|
||||||
* purge-all Remove ALL orgs/users except platform owner
|
|
||||||
* reseed Purge all, then re-run db/seed/seed.sql
|
|
||||||
*
|
|
||||||
* Options:
|
|
||||||
* --dry-run Show what would be deleted without executing
|
|
||||||
* --force Skip confirmation prompts
|
|
||||||
*
|
|
||||||
* Environment:
|
|
||||||
* DATABASE_URL - PostgreSQL connection string (reads from ../.env)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as dotenv from 'dotenv';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { Pool } from 'pg';
|
|
||||||
import * as readline from 'readline';
|
|
||||||
|
|
||||||
// ── Load environment ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
dotenv.config({ path: resolve(__dirname, '..', '.env') });
|
|
||||||
|
|
||||||
const DATABASE_URL = process.env.DATABASE_URL;
|
|
||||||
if (!DATABASE_URL) {
|
|
||||||
console.error(red('✗ DATABASE_URL not set. Check your .env file.'));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CLI colors ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function red(s: string): string { return `\x1b[31m${s}\x1b[0m`; }
|
|
||||||
function green(s: string): string { return `\x1b[32m${s}\x1b[0m`; }
|
|
||||||
function yellow(s: string): string { return `\x1b[33m${s}\x1b[0m`; }
|
|
||||||
function cyan(s: string): string { return `\x1b[36m${s}\x1b[0m`; }
|
|
||||||
function bold(s: string): string { return `\x1b[1m${s}\x1b[0m`; }
|
|
||||||
function dim(s: string): string { return `\x1b[2m${s}\x1b[0m`; }
|
|
||||||
|
|
||||||
// ── CLI argument parsing ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const command = args.find(a => !a.startsWith('--')) || '';
|
|
||||||
const target = args.filter(a => !a.startsWith('--')).slice(1).join(' ');
|
|
||||||
const dryRun = args.includes('--dry-run');
|
|
||||||
const force = args.includes('--force');
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function isUUID(s: string): boolean {
|
|
||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
function padRight(s: string, len: number): string {
|
|
||||||
return s.length >= len ? s.substring(0, len) : s + ' '.repeat(len - s.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncate(s: string, len: number): string {
|
|
||||||
return s.length > len ? s.substring(0, len - 1) + '…' : s;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(d: Date | string | null): string {
|
|
||||||
if (!d) return '—';
|
|
||||||
const date = typeof d === 'string' ? new Date(d) : d;
|
|
||||||
return date.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirm(prompt: string): Promise<boolean> {
|
|
||||||
if (force) return true;
|
|
||||||
if (dryRun) return false;
|
|
||||||
|
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
rl.question(`${prompt} [y/N]: `, (answer) => {
|
|
||||||
rl.close();
|
|
||||||
resolve(answer.trim().toLowerCase() === 'y');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function logDryRun(sql: string): void {
|
|
||||||
console.log(dim(` [DRY RUN] ${sql}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Database pool ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const pool = new Pool({ connectionString: DATABASE_URL });
|
|
||||||
|
|
||||||
async function query(sql: string, params?: any[]): Promise<any[]> {
|
|
||||||
const result = await pool.query(sql, params);
|
|
||||||
return result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── List command ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function listAll(): Promise<void> {
|
|
||||||
console.log(bold('\n📋 Organizations\n'));
|
|
||||||
|
|
||||||
const orgs = await query(`
|
|
||||||
SELECT
|
|
||||||
o.id, o.name, o.schema_name, o.status, o.plan_level,
|
|
||||||
o.billing_interval, o.collection_method,
|
|
||||||
o.stripe_customer_id, o.stripe_subscription_id,
|
|
||||||
o.trial_ends_at, o.created_at,
|
|
||||||
COUNT(uo.id) AS user_count
|
|
||||||
FROM shared.organizations o
|
|
||||||
LEFT JOIN shared.user_organizations uo ON uo.organization_id = o.id
|
|
||||||
GROUP BY o.id
|
|
||||||
ORDER BY o.created_at
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (orgs.length === 0) {
|
|
||||||
console.log(dim(' No organizations found.\n'));
|
|
||||||
} else {
|
|
||||||
// Header
|
|
||||||
console.log(
|
|
||||||
' ' +
|
|
||||||
padRight('Name', 30) +
|
|
||||||
padRight('Status', 12) +
|
|
||||||
padRight('Plan', 16) +
|
|
||||||
padRight('Billing', 10) +
|
|
||||||
padRight('Users', 7) +
|
|
||||||
padRight('Stripe Customer', 22) +
|
|
||||||
'Created'
|
|
||||||
);
|
|
||||||
console.log(' ' + '─'.repeat(110));
|
|
||||||
|
|
||||||
for (const o of orgs) {
|
|
||||||
const statusColor = o.status === 'active' ? green : o.status === 'trial' ? cyan : o.status === 'past_due' ? yellow : red;
|
|
||||||
console.log(
|
|
||||||
' ' +
|
|
||||||
padRight(truncate(o.name, 28), 30) +
|
|
||||||
padRight(statusColor(o.status), 12 + 9) + // +9 for ANSI escape codes
|
|
||||||
padRight(`${o.plan_level}/${o.billing_interval || 'month'}`, 16) +
|
|
||||||
padRight(String(o.user_count), 7) +
|
|
||||||
padRight(o.stripe_customer_id ? truncate(o.stripe_customer_id, 20) : '—', 22) +
|
|
||||||
formatDate(o.created_at)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(dim(`\n ${orgs.length} organization(s) total`));
|
|
||||||
|
|
||||||
// Show IDs for reference
|
|
||||||
console.log(dim('\n IDs:'));
|
|
||||||
for (const o of orgs) {
|
|
||||||
console.log(dim(` ${o.name}: ${o.id}`));
|
|
||||||
console.log(dim(` schema: ${o.schema_name}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(bold('\n👤 Users\n'));
|
|
||||||
|
|
||||||
const users = await query(`
|
|
||||||
SELECT
|
|
||||||
u.id, u.email, u.first_name, u.last_name,
|
|
||||||
u.is_superadmin, u.is_platform_owner,
|
|
||||||
u.last_login_at, u.created_at,
|
|
||||||
COALESCE(
|
|
||||||
STRING_AGG(
|
|
||||||
o.name || ' (' || uo.role || ')',
|
|
||||||
', '
|
|
||||||
),
|
|
||||||
'—'
|
|
||||||
) AS memberships,
|
|
||||||
COUNT(uo.id) AS org_count
|
|
||||||
FROM shared.users u
|
|
||||||
LEFT JOIN shared.user_organizations uo ON uo.user_id = u.id
|
|
||||||
LEFT JOIN shared.organizations o ON o.id = uo.organization_id
|
|
||||||
GROUP BY u.id
|
|
||||||
ORDER BY u.created_at
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (users.length === 0) {
|
|
||||||
console.log(dim(' No users found.\n'));
|
|
||||||
} else {
|
|
||||||
// Header
|
|
||||||
console.log(
|
|
||||||
' ' +
|
|
||||||
padRight('Email', 35) +
|
|
||||||
padRight('Name', 25) +
|
|
||||||
padRight('Flags', 18) +
|
|
||||||
padRight('Orgs', 6) +
|
|
||||||
'Created'
|
|
||||||
);
|
|
||||||
console.log(' ' + '─'.repeat(100));
|
|
||||||
|
|
||||||
for (const u of users) {
|
|
||||||
const flags: string[] = [];
|
|
||||||
if (u.is_platform_owner) flags.push(cyan('owner'));
|
|
||||||
if (u.is_superadmin) flags.push(yellow('super'));
|
|
||||||
|
|
||||||
const name = [u.first_name, u.last_name].filter(Boolean).join(' ') || '—';
|
|
||||||
console.log(
|
|
||||||
' ' +
|
|
||||||
padRight(truncate(u.email, 33), 35) +
|
|
||||||
padRight(truncate(name, 23), 25) +
|
|
||||||
padRight(flags.length ? flags.join(', ') : '—', 18 + (flags.length * 9)) +
|
|
||||||
padRight(String(u.org_count), 6) +
|
|
||||||
formatDate(u.created_at)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(dim(`\n ${users.length} user(s) total`));
|
|
||||||
|
|
||||||
// Show memberships
|
|
||||||
console.log(dim('\n Memberships:'));
|
|
||||||
for (const u of users) {
|
|
||||||
console.log(dim(` ${u.email}: ${u.memberships}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tenant schemas
|
|
||||||
console.log(bold('\n🗄️ Tenant Schemas\n'));
|
|
||||||
const schemas = await query(`
|
|
||||||
SELECT schema_name
|
|
||||||
FROM information_schema.schemata
|
|
||||||
WHERE schema_name LIKE 'tenant_%'
|
|
||||||
ORDER BY schema_name
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (schemas.length === 0) {
|
|
||||||
console.log(dim(' No tenant schemas found.\n'));
|
|
||||||
} else {
|
|
||||||
for (const s of schemas) {
|
|
||||||
console.log(` • ${s.schema_name}`);
|
|
||||||
}
|
|
||||||
console.log(dim(`\n ${schemas.length} tenant schema(s) total\n`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete organization ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function deleteOrg(identifier: string): Promise<void> {
|
|
||||||
if (!identifier) {
|
|
||||||
console.error(red('✗ Please provide an organization name or ID.'));
|
|
||||||
console.log(' Usage: npx tsx cleanup-test-data.ts delete-org <name-or-id>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up org
|
|
||||||
const whereClause = isUUID(identifier) ? 'id = $1' : 'LOWER(name) = LOWER($1)';
|
|
||||||
const orgs = await query(
|
|
||||||
`SELECT id, name, schema_name, status, stripe_customer_id, stripe_subscription_id
|
|
||||||
FROM shared.organizations WHERE ${whereClause}`,
|
|
||||||
[identifier]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (orgs.length === 0) {
|
|
||||||
console.error(red(`✗ Organization not found: ${identifier}`));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const org = orgs[0];
|
|
||||||
|
|
||||||
// Show what will be deleted
|
|
||||||
console.log(bold(`\n🏢 Delete Organization: ${org.name}\n`));
|
|
||||||
console.log(` ID: ${org.id}`);
|
|
||||||
console.log(` Schema: ${org.schema_name}`);
|
|
||||||
console.log(` Status: ${org.status}`);
|
|
||||||
|
|
||||||
if (org.stripe_customer_id) {
|
|
||||||
console.log(yellow(`\n ⚠ Stripe Customer: ${org.stripe_customer_id}`));
|
|
||||||
console.log(yellow(` You should manually delete/archive this customer in the Stripe Dashboard.`));
|
|
||||||
}
|
|
||||||
if (org.stripe_subscription_id) {
|
|
||||||
console.log(yellow(` ⚠ Stripe Subscription: ${org.stripe_subscription_id}`));
|
|
||||||
console.log(yellow(` You should manually cancel this subscription in the Stripe Dashboard.`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count related data
|
|
||||||
const userCount = (await query(
|
|
||||||
'SELECT COUNT(*) as cnt FROM shared.user_organizations WHERE organization_id = $1',
|
|
||||||
[org.id]
|
|
||||||
))[0].cnt;
|
|
||||||
|
|
||||||
const inviteCount = (await query(
|
|
||||||
'SELECT COUNT(*) as cnt FROM shared.invitations WHERE organization_id = $1',
|
|
||||||
[org.id]
|
|
||||||
))[0].cnt;
|
|
||||||
|
|
||||||
// Check if tenant schema exists
|
|
||||||
const schemaExists = (await query(
|
|
||||||
`SELECT COUNT(*) as cnt FROM information_schema.schemata WHERE schema_name = $1`,
|
|
||||||
[org.schema_name]
|
|
||||||
))[0].cnt > 0;
|
|
||||||
|
|
||||||
console.log(`\n Will delete:`);
|
|
||||||
console.log(` • Organization record from shared.organizations`);
|
|
||||||
console.log(` • ${userCount} user-organization membership(s) (users themselves are preserved)`);
|
|
||||||
console.log(` • ${inviteCount} invitation(s)`);
|
|
||||||
if (schemaExists) {
|
|
||||||
console.log(red(` • DROP SCHEMA ${org.schema_name} CASCADE (all tenant financial data)`));
|
|
||||||
} else {
|
|
||||||
console.log(dim(` • Schema ${org.schema_name} does not exist (skip)`));
|
|
||||||
}
|
|
||||||
console.log(` • Related rows in: onboarding_progress, stripe_events, email_log`);
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
console.log(yellow('\n [DRY RUN] No changes made.\n'));
|
|
||||||
logDryRun(`DROP SCHEMA IF EXISTS ${org.schema_name} CASCADE`);
|
|
||||||
logDryRun(`DELETE FROM shared.onboarding_progress WHERE organization_id = '${org.id}'`);
|
|
||||||
logDryRun(`DELETE FROM shared.stripe_events WHERE ... (related to org)`);
|
|
||||||
logDryRun(`DELETE FROM shared.organizations WHERE id = '${org.id}'`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmed = await confirm(red(`\n This is destructive and cannot be undone. Proceed?`));
|
|
||||||
if (!confirmed) {
|
|
||||||
console.log(dim(' Aborted.\n'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute deletion
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN');
|
|
||||||
|
|
||||||
// 1. Drop tenant schema
|
|
||||||
if (schemaExists) {
|
|
||||||
console.log(` Dropping schema ${org.schema_name}...`);
|
|
||||||
await client.query(`DROP SCHEMA IF EXISTS "${org.schema_name}" CASCADE`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Clean up shared tables with org FK
|
|
||||||
await client.query('DELETE FROM shared.onboarding_progress WHERE organization_id = $1', [org.id]);
|
|
||||||
await client.query('DELETE FROM shared.invitations WHERE organization_id = $1', [org.id]);
|
|
||||||
|
|
||||||
// 3. Delete organization (cascades to user_organizations, invite_tokens)
|
|
||||||
await client.query('DELETE FROM shared.organizations WHERE id = $1', [org.id]);
|
|
||||||
|
|
||||||
await client.query('COMMIT');
|
|
||||||
console.log(green(`\n ✓ Organization "${org.name}" and schema "${org.schema_name}" deleted successfully.\n`));
|
|
||||||
} catch (err) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
console.error(red(`\n ✗ Error deleting organization: ${(err as Error).message}\n`));
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete user ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function deleteUser(identifier: string): Promise<void> {
|
|
||||||
if (!identifier) {
|
|
||||||
console.error(red('✗ Please provide a user email or ID.'));
|
|
||||||
console.log(' Usage: npx tsx cleanup-test-data.ts delete-user <email-or-id>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause = isUUID(identifier) ? 'id = $1' : 'LOWER(email) = LOWER($1)';
|
|
||||||
const users = await query(
|
|
||||||
`SELECT id, email, first_name, last_name, is_superadmin, is_platform_owner
|
|
||||||
FROM shared.users WHERE ${whereClause}`,
|
|
||||||
[identifier]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (users.length === 0) {
|
|
||||||
console.error(red(`✗ User not found: ${identifier}`));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = users[0];
|
|
||||||
const name = [user.first_name, user.last_name].filter(Boolean).join(' ') || '(no name)';
|
|
||||||
|
|
||||||
// Platform owner protection
|
|
||||||
if (user.is_platform_owner) {
|
|
||||||
console.error(red(`\n ✗ Cannot delete platform owner: ${user.email}`));
|
|
||||||
console.error(red(' The platform owner account is protected and cannot be removed.\n'));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(bold(`\n👤 Delete User: ${user.email}\n`));
|
|
||||||
console.log(` ID: ${user.id}`);
|
|
||||||
console.log(` Name: ${name}`);
|
|
||||||
|
|
||||||
if (user.is_superadmin) {
|
|
||||||
console.log(yellow(' ⚠ This user is a SUPERADMIN'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count related data
|
|
||||||
const memberships = await query(
|
|
||||||
`SELECT o.name, uo.role FROM shared.user_organizations uo
|
|
||||||
JOIN shared.organizations o ON o.id = uo.organization_id
|
|
||||||
WHERE uo.user_id = $1`,
|
|
||||||
[user.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const tokenCounts = {
|
|
||||||
refresh: (await query('SELECT COUNT(*) as cnt FROM shared.refresh_tokens WHERE user_id = $1', [user.id]))[0].cnt,
|
|
||||||
passkeys: (await query('SELECT COUNT(*) as cnt FROM shared.user_passkeys WHERE user_id = $1', [user.id]))[0].cnt,
|
|
||||||
loginHistory: (await query('SELECT COUNT(*) as cnt FROM shared.login_history WHERE user_id = $1', [user.id]))[0].cnt,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`\n Will delete:`);
|
|
||||||
console.log(` • User record from shared.users`);
|
|
||||||
console.log(` • ${memberships.length} org membership(s):`);
|
|
||||||
for (const m of memberships) {
|
|
||||||
console.log(` – ${m.name} (${m.role})`);
|
|
||||||
}
|
|
||||||
console.log(` • ${tokenCounts.refresh} refresh token(s)`);
|
|
||||||
console.log(` • ${tokenCounts.passkeys} passkey(s)`);
|
|
||||||
console.log(` • ${tokenCounts.loginHistory} login history record(s)`);
|
|
||||||
console.log(` • Related: password_reset_tokens, invite_tokens (cascade)`);
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
console.log(yellow('\n [DRY RUN] No changes made.\n'));
|
|
||||||
logDryRun(`DELETE FROM shared.users WHERE id = '${user.id}'`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmMsg = user.is_superadmin
|
|
||||||
? red(`\n ⚠ This is a SUPERADMIN account. Are you SURE you want to delete it?`)
|
|
||||||
: red(`\n This is destructive and cannot be undone. Proceed?`);
|
|
||||||
|
|
||||||
const confirmed = await confirm(confirmMsg);
|
|
||||||
if (!confirmed) {
|
|
||||||
console.log(dim(' Aborted.\n'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute deletion (cascade handles related tables)
|
|
||||||
await query('DELETE FROM shared.users WHERE id = $1', [user.id]);
|
|
||||||
console.log(green(`\n ✓ User "${user.email}" deleted successfully.\n`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Purge all ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function purgeAll(): Promise<void> {
|
|
||||||
console.log(bold('\n🔥 Purge All Test Data\n'));
|
|
||||||
|
|
||||||
// Gather current state
|
|
||||||
const orgs = await query(
|
|
||||||
`SELECT id, name, schema_name, stripe_customer_id, stripe_subscription_id
|
|
||||||
FROM shared.organizations ORDER BY name`
|
|
||||||
);
|
|
||||||
|
|
||||||
const userCount = (await query(
|
|
||||||
'SELECT COUNT(*) as cnt FROM shared.users WHERE is_platform_owner = false'
|
|
||||||
))[0].cnt;
|
|
||||||
|
|
||||||
const platformOwner = (await query(
|
|
||||||
'SELECT email FROM shared.users WHERE is_platform_owner = true'
|
|
||||||
));
|
|
||||||
|
|
||||||
const schemas = await query(
|
|
||||||
`SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%' ORDER BY schema_name`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stripe warnings
|
|
||||||
const stripeOrgs = orgs.filter((o: any) => o.stripe_customer_id || o.stripe_subscription_id);
|
|
||||||
|
|
||||||
console.log(` This will:`);
|
|
||||||
console.log(red(` • Drop ${schemas.length} tenant schema(s):`));
|
|
||||||
for (const s of schemas) {
|
|
||||||
console.log(red(` – ${s.schema_name}`));
|
|
||||||
}
|
|
||||||
console.log(red(` • Delete ${orgs.length} organization(s):`));
|
|
||||||
for (const o of orgs) {
|
|
||||||
console.log(red(` – ${o.name}`));
|
|
||||||
}
|
|
||||||
console.log(red(` • Delete ${userCount} non-owner user(s)`));
|
|
||||||
console.log(` • Truncate: user_organizations, invitations, refresh_tokens,`);
|
|
||||||
console.log(` password_reset_tokens, invite_tokens, user_passkeys,`);
|
|
||||||
console.log(` login_history, ai_recommendation_log, stripe_events,`);
|
|
||||||
console.log(` onboarding_progress, email_log`);
|
|
||||||
console.log(green(` • Preserve: platform owner (${platformOwner.length ? platformOwner[0].email : 'none found'})`));
|
|
||||||
console.log(green(` • Preserve: cd_rates (market data)`));
|
|
||||||
|
|
||||||
if (stripeOrgs.length > 0) {
|
|
||||||
console.log(yellow('\n ⚠ Stripe data that should be cleaned up manually:'));
|
|
||||||
for (const o of stripeOrgs) {
|
|
||||||
if (o.stripe_customer_id) {
|
|
||||||
console.log(yellow(` Customer: ${o.stripe_customer_id} (${o.name})`));
|
|
||||||
}
|
|
||||||
if (o.stripe_subscription_id) {
|
|
||||||
console.log(yellow(` Subscription: ${o.stripe_subscription_id} (${o.name})`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
console.log(yellow('\n [DRY RUN] No changes made.\n'));
|
|
||||||
for (const s of schemas) {
|
|
||||||
logDryRun(`DROP SCHEMA "${s.schema_name}" CASCADE`);
|
|
||||||
}
|
|
||||||
logDryRun('TRUNCATE shared.user_organizations, shared.invitations, ...');
|
|
||||||
logDryRun('DELETE FROM shared.organizations');
|
|
||||||
logDryRun("DELETE FROM shared.users WHERE is_platform_owner = false");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmed = await confirm(red(`\n ⚠ THIS WILL DESTROY ALL DATA. Are you absolutely sure?`));
|
|
||||||
if (!confirmed) {
|
|
||||||
console.log(dim(' Aborted.\n'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN');
|
|
||||||
|
|
||||||
// 1. Drop all tenant schemas
|
|
||||||
for (const s of schemas) {
|
|
||||||
console.log(` Dropping schema ${s.schema_name}...`);
|
|
||||||
await client.query(`DROP SCHEMA IF EXISTS "${s.schema_name}" CASCADE`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Truncate shared junction/log tables (order matters for FK constraints)
|
|
||||||
console.log(' Truncating shared tables...');
|
|
||||||
|
|
||||||
// Tables with FK to users AND organizations — truncate first
|
|
||||||
await client.query('DELETE FROM shared.user_organizations');
|
|
||||||
await client.query('DELETE FROM shared.invitations');
|
|
||||||
await client.query('DELETE FROM shared.invite_tokens');
|
|
||||||
await client.query('DELETE FROM shared.onboarding_progress');
|
|
||||||
|
|
||||||
// Tables with FK to users only
|
|
||||||
await client.query('DELETE FROM shared.refresh_tokens');
|
|
||||||
await client.query('DELETE FROM shared.password_reset_tokens');
|
|
||||||
await client.query('DELETE FROM shared.user_passkeys');
|
|
||||||
await client.query('DELETE FROM shared.login_history');
|
|
||||||
|
|
||||||
// Tables with FK to organizations (ON DELETE SET NULL)
|
|
||||||
await client.query('DELETE FROM shared.ai_recommendation_log');
|
|
||||||
await client.query('DELETE FROM shared.stripe_events');
|
|
||||||
await client.query('DELETE FROM shared.email_log');
|
|
||||||
|
|
||||||
// 3. Delete organizations
|
|
||||||
console.log(' Deleting organizations...');
|
|
||||||
await client.query('DELETE FROM shared.organizations');
|
|
||||||
|
|
||||||
// 4. Delete non-owner users
|
|
||||||
console.log(' Deleting non-owner users...');
|
|
||||||
await client.query('DELETE FROM shared.users WHERE is_platform_owner = false');
|
|
||||||
|
|
||||||
await client.query('COMMIT');
|
|
||||||
|
|
||||||
console.log(green(`\n ✓ Purge complete.`));
|
|
||||||
console.log(green(` Dropped ${schemas.length} schema(s), deleted ${orgs.length} org(s), deleted ${userCount} user(s).`));
|
|
||||||
if (platformOwner.length) {
|
|
||||||
console.log(green(` Platform owner preserved: ${platformOwner[0].email}\n`));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
console.error(red(`\n ✗ Error during purge: ${(err as Error).message}\n`));
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Reseed ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function reseed(): Promise<void> {
|
|
||||||
console.log(bold('\n🌱 Purge All + Re-Seed\n'));
|
|
||||||
console.log(' This will purge all test data and then run db/seed/seed.sql');
|
|
||||||
console.log(' to restore the default test environment.\n');
|
|
||||||
|
|
||||||
if (!dryRun && !force) {
|
|
||||||
const confirmed = await confirm(red(' This will destroy all data and re-seed. Proceed?'));
|
|
||||||
if (!confirmed) {
|
|
||||||
console.log(dim(' Aborted.\n'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Set force for the inner purge to avoid double-prompting
|
|
||||||
(global as any).__forceOverride = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run purge
|
|
||||||
const origForce = force;
|
|
||||||
try {
|
|
||||||
// Temporarily force purge to skip its own confirmation
|
|
||||||
if (!dryRun) {
|
|
||||||
Object.defineProperty(global, '__forceOverride', { value: true, writable: true, configurable: true });
|
|
||||||
}
|
|
||||||
await purgeAllInternal();
|
|
||||||
} finally {
|
|
||||||
delete (global as any).__forceOverride;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
logDryRun('Execute db/seed/seed.sql');
|
|
||||||
console.log(yellow('\n [DRY RUN] No changes made.\n'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run seed SQL
|
|
||||||
console.log('\n Running seed script...');
|
|
||||||
const seedPath = resolve(__dirname, '..', 'db', 'seed', 'seed.sql');
|
|
||||||
let seedSql: string;
|
|
||||||
try {
|
|
||||||
seedSql = readFileSync(seedPath, 'utf-8');
|
|
||||||
} catch (err) {
|
|
||||||
console.error(red(` ✗ Could not read seed file: ${seedPath}`));
|
|
||||||
console.error(red(` ${(err as Error).message}\n`));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query(seedSql);
|
|
||||||
console.log(green(`\n ✓ Re-seed complete. Database restored to seed state.\n`));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(red(`\n ✗ Error running seed: ${(err as Error).message}\n`));
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal purge that respects __forceOverride to skip confirmation
|
|
||||||
* when called from reseed().
|
|
||||||
*/
|
|
||||||
async function purgeAllInternal(): Promise<void> {
|
|
||||||
const orgs = await query(
|
|
||||||
`SELECT id, name, schema_name, stripe_customer_id, stripe_subscription_id
|
|
||||||
FROM shared.organizations ORDER BY name`
|
|
||||||
);
|
|
||||||
|
|
||||||
const userCount = (await query(
|
|
||||||
'SELECT COUNT(*) as cnt FROM shared.users WHERE is_platform_owner = false'
|
|
||||||
))[0].cnt;
|
|
||||||
|
|
||||||
const platformOwner = await query(
|
|
||||||
'SELECT email FROM shared.users WHERE is_platform_owner = true'
|
|
||||||
);
|
|
||||||
|
|
||||||
const schemas = await query(
|
|
||||||
`SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%' ORDER BY schema_name`
|
|
||||||
);
|
|
||||||
|
|
||||||
const stripeOrgs = orgs.filter((o: any) => o.stripe_customer_id || o.stripe_subscription_id);
|
|
||||||
|
|
||||||
if (stripeOrgs.length > 0) {
|
|
||||||
console.log(yellow(' ⚠ Stripe data that should be cleaned up manually:'));
|
|
||||||
for (const o of stripeOrgs) {
|
|
||||||
if (o.stripe_customer_id) console.log(yellow(` Customer: ${o.stripe_customer_id} (${o.name})`));
|
|
||||||
if (o.stripe_subscription_id) console.log(yellow(` Subscription: ${o.stripe_subscription_id} (${o.name})`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
for (const s of schemas) {
|
|
||||||
logDryRun(`DROP SCHEMA "${s.schema_name}" CASCADE`);
|
|
||||||
}
|
|
||||||
logDryRun('DELETE FROM shared tables...');
|
|
||||||
logDryRun('DELETE FROM shared.organizations');
|
|
||||||
logDryRun("DELETE FROM shared.users WHERE is_platform_owner = false");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query('BEGIN');
|
|
||||||
|
|
||||||
for (const s of schemas) {
|
|
||||||
console.log(` Dropping schema ${s.schema_name}...`);
|
|
||||||
await client.query(`DROP SCHEMA IF EXISTS "${s.schema_name}" CASCADE`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(' Truncating shared tables...');
|
|
||||||
await client.query('DELETE FROM shared.user_organizations');
|
|
||||||
await client.query('DELETE FROM shared.invitations');
|
|
||||||
await client.query('DELETE FROM shared.invite_tokens');
|
|
||||||
await client.query('DELETE FROM shared.onboarding_progress');
|
|
||||||
await client.query('DELETE FROM shared.refresh_tokens');
|
|
||||||
await client.query('DELETE FROM shared.password_reset_tokens');
|
|
||||||
await client.query('DELETE FROM shared.user_passkeys');
|
|
||||||
await client.query('DELETE FROM shared.login_history');
|
|
||||||
await client.query('DELETE FROM shared.ai_recommendation_log');
|
|
||||||
await client.query('DELETE FROM shared.stripe_events');
|
|
||||||
await client.query('DELETE FROM shared.email_log');
|
|
||||||
|
|
||||||
console.log(' Deleting organizations...');
|
|
||||||
await client.query('DELETE FROM shared.organizations');
|
|
||||||
|
|
||||||
console.log(' Deleting non-owner users...');
|
|
||||||
await client.query('DELETE FROM shared.users WHERE is_platform_owner = false');
|
|
||||||
|
|
||||||
await client.query('COMMIT');
|
|
||||||
|
|
||||||
console.log(green(` ✓ Purged ${schemas.length} schema(s), ${orgs.length} org(s), ${userCount} user(s).`));
|
|
||||||
if (platformOwner.length) {
|
|
||||||
console.log(green(` Platform owner preserved: ${platformOwner[0].email}`));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
console.error(red(` ✗ Error during purge: ${(err as Error).message}`));
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Help ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showHelp(): void {
|
|
||||||
console.log(`
|
|
||||||
${bold('HOA LedgerIQ — Test Data Cleanup Utility')}
|
|
||||||
|
|
||||||
${bold('Usage:')}
|
|
||||||
npx tsx cleanup-test-data.ts <command> [target] [options]
|
|
||||||
|
|
||||||
${bold('Commands:')}
|
|
||||||
${cyan('list')} Show all organizations, users, and tenant schemas
|
|
||||||
${cyan('delete-org')} <name-or-id> Delete an organization and its tenant schema
|
|
||||||
${cyan('delete-user')} <email-or-id> Delete a user and all related data
|
|
||||||
${cyan('purge-all')} Remove ALL data except the platform owner
|
|
||||||
${cyan('reseed')} Purge all, then re-run db/seed/seed.sql
|
|
||||||
|
|
||||||
${bold('Options:')}
|
|
||||||
${dim('--dry-run')} Show what would happen without making changes
|
|
||||||
${dim('--force')} Skip confirmation prompts
|
|
||||||
|
|
||||||
${bold('Examples:')}
|
|
||||||
npx tsx cleanup-test-data.ts list
|
|
||||||
npx tsx cleanup-test-data.ts delete-org "Sunrise Valley HOA"
|
|
||||||
npx tsx cleanup-test-data.ts delete-org 550e8400-e29b-41d4-a716-446655440000
|
|
||||||
npx tsx cleanup-test-data.ts delete-user admin@sunrisevalley.org
|
|
||||||
npx tsx cleanup-test-data.ts delete-user admin@sunrisevalley.org --dry-run
|
|
||||||
npx tsx cleanup-test-data.ts purge-all --dry-run
|
|
||||||
npx tsx cleanup-test-data.ts reseed --force
|
|
||||||
|
|
||||||
${bold('Safety:')}
|
|
||||||
• Platform owner account (is_platform_owner=true) is ${green('never deleted')}
|
|
||||||
• Superadmin deletions require extra confirmation
|
|
||||||
• Stripe customer/subscription IDs are shown as warnings for manual cleanup
|
|
||||||
• cd_rates market data is ${green('always preserved')}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
if (dryRun) {
|
|
||||||
console.log(yellow('\n ── DRY RUN MODE ── No changes will be made ──\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
switch (command) {
|
|
||||||
case 'list':
|
|
||||||
await listAll();
|
|
||||||
break;
|
|
||||||
case 'delete-org':
|
|
||||||
await deleteOrg(target);
|
|
||||||
break;
|
|
||||||
case 'delete-user':
|
|
||||||
await deleteUser(target);
|
|
||||||
break;
|
|
||||||
case 'purge-all':
|
|
||||||
await purgeAll();
|
|
||||||
break;
|
|
||||||
case 'reseed':
|
|
||||||
await reseed();
|
|
||||||
break;
|
|
||||||
case 'help':
|
|
||||||
case '--help':
|
|
||||||
case '-h':
|
|
||||||
showHelp();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (command) {
|
|
||||||
console.error(red(`\n ✗ Unknown command: ${command}\n`));
|
|
||||||
}
|
|
||||||
showHelp();
|
|
||||||
process.exit(command ? 1 : 0);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(red(`\nFatal error: ${(err as Error).message}`));
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -4,8 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"description": "Standalone scripts for HOA LedgerIQ platform (cron jobs, data fetching)",
|
"description": "Standalone scripts for HOA LedgerIQ platform (cron jobs, data fetching)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"fetch-cd-rates": "tsx fetch-cd-rates.ts",
|
"fetch-cd-rates": "tsx fetch-cd-rates.ts"
|
||||||
"cleanup": "tsx cleanup-test-data.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
|||||||
Reference in New Issue
Block a user