10 Commits

Author SHA1 Message Date
fb20c917e1 feat(security): address findings from v2 security assessment
- L2: Add server_tokens off to nginx configs to hide version
- M1: Add X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
  Permissions-Policy headers to all nginx routes
- L3: Add global NoCacheInterceptor (Cache-Control: no-store) on all
  API responses to prevent caching of sensitive financial data
- C1: Disable open registration by default (ALLOW_OPEN_REGISTRATION env)
- H3: Add logout endpoint with correct HTTP 200 status code
- M2: Implement full password reset flow (forgot-password, reset-password,
  change-password) with hashed tokens, 15-min expiry, single-use
- Reduce JWT access token expiry from 24h to 1h
- Add EmailService stub (logs to shared.email_log)
- Add DB migration 016 for password_reset_tokens table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 07:38:48 -04:00
9cd20a1867 Merge branch 'ai-improvements' 2026-03-16 16:34:11 -04:00
420227d70c Merge branch 'feature/invoice-billing-frequency'
# Conflicts:
#	frontend/src/pages/invoices/InvoicesPage.tsx
2026-03-16 16:34:04 -04:00
e893319cfe Merge branch 'fix/viewer-readonly-audit'
# Conflicts:
#	frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx
2026-03-16 16:33:24 -04:00
93eeacfe8f Merge branch 'claude/reverent-moore' into feature/board-planning 2026-03-16 16:28:52 -04:00
267d92933e chore: reorganize sidebar navigation and bump version to 2026.03.16
Remove the Planning section. Move Projects and Capital Planning (as
sub-item) into Board Planning. Move Investment Planning with Investment
Scenarios as sub-item into Board Planning. Move Vendors into new Board
Reference section. Board Planning order: Budget Planning, Projects >
Capital Planning, Assessment Scenarios, Investment Planning > Investment
Scenarios, Compare Scenarios. Sidebar now supports parent items with
their own route plus nested children.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:21:58 -04:00
9d137a40d3 fix: enforce read-only restrictions for viewer role across 5 pages
Audit and fix viewer (read-only) user permissions:
- Dashboard: hide health score refresh buttons
- Accounts: hide investment edit icons
- Invoices: hide Apply Late Fees and Generate Invoices buttons
- Capital Planning: disable drag-and-drop, hide grip handles and edit buttons
- Investment Planning: hide AI Recommendations refresh button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:59:20 -04:00
2b83defbc3 fix: resolve 5 invoice/payment issues from user feedback
- Replace misleading 'sent' status with 'pending' (no email capability)
- Show assessment group name instead of raw 'regular_assessment' type
- Add owner last name to invoice table
- Fix payment creation Internal Server Error (PostgreSQL $2 type cast)
- Add edit/delete capability for payment records with invoice recalc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 11:53:54 -05:00
a59dac7fe1 Merge remote-tracking branch 'origin/feature/invoice-billing-frequency' into ai-improvements 2026-03-06 19:18:11 -05:00
1e31595d7f feat: add flexible billing frequency support for invoices
Assessment groups can now define billing frequency (monthly, quarterly,
annual) with configurable due months and due day. Invoice generation
respects each group's schedule - only generating invoices when the
selected month is a billing month for that group. Adds a generation
preview showing which groups will be billed, period tracking on
invoices, and billing period context in the payments UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:08:56 -05:00
53 changed files with 407 additions and 5622 deletions

View File

@@ -1,576 +0,0 @@
# HOA LedgerIQ -- Payment, Onboarding & Authentication Guide
> **Version:** 2026.03.17
> **Last updated:** March 17, 2026
> **Migration:** `db/migrations/015-saas-onboarding-auth.sql`
---
## Table of Contents
1. [High-Level Flow](#1-high-level-flow)
2. [Stripe Billing & Checkout](#2-stripe-billing--checkout)
3. [Provisioning Pipeline](#3-provisioning-pipeline)
4. [Account Activation (Magic Link)](#4-account-activation-magic-link)
5. [Guided Onboarding Checklist](#5-guided-onboarding-checklist)
6. [Authentication & Sessions](#6-authentication--sessions)
7. [Multi-Factor Authentication (TOTP)](#7-multi-factor-authentication-totp)
8. [Single Sign-On (SSO)](#8-single-sign-on-sso)
9. [Passkeys (WebAuthn)](#9-passkeys-webauthn)
10. [Environment Variables Reference](#10-environment-variables-reference)
11. [Manual Intervention & Ops Tasks](#11-manual-intervention--ops-tasks)
12. [What's Stubbed vs. Production-Ready](#12-whats-stubbed-vs-production-ready)
13. [API Endpoint Reference](#13-api-endpoint-reference)
---
## 1. High-Level Flow
```
Visitor hits /pricing
|
v
Selects a plan (Starter $29 / Professional $79 / Enterprise $199)
|
v
POST /api/billing/create-checkout-session
|
v
Redirect to Stripe Checkout (hosted by Stripe)
|
v
Stripe fires `checkout.session.completed` webhook
|
v
Backend provisions: org -> schema -> user -> invite token -> "email"
|
v
Frontend polls GET /api/billing/status?session_id=xxx
| (OnboardingPendingPage polls every 3s)
v
Status returns "active" -> user is redirected to /login
|
v
User clicks activation link from "email" (logged to console + DB)
|
v
GET /activate?token=xxx -> validates token
POST /activate -> sets password + name, issues session
|
v
Redirect to /onboarding (4-step guided wizard)
|
v
Dashboard
```
---
## 2. Stripe Billing & Checkout
### Plans
| Plan ID | Name | Price | Unit Limit |
|---------------|--------------|---------|------------|
| `starter` | Starter | $29/mo | 50 units |
| `professional` | Professional | $79/mo | 200 units |
| `enterprise` | Enterprise | $199/mo | Unlimited |
### Checkout Flow
1. **Frontend** (`PricingPage.tsx`): User enters email + business name, selects a plan.
2. **API call**: `POST /api/billing/create-checkout-session`
- Body: `{ planId, email?, businessName? }`
- Returns: `{ url }` (Stripe hosted checkout URL)
- No auth required.
3. **Redirect**: Frontend does `window.location.href = url` to send user to Stripe.
4. **On success**: Stripe redirects to `/onboarding/pending?session_id={CHECKOUT_SESSION_ID}`.
5. **On cancel**: Stripe redirects back to `/pricing`.
### Webhook Events Handled
The webhook endpoint is `POST /api/webhooks/stripe`.
| Event | Action |
|-------|--------|
| `checkout.session.completed` | Triggers full provisioning pipeline |
| `invoice.payment_succeeded` | Sets org status to `active` (handles reactivation after failed payment) |
| `invoice.payment_failed` | Sends payment-failed "email" (stubbed) |
| `customer.subscription.deleted` | Sets org status to `archived` |
All webhook events are deduplicated via the `shared.stripe_events` table (idempotency by Stripe event ID).
### Stripe Customer Portal
`POST /api/billing/portal` (auth required) -- creates a Stripe Customer Portal session for managing subscription/payment methods. **Note: currently throws "not implemented" -- needs org-context customer ID lookup.**
---
## 3. Provisioning Pipeline
When `checkout.session.completed` fires, the backend runs **inline provisioning** (no background queue):
1. **Create organization** in `shared.organizations` with:
- `name` = business name from checkout metadata
- `schema_name` = `tenant_{random_12_chars}`
- `status` = `active`
- `plan_level` = selected plan
- `stripe_customer_id` + `stripe_subscription_id`
- Uses `ON CONFLICT (stripe_customer_id)` for idempotency
2. **Create tenant schema** via `TenantSchemaService.createTenantSchema()`:
- Runs the full tenant DDL (accounts, journal entries, etc.)
- Skips if schema already exists
3. **Create or find user** in `shared.users` by email:
- New users are created with `is_email_verified = false` and no password
- Existing users are reused (linked to new org)
4. **Create membership** in `shared.user_organizations`:
- Role: `president`
- Idempotent via `ON CONFLICT DO NOTHING`
5. **Generate invite token** (JWT signed with `INVITE_TOKEN_SECRET`, 72-hour expiry):
- SHA-256 hash stored in `shared.invite_tokens`
- Raw token used in activation URL
6. **Send activation "email"** (stubbed -- see section 12):
- Logged to console and `shared.email_log` table
- Contains activation URL: `{APP_URL}/activate?token={jwt}`
7. **Initialize onboarding** progress row in `shared.onboarding_progress`
### Provisioning Status Polling
`GET /api/billing/status?session_id=xxx` (no auth required)
Returns: `{ status }` where status is one of:
- `not_configured` -- Stripe not set up
- `pending` -- no customer ID yet
- `provisioning` -- org exists but not active yet
- `active` -- ready to go
The `OnboardingPendingPage` polls this every 3 seconds and redirects to `/login` once active.
---
## 4. Account Activation (Magic Link)
### Validate Token
`GET /api/auth/activate?token=xxx` (no auth required)
- Verifies JWT signature (using `INVITE_TOKEN_SECRET`)
- Checks `shared.invite_tokens` for existence, expiration, and prior use
- Returns: `{ valid, email, orgName, orgId, userId }`
### Activate Account
`POST /api/auth/activate` (no auth required)
- Body: `{ token, password, fullName }`
- Password must be >= 8 characters
- Sets `password_hash`, `first_name`, `last_name`, `is_email_verified = true`
- Marks invite token as used (`used_at = NOW()`)
- Issues full session (access token + refresh token cookie)
- Frontend redirects to `/onboarding`
### Frontend (ActivatePage.tsx)
- Validates token on mount
- Shows password setup form with strength indicator (color-coded bar)
- On success: stores auth in Zustand and navigates to `/onboarding`
---
## 5. Guided Onboarding Checklist
### Required Steps
| 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 |
### API
- `GET /api/onboarding/progress` (auth required): Returns `{ completedSteps[], completedAt, requiredSteps[] }`
- `PATCH /api/onboarding/progress` (auth required): Body `{ step }` -- marks a step complete
Steps are stored as a PostgreSQL text array. When all 4 required steps are complete, `completed_at` is set. Users can skip onboarding via a "Finish Later" button (navigates to dashboard).
### Frontend (OnboardingPage.tsx)
- Mantine Stepper with 4 steps
- Each step calls `PATCH /onboarding/progress` on completion
- Celebration screen shown when all steps are done
---
## 6. Authentication & Sessions
### Token Architecture
| Token | Type | Lifetime | Storage |
|-------|------|----------|---------|
| Access token | JWT | 1 hour | Frontend Zustand store (memory/localStorage) |
| Refresh token | Opaque (base64url, 64 bytes) | 30 days | httpOnly cookie (`ledgeriq_rt`) |
| MFA challenge | JWT | 5 minutes | Frontend state (in-memory only) |
| Invite/activation | JWT | 72 hours | URL query parameter |
### Refresh Token Flow
1. Access token expires (401 from any API call)
2. Axios interceptor catches 401, calls `POST /api/auth/refresh`
3. Refresh token is sent automatically via httpOnly cookie
4. Server validates token hash in `shared.refresh_tokens` table
5. New access token issued (refresh token is NOT rotated)
6. Original failed request is replayed with new token
7. Concurrent requests are queued during refresh (no thundering herd)
### Cookie Configuration
- Name: `ledgeriq_rt`
- Path: `/api/auth`
- httpOnly: `true`
- Secure: `true` in production, `false` in dev
- SameSite: `strict`
- Max-Age: 30 days
### 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 refresh token |
| `POST` | `/api/auth/logout-everywhere` | JWT | Revoke all user sessions |
| `POST` | `/api/auth/switch-org` | JWT | Switch org context (new tokens) |
---
## 7. Multi-Factor Authentication (TOTP)
### Setup Flow
1. User goes to Settings > Security > Two-Factor Auth tab
2. `POST /api/auth/mfa/setup` -- returns `{ qrCodeDataUrl, secret, uri }`
3. User scans QR code in authenticator app (Google Authenticator, Authy, etc.)
4. User enters 6-digit code to confirm
5. `POST /api/auth/mfa/enable` with `{ token }` -- returns `{ recoveryCodes[] }`
6. **User must save their 10 recovery codes** (displayed once, bcrypt-hashed in DB)
### Login with MFA
1. `POST /api/auth/login` returns `{ mfaRequired: true, mfaToken }` instead of session
2. Frontend shows 6-digit PIN input (or recovery code input)
3. `POST /api/auth/mfa/verify` with `{ mfaToken, token, useRecovery? }`
4. On success: full session issued (access token + refresh cookie)
### Recovery Codes
- 10 codes generated on MFA enable
- Each code is single-use (removed from array after verification)
- Codes are bcrypt-hashed in `shared.users.recovery_codes` (JSON array)
### MFA Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/auth/mfa/setup` | JWT | Generate QR + secret |
| `POST` | `/api/auth/mfa/enable` | JWT | Verify code and enable MFA |
| `POST` | `/api/auth/mfa/verify` | No (uses mfaToken) | Verify during login |
| `POST` | `/api/auth/mfa/disable` | JWT | Disable MFA (requires password) |
| `GET` | `/api/auth/mfa/status` | JWT | Check if MFA is enabled |
### Tech Stack
- Library: `otplib` v4 (`generateSecret`, `generateURI`, `verifySync`)
- QR codes: `qrcode` package (data URL output)
- Recovery codes: `crypto.randomBytes` + `bcryptjs`
---
## 8. Single Sign-On (SSO)
### Supported Providers
| Provider | Library | Env Vars Required |
|----------|---------|-------------------|
| Google | `passport-google-oauth20` | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_CALLBACK_URL` |
| Microsoft/Azure AD | `passport-azure-ad` | `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_TENANT_ID`, `AZURE_CALLBACK_URL` |
SSO providers are **conditionally loaded** -- they only appear on the login page if their env vars are configured. The `GET /api/auth/sso/providers` endpoint returns `{ google: boolean, azure: boolean }`.
### SSO Login Flow
1. Frontend redirects to `/api/auth/google` or `/api/auth/azure`
2. Passport handles OAuth redirect to provider
3. Provider redirects back to `/api/auth/{provider}/callback`
4. Backend creates or links user via `SsoService.findOrCreateSsoUser()`
5. Session tokens issued, redirect to `/sso-callback?token={accessToken}`
### Account Linking
- SSO fields stored on `shared.users`: `sso_provider`, `sso_id`
- If email matches existing user, SSO is auto-linked on first login
- Users can unlink: `DELETE /api/auth/sso/unlink/:provider`
### SSO Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/auth/sso/providers` | No | List configured providers |
| `GET` | `/api/auth/google/callback` | No (OAuth) | Google callback |
| `GET` | `/api/auth/azure/callback` | No (OAuth) | Azure callback |
| `DELETE` | `/api/auth/sso/unlink/:provider` | JWT | Unlink SSO provider |
---
## 9. Passkeys (WebAuthn)
### Registration Flow (authenticated user)
1. `POST /api/auth/passkeys/register-options` -- returns WebAuthn creation options
2. Browser `navigator.credentials.create()` via `@simplewebauthn/browser`
3. `POST /api/auth/passkeys/register` with `{ response, deviceName? }`
4. Credential stored in `shared.user_passkeys`
### Login Flow (unauthenticated)
1. `POST /api/auth/passkeys/login-options` with `{ email? }` -- returns assertion options
2. Browser `navigator.credentials.get()` via `@simplewebauthn/browser`
3. `POST /api/auth/passkeys/login` with `{ response, challenge }`
4. Full session issued on success
### Passkey Endpoints
| 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 registered passkeys |
| `DELETE` | `/api/auth/passkeys/:id` | JWT | Remove a passkey |
### Configuration
- `WEBAUTHN_RP_ID` -- Relying party ID (e.g., `localhost` for dev, `yourdomain.com` for prod)
- `WEBAUTHN_RP_ORIGIN` -- Expected origin (e.g., `http://localhost` or `https://yourdomain.com`)
- Passkey removal is blocked if the user has no password and no other passkeys (prevents lockout)
---
## 10. Environment Variables Reference
### Stripe (Required for billing)
| Variable | Example | Description |
|----------|---------|-------------|
| `STRIPE_SECRET_KEY` | `sk_test_...` | Stripe secret key. Must NOT contain "placeholder" to activate. |
| `STRIPE_WEBHOOK_SECRET` | `whsec_...` | Webhook endpoint signing secret |
| `STRIPE_STARTER_PRICE_ID` | `price_...` | Stripe Price ID for Starter plan |
| `STRIPE_PROFESSIONAL_PRICE_ID` | `price_...` | Stripe Price ID for Professional plan |
| `STRIPE_ENTERPRISE_PRICE_ID` | `price_...` | Stripe Price ID for Enterprise plan |
### SSO (Optional -- features hidden when not set)
| Variable | Example | Description |
|----------|---------|-------------|
| `GOOGLE_CLIENT_ID` | `xxx.apps.googleusercontent.com` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | `GOCSPX-...` | Google OAuth client secret |
| `GOOGLE_CALLBACK_URL` | `http://localhost/api/auth/google/callback` | OAuth redirect URI |
| `AZURE_CLIENT_ID` | `uuid` | Azure AD application (client) ID |
| `AZURE_CLIENT_SECRET` | `...` | Azure AD client secret |
| `AZURE_TENANT_ID` | `uuid` | Azure AD tenant (directory) ID |
| `AZURE_CALLBACK_URL` | `http://localhost/api/auth/azure/callback` | OAuth redirect URI |
### WebAuthn / Passkeys
| Variable | Default | Description |
|----------|---------|-------------|
| `WEBAUTHN_RP_ID` | `localhost` | Relying party identifier (your domain) |
| `WEBAUTHN_RP_ORIGIN` | `http://localhost` | Expected browser origin |
### Other
| Variable | Default | Description |
|----------|---------|-------------|
| `INVITE_TOKEN_SECRET` | `dev-invite-secret` | Secret for signing invite/activation JWTs. **Change in production.** |
| `APP_URL` | `http://localhost` | Base URL for generated links (activation emails, Stripe redirects) |
---
## 11. Manual Intervention & Ops Tasks
### Before Going Live
1. **Set up Stripe products and prices** in the Stripe Dashboard:
- Create 3 products (Starter, Professional, Enterprise)
- Create monthly recurring prices for each
- Copy the Price IDs into `STRIPE_STARTER_PRICE_ID`, etc.
2. **Configure the Stripe webhook** in the Stripe Dashboard:
- Endpoint URL: `https://yourdomain.com/api/webhooks/stripe`
- Events to subscribe: `checkout.session.completed`, `invoice.payment_succeeded`, `invoice.payment_failed`, `customer.subscription.deleted`
- Copy the webhook signing secret to `STRIPE_WEBHOOK_SECRET`
3. **Replace the email stub** with a real provider:
- `backend/src/modules/email/email.service.ts` currently logs to console + DB
- Swap in Resend, SendGrid, SES, or your preferred provider
- The four email methods to implement: `sendActivationEmail`, `sendWelcomeEmail`, `sendPaymentFailedEmail`, `sendInviteMemberEmail`
4. **Set production secrets**:
- `INVITE_TOKEN_SECRET` -- use a strong random string (not `dev-invite-secret`)
- `JWT_SECRET` -- already required, verify it's strong
- `WEBAUTHN_RP_ID` and `WEBAUTHN_RP_ORIGIN` -- set to your production domain
5. **Configure SSO providers** (if desired):
- Register apps in Google Cloud Console and/or Azure AD
- Set the callback URLs to your production domain
- Add client IDs and secrets to env vars
6. **Set up the Stripe Customer Portal** in Stripe Dashboard:
- Configure allowed actions (cancel, upgrade/downgrade, payment method updates)
- The `/api/billing/portal` endpoint needs the org-context customer ID lookup completed
### Ongoing Ops
7. **Refresh token cleanup**: The `RefreshTokenService.cleanupExpired()` method deletes tokens that have been expired or revoked for 7+ days. **This is not currently called on a schedule.** Options:
- Add a cron job / scheduled task that calls it periodically
- Or add a NestJS `@Cron()` decorator (requires `@nestjs/schedule`)
8. **Monitor `shared.email_log`**: While email is stubbed, activation URLs are only visible in:
- Backend console logs (look for lines starting with `EMAIL STUB`)
- The `shared.email_log` table (query: `SELECT * FROM shared.email_log ORDER BY sent_at DESC`)
9. **Finding activation URLs manually** (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;
```
10. **Resend an activation email**: `POST /api/auth/resend-activation` with `{ email }` is stubbed (always returns success). To manually generate a new token:
```sql
-- Find the user and org
SELECT u.id AS user_id, uo.organization_id
FROM shared.users u
JOIN shared.user_organizations uo ON uo.user_id = u.id
WHERE u.email = 'user@example.com';
```
Then call `authService.generateInviteToken(userId, orgId, email)` or trigger a fresh checkout.
11. **Deprovisioning / cancellation**: When a Stripe subscription is deleted, the org is set to `archived`. Archived orgs:
- Block login (users see "Your organization has been suspended")
- Block API access (403 on org-scoped endpoints)
- Data is preserved (schema is NOT deleted)
- To restore: update `status` back to `active` in `shared.organizations`
---
## 12. What's Stubbed vs. Production-Ready
| Component | Status | Notes |
|-----------|--------|-------|
| Stripe Checkout | **Ready** (test mode) | Switch to live keys for production |
| Stripe Webhooks | **Ready** | Signature verification, idempotency, event dispatch all implemented |
| Stripe Customer Portal | **Stubbed** | Endpoint exists but needs org-context customer ID lookup |
| Provisioning (org + schema + user) | **Ready** | Inline (synchronous). Consider BullMQ queue for production scale. |
| Email service | **Stubbed** | Logs to console + `shared.email_log`. Replace with real SMTP/API provider. |
| Activation (magic link) | **Ready** | Works end-to-end (token generation, validation, password set, session issue) |
| Onboarding checklist | **Ready** | Server-side progress tracking, step completion, UI wizard |
| Refresh tokens | **Ready** | Creation, validation, revocation, cleanup method (needs scheduling) |
| TOTP MFA | **Ready** | Setup, enable, verify, recovery codes, disable |
| SSO (Google) | **Ready** (needs keys) | Conditional loading, user creation/linking |
| SSO (Azure AD) | **Ready** (needs keys) | Uses deprecated `passport-azure-ad` (works, consider `@azure/msal-node`) |
| Passkeys (WebAuthn) | **Ready** | Registration, authentication, removal with lockout protection |
| Resend activation | **Stubbed** | Always returns success, no actual email sent |
---
## 13. API Endpoint Reference
### Billing (no auth unless noted)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/billing/create-checkout-session` | No | Create Stripe Checkout, returns `{ url }` |
| `POST` | `/api/webhooks/stripe` | Stripe sig | Webhook receiver |
| `GET` | `/api/billing/status?session_id=` | No | Poll provisioning status |
| `POST` | `/api/billing/portal` | JWT | Stripe Customer Portal (stubbed) |
### 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 (stubbed) |
| `GET` | `/api/auth/profile` | JWT | Get user profile |
| `POST` | `/api/auth/switch-org` | JWT | Switch organization |
### MFA
| 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 |
### SSO
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/auth/sso/providers` | No | List available providers |
| `GET` | `/api/auth/google/callback` | OAuth | Google callback handler |
| `GET` | `/api/auth/azure/callback` | OAuth | Azure callback handler |
| `DELETE` | `/api/auth/sso/unlink/:provider` | JWT | Unlink SSO account |
### Passkeys
| 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 |
### Onboarding
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/onboarding/progress` | JWT | Get onboarding progress |
| `PATCH` | `/api/onboarding/progress` | JWT | Mark step complete |
---
## Database Tables Added (Migration 015)
| Table | Purpose |
|-------|---------|
| `shared.refresh_tokens` | Stores SHA-256 hashed refresh tokens with expiry/revocation |
| `shared.stripe_events` | Idempotency ledger for Stripe webhook events |
| `shared.invite_tokens` | Tracks activation/invite magic links |
| `shared.onboarding_progress` | Per-org onboarding step completion |
| `shared.user_passkeys` | WebAuthn credential storage |
| `shared.email_log` | Stubbed email audit trail |
Columns added to existing tables:
- `shared.organizations`: `stripe_customer_id`, `stripe_subscription_id`, `trial_ends_at`
- `shared.users`: `totp_verified_at`, `recovery_codes`, `webauthn_challenge`

1190
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.3.17",
"version": "2026.03.16",
"description": "HOA LedgerIQ - Backend API",
"private": true,
"scripts": {
@@ -27,27 +27,18 @@
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.2",
"@simplewebauthn/server": "^13.3.0",
"bcryptjs": "^3.0.3",
"bullmq": "^5.71.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.7",
"helmet": "^8.1.0",
"ioredis": "^5.4.2",
"newrelic": "latest",
"otplib": "^13.3.0",
"passport": "^0.7.0",
"passport-azure-ad": "^4.3.5",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.13.1",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"resend": "^6.9.4",
"rxjs": "^7.8.1",
"stripe": "^20.4.1",
"typeorm": "^0.3.20",
"uuid": "^9.0.1"
},
@@ -56,15 +47,12 @@
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/multer": "^2.0.0",
"@types/node": "^20.17.12",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/qrcode": "^1.5.6",
"@types/uuid": "^9.0.8",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",

View File

@@ -1,5 +1,5 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
@@ -7,6 +7,7 @@ import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard';
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module';
import { UsersModule } from './modules/users/users.module';
@@ -29,9 +30,7 @@ import { AttachmentsModule } from './modules/attachments/attachments.module';
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
import { HealthScoresModule } from './modules/health-scores/health-scores.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 { OnboardingModule } from './modules/onboarding/onboarding.module';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
@@ -84,9 +83,7 @@ import { ScheduleModule } from '@nestjs/schedule';
InvestmentPlanningModule,
HealthScoresModule,
BoardPlanningModule,
BillingModule,
EmailModule,
OnboardingModule,
ScheduleModule.forRoot(),
],
controllers: [AppController],
@@ -95,6 +92,10 @@ import { ScheduleModule } from '@nestjs/schedule';
provide: APP_GUARD,
useClass: WriteAccessGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: NoCacheInterceptor,
},
],
})
export class AppModule implements NestModule {

View File

@@ -0,0 +1,16 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
/**
* Prevents browsers and proxies from caching authenticated API responses
* containing sensitive financial data (account balances, transactions, PII).
*/
@Injectable()
export class NoCacheInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const res = context.switchToHttp().getResponse();
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.setHeader('Pragma', 'no-cache');
return next.handle();
}
}

View File

@@ -4,7 +4,6 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import * as cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
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() {
const app = await NestFactory.create(AppModule, {
logger: isProduction ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'],
// Enable raw body for Stripe webhook signature verification
rawBody: true,
});
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,
// Referrer-Policy, Permissions-Policy, and removes X-Powered-By
app.use(

View File

@@ -6,14 +6,13 @@ import {
UseGuards,
Request,
Get,
Res,
Query,
HttpCode,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { Response } from 'express';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@@ -21,27 +20,7 @@ import { SwitchOrgDto } from './dto/switch-org.dto';
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';
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',
});
}
const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true';
@ApiTags('auth')
@Controller('auth')
@@ -49,66 +28,34 @@ export class AuthController {
constructor(private authService: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@ApiOperation({ summary: 'Register a new user (disabled unless ALLOW_OPEN_REGISTRATION=true)' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.register(dto);
if (result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
async register(@Body() dto: RegisterDto) {
if (!isOpenRegistration) {
throw new ForbiddenException(
'Open registration is disabled. Please use an invitation link to create your account.',
);
}
const { refreshToken, ...response } = result;
return response;
return this.authService.register(dto);
}
@Post('login')
@ApiOperation({ summary: 'Login with email and password' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
@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 ua = req.headers['user-agent'];
const result = await 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);
return this.authService.login(req.user, ip, ua);
}
@Post('logout')
@ApiOperation({ summary: 'Logout and revoke refresh token' })
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' })
@ApiOperation({ summary: 'Logout (invalidate current session)' })
@HttpCode(200)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) {
await this.authService.logoutEverywhere(req.user.sub);
clearRefreshCookie(res);
async logout(@Request() req: any) {
await this.authService.logout(req.user.sub);
return { success: true };
}
@@ -135,52 +82,56 @@ export class AuthController {
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@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 ua = req.headers['user-agent'];
const result = await this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
if (result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
}
const { refreshToken, ...response } = result;
return response;
return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
}
// ─── Activation Endpoints ─────────────────────────────────────────
// ─── Password Reset Flow ──────────────────────────────────────────
@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('forgot-password')
@ApiOperation({ summary: 'Request a password reset email' })
@HttpCode(200)
@Throttle({ default: { limit: 3, ttl: 60000 } })
async forgotPassword(@Body() body: { email: string }) {
if (!body.email) throw new BadRequestException('Email is required');
await this.authService.requestPasswordReset(body.email);
// Always return same message to prevent account enumeration
return { message: 'If that email exists, a password reset link has been sent.' };
}
@Post('activate')
@ApiOperation({ summary: 'Activate user account with password' })
@Post('reset-password')
@ApiOperation({ summary: 'Reset password using a reset token' })
@HttpCode(200)
@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');
async resetPassword(@Body() body: { token: string; newPassword: string }) {
if (!body.token || !body.newPassword) {
throw new BadRequestException('Token and newPassword are required');
}
if (body.password.length < 8) {
if (body.newPassword.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;
await this.authService.resetPassword(body.token, body.newPassword);
return { message: 'Password updated successfully.' };
}
@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.' };
@Patch('change-password')
@ApiOperation({ summary: 'Change password (authenticated)' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@AllowViewer()
async changePassword(
@Request() req: any,
@Body() body: { currentPassword: string; newPassword: string },
) {
if (!body.currentPassword || !body.newPassword) {
throw new BadRequestException('currentPassword and newPassword are required');
}
if (body.newPassword.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
await this.authService.changePassword(req.user.sub, body.currentPassword, body.newPassword);
return { message: 'Password changed successfully.' };
}
}

View File

@@ -4,15 +4,8 @@ import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.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 { 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 { LocalStrategy } from './strategies/local.strategy';
import { UsersModule } from '../users/users.module';
@@ -32,23 +25,8 @@ import { OrganizationsModule } from '../organizations/organizations.module';
}),
}),
],
controllers: [
AuthController,
AdminController,
MfaController,
SsoController,
PasskeyController,
],
providers: [
AuthService,
AdminAnalyticsService,
RefreshTokenService,
MfaService,
SsoService,
PasskeyService,
JwtStrategy,
LocalStrategy,
],
exports: [AuthService, RefreshTokenService, JwtModule],
controllers: [AuthController, AdminController],
providers: [AuthService, AdminAnalyticsService, JwtStrategy, LocalStrategy],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -11,25 +11,25 @@ import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { createHash } from 'crypto';
import { randomBytes, createHash } from 'crypto';
import { UsersService } from '../users/users.service';
import { EmailService } from '../email/email.service';
import { RegisterDto } from './dto/register.dto';
import { User } from '../users/entities/user.entity';
import { RefreshTokenService } from './refresh-token.service';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly inviteSecret: string;
private readonly appUrl: string;
constructor(
private usersService: UsersService,
private jwtService: JwtService,
private configService: ConfigService,
private dataSource: DataSource,
private refreshTokenService: RefreshTokenService,
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';
}
async register(dto: RegisterDto) {
@@ -84,25 +84,15 @@ export class AuthService {
// Record login in history (org_id is null at initial login)
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);
}
/**
* 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> {
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) throw new UnauthorizedException('User not found');
return this.generateTokenResponse(user);
async logout(_userId: string): Promise<void> {
// Placeholder for refresh token revocation
}
async getProfile(userId: string) {
@@ -115,7 +105,6 @@ export class AuthService {
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
mfaEnabled: user.mfaEnabled || false,
organizations: user.userOrganizations?.map((uo) => ({
id: uo.organization.id,
name: uo.organization.name,
@@ -155,12 +144,8 @@ export class AuthService {
// Record org switch in login history
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
// Generate new refresh token for org switch
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
return {
accessToken: this.jwtService.sign(payload),
refreshToken,
organization: {
id: membership.organization.id,
name: membership.organization.name,
@@ -170,145 +155,109 @@ 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> {
await this.usersService.markIntroSeen(userId);
}
// ─── Invite Token (Activation) Methods ──────────────────────────────
// ─── Password Reset Flow ──────────────────────────────────────────
/**
* Validate an invite/activation token.
* Request a password reset. Generates a token, stores its hash, and sends an email.
* Silently succeeds even if the email doesn't exist (prevents enumeration).
*/
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');
async requestPasswordReset(email: string): Promise<void> {
const user = await this.usersService.findByEmail(email);
if (!user) {
// Silently return — don't reveal whether the account exists
return;
}
// Invalidate any existing reset tokens for this user
await this.dataSource.query(
`UPDATE shared.password_reset_tokens SET used_at = NOW()
WHERE user_id = $1 AND used_at IS NULL`,
[user.id],
);
// Generate a 64-byte random token
const rawToken = randomBytes(64).toString('base64url');
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
await this.dataSource.query(
`INSERT INTO shared.password_reset_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`,
[user.id, tokenHash, expiresAt],
);
const resetUrl = `${this.appUrl}/reset-password?token=${rawToken}`;
await this.emailService.sendPasswordResetEmail(user.email, resetUrl);
}
/**
* Activate a user from an invite token (set password, activate, issue session).
* Reset password using a valid reset token.
*/
async activateUser(token: string, password: string, fullName: string) {
const info = await this.validateInviteToken(token);
async resetPassword(rawToken: string, newPassword: string): Promise<void> {
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
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`,
const rows = await this.dataSource.query(
`SELECT id, user_id, expires_at, used_at
FROM shared.password_reset_tokens
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');
if (rows.length === 0) {
throw new BadRequestException('Invalid or expired reset token');
}
return this.generateTokenResponse(user);
const record = rows[0];
if (record.used_at) {
throw new BadRequestException('This reset link has already been used');
}
if (new Date(record.expires_at) < new Date()) {
throw new BadRequestException('This reset link has expired');
}
// Update password
const passwordHash = await bcrypt.hash(newPassword, 12);
await this.dataSource.query(
`UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
[passwordHash, record.user_id],
);
// Mark token as used
await this.dataSource.query(
`UPDATE shared.password_reset_tokens SET used_at = NOW() WHERE id = $1`,
[record.id],
);
}
/**
* Generate a signed invite token for a user/org pair.
* Change password for an authenticated user (requires current password).
*/
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' },
);
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<void> {
const user = await this.usersService.findById(userId);
if (!user || !user.passwordHash) {
throw new UnauthorizedException('User not found');
}
const tokenHash = createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000);
const isValid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!isValid) {
throw new UnauthorizedException('Current password is incorrect');
}
const passwordHash = await bcrypt.hash(newPassword, 12);
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],
`UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
[passwordHash, userId],
);
return token;
}
// ─── Private Helpers ──────────────────────────────────────────────
private async recordLoginHistory(
userId: string,
organizationId: string | null,
@@ -326,7 +275,7 @@ export class AuthService {
}
}
async generateTokenResponse(user: User, impersonatedBy?: string) {
private generateTokenResponse(user: User, impersonatedBy?: string) {
const allOrgs = user.userOrganizations || [];
// Filter out suspended/archived organizations
const orgs = allOrgs.filter(
@@ -349,12 +298,8 @@ export class AuthService {
payload.role = defaultOrg.role;
}
// Create refresh token
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
return {
accessToken: this.jwtService.sign(payload),
refreshToken,
user: {
id: user.id,
email: user.email,
@@ -363,7 +308,6 @@ export class AuthService {
isSuperadmin: user.isSuperadmin || false,
isPlatformOwner: user.isPlatformOwner || false,
hasSeenIntro: user.hasSeenIntro || false,
mfaEnabled: user.mfaEnabled || false,
},
organizations: orgs.map((uo) => ({
id: uo.organizationId,

View File

@@ -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);
}
}

View File

@@ -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,
};
}
}

View File

@@ -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 };
}
}

View File

@@ -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],
);
}
}

View File

@@ -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');
}
}

View File

@@ -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 };
}
}

View File

@@ -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),
};
}
}

View File

@@ -1,63 +0,0 @@
import {
Controller,
Post,
Get,
Body,
Query,
Req,
UseGuards,
RawBodyRequest,
BadRequestException,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { Request as ExpressRequest } from 'express';
import { BillingService } from './billing.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@ApiTags('billing')
@Controller()
export class BillingController {
constructor(private billingService: BillingService) {}
@Post('billing/create-checkout-session')
@ApiOperation({ summary: 'Create a Stripe Checkout Session' })
@Throttle({ default: { limit: 10, ttl: 60000 } })
async createCheckout(
@Body() body: { planId: string; email?: string; businessName?: string },
) {
if (!body.planId) throw new BadRequestException('planId is required');
return this.billingService.createCheckoutSession(body.planId, 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' })
async getStatus(@Query('session_id') sessionId: string) {
if (!sessionId) throw new BadRequestException('session_id required');
return this.billingService.getProvisioningStatus(sessionId);
}
@Post('billing/portal')
@ApiOperation({ summary: 'Create Stripe Customer Portal session' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async createPortal(@Request() req: any) {
// Lookup the org's stripe_customer_id
// Only allow president or superadmin
const orgId = req.user.orgId;
if (!orgId) throw new BadRequestException('No organization context');
// For now, we'd look this up from the org
throw new BadRequestException('Portal session requires stripe_customer_id lookup — implement per org context');
}
}

View File

@@ -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 {}

View File

@@ -1,294 +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 },
};
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name);
private stripe: Stripe | null = null;
private webhookSecret: string;
private priceMap: Record<string, string>;
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.priceMap = {
starter: this.configService.get<string>('STRIPE_STARTER_PRICE_ID') || '',
professional: this.configService.get<string>('STRIPE_PROFESSIONAL_PRICE_ID') || '',
enterprise: this.configService.get<string>('STRIPE_ENTERPRISE_PRICE_ID') || '',
};
}
/**
* Create a Stripe Checkout Session for a new subscription.
*/
async createCheckoutSession(planId: string, email?: string, businessName?: string): Promise<{ url: string }> {
if (!this.stripe) {
throw new BadRequestException('Stripe not configured');
}
const priceId = this.priceMap[planId];
if (!priceId || priceId.includes('placeholder')) {
throw new BadRequestException(`Invalid plan: ${planId}`);
}
const session = await this.stripe.checkout.sessions.create({
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 || '',
},
});
return { url: session.url! };
}
/**
* 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;
default:
this.logger.log(`Unhandled Stripe event: ${event.type}`);
}
}
/**
* Get provisioning status for a checkout session.
*/
async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> {
if (!this.stripe) return { status: 'not_configured' };
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
const customerId = session.customer as string;
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 (rows[0].status === 'active') return { status: 'active' };
return { status: 'provisioning' };
}
/**
* Create a Stripe Customer Portal session.
*/
async createPortalSession(customerId: string): Promise<{ url: string }> {
if (!this.stripe) throw new BadRequestException('Stripe not configured');
const session = await this.stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${this.getAppUrl()}/settings`,
});
return { url: session.url };
}
// ─── Provisioning (inline, no BullMQ for now — add queue later) ─────
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';
this.logger.log(`Provisioning org for ${email}, plan=${planId}, customer=${customerId}`);
try {
await this.provisionOrganization(customerId, subscriptionId, email, planId, businessName);
} 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
await this.dataSource.query(
`UPDATE shared.organizations SET status = 'active', updated_at = NOW()
WHERE stripe_customer_id = $1 AND status != 'active'`,
[customerId],
);
}
private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
const customerId = invoice.customer as string;
const rows = await this.dataSource.query(
`SELECT email FROM shared.organizations WHERE stripe_customer_id = $1`,
[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}`);
}
/**
* Full provisioning flow: create org, schema, user, invite token, email.
*/
async provisionOrganization(
customerId: string,
subscriptionId: string,
email: string,
planId: string,
businessName: string,
): 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)
VALUES ($1, $2, 'active', $3, $4, $5, $6)
ON CONFLICT (stripe_customer_id) DO UPDATE SET
stripe_subscription_id = EXCLUDED.stripe_subscription_id,
plan_level = EXCLUDED.plan_level,
status = 'active',
updated_at = NOW()
RETURNING id, schema_name`,
[businessName, schemaName, planId, customerId, subscriptionId, email],
);
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}`);
}
private getAppUrl(): string {
return this.configService.get<string>('APP_URL') || 'http://localhost';
}
}

View File

@@ -1,159 +1,30 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private resend: Resend | null = null;
private fromAddress: string;
private replyToAddress: string;
constructor(
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 });
}
constructor(private dataSource: DataSource) {}
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
const subject = 'Reset your HOA LedgerIQ password';
const html = this.buildTemplate({
preheader: 'Password reset requested for your HOA LedgerIQ account.',
heading: 'Password Reset',
body: `
<p>We received a request to reset your password. Click the button below to choose a new one:</p>
`,
ctaText: 'Reset Password',
ctaUrl: resetUrl,
footer: 'This link expires in 1 hour. If you did not request a password reset, please ignore this email — your password will remain unchanged.',
});
const body = [
`You requested a password reset for your HOA LedgerIQ account.`,
``,
`Click the link below to reset your password:`,
resetUrl,
``,
`This link expires in 15 minutes. If you didn't request this, ignore this email.`,
].join('\n');
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(
toEmail: string,
subject: string,
@@ -161,6 +32,10 @@ export class EmailService {
template: string,
metadata: Record<string, any>,
): Promise<void> {
this.logger.log(`EMAIL STUB -> ${toEmail}`);
this.logger.log(` Subject: ${subject}`);
this.logger.log(` Body:\n${body}`);
try {
await this.dataSource.query(
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
@@ -171,119 +46,4 @@ export class EmailService {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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;">
&copy; ${new Date().getFullYear()} HOA LedgerIQ &mdash; Smart Financial Management for HOAs
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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);
}
}

View File

@@ -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()
);

View File

@@ -0,0 +1,25 @@
-- Migration 016: Password Reset Tokens
-- Adds table for password reset token storage (hashed, single-use, short-lived).
CREATE TABLE IF NOT EXISTS shared.password_reset_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,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_hash ON shared.password_reset_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON shared.password_reset_tokens(user_id);
-- Also ensure email_log table exists (may not exist if migration 015 hasn't been applied)
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()
);

View File

@@ -40,25 +40,6 @@ services:
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
- 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:-}
- 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:
resources:
limits:

View File

@@ -29,25 +29,6 @@ services:
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
- 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:-}
- 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:
- ./backend/src:/app/src
- ./backend/nest-cli.json:/app/nest-cli.json

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.17",
"version": "2026.03.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.17",
"version": "2026.03.10",
"dependencies": {
"@mantine/core": "^7.15.3",
"@mantine/dates": "^7.15.3",
@@ -14,7 +14,6 @@
"@mantine/hooks": "^7.15.3",
"@mantine/modals": "^7.15.3",
"@mantine/notifications": "^7.15.3",
"@simplewebauthn/browser": "^13.3.0",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.64.2",
"axios": "^1.7.9",
@@ -1290,12 +1289,6 @@
"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": {
"version": "3.36.1",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.17",
"version": "2026.03.16",
"private": true,
"type": "module",
"scripts": {
@@ -16,7 +16,6 @@
"@mantine/hooks": "^7.15.3",
"@mantine/modals": "^7.15.3",
"@mantine/notifications": "^7.15.3",
"@simplewebauthn/browser": "^13.3.0",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.64.2",
"axios": "^1.7.9",

View File

@@ -4,7 +4,6 @@ import { AppLayout } from './components/layout/AppLayout';
import { LoginPage } from './pages/auth/LoginPage';
import { RegisterPage } from './pages/auth/RegisterPage';
import { SelectOrgPage } from './pages/auth/SelectOrgPage';
import { ActivatePage } from './pages/auth/ActivatePage';
import { DashboardPage } from './pages/dashboard/DashboardPage';
import { AccountsPage } from './pages/accounts/AccountsPage';
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 { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
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 }) {
const token = useAuthStore((s) => s.token);
@@ -81,12 +77,6 @@ function AuthRoute({ children }: { children: React.ReactNode }) {
export function App() {
return (
<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
path="/login"
element={
@@ -111,18 +101,6 @@ export function App() {
</ProtectedRoute>
}
/>
{/* Onboarding (requires auth but not org selection) */}
<Route
path="/onboarding"
element={
<ProtectedRoute>
<OnboardingPage />
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route
path="/admin"
element={
@@ -133,8 +111,6 @@ export function App() {
>
<Route index element={<AdminPage />} />
</Route>
{/* Main app routes (require auth + org) */}
<Route
path="/*"
element={

View File

@@ -17,6 +17,7 @@ import {
IconChartAreaLine,
IconClipboardCheck,
IconSparkles,
IconHeartRateMonitor,
IconCalculator,
IconGitCompare,
IconScale,
@@ -45,6 +46,14 @@ const navSections = [
{ 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',
items: [
@@ -58,8 +67,12 @@ const navSections = [
{
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' },
],
},
@@ -69,14 +82,6 @@ const navSections = [
{ 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',
items: [

View File

@@ -9,7 +9,7 @@ import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import { App } from './App';
import { defaultTheme, compactTheme } from './theme/theme';
import { theme } from './theme/theme';
import { usePreferencesStore } from './stores/preferencesStore';
const queryClient = new QueryClient({
@@ -24,11 +24,9 @@ const queryClient = new QueryClient({
function Root() {
const colorScheme = usePreferencesStore((s) => s.colorScheme);
const compactView = usePreferencesStore((s) => s.compactView);
const activeTheme = compactView ? compactTheme : defaultTheme;
return (
<MantineProvider theme={activeTheme} forceColorScheme={colorScheme}>
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
<Notifications position="top-right" />
<ModalsProvider>
<QueryClientProvider client={queryClient}>

View File

@@ -587,7 +587,7 @@ export function AccountsPage() {
{investments.filter(i => i.is_active).length > 0 && (
<>
<Divider label="Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -605,7 +605,7 @@ export function AccountsPage() {
{operatingInvestments.length > 0 && (
<>
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -623,7 +623,7 @@ export function AccountsPage() {
{reserveInvestments.length > 0 && (
<>
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -1087,9 +1087,11 @@ function AccountTable({
function InvestmentMiniTable({
investments,
onEdit,
isReadOnly = false,
}: {
investments: Investment[];
onEdit: (inv: Investment) => void;
isReadOnly?: boolean;
}) {
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
const totalValue = investments.reduce(
@@ -1132,7 +1134,7 @@ function InvestmentMiniTable({
<Table.Th ta="right">Maturity Value</Table.Th>
<Table.Th>Maturity Date</Table.Th>
<Table.Th ta="right">Days Remaining</Table.Th>
<Table.Th></Table.Th>
{!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -1182,13 +1184,15 @@ function InvestmentMiniTable({
'-'
)}
</Table.Td>
<Table.Td>
<Tooltip label="Edit investment">
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
{!isReadOnly && (
<Table.Td>
<Tooltip label="Edit investment">
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>

View File

@@ -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>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import {
Center,
Container,
@@ -10,41 +10,18 @@ import {
Anchor,
Stack,
Alert,
Divider,
Group,
PinInput,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import {
IconAlertCircle,
IconBrandGoogle,
IconBrandWindows,
IconFingerprint,
IconShieldLock,
} from '@tabler/icons-react';
import { IconAlertCircle } from '@tabler/icons-react';
import { useNavigate, Link } from 'react-router-dom';
import { startAuthentication } from '@simplewebauthn/browser';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import logoSrc from '../../assets/logo.png';
type LoginState = 'credentials' | 'mfa';
export function LoginPage() {
const [loading, setLoading] = useState(false);
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 setAuth = useAuthStore((s) => s.setAuth);
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) => {
setLoading(true);
setError('');
try {
const { data } = await api.post('/auth/login', values);
if (data.mfaRequired) {
setMfaToken(data.mfaToken);
setLoginState('mfa');
setAuth(data.accessToken, data.user, data.organizations);
// Platform owner / superadmin with no orgs → admin panel
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 {
handleLoginSuccess(data);
navigate('/');
}
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed');
@@ -101,181 +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 (
<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>
<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>
@@ -284,12 +64,9 @@ export function LoginPage() {
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))',
}
: {}),
...(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>
@@ -325,53 +102,6 @@ export function LoginPage() {
</Button>
</Stack>
</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>
</Container>
);

View File

@@ -72,9 +72,10 @@ interface KanbanCardProps {
project: Project;
onEdit: (p: Project) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
isReadOnly?: boolean;
}
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
function KanbanCard({ project, onEdit, onDragStart, isReadOnly }: KanbanCardProps) {
const plannedLabel = formatPlannedDate(project.planned_date);
// For projects in the Future bucket with a specific year, show the year
const currentYear = new Date().getFullYear();
@@ -86,21 +87,23 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
padding="sm"
radius="md"
withBorder
draggable
onDragStart={(e) => onDragStart(e, project)}
style={{ cursor: 'grab', userSelect: 'none' }}
draggable={!isReadOnly}
onDragStart={!isReadOnly ? (e) => onDragStart(e, project) : undefined}
style={{ cursor: isReadOnly ? 'default' : 'grab', userSelect: 'none' }}
mb="xs"
>
<Group justify="space-between" wrap="nowrap" mb={4}>
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />
{!isReadOnly && <IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />}
<Text fw={600} size="sm" truncate>
{project.name}
</Text>
</Group>
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
<IconEdit size={14} />
</ActionIcon>
{!isReadOnly && (
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
<IconEdit size={14} />
</ActionIcon>
)}
</Group>
<Group gap={6} mb={6}>
@@ -148,11 +151,12 @@ interface KanbanColumnProps {
isDragOver: boolean;
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
onDragLeave: () => void;
isReadOnly?: boolean;
}
function KanbanColumn({
year, projects, onEdit, onDragStart, onDrop,
isDragOver, onDragOverHandler, onDragLeave,
isDragOver, onDragOverHandler, onDragLeave, isReadOnly,
}: KanbanColumnProps) {
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
const isFuture = year === FUTURE_YEAR;
@@ -178,9 +182,9 @@ function KanbanColumn({
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
transition: 'background-color 150ms ease, border 150ms ease',
}}
onDragOver={(e) => onDragOverHandler(e, year)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, year)}
onDragOver={!isReadOnly ? (e) => onDragOverHandler(e, year) : undefined}
onDragLeave={!isReadOnly ? onDragLeave : undefined}
onDrop={!isReadOnly ? (e) => onDrop(e, year) : undefined}
>
<Group justify="space-between" mb="sm">
<Title order={5}>{yearLabel(year)}</Title>
@@ -199,7 +203,7 @@ function KanbanColumn({
<Box style={{ flex: 1, minHeight: 60 }}>
{projects.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="lg">
Drop projects here
{isReadOnly ? 'No projects' : 'Drop projects here'}
</Text>
) : useWideLayout ? (
<div style={{
@@ -208,12 +212,12 @@ function KanbanColumn({
gap: 'var(--mantine-spacing-xs)',
}}>
{projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
))}
</div>
) : (
projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
))
)}
</Box>
@@ -595,6 +599,7 @@ export function CapitalProjectsPage() {
isDragOver={dragOverYear === year}
onDragOverHandler={handleDragOver}
onDragLeave={handleDragLeave}
isReadOnly={isReadOnly}
/>
);
})}

View File

@@ -18,7 +18,7 @@ import {
} from '@tabler/icons-react';
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore';
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import api from '../../services/api';
interface HealthScore {
@@ -313,6 +313,7 @@ interface DashboardData {
export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg);
const isReadOnly = useIsReadOnly();
const queryClient = useQueryClient();
// Track whether a refresh is in progress (per score type) for async polling
@@ -426,7 +427,7 @@ export function DashboardPage() {
</ThemeIcon>
}
isRefreshing={operatingRefreshing}
onRefresh={handleRefreshOperating}
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
lastFailed={!!healthScores?.operating_last_failed}
/>
<HealthScoreCard
@@ -438,7 +439,7 @@ export function DashboardPage() {
</ThemeIcon>
}
isRefreshing={reserveRefreshing}
onRefresh={handleRefreshReserve}
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
lastFailed={!!healthScores?.reserve_last_failed}
/>
</SimpleGrid>
@@ -493,6 +494,35 @@ export function DashboardPage() {
</SimpleGrid>
<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">
<Title order={4}>Quick Stats</Title>
<Stack mt="sm" gap="xs">
@@ -553,35 +583,6 @@ export function DashboardPage() {
</Group>
</Stack>
</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>
</>
)}

View File

@@ -43,6 +43,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
// ── Types ──
@@ -384,6 +385,7 @@ export function InvestmentPlanningPage() {
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
const [newScenarioName, setNewScenarioName] = useState('');
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
const isReadOnly = useIsReadOnly();
// Load investment scenarios for the "Add to Plan" modal
const { data: investmentScenarios } = useQuery<any[]>({
@@ -821,15 +823,17 @@ export function InvestmentPlanningPage() {
</Text>
</div>
</Group>
<Button
leftSection={<IconSparkles size={16} />}
onClick={handleTriggerAI}
loading={isProcessing}
variant="gradient"
gradient={{ from: 'grape', to: 'violet' }}
>
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button>
{!isReadOnly && (
<Button
leftSection={<IconSparkles size={16} />}
onClick={handleTriggerAI}
loading={isProcessing}
variant="gradient"
gradient={{ from: 'grape', to: 'violet' }}
>
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
</Button>
)}
</Group>
{/* Processing State - shown as banner when refreshing with existing results */}

View File

@@ -9,6 +9,7 @@ import { notifications } from '@mantine/notifications';
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string;
@@ -64,6 +65,7 @@ export function InvoicesPage() {
const [preview, setPreview] = useState<Preview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'],
@@ -124,10 +126,12 @@ export function InvoicesPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Invoices</Title>
<Group>
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
</Group>
{!isReadOnly && (
<Group>
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
</Group>
)}
</Group>
<Group>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -10,7 +10,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
export function UserPreferencesPage() {
const { user, currentOrg } = useAuthStore();
const { colorScheme, toggleColorScheme, compactView, toggleCompactView } = usePreferencesStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
return (
<Stack>
@@ -78,7 +78,7 @@ export function UserPreferencesPage() {
<Text size="sm">Compact View</Text>
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
</div>
<Switch checked={compactView} onChange={toggleCompactView} />
<Switch disabled />
</Group>
<Divider />
<Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>

View File

@@ -1,216 +0,0 @@
import { useState } from 'react';
import {
Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge,
Button, List, ThemeIcon, TextInput, Center, Alert,
} 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';
const plans = [
{
id: 'starter',
name: 'Starter',
price: '$29',
period: '/month',
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',
price: '$79',
period: '/month',
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',
price: 'Custom',
period: '',
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 },
],
},
];
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 handleSelectPlan = async (planId: string) => {
setLoading(planId);
setError('');
try {
const { data } = await api.post('/billing/create-checkout-session', {
planId,
email: email || undefined,
businessName: businessName || undefined,
});
if (data.url) {
window.location.href = data.url;
} else {
setError('Unable to create checkout session');
}
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to start checkout');
} 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>
{/* Optional pre-capture fields */}
<Center mb="xl">
<Group>
<TextInput
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
style={{ width: 220 }}
/>
<TextInput
placeholder="HOA / Business name"
value={businessName}
onChange={(e) => setBusinessName(e.currentTarget.value)}
style={{ width: 220 }}
/>
</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) => (
<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>
<Group align="baseline" gap={4}>
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}>
{plan.externalUrl ? 'Request Quote' : plan.price}
</Text>
{plan.period && <Text size="sm" c="dimmed">{plan.period}</Text>}
</Group>
<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')
: handleSelectPlan(plan.id)
}
>
{plan.externalUrl ? 'Request Quote' : 'Get Started'}
</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 to start.
</Text>
</Container>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,36 +1,14 @@
import { useState } from 'react';
import {
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
Tabs, Button, Switch,
} from '@mantine/core';
import {
IconBuilding, IconUser, IconSettings, IconShieldLock,
IconFingerprint, IconLink, IconLogout,
IconBuilding, IconUser, IconUsers, IconSettings, IconShieldLock,
IconCalendar,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
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';
export function SettingsPage() {
const { user, currentOrg } = useAuthStore();
const { compactView, toggleCompactView } = usePreferencesStore();
const [loggingOutAll, setLoggingOutAll] = useState(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);
}
};
return (
<Stack>
@@ -90,6 +68,33 @@ export function SettingsPage() {
</Stack>
</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 */}
<Card withBorder padding="lg">
<Group mb="md">
@@ -108,87 +113,15 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.03.17</Badge>
<Badge variant="light">2026.03.10</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>
<Text size="sm" ff="monospace" c="dimmed">/api/docs</Text>
</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>
</Card>
</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>
);
}

View File

@@ -1,10 +1,9 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import axios from 'axios';
import { useAuthStore } from '../stores/authStore';
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // Send httpOnly cookies for refresh token
});
api.interceptors.request.use((config) => {
@@ -15,89 +14,23 @@ api.interceptors.request.use((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(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// 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')) {
(error) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout();
window.location.href = '/login';
}
// Handle org suspended/archived — redirect to org selection
const responseData = error.response?.data as any;
if (
error.response?.status === 403 &&
typeof responseData?.message === 'string' &&
responseData.message.includes('has been')
typeof error.response?.data?.message === 'string' &&
error.response.data.message.includes('has been')
) {
const store = useAuthStore.getState();
store.setCurrentOrg({ id: '', name: '', role: '' }); // Clear current org
window.location.href = '/select-org';
}
return Promise.reject(error);
},
);

View File

@@ -33,7 +33,6 @@ interface AuthState {
currentOrg: Organization | null;
impersonationOriginal: ImpersonationOriginal | null;
setAuth: (token: string, user: User, organizations: Organization[]) => void;
setToken: (token: string) => void;
setCurrentOrg: (org: Organization, token?: string) => void;
setUserIntroSeen: () => 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
currentOrg: null,
}),
setToken: (token) => set({ token }),
setCurrentOrg: (org, token) =>
set((state) => ({
currentOrg: org,
@@ -104,17 +102,14 @@ export const useAuthStore = create<AuthState>()(
});
}
},
logout: () => {
// Fire-and-forget server-side logout to revoke refresh token cookie
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {});
logout: () =>
set({
token: null,
user: null,
organizations: [],
currentOrg: null,
impersonationOriginal: null,
});
},
}),
}),
{
name: 'ledgeriq-auth',

View File

@@ -5,26 +5,19 @@ type ColorScheme = 'light' | 'dark';
interface PreferencesState {
colorScheme: ColorScheme;
compactView: boolean;
toggleColorScheme: () => void;
setColorScheme: (scheme: ColorScheme) => void;
toggleCompactView: () => void;
setCompactView: (compact: boolean) => void;
}
export const usePreferencesStore = create<PreferencesState>()(
persist(
(set) => ({
colorScheme: 'light',
compactView: false,
toggleColorScheme: () =>
set((state) => ({
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
})),
setColorScheme: (scheme) => set({ colorScheme: scheme }),
toggleCompactView: () =>
set((state) => ({ compactView: !state.compactView })),
setCompactView: (compact) => set({ compactView: compact }),
}),
{
name: 'ledgeriq-preferences',

View File

@@ -1,57 +1,10 @@
import { createTheme } from '@mantine/core';
const baseFontFamily = '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif';
export const defaultTheme = createTheme({
export const theme = createTheme({
primaryColor: 'blue',
fontFamily: baseFontFamily,
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
headings: {
fontFamily: baseFontFamily,
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
},
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;

View File

@@ -12,6 +12,9 @@
#
# Replace "app.yourdomain.com" with your actual hostname throughout this file.
# Hide nginx version from Server header
server_tokens off;
# --- Rate limiting ---
# 10 requests/sec per IP for API routes (shared memory zone: 10 MB ≈ 160k IPs)
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
@@ -49,6 +52,12 @@ server {
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Security headers — applied to all routes
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# --- Proxy defaults ---
proxy_http_version 1.1;
proxy_set_header Host $host;

View File

@@ -8,6 +8,9 @@ upstream frontend {
keepalive 16;
}
# Hide nginx version from Server header
server_tokens off;
# Shared proxy settings
proxy_http_version 1.1;
proxy_set_header Connection ""; # enable keepalive to upstreams
@@ -30,6 +33,12 @@ server {
listen 80;
server_name _;
# Security headers — applied to all routes at the nginx layer
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# --- API routes → backend ---
location /api/ {
limit_req zone=api_limit burst=30 nodelay;