Compare commits
10 Commits
claude/rev
...
e3022f20c5
| Author | SHA1 | Date | |
|---|---|---|---|
| e3022f20c5 | |||
| 9cd20a1867 | |||
| 420227d70c | |||
| e893319cfe | |||
| 93eeacfe8f | |||
| 267d92933e | |||
| 9d137a40d3 | |||
| 2b83defbc3 | |||
| a59dac7fe1 | |||
| 1e31595d7f |
@@ -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`
|
|
||||||
77
backend/package-lock.json
generated
77
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.3.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.3.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
"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",
|
||||||
@@ -2792,12 +2791,6 @@
|
|||||||
"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",
|
||||||
@@ -5364,12 +5357,6 @@
|
|||||||
"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",
|
||||||
@@ -8736,12 +8723,6 @@
|
|||||||
"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",
|
||||||
@@ -9226,27 +9207,6 @@
|
|||||||
"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",
|
||||||
@@ -9819,16 +9779,6 @@
|
|||||||
"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",
|
||||||
@@ -10087,29 +10037,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -1,159 +1,50 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stubbed email service — logs to console and stores in shared.email_log.
|
||||||
|
* Replace internals with Resend/SendGrid when ready for production.
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailService {
|
export class EmailService {
|
||||||
private readonly logger = new Logger(EmailService.name);
|
private readonly logger = new Logger(EmailService.name);
|
||||||
private resend: Resend | null = null;
|
|
||||||
private fromAddress: string;
|
|
||||||
private replyToAddress: string;
|
|
||||||
|
|
||||||
constructor(
|
constructor(private dataSource: DataSource) {}
|
||||||
private configService: ConfigService,
|
|
||||||
private dataSource: DataSource,
|
|
||||||
) {
|
|
||||||
const apiKey = this.configService.get<string>('RESEND_API_KEY');
|
|
||||||
if (apiKey && !apiKey.includes('placeholder')) {
|
|
||||||
this.resend = new Resend(apiKey);
|
|
||||||
this.logger.log('Resend email service initialized');
|
|
||||||
} else {
|
|
||||||
this.logger.warn('Resend not configured — emails will be logged only (stub mode)');
|
|
||||||
}
|
|
||||||
this.fromAddress = this.configService.get<string>('RESEND_FROM_ADDRESS') || 'noreply@hoaledgeriq.com';
|
|
||||||
this.replyToAddress = this.configService.get<string>('RESEND_REPLY_TO') || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Public API ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
|
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 html = this.buildTemplate({
|
const body = [
|
||||||
preheader: 'Your HOA LedgerIQ account is ready to activate.',
|
`Welcome to HOA LedgerIQ!`,
|
||||||
heading: 'Welcome to HOA LedgerIQ!',
|
``,
|
||||||
body: `
|
`Your organization "${businessName}" has been created.`,
|
||||||
<p>Your organization <strong>${this.esc(businessName)}</strong> has been created and is ready to go.</p>
|
`Please activate your account by clicking the link below:`,
|
||||||
<p>Click the button below to set your password and activate your account:</p>
|
``,
|
||||||
`,
|
activationUrl,
|
||||||
ctaText: 'Activate My Account',
|
``,
|
||||||
ctaUrl: activationUrl,
|
`This link expires in 72 hours.`,
|
||||||
footer: 'This activation link expires in 72 hours. If you did not sign up for HOA LedgerIQ, please ignore this email.',
|
].join('\n');
|
||||||
});
|
|
||||||
|
|
||||||
await this.send(email, subject, html, 'activation', { businessName, activationUrl });
|
await this.log(email, subject, body, '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 html = this.buildTemplate({
|
const body = `Your account is active. Log in at http://localhost to get started.`;
|
||||||
preheader: `${businessName} is all set up on HOA LedgerIQ.`,
|
await this.log(email, subject, body, 'welcome', { businessName });
|
||||||
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 = `Action required: Payment failed for ${businessName}`;
|
const subject = `Payment failed for ${businessName} on HOA LedgerIQ`;
|
||||||
const html = this.buildTemplate({
|
const body = `We were unable to process your payment. Please update your payment method.`;
|
||||||
preheader: 'We were unable to process your payment.',
|
await this.log(email, subject, body, 'payment_failed', { businessName });
|
||||||
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 html = this.buildTemplate({
|
const body = `You've been invited to join ${orgName}. Click here to accept: ${inviteUrl}`;
|
||||||
preheader: `Join ${orgName} on HOA LedgerIQ.`,
|
await this.log(email, subject, body, 'invite_member', { orgName, inviteUrl });
|
||||||
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,
|
||||||
@@ -161,6 +52,10 @@ export class EmailService {
|
|||||||
template: string,
|
template: string,
|
||||||
metadata: Record<string, any>,
|
metadata: Record<string, any>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
this.logger.log(`📧 EMAIL STUB → ${toEmail}`);
|
||||||
|
this.logger.log(` Subject: ${subject}`);
|
||||||
|
this.logger.log(` Body:\n${body}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
|
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
|
||||||
@@ -171,119 +66,4 @@ export class EmailService {
|
|||||||
this.logger.warn(`Failed to log email: ${err}`);
|
this.logger.warn(`Failed to log email: ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateLogStatus(toEmail: string, template: string, status: string, detail?: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.dataSource.query(
|
|
||||||
`UPDATE shared.email_log
|
|
||||||
SET metadata = metadata || $1::jsonb
|
|
||||||
WHERE to_email = $2 AND template = $3
|
|
||||||
AND created_at = (
|
|
||||||
SELECT MAX(created_at) FROM shared.email_log
|
|
||||||
WHERE to_email = $2 AND template = $3
|
|
||||||
)`,
|
|
||||||
[JSON.stringify({ send_status: status, send_detail: detail || '' }), toEmail, template],
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// Best effort — don't block the flow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── HTML email template ────────────────────────────────────
|
|
||||||
|
|
||||||
private esc(text: string): string {
|
|
||||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildTemplate(opts: {
|
|
||||||
preheader: string;
|
|
||||||
heading: string;
|
|
||||||
body: string;
|
|
||||||
ctaText: string;
|
|
||||||
ctaUrl: string;
|
|
||||||
footer: string;
|
|
||||||
}): string {
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${this.esc(opts.heading)}</title>
|
|
||||||
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
|
||||||
</head>
|
|
||||||
<body style="margin:0;padding:0;background-color:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
|
||||||
<!-- Preheader (hidden preview text) -->
|
|
||||||
<div style="display:none;max-height:0;overflow:hidden;">${this.esc(opts.preheader)}</div>
|
|
||||||
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7;padding:24px 0;">
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
|
||||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;">
|
|
||||||
|
|
||||||
<!-- Logo bar -->
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:24px 0 16px;">
|
|
||||||
<span style="font-size:22px;font-weight:700;color:#1a73e8;letter-spacing:-0.5px;">
|
|
||||||
HOA LedgerIQ
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Main card -->
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
|
||||||
style="background-color:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:40px 32px;">
|
|
||||||
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#1a1a2e;">
|
|
||||||
${this.esc(opts.heading)}
|
|
||||||
</h1>
|
|
||||||
<div style="font-size:15px;line-height:1.6;color:#4a4a68;">
|
|
||||||
${opts.body}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CTA Button -->
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:28px 0 8px;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="background-color:#1a73e8;border-radius:6px;">
|
|
||||||
<a href="${opts.ctaUrl}"
|
|
||||||
target="_blank"
|
|
||||||
style="display:inline-block;padding:14px 32px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;border-radius:6px;">
|
|
||||||
${this.esc(opts.ctaText)}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Fallback URL -->
|
|
||||||
<p style="font-size:12px;color:#999;word-break:break-all;margin-top:16px;">
|
|
||||||
If the button doesn't work, copy and paste this link into your browser:<br>
|
|
||||||
<a href="${opts.ctaUrl}" style="color:#1a73e8;">${opts.ctaUrl}</a>
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding:24px 32px;text-align:center;">
|
|
||||||
<p style="font-size:12px;color:#999;line-height:1.5;margin:0;">
|
|
||||||
${this.esc(opts.footer)}
|
|
||||||
</p>
|
|
||||||
<p style="font-size:12px;color:#bbb;margin:12px 0 0;">
|
|
||||||
© ${new Date().getFullYear()} HOA LedgerIQ — Smart Financial Management for HOAs
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,25 +40,6 @@ services:
|
|||||||
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
||||||
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
||||||
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
||||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
|
||||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
|
||||||
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
|
|
||||||
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
|
|
||||||
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
|
|
||||||
- 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:
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ 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
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.3.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.3.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.15.3",
|
"@mantine/core": "^7.15.3",
|
||||||
"@mantine/dates": "^7.15.3",
|
"@mantine/dates": "^7.15.3",
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ 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';
|
||||||
|
|
||||||
@@ -45,6 +47,14 @@ const navSections = [
|
|||||||
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
|
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Transactions',
|
||||||
|
items: [
|
||||||
|
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
||||||
|
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||||
|
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Board Planning',
|
label: 'Board Planning',
|
||||||
items: [
|
items: [
|
||||||
@@ -58,8 +68,12 @@ const navSections = [
|
|||||||
{
|
{
|
||||||
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
|
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
|
||||||
},
|
},
|
||||||
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
|
{
|
||||||
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
|
label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning',
|
||||||
|
children: [
|
||||||
|
{ label: 'Investment Scenarios', path: '/board-planning/investments' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -69,14 +83,6 @@ const navSections = [
|
|||||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Transactions',
|
|
||||||
items: [
|
|
||||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
|
||||||
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
|
||||||
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Reports',
|
label: 'Reports',
|
||||||
items: [
|
items: [
|
||||||
@@ -97,6 +103,12 @@ const navSections = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Account',
|
||||||
|
items: [
|
||||||
|
{ label: 'Settings', icon: IconSettings, path: '/settings' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import '@mantine/core/styles.css';
|
|||||||
import '@mantine/dates/styles.css';
|
import '@mantine/dates/styles.css';
|
||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { defaultTheme, compactTheme } from './theme/theme';
|
import { theme } from './theme/theme';
|
||||||
import { usePreferencesStore } from './stores/preferencesStore';
|
import { usePreferencesStore } from './stores/preferencesStore';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -24,11 +24,9 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
function Root() {
|
function Root() {
|
||||||
const colorScheme = usePreferencesStore((s) => s.colorScheme);
|
const colorScheme = usePreferencesStore((s) => s.colorScheme);
|
||||||
const compactView = usePreferencesStore((s) => s.compactView);
|
|
||||||
const activeTheme = compactView ? compactTheme : defaultTheme;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider theme={activeTheme} forceColorScheme={colorScheme}>
|
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
|
||||||
<Notifications position="top-right" />
|
<Notifications position="top-right" />
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -587,7 +587,7 @@ export function AccountsPage() {
|
|||||||
{investments.filter(i => i.is_active).length > 0 && (
|
{investments.filter(i => i.is_active).length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider label="Investment Accounts" labelPosition="center" my="xs" />
|
<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>
|
</Stack>
|
||||||
@@ -605,7 +605,7 @@ export function AccountsPage() {
|
|||||||
{operatingInvestments.length > 0 && (
|
{operatingInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
|
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
|
||||||
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} />
|
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -623,7 +623,7 @@ export function AccountsPage() {
|
|||||||
{reserveInvestments.length > 0 && (
|
{reserveInvestments.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
|
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
|
||||||
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} />
|
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1087,9 +1087,11 @@ function AccountTable({
|
|||||||
function InvestmentMiniTable({
|
function InvestmentMiniTable({
|
||||||
investments,
|
investments,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
isReadOnly = false,
|
||||||
}: {
|
}: {
|
||||||
investments: Investment[];
|
investments: Investment[];
|
||||||
onEdit: (inv: Investment) => void;
|
onEdit: (inv: Investment) => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
|
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
|
||||||
const totalValue = investments.reduce(
|
const totalValue = investments.reduce(
|
||||||
@@ -1132,7 +1134,7 @@ function InvestmentMiniTable({
|
|||||||
<Table.Th ta="right">Maturity Value</Table.Th>
|
<Table.Th ta="right">Maturity Value</Table.Th>
|
||||||
<Table.Th>Maturity Date</Table.Th>
|
<Table.Th>Maturity Date</Table.Th>
|
||||||
<Table.Th ta="right">Days Remaining</Table.Th>
|
<Table.Th ta="right">Days Remaining</Table.Th>
|
||||||
<Table.Th></Table.Th>
|
{!isReadOnly && <Table.Th></Table.Th>}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
@@ -1182,6 +1184,7 @@ function InvestmentMiniTable({
|
|||||||
'-'
|
'-'
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
{!isReadOnly && (
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Tooltip label="Edit investment">
|
<Tooltip label="Edit investment">
|
||||||
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
|
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
|
||||||
@@ -1189,6 +1192,7 @@ function InvestmentMiniTable({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
)}
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
|
|||||||
@@ -72,9 +72,10 @@ interface KanbanCardProps {
|
|||||||
project: Project;
|
project: Project;
|
||||||
onEdit: (p: Project) => void;
|
onEdit: (p: Project) => void;
|
||||||
onDragStart: (e: DragEvent<HTMLDivElement>, project: 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);
|
const plannedLabel = formatPlannedDate(project.planned_date);
|
||||||
// For projects in the Future bucket with a specific year, show the year
|
// For projects in the Future bucket with a specific year, show the year
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
@@ -86,21 +87,23 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
|
|||||||
padding="sm"
|
padding="sm"
|
||||||
radius="md"
|
radius="md"
|
||||||
withBorder
|
withBorder
|
||||||
draggable
|
draggable={!isReadOnly}
|
||||||
onDragStart={(e) => onDragStart(e, project)}
|
onDragStart={!isReadOnly ? (e) => onDragStart(e, project) : undefined}
|
||||||
style={{ cursor: 'grab', userSelect: 'none' }}
|
style={{ cursor: isReadOnly ? 'default' : 'grab', userSelect: 'none' }}
|
||||||
mb="xs"
|
mb="xs"
|
||||||
>
|
>
|
||||||
<Group justify="space-between" wrap="nowrap" mb={4}>
|
<Group justify="space-between" wrap="nowrap" mb={4}>
|
||||||
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
|
<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>
|
<Text fw={600} size="sm" truncate>
|
||||||
{project.name}
|
{project.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
{!isReadOnly && (
|
||||||
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
|
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
|
||||||
<IconEdit size={14} />
|
<IconEdit size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group gap={6} mb={6}>
|
<Group gap={6} mb={6}>
|
||||||
@@ -148,11 +151,12 @@ interface KanbanColumnProps {
|
|||||||
isDragOver: boolean;
|
isDragOver: boolean;
|
||||||
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
|
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
|
||||||
onDragLeave: () => void;
|
onDragLeave: () => void;
|
||||||
|
isReadOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanColumn({
|
function KanbanColumn({
|
||||||
year, projects, onEdit, onDragStart, onDrop,
|
year, projects, onEdit, onDragStart, onDrop,
|
||||||
isDragOver, onDragOverHandler, onDragLeave,
|
isDragOver, onDragOverHandler, onDragLeave, isReadOnly,
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
|
||||||
const isFuture = year === FUTURE_YEAR;
|
const isFuture = year === FUTURE_YEAR;
|
||||||
@@ -178,9 +182,9 @@ function KanbanColumn({
|
|||||||
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
|
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
|
||||||
transition: 'background-color 150ms ease, border 150ms ease',
|
transition: 'background-color 150ms ease, border 150ms ease',
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => onDragOverHandler(e, year)}
|
onDragOver={!isReadOnly ? (e) => onDragOverHandler(e, year) : undefined}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={!isReadOnly ? onDragLeave : undefined}
|
||||||
onDrop={(e) => onDrop(e, year)}
|
onDrop={!isReadOnly ? (e) => onDrop(e, year) : undefined}
|
||||||
>
|
>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Title order={5}>{yearLabel(year)}</Title>
|
<Title order={5}>{yearLabel(year)}</Title>
|
||||||
@@ -199,7 +203,7 @@ function KanbanColumn({
|
|||||||
<Box style={{ flex: 1, minHeight: 60 }}>
|
<Box style={{ flex: 1, minHeight: 60 }}>
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<Text size="xs" c="dimmed" ta="center" py="lg">
|
<Text size="xs" c="dimmed" ta="center" py="lg">
|
||||||
Drop projects here
|
{isReadOnly ? 'No projects' : 'Drop projects here'}
|
||||||
</Text>
|
</Text>
|
||||||
) : useWideLayout ? (
|
) : useWideLayout ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -208,12 +212,12 @@ function KanbanColumn({
|
|||||||
gap: 'var(--mantine-spacing-xs)',
|
gap: 'var(--mantine-spacing-xs)',
|
||||||
}}>
|
}}>
|
||||||
{projects.map((p) => (
|
{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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
projects.map((p) => (
|
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>
|
</Box>
|
||||||
@@ -595,6 +599,7 @@ export function CapitalProjectsPage() {
|
|||||||
isDragOver={dragOverYear === year}
|
isDragOver={dragOverYear === year}
|
||||||
onDragOverHandler={handleDragOver}
|
onDragOverHandler={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
interface HealthScore {
|
interface HealthScore {
|
||||||
@@ -313,6 +313,7 @@ interface DashboardData {
|
|||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Track whether a refresh is in progress (per score type) for async polling
|
// Track whether a refresh is in progress (per score type) for async polling
|
||||||
@@ -426,7 +427,7 @@ export function DashboardPage() {
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
isRefreshing={operatingRefreshing}
|
isRefreshing={operatingRefreshing}
|
||||||
onRefresh={handleRefreshOperating}
|
onRefresh={!isReadOnly ? handleRefreshOperating : undefined}
|
||||||
lastFailed={!!healthScores?.operating_last_failed}
|
lastFailed={!!healthScores?.operating_last_failed}
|
||||||
/>
|
/>
|
||||||
<HealthScoreCard
|
<HealthScoreCard
|
||||||
@@ -438,7 +439,7 @@ export function DashboardPage() {
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
isRefreshing={reserveRefreshing}
|
isRefreshing={reserveRefreshing}
|
||||||
onRefresh={handleRefreshReserve}
|
onRefresh={!isReadOnly ? handleRefreshReserve : undefined}
|
||||||
lastFailed={!!healthScores?.reserve_last_failed}
|
lastFailed={!!healthScores?.reserve_last_failed}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
@@ -384,6 +385,7 @@ export function InvestmentPlanningPage() {
|
|||||||
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
||||||
const [newScenarioName, setNewScenarioName] = useState('');
|
const [newScenarioName, setNewScenarioName] = useState('');
|
||||||
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
|
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
// Load investment scenarios for the "Add to Plan" modal
|
// Load investment scenarios for the "Add to Plan" modal
|
||||||
const { data: investmentScenarios } = useQuery<any[]>({
|
const { data: investmentScenarios } = useQuery<any[]>({
|
||||||
@@ -821,6 +823,7 @@ export function InvestmentPlanningPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
{!isReadOnly && (
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconSparkles size={16} />}
|
leftSection={<IconSparkles size={16} />}
|
||||||
onClick={handleTriggerAI}
|
onClick={handleTriggerAI}
|
||||||
@@ -830,6 +833,7 @@ export function InvestmentPlanningPage() {
|
|||||||
>
|
>
|
||||||
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
{aiResult ? 'Refresh Recommendations' : 'Get AI Recommendations'}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Processing State - shown as banner when refreshing with existing results */}
|
{/* Processing State - shown as banner when refreshing with existing results */}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
id: string; invoice_number: string; unit_number: string; unit_id: string;
|
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 [preview, setPreview] = useState<Preview | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||||
queryKey: ['invoices'],
|
queryKey: ['invoices'],
|
||||||
@@ -124,10 +126,12 @@ export function InvoicesPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Title order={2}>Invoices</Title>
|
<Title order={2}>Invoices</Title>
|
||||||
|
{!isReadOnly && (
|
||||||
<Group>
|
<Group>
|
||||||
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
|
||||||
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
|
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>
|
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
|
|||||||
|
|
||||||
export function UserPreferencesPage() {
|
export function UserPreferencesPage() {
|
||||||
const { user, currentOrg } = useAuthStore();
|
const { user, currentOrg } = useAuthStore();
|
||||||
const { colorScheme, toggleColorScheme, compactView, toggleCompactView } = usePreferencesStore();
|
const { colorScheme, toggleColorScheme } = usePreferencesStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -78,7 +78,7 @@ export function UserPreferencesPage() {
|
|||||||
<Text size="sm">Compact View</Text>
|
<Text size="sm">Compact View</Text>
|
||||||
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
|
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={compactView} onChange={toggleCompactView} />
|
<Switch disabled />
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
|
<Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
|
||||||
|
|||||||
@@ -47,12 +47,11 @@ const plans = [
|
|||||||
{
|
{
|
||||||
id: 'enterprise',
|
id: 'enterprise',
|
||||||
name: 'Enterprise',
|
name: 'Enterprise',
|
||||||
price: 'Custom',
|
price: '$199',
|
||||||
period: '',
|
period: '/month',
|
||||||
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 },
|
||||||
@@ -163,10 +162,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: plan.externalUrl ? 28 : 36 }}>
|
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: 36 }}>
|
||||||
{plan.externalUrl ? 'Request Quote' : plan.price}
|
{plan.price}
|
||||||
</Text>
|
</Text>
|
||||||
{plan.period && <Text size="sm" c="dimmed">{plan.period}</Text>}
|
<Text size="sm" c="dimmed">{plan.period}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<List spacing="xs" size="sm" center>
|
<List spacing="xs" size="sm" center>
|
||||||
@@ -194,14 +193,10 @@ export function PricingPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
color={plan.color}
|
color={plan.color}
|
||||||
variant={plan.popular ? 'filled' : 'light'}
|
variant={plan.popular ? 'filled' : 'light'}
|
||||||
loading={!plan.externalUrl ? loading === plan.id : false}
|
loading={loading === plan.id}
|
||||||
onClick={() =>
|
onClick={() => handleSelectPlan(plan.id)}
|
||||||
plan.externalUrl
|
|
||||||
? window.open(plan.externalUrl, '_blank', 'noopener')
|
|
||||||
: handleSelectPlan(plan.id)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{plan.externalUrl ? 'Request Quote' : 'Get Started'}
|
Get Started
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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, Switch,
|
Tabs, Button,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconBuilding, IconUser, IconSettings, IconShieldLock,
|
IconBuilding, IconUser, IconSettings, IconShieldLock,
|
||||||
@@ -9,7 +9,6 @@ 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';
|
||||||
@@ -17,7 +16,6 @@ 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 () => {
|
||||||
@@ -114,14 +112,6 @@ 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>
|
||||||
|
|
||||||
|
|||||||
@@ -5,26 +5,19 @@ type ColorScheme = 'light' | 'dark';
|
|||||||
|
|
||||||
interface PreferencesState {
|
interface PreferencesState {
|
||||||
colorScheme: ColorScheme;
|
colorScheme: ColorScheme;
|
||||||
compactView: boolean;
|
|
||||||
toggleColorScheme: () => void;
|
toggleColorScheme: () => void;
|
||||||
setColorScheme: (scheme: ColorScheme) => void;
|
setColorScheme: (scheme: ColorScheme) => void;
|
||||||
toggleCompactView: () => void;
|
|
||||||
setCompactView: (compact: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePreferencesStore = create<PreferencesState>()(
|
export const usePreferencesStore = create<PreferencesState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
colorScheme: 'light',
|
colorScheme: 'light',
|
||||||
compactView: false,
|
|
||||||
toggleColorScheme: () =>
|
toggleColorScheme: () =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
|
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
|
||||||
})),
|
})),
|
||||||
setColorScheme: (scheme) => set({ colorScheme: scheme }),
|
setColorScheme: (scheme) => set({ colorScheme: scheme }),
|
||||||
toggleCompactView: () =>
|
|
||||||
set((state) => ({ compactView: !state.compactView })),
|
|
||||||
setCompactView: (compact) => set({ compactView: compact }),
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ledgeriq-preferences',
|
name: 'ledgeriq-preferences',
|
||||||
|
|||||||
@@ -1,57 +1,10 @@
|
|||||||
import { createTheme } from '@mantine/core';
|
import { createTheme } from '@mantine/core';
|
||||||
|
|
||||||
const baseFontFamily = '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif';
|
export const theme = createTheme({
|
||||||
|
|
||||||
export const defaultTheme = createTheme({
|
|
||||||
primaryColor: 'blue',
|
primaryColor: 'blue',
|
||||||
fontFamily: baseFontFamily,
|
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
|
||||||
headings: {
|
headings: {
|
||||||
fontFamily: baseFontFamily,
|
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
|
||||||
},
|
},
|
||||||
defaultRadius: 'md',
|
defaultRadius: 'md',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const compactTheme = createTheme({
|
|
||||||
primaryColor: 'blue',
|
|
||||||
fontFamily: baseFontFamily,
|
|
||||||
headings: {
|
|
||||||
fontFamily: baseFontFamily,
|
|
||||||
},
|
|
||||||
defaultRadius: 'md',
|
|
||||||
spacing: {
|
|
||||||
xs: '4px',
|
|
||||||
sm: '6px',
|
|
||||||
md: '10px',
|
|
||||||
lg: '12px',
|
|
||||||
xl: '16px',
|
|
||||||
},
|
|
||||||
fontSizes: {
|
|
||||||
xs: '11px',
|
|
||||||
sm: '12px',
|
|
||||||
md: '13px',
|
|
||||||
lg: '15px',
|
|
||||||
xl: '18px',
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
Table: {
|
|
||||||
defaultProps: {
|
|
||||||
verticalSpacing: 'xs',
|
|
||||||
horizontalSpacing: 'xs',
|
|
||||||
fz: 'sm',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Card: {
|
|
||||||
defaultProps: {
|
|
||||||
padding: 'sm',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AppShell: {
|
|
||||||
defaultProps: {
|
|
||||||
padding: 'xs',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/** @deprecated Use `defaultTheme` or `compactTheme` instead */
|
|
||||||
export const theme = defaultTheme;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user