diff --git a/ONBOARDING-AND-AUTH.md b/ONBOARDING-AND-AUTH.md new file mode 100644 index 0000000..d104476 --- /dev/null +++ b/ONBOARDING-AND-AUTH.md @@ -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` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a3267ab..5a53fd5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hoa-ledgeriq-frontend", - "version": "2026.3.11", + "version": "2026.3.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hoa-ledgeriq-frontend", - "version": "2026.3.11", + "version": "2026.3.17", "dependencies": { "@mantine/core": "^7.15.3", "@mantine/dates": "^7.15.3", diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index e409541..9085993 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -17,11 +17,9 @@ import { IconChartAreaLine, IconClipboardCheck, IconSparkles, - IconHeartRateMonitor, IconCalculator, IconGitCompare, IconScale, - IconSettings, } from '@tabler/icons-react'; import { useAuthStore } from '../../stores/authStore'; @@ -47,14 +45,6 @@ const navSections = [ { label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' }, ], }, - { - label: 'Transactions', - items: [ - { label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' }, - { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' }, - { label: 'Payments', icon: IconCash, path: '/payments' }, - ], - }, { label: 'Board Planning', items: [ @@ -68,12 +58,8 @@ const navSections = [ { label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments', }, - { - label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning', - children: [ - { label: 'Investment Scenarios', path: '/board-planning/investments' }, - ], - }, + { label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' }, + { label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' }, { label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' }, ], }, @@ -83,6 +69,14 @@ const navSections = [ { label: 'Vendors', icon: IconUsers, path: '/vendors' }, ], }, + { + label: 'Transactions', + items: [ + { label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' }, + { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' }, + { label: 'Payments', icon: IconCash, path: '/payments' }, + ], + }, { label: 'Reports', items: [ @@ -103,12 +97,6 @@ const navSections = [ }, ], }, - { - label: 'Account', - items: [ - { label: 'Settings', icon: IconSettings, path: '/settings' }, - ], - }, ]; interface SidebarProps { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 609a2d6..719cfbc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -9,7 +9,7 @@ import '@mantine/core/styles.css'; import '@mantine/dates/styles.css'; import '@mantine/notifications/styles.css'; import { App } from './App'; -import { theme } from './theme/theme'; +import { defaultTheme, compactTheme } from './theme/theme'; import { usePreferencesStore } from './stores/preferencesStore'; const queryClient = new QueryClient({ @@ -24,9 +24,11 @@ const queryClient = new QueryClient({ function Root() { const colorScheme = usePreferencesStore((s) => s.colorScheme); + const compactView = usePreferencesStore((s) => s.compactView); + const activeTheme = compactView ? compactTheme : defaultTheme; return ( - + diff --git a/frontend/src/pages/preferences/UserPreferencesPage.tsx b/frontend/src/pages/preferences/UserPreferencesPage.tsx index 5633209..89deb9f 100644 --- a/frontend/src/pages/preferences/UserPreferencesPage.tsx +++ b/frontend/src/pages/preferences/UserPreferencesPage.tsx @@ -10,7 +10,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore'; export function UserPreferencesPage() { const { user, currentOrg } = useAuthStore(); - const { colorScheme, toggleColorScheme } = usePreferencesStore(); + const { colorScheme, toggleColorScheme, compactView, toggleCompactView } = usePreferencesStore(); return ( @@ -78,7 +78,7 @@ export function UserPreferencesPage() { Compact View Reduce spacing in tables and lists - + More display preferences coming in a future release diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index cbb813c..0992716 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider, - Tabs, Button, + Tabs, Button, Switch, } from '@mantine/core'; import { IconBuilding, IconUser, IconSettings, IconShieldLock, @@ -9,6 +9,7 @@ import { } from '@tabler/icons-react'; import { notifications } from '@mantine/notifications'; import { useAuthStore } from '../../stores/authStore'; +import { usePreferencesStore } from '../../stores/preferencesStore'; import { MfaSettings } from './MfaSettings'; import { PasskeySettings } from './PasskeySettings'; import { LinkedAccounts } from './LinkedAccounts'; @@ -16,6 +17,7 @@ import api from '../../services/api'; export function SettingsPage() { const { user, currentOrg } = useAuthStore(); + const { compactView, toggleCompactView } = usePreferencesStore(); const [loggingOutAll, setLoggingOutAll] = useState(false); const handleLogoutEverywhere = async () => { @@ -112,6 +114,14 @@ export function SettingsPage() { API /api/docs + + +
+ Compact View + Reduce spacing in tables and lists +
+ +
diff --git a/frontend/src/stores/preferencesStore.ts b/frontend/src/stores/preferencesStore.ts index f2160fc..992d9ce 100644 --- a/frontend/src/stores/preferencesStore.ts +++ b/frontend/src/stores/preferencesStore.ts @@ -5,19 +5,26 @@ type ColorScheme = 'light' | 'dark'; interface PreferencesState { colorScheme: ColorScheme; + compactView: boolean; toggleColorScheme: () => void; setColorScheme: (scheme: ColorScheme) => void; + toggleCompactView: () => void; + setCompactView: (compact: boolean) => void; } export const usePreferencesStore = create()( persist( (set) => ({ colorScheme: 'light', + compactView: false, toggleColorScheme: () => set((state) => ({ colorScheme: state.colorScheme === 'light' ? 'dark' : 'light', })), setColorScheme: (scheme) => set({ colorScheme: scheme }), + toggleCompactView: () => + set((state) => ({ compactView: !state.compactView })), + setCompactView: (compact) => set({ compactView: compact }), }), { name: 'ledgeriq-preferences', diff --git a/frontend/src/theme/theme.ts b/frontend/src/theme/theme.ts index 6a0c729..2e9d775 100644 --- a/frontend/src/theme/theme.ts +++ b/frontend/src/theme/theme.ts @@ -1,10 +1,57 @@ 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', - fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif', + fontFamily: baseFontFamily, headings: { - fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif', + fontFamily: baseFontFamily, }, 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;