12 Commits

Author SHA1 Message Date
5845334454 fix: remove cash flow summary cards and restore area chart shading
Remove the 4 summary cards from the Cash Flow page as they don't
properly represent the story over time. Increase gradient opacity
on stacked area charts (cash flow and investment scenarios) from
0.3-0.4/0-0.05 to 0.6/0.15 for better visual shading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 20:41:13 -04:00
170461c359 Merge branch 'claude/reverent-moore' - Resend email integration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:33:17 -04:00
aacec1cce3 feat: integrate Resend for transactional email delivery
Replace the stubbed email service with Resend API integration.
Emails are sent with branded HTML templates including activation,
welcome, payment failed, member invite, and password reset flows.

- Install resend@6.9.4 in backend
- Rewrite EmailService with Resend SDK + graceful fallback to
  stub mode when API key is not configured
- Add branded HTML email template with CTA buttons, preheader
  text, and fallback URL for all email types
- Add reply-to support (sales@hoaledgeriq.com in production)
- Track send status (sent/failed) in shared.email_log metadata
- Add RESEND_API_KEY, RESEND_FROM_ADDRESS, RESEND_REPLY_TO env
  vars to both docker-compose.yml and docker-compose.prod.yml
- Add sendPasswordResetEmail() method for future use

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 18:29:20 -04:00
6b12fcd7d7 Merge branch 'claude/reverent-moore' 2026-03-17 18:04:14 -04:00
8e58d04568 fix: add APP_URL and missing env vars to Docker Compose configs
APP_URL was never passed to the backend container, causing Stripe
checkout success_url to redirect to http://localhost instead of the
production domain. The prod overlay also completely replaced the base
environment block, dropping all Stripe, SSO, WebAuthn, and invite
token variables.

- Add APP_URL to base docker-compose.yml (default: http://localhost)
- Add all missing vars to docker-compose.prod.yml with production
  defaults (app.hoaledgeriq.com)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:51:34 -04:00
c2e52bee64 Merge pull request 'feat: enterprise pricing shows "Request Quote" linking to interest form' (#8) from claude/reverent-moore into main
Reviewed-on: #8
2026-03-17 07:53:16 -04:00
9cd641923d feat: enterprise pricing shows "Request Quote" linking to interest form
Enterprise plan no longer displays a fixed price. Instead it shows
"Request Quote" and the CTA opens the interest form on hoaledgeriq.com
in a new tab to capture leads for custom quotes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 07:47:19 -04:00
8abab40778 Merge pull request 'Security hardening: v2 assessment remediation' (#7) from claude/tender-murdock into main 2026-03-17 07:46:56 -04:00
19fb2c037c 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:46:11 -04:00
e62f3e7b07 Merge pull request 'claude/reverent-moore' (#6) from claude/reverent-moore into main
Reviewed-on: #6
2026-03-17 06:55:45 -04:00
af68304692 feat: sidebar reorg, compact view preference, and UI polish
- Remove redundant Settings link from sidebar (accessible via user menu)
- Move Transactions section below Board Reference for better grouping
- Promote Investment Scenarios to its own top-level sidebar item
- Add Compact View preference with tighter spacing theme
- Wire compact theme into MantineProvider with dynamic switching
- Enable Compact View toggle in both Preferences and Settings pages
- Install missing @simplewebauthn/browser package (lock file update)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 06:39:41 -04:00
20438b7ef5 docs: add payment, onboarding, and auth reference guide
Covers Stripe billing flow, provisioning pipeline, activation magic links,
onboarding checklist, refresh tokens, MFA, SSO, passkeys, env var reference,
manual intervention checklist, and full API endpoint reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 06:24:59 -04:00
23 changed files with 1265 additions and 173 deletions

576
ONBOARDING-AND-AUTH.md Normal file
View File

@@ -0,0 +1,576 @@
# 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`

View File

@@ -1,12 +1,12 @@
{ {
"name": "hoa-ledgeriq-backend", "name": "hoa-ledgeriq-backend",
"version": "2026.3.11", "version": "2026.3.17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hoa-ledgeriq-backend", "name": "hoa-ledgeriq-backend",
"version": "2026.3.11", "version": "2026.3.17",
"dependencies": { "dependencies": {
"@nestjs/common": "^10.4.15", "@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",
@@ -36,6 +36,7 @@
"pg": "^8.13.1", "pg": "^8.13.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"resend": "^6.9.4",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"stripe": "^20.4.1", "stripe": "^20.4.1",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",
@@ -2791,6 +2792,12 @@
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@tokenizer/inflate": { "node_modules/@tokenizer/inflate": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
@@ -5357,6 +5364,12 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fb-watchman": { "node_modules/fb-watchman": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -8723,6 +8736,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/postal-mime": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
"integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==",
"license": "MIT-0"
},
"node_modules/postgres-array": { "node_modules/postgres-array": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -9207,6 +9226,27 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/resend": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.4.tgz",
"integrity": "sha512-/M3dsJzu5OgozqVsA4Psd/1L7EdePgOIIxClas453GOQYFG3VHc2ZyCHZFlvqsc9aZCCd2BJRRqZgWC8D9c7/g==",
"license": "MIT",
"dependencies": {
"postal-mime": "2.7.3",
"svix": "1.86.0"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@react-email/render": "*"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -9779,6 +9819,16 @@
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -10037,6 +10087,29 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svix": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.86.0.tgz",
"integrity": "sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ==",
"license": "MIT",
"dependencies": {
"standardwebhooks": "1.0.0",
"uuid": "^10.0.0"
}
},
"node_modules/svix/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/swagger-ui-dist": { "node_modules/swagger-ui-dist": {
"version": "5.17.14", "version": "5.17.14",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz",

View File

@@ -45,6 +45,7 @@
"pg": "^8.13.1", "pg": "^8.13.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"resend": "^6.9.4",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"stripe": "^20.4.1", "stripe": "^20.4.1",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",

View File

@@ -1,5 +1,5 @@
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; 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 { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
@@ -7,6 +7,7 @@ import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware'; import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard'; import { WriteAccessGuard } from './common/guards/write-access.guard';
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module'; import { OrganizationsModule } from './modules/organizations/organizations.module';
import { UsersModule } from './modules/users/users.module'; import { UsersModule } from './modules/users/users.module';
@@ -95,6 +96,10 @@ import { ScheduleModule } from '@nestjs/schedule';
provide: APP_GUARD, provide: APP_GUARD,
useClass: WriteAccessGuard, useClass: WriteAccessGuard,
}, },
{
provide: APP_INTERCEPTOR,
useClass: NoCacheInterceptor,
},
], ],
}) })
export class AppModule implements NestModule { 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

@@ -8,6 +8,8 @@ import {
Get, Get,
Res, Res,
Query, Query,
HttpCode,
ForbiddenException,
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@@ -23,6 +25,7 @@ import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
const COOKIE_NAME = 'ledgeriq_rt'; const COOKIE_NAME = 'ledgeriq_rt';
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true';
function setRefreshCookie(res: Response, token: string) { function setRefreshCookie(res: Response, token: string) {
res.cookie(COOKIE_NAME, token, { res.cookie(COOKIE_NAME, token, {
@@ -49,9 +52,14 @@ export class AuthController {
constructor(private authService: AuthService) {} constructor(private authService: AuthService) {}
@Post('register') @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 } }) @Throttle({ default: { limit: 5, ttl: 60000 } })
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) { async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
if (!isOpenRegistration) {
throw new ForbiddenException(
'Open registration is disabled. Please use an invitation link to create your account.',
);
}
const result = await this.authService.register(dto); const result = await this.authService.register(dto);
if (result.refreshToken) { if (result.refreshToken) {
setRefreshCookie(res, result.refreshToken); setRefreshCookie(res, result.refreshToken);
@@ -93,6 +101,7 @@ export class AuthController {
@Post('logout') @Post('logout')
@ApiOperation({ summary: 'Logout and revoke refresh token' }) @ApiOperation({ summary: 'Logout and revoke refresh token' })
@HttpCode(200)
async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) { async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) {
const rawToken = req.cookies?.[COOKIE_NAME]; const rawToken = req.cookies?.[COOKIE_NAME];
if (rawToken) { if (rawToken) {
@@ -104,6 +113,7 @@ export class AuthController {
@Post('logout-everywhere') @Post('logout-everywhere')
@ApiOperation({ summary: 'Revoke all sessions' }) @ApiOperation({ summary: 'Revoke all sessions' })
@HttpCode(200)
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) { async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) {
@@ -183,4 +193,51 @@ export class AuthController {
// Stubbed — will be implemented when email service is ready // Stubbed — will be implemented when email service is ready
return { success: true, message: 'If an account exists, a new activation link has been sent.' }; return { success: true, message: 'If an account exists, a new activation link has been sent.' };
} }
// ─── Password Reset Flow ──────────────────────────────────────────
@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('reset-password')
@ApiOperation({ summary: 'Reset password using a reset token' })
@HttpCode(200)
@Throttle({ default: { limit: 5, ttl: 60000 } })
async resetPassword(@Body() body: { token: string; newPassword: string }) {
if (!body.token || !body.newPassword) {
throw new BadRequestException('Token and newPassword are required');
}
if (body.newPassword.length < 8) {
throw new BadRequestException('Password must be at least 8 characters');
}
await this.authService.resetPassword(body.token, body.newPassword);
return { message: 'Password updated successfully.' };
}
@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

@@ -11,8 +11,9 @@ import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { createHash } from 'crypto'; import { randomBytes, createHash } from 'crypto';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { EmailService } from '../email/email.service';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { User } from '../users/entities/user.entity'; import { User } from '../users/entities/user.entity';
import { RefreshTokenService } from './refresh-token.service'; import { RefreshTokenService } from './refresh-token.service';
@@ -21,6 +22,7 @@ import { RefreshTokenService } from './refresh-token.service';
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name); private readonly logger = new Logger(AuthService.name);
private readonly inviteSecret: string; private readonly inviteSecret: string;
private readonly appUrl: string;
constructor( constructor(
private usersService: UsersService, private usersService: UsersService,
@@ -28,8 +30,10 @@ export class AuthService {
private configService: ConfigService, private configService: ConfigService,
private dataSource: DataSource, private dataSource: DataSource,
private refreshTokenService: RefreshTokenService, private refreshTokenService: RefreshTokenService,
private emailService: EmailService,
) { ) {
this.inviteSecret = this.configService.get<string>('INVITE_TOKEN_SECRET') || 'dev-invite-secret'; 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) { async register(dto: RegisterDto) {
@@ -309,6 +313,105 @@ export class AuthService {
return token; return token;
} }
// ─── Password Reset Flow ──────────────────────────────────────────
/**
* 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 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);
}
/**
* Reset password using a valid reset token.
*/
async resetPassword(rawToken: string, newPassword: string): Promise<void> {
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const rows = await this.dataSource.query(
`SELECT id, user_id, expires_at, used_at
FROM shared.password_reset_tokens
WHERE token_hash = $1`,
[tokenHash],
);
if (rows.length === 0) {
throw new BadRequestException('Invalid or expired reset token');
}
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],
);
}
/**
* Change password for an authenticated user (requires current password).
*/
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 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(
`UPDATE shared.users SET password_hash = $1, updated_at = NOW() WHERE id = $2`,
[passwordHash, userId],
);
}
// ─── Private Helpers ──────────────────────────────────────────────
private async recordLoginHistory( private async recordLoginHistory(
userId: string, userId: string,
organizationId: string | null, organizationId: string | null,

View File

@@ -1,50 +1,159 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { Resend } from 'resend';
/**
* Stubbed email service — logs to console and stores in shared.email_log.
* Replace internals with Resend/SendGrid when ready for production.
*/
@Injectable() @Injectable()
export class EmailService { export class EmailService {
private readonly logger = new Logger(EmailService.name); private readonly logger = new Logger(EmailService.name);
private resend: Resend | null = null;
private fromAddress: string;
private replyToAddress: string;
constructor(private dataSource: DataSource) {} 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> { async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
const subject = `Activate your ${businessName} account on HOA LedgerIQ`; const subject = `Activate your ${businessName} account on HOA LedgerIQ`;
const body = [ const html = this.buildTemplate({
`Welcome to HOA LedgerIQ!`, preheader: 'Your HOA LedgerIQ account is ready to activate.',
``, heading: 'Welcome to HOA LedgerIQ!',
`Your organization "${businessName}" has been created.`, body: `
`Please activate your account by clicking the link below:`, <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>
activationUrl, `,
``, ctaText: 'Activate My Account',
`This link expires in 72 hours.`, ctaUrl: activationUrl,
].join('\n'); footer: 'This activation link expires in 72 hours. If you did not sign up for HOA LedgerIQ, please ignore this email.',
});
await this.log(email, subject, body, 'activation', { businessName, activationUrl }); await this.send(email, subject, html, 'activation', { businessName, activationUrl });
} }
async sendWelcomeEmail(email: string, businessName: string): Promise<void> { 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 subject = `Welcome to HOA LedgerIQ — ${businessName}`;
const body = `Your account is active. Log in at http://localhost to get started.`; const html = this.buildTemplate({
await this.log(email, subject, body, 'welcome', { businessName }); 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> { async sendPaymentFailedEmail(email: string, businessName: string): Promise<void> {
const subject = `Payment failed for ${businessName} on HOA LedgerIQ`; const subject = `Action required: Payment failed for ${businessName}`;
const body = `We were unable to process your payment. Please update your payment method.`; const html = this.buildTemplate({
await this.log(email, subject, body, 'payment_failed', { businessName }); 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> { async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise<void> {
const subject = `You've been invited to ${orgName} on HOA LedgerIQ`; const subject = `You've been invited to ${orgName} on HOA LedgerIQ`;
const body = `You've been invited to join ${orgName}. Click here to accept: ${inviteUrl}`; const html = this.buildTemplate({
await this.log(email, subject, body, 'invite_member', { orgName, inviteUrl }); preheader: `Join ${orgName} on HOA LedgerIQ.`,
heading: 'You\'re Invited!',
body: `
<p>You've been invited to join <strong>${this.esc(orgName)}</strong> on HOA LedgerIQ.</p>
<p>Click below to accept the invitation and set up your account:</p>
`,
ctaText: 'Accept Invitation',
ctaUrl: inviteUrl,
footer: 'This invitation link expires in 7 days. If you were not expecting this, please ignore this email.',
});
await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl });
} }
async 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.',
});
await this.send(email, subject, html, 'password_reset', { resetUrl });
}
// ─── Core send logic ────────────────────────────────────────
private async send(
toEmail: string,
subject: string,
html: string,
template: string,
metadata: Record<string, any>,
): Promise<void> {
// Always log to the database
await this.log(toEmail, subject, html, template, metadata);
if (!this.resend) {
this.logger.log(`📧 EMAIL STUB → ${toEmail}`);
this.logger.log(` Subject: ${subject}`);
return;
}
try {
const result = await this.resend.emails.send({
from: this.fromAddress,
to: [toEmail],
replyTo: this.replyToAddress || undefined,
subject,
html,
});
if (result.error) {
this.logger.error(`Resend error for ${toEmail}: ${JSON.stringify(result.error)}`);
await this.updateLogStatus(toEmail, template, 'failed', result.error.message);
} else {
this.logger.log(`✅ Email sent to ${toEmail} (id: ${result.data?.id})`);
await this.updateLogStatus(toEmail, template, 'sent', result.data?.id);
}
} catch (err: any) {
this.logger.error(`Failed to send email to ${toEmail}: ${err.message}`);
await this.updateLogStatus(toEmail, template, 'failed', err.message);
}
}
// ─── Database logging ───────────────────────────────────────
private async log( private async log(
toEmail: string, toEmail: string,
subject: string, subject: string,
@@ -52,10 +161,6 @@ export class EmailService {
template: string, template: string,
metadata: Record<string, any>, metadata: Record<string, any>,
): Promise<void> { ): Promise<void> {
this.logger.log(`📧 EMAIL STUB → ${toEmail}`);
this.logger.log(` Subject: ${subject}`);
this.logger.log(` Body:\n${body}`);
try { try {
await this.dataSource.query( await this.dataSource.query(
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata) `INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
@@ -66,4 +171,119 @@ export class EmailService {
this.logger.warn(`Failed to log email: ${err}`); this.logger.warn(`Failed to log email: ${err}`);
} }
} }
private async updateLogStatus(toEmail: string, template: string, status: string, detail?: string): Promise<void> {
try {
await this.dataSource.query(
`UPDATE shared.email_log
SET metadata = metadata || $1::jsonb
WHERE to_email = $2 AND template = $3
AND created_at = (
SELECT MAX(created_at) FROM shared.email_log
WHERE to_email = $2 AND template = $3
)`,
[JSON.stringify({ send_status: status, send_detail: detail || '' }), toEmail, template],
);
} catch {
// Best effort — don't block the flow
}
}
// ─── HTML email template ────────────────────────────────────
private esc(text: string): string {
return text.replace(/&/g, '&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

@@ -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,6 +40,25 @@ services:
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false} - NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-} - NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App} - NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/google/callback}
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-}
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-}
- AZURE_TENANT_ID=${AZURE_TENANT_ID:-}
- AZURE_CALLBACK_URL=${AZURE_CALLBACK_URL:-https://app.hoaledgeriq.com/api/auth/azure/callback}
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-app.hoaledgeriq.com}
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-https://app.hoaledgeriq.com}
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-}
- APP_URL=${APP_URL:-https://app.hoaledgeriq.com}
- RESEND_API_KEY=${RESEND_API_KEY:-}
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-sales@hoaledgeriq.com}
deploy: deploy:
resources: resources:
limits: limits:

View File

@@ -44,6 +44,10 @@ services:
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost} - WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost}
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost} - WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost}
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret} - INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret}
- APP_URL=${APP_URL:-http://localhost}
- RESEND_API_KEY=${RESEND_API_KEY:-}
- RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com}
- RESEND_REPLY_TO=${RESEND_REPLY_TO:-}
volumes: volumes:
- ./backend/src:/app/src - ./backend/src:/app/src
- ./backend/nest-cli.json:/app/nest-cli.json - ./backend/nest-cli.json:/app/nest-cli.json

View File

@@ -1,12 +1,12 @@
{ {
"name": "hoa-ledgeriq-frontend", "name": "hoa-ledgeriq-frontend",
"version": "2026.3.11", "version": "2026.3.17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hoa-ledgeriq-frontend", "name": "hoa-ledgeriq-frontend",
"version": "2026.3.11", "version": "2026.3.17",
"dependencies": { "dependencies": {
"@mantine/core": "^7.15.3", "@mantine/core": "^7.15.3",
"@mantine/dates": "^7.15.3", "@mantine/dates": "^7.15.3",

View File

@@ -17,11 +17,9 @@ import {
IconChartAreaLine, IconChartAreaLine,
IconClipboardCheck, IconClipboardCheck,
IconSparkles, IconSparkles,
IconHeartRateMonitor,
IconCalculator, IconCalculator,
IconGitCompare, IconGitCompare,
IconScale, IconScale,
IconSettings,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
@@ -47,14 +45,6 @@ const navSections = [
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' }, { label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
], ],
}, },
{
label: 'Transactions',
items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' },
],
},
{ {
label: 'Board Planning', label: 'Board Planning',
items: [ items: [
@@ -68,12 +58,8 @@ const navSections = [
{ {
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments', label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
}, },
{ { label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning', { label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
children: [
{ label: 'Investment Scenarios', path: '/board-planning/investments' },
],
},
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' }, { label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
], ],
}, },
@@ -83,6 +69,14 @@ const navSections = [
{ label: 'Vendors', icon: IconUsers, path: '/vendors' }, { label: 'Vendors', icon: IconUsers, path: '/vendors' },
], ],
}, },
{
label: 'Transactions',
items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' },
],
},
{ {
label: 'Reports', label: 'Reports',
items: [ items: [
@@ -103,12 +97,6 @@ const navSections = [
}, },
], ],
}, },
{
label: 'Account',
items: [
{ label: 'Settings', icon: IconSettings, path: '/settings' },
],
},
]; ];
interface SidebarProps { interface SidebarProps {

View File

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

View File

@@ -89,20 +89,20 @@ export function ProjectionChart({ datapoints, title = 'Financial Projection', su
<AreaChart data={chartData}> <AreaChart data={chartData}>
<defs> <defs>
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#228be6" stopOpacity={0.3} /> <stop offset="5%" stopColor="#228be6" stopOpacity={0.6} />
<stop offset="95%" stopColor="#228be6" stopOpacity={0} /> <stop offset="95%" stopColor="#228be6" stopOpacity={0.15} />
</linearGradient> </linearGradient>
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.3} /> <stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0} /> <stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
</linearGradient> </linearGradient>
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.3} /> <stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
<stop offset="95%" stopColor="#7950f2" stopOpacity={0} /> <stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
</linearGradient> </linearGradient>
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.3} /> <stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
<stop offset="95%" stopColor="#b197fc" stopOpacity={0} /> <stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} /> <CartesianGrid strokeDasharray="3 3" opacity={0.3} />

View File

@@ -1,10 +1,9 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { import {
Title, Text, Stack, Card, Group, SimpleGrid, ThemeIcon, Title, Text, Stack, Card, Group,
SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge, SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconCash, IconBuildingBank, IconChartAreaLine,
IconArrowLeft, IconArrowRight, IconCalendar, IconArrowLeft, IconArrowRight, IconCalendar,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -108,30 +107,6 @@ export function CashFlowForecastPage() {
return datapoints.slice(viewStartIndex, viewStartIndex + 12); return datapoints.slice(viewStartIndex, viewStartIndex + 12);
}, [datapoints, viewStartIndex]); }, [datapoints, viewStartIndex]);
// Compute summary stats for the current view
const summaryStats = useMemo(() => {
if (!viewData.length) return null;
const last = viewData[viewData.length - 1];
const first = viewData[0];
const totalOperating = last.operating_cash + last.operating_investments;
const totalReserve = last.reserve_cash + last.reserve_investments;
const totalAll = totalOperating + totalReserve;
const firstTotal = first.operating_cash + first.operating_investments +
first.reserve_cash + first.reserve_investments;
const netChange = totalAll - firstTotal;
return {
totalOperating,
totalReserve,
totalAll,
netChange,
periodStart: first.month,
periodEnd: last.month,
};
}, [viewData]);
// Determine the first forecast month index within the view // Determine the first forecast month index within the view
const forecastStartLabel = useMemo(() => { const forecastStartLabel = useMemo(() => {
const idx = viewData.findIndex((d) => d.is_forecast); const idx = viewData.findIndex((d) => d.is_forecast);
@@ -181,65 +156,6 @@ export function CashFlowForecastPage() {
/> />
</Group> </Group>
{/* Summary Cards */}
{summaryStats && (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="blue" size="sm">
<IconCash size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Operating Total</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(summaryStats.totalOperating)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="violet" size="sm">
<IconBuildingBank size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Total</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(summaryStats.totalReserve)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon variant="light" color="teal" size="sm">
<IconChartAreaLine size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Combined Total</Text>
</Group>
<Text fw={700} size="xl" ff="monospace">
{fmt(summaryStats.totalAll)}
</Text>
</Card>
<Card withBorder p="md">
<Group gap="xs" mb={4}>
<ThemeIcon
variant="light"
color={summaryStats.netChange >= 0 ? 'green' : 'red'}
size="sm"
>
<IconCash size={14} />
</ThemeIcon>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
</Group>
<Text
fw={700}
size="xl"
ff="monospace"
c={summaryStats.netChange >= 0 ? 'green' : 'red'}
>
{fmt(summaryStats.netChange)}
</Text>
</Card>
</SimpleGrid>
)}
{/* Chart Navigation */} {/* Chart Navigation */}
<Card withBorder p="lg"> <Card withBorder p="lg">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
@@ -287,20 +203,20 @@ export function CashFlowForecastPage() {
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}> <AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<defs> <defs>
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#339af0" stopOpacity={0.4} /> <stop offset="5%" stopColor="#339af0" stopOpacity={0.6} />
<stop offset="95%" stopColor="#339af0" stopOpacity={0.05} /> <stop offset="95%" stopColor="#339af0" stopOpacity={0.15} />
</linearGradient> </linearGradient>
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.4} /> <stop offset="5%" stopColor="#74c0fc" stopOpacity={0.6} />
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0.05} /> <stop offset="95%" stopColor="#74c0fc" stopOpacity={0.15} />
</linearGradient> </linearGradient>
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.4} /> <stop offset="5%" stopColor="#7950f2" stopOpacity={0.6} />
<stop offset="95%" stopColor="#7950f2" stopOpacity={0.05} /> <stop offset="95%" stopColor="#7950f2" stopOpacity={0.15} />
</linearGradient> </linearGradient>
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.4} /> <stop offset="5%" stopColor="#b197fc" stopOpacity={0.6} />
<stop offset="95%" stopColor="#b197fc" stopOpacity={0.05} /> <stop offset="95%" stopColor="#b197fc" stopOpacity={0.15} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" /> <CartesianGrid strokeDasharray="3 3" stroke="#e9ecef" />

View File

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

View File

@@ -47,11 +47,12 @@ const plans = [
{ {
id: 'enterprise', id: 'enterprise',
name: 'Enterprise', name: 'Enterprise',
price: '$199', price: 'Custom',
period: '/month', period: '',
description: 'For large communities and management firms', description: 'For large communities and management firms',
icon: IconCrown, icon: IconCrown,
color: 'orange', color: 'orange',
externalUrl: 'https://www.hoaledgeriq.com/#preview-signup',
features: [ features: [
{ text: 'Unlimited units', included: true }, { text: 'Unlimited units', included: true },
{ text: 'Everything in Professional', included: true }, { text: 'Everything in Professional', included: true },
@@ -162,10 +163,10 @@ export function PricingPage() {
</Group> </Group>
<Group align="baseline" gap={4}> <Group align="baseline" gap={4}>
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: 36 }}> <Text fw={800} size="xl" ff="monospace" style={{ fontSize: plan.externalUrl ? 28 : 36 }}>
{plan.price} {plan.externalUrl ? 'Request Quote' : plan.price}
</Text> </Text>
<Text size="sm" c="dimmed">{plan.period}</Text> {plan.period && <Text size="sm" c="dimmed">{plan.period}</Text>}
</Group> </Group>
<List spacing="xs" size="sm" center> <List spacing="xs" size="sm" center>
@@ -193,10 +194,14 @@ export function PricingPage() {
size="md" size="md"
color={plan.color} color={plan.color}
variant={plan.popular ? 'filled' : 'light'} variant={plan.popular ? 'filled' : 'light'}
loading={loading === plan.id} loading={!plan.externalUrl ? loading === plan.id : false}
onClick={() => handleSelectPlan(plan.id)} onClick={() =>
plan.externalUrl
? window.open(plan.externalUrl, '_blank', 'noopener')
: handleSelectPlan(plan.id)
}
> >
Get Started {plan.externalUrl ? 'Request Quote' : 'Get Started'}
</Button> </Button>
</Stack> </Stack>
</Card> </Card>

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider, Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
Tabs, Button, Tabs, Button, Switch,
} from '@mantine/core'; } from '@mantine/core';
import { import {
IconBuilding, IconUser, IconSettings, IconShieldLock, IconBuilding, IconUser, IconSettings, IconShieldLock,
@@ -9,6 +9,7 @@ import {
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { MfaSettings } from './MfaSettings'; import { MfaSettings } from './MfaSettings';
import { PasskeySettings } from './PasskeySettings'; import { PasskeySettings } from './PasskeySettings';
import { LinkedAccounts } from './LinkedAccounts'; import { LinkedAccounts } from './LinkedAccounts';
@@ -16,6 +17,7 @@ import api from '../../services/api';
export function SettingsPage() { export function SettingsPage() {
const { user, currentOrg } = useAuthStore(); const { user, currentOrg } = useAuthStore();
const { compactView, toggleCompactView } = usePreferencesStore();
const [loggingOutAll, setLoggingOutAll] = useState(false); const [loggingOutAll, setLoggingOutAll] = useState(false);
const handleLogoutEverywhere = async () => { const handleLogoutEverywhere = async () => {
@@ -112,6 +114,14 @@ export function SettingsPage() {
<Text size="sm" c="dimmed">API</Text> <Text size="sm" c="dimmed">API</Text>
<Text size="sm" ff="monospace" c="dimmed">/api/docs</Text> <Text size="sm" ff="monospace" c="dimmed">/api/docs</Text>
</Group> </Group>
<Divider />
<Group justify="space-between">
<div>
<Text size="sm">Compact View</Text>
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
</div>
<Switch checked={compactView} onChange={toggleCompactView} />
</Group>
</Stack> </Stack>
</Card> </Card>

View File

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

View File

@@ -1,10 +1,57 @@
import { createTheme } from '@mantine/core'; import { createTheme } from '@mantine/core';
export const theme = createTheme({ const baseFontFamily = '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif';
export const defaultTheme = createTheme({
primaryColor: 'blue', primaryColor: 'blue',
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif', fontFamily: baseFontFamily,
headings: { headings: {
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif', fontFamily: baseFontFamily,
}, },
defaultRadius: 'md', defaultRadius: 'md',
}); });
export const compactTheme = createTheme({
primaryColor: 'blue',
fontFamily: baseFontFamily,
headings: {
fontFamily: baseFontFamily,
},
defaultRadius: 'md',
spacing: {
xs: '4px',
sm: '6px',
md: '10px',
lg: '12px',
xl: '16px',
},
fontSizes: {
xs: '11px',
sm: '12px',
md: '13px',
lg: '15px',
xl: '18px',
},
components: {
Table: {
defaultProps: {
verticalSpacing: 'xs',
horizontalSpacing: 'xs',
fz: 'sm',
},
},
Card: {
defaultProps: {
padding: 'sm',
},
},
AppShell: {
defaultProps: {
padding: 'xs',
},
},
},
});
/** @deprecated Use `defaultTheme` or `compactTheme` instead */
export const theme = defaultTheme;

View File

@@ -12,6 +12,9 @@
# #
# Replace "app.yourdomain.com" with your actual hostname throughout this file. # Replace "app.yourdomain.com" with your actual hostname throughout this file.
# Hide nginx version from Server header
server_tokens off;
# --- Rate limiting --- # --- Rate limiting ---
# 10 requests/sec per IP for API routes (shared memory zone: 10 MB ≈ 160k IPs) # 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; limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
@@ -49,6 +52,12 @@ server {
ssl_session_timeout 10m; ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; 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 defaults ---
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@@ -8,6 +8,9 @@ upstream frontend {
keepalive 16; keepalive 16;
} }
# Hide nginx version from Server header
server_tokens off;
# Shared proxy settings # Shared proxy settings
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Connection ""; # enable keepalive to upstreams proxy_set_header Connection ""; # enable keepalive to upstreams
@@ -30,6 +33,12 @@ server {
listen 80; listen 80;
server_name _; 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 --- # --- API routes → backend ---
location /api/ { location /api/ {
limit_req zone=api_limit burst=30 nodelay; limit_req zone=api_limit burst=30 nodelay;