Compare commits
1 Commits
claude/ten
...
fb20c917e1
| Author | SHA1 | Date | |
|---|---|---|---|
| fb20c917e1 |
@@ -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`
|
||||
1117
backend/package-lock.json
generated
1117
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-backend",
|
||||
"version": "2026.3.17",
|
||||
"version": "2026.03.16",
|
||||
"description": "HOA LedgerIQ - Backend API",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -27,26 +27,18 @@
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bullmq": "^5.71.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"newrelic": "latest",
|
||||
"otplib": "^13.3.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-azure-ad": "^4.3.5",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^8.13.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"stripe": "^20.4.1",
|
||||
"typeorm": "^0.3.20",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
@@ -55,15 +47,12 @@
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^20.17.12",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
|
||||
@@ -30,9 +30,7 @@ import { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
||||
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
|
||||
import { BoardPlanningModule } from './modules/board-planning/board-planning.module';
|
||||
import { BillingModule } from './modules/billing/billing.module';
|
||||
import { EmailModule } from './modules/email/email.module';
|
||||
import { OnboardingModule } from './modules/onboarding/onboarding.module';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
@Module({
|
||||
@@ -85,9 +83,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
InvestmentPlanningModule,
|
||||
HealthScoresModule,
|
||||
BoardPlanningModule,
|
||||
BillingModule,
|
||||
EmailModule,
|
||||
OnboardingModule,
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -4,7 +4,6 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import helmet from 'helmet';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
|
||||
@@ -39,15 +38,10 @@ if (WORKERS > 1 && cluster.isPrimary) {
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: isProduction ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
// Enable raw body for Stripe webhook signature verification
|
||||
rawBody: true,
|
||||
});
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// Cookie parser — needed for refresh token httpOnly cookies
|
||||
app.use(cookieParser());
|
||||
|
||||
// Security headers — Helmet sets CSP, X-Frame-Options, X-Content-Type-Options,
|
||||
// Referrer-Policy, Permissions-Policy, and removes X-Powered-By
|
||||
app.use(
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
UseGuards,
|
||||
Request,
|
||||
Get,
|
||||
Res,
|
||||
Query,
|
||||
HttpCode,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
@@ -15,7 +13,6 @@ import {
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
@@ -23,29 +20,8 @@ import { SwitchOrgDto } from './dto/switch-org.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||
|
||||
const COOKIE_NAME = 'ledgeriq_rt';
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isOpenRegistration = process.env.ALLOW_OPEN_REGISTRATION === 'true';
|
||||
|
||||
function setRefreshCookie(res: Response, token: string) {
|
||||
res.cookie(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
});
|
||||
}
|
||||
|
||||
function clearRefreshCookie(res: Response) {
|
||||
res.clearCookie(COOKIE_NAME, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
});
|
||||
}
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -54,71 +30,32 @@ export class AuthController {
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new user (disabled unless ALLOW_OPEN_REGISTRATION=true)' })
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||
async register(@Body() dto: RegisterDto) {
|
||||
if (!isOpenRegistration) {
|
||||
throw new ForbiddenException(
|
||||
'Open registration is disabled. Please use an invitation link to create your account.',
|
||||
);
|
||||
}
|
||||
const result = await this.authService.register(dto);
|
||||
if (result.refreshToken) {
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
}
|
||||
const { refreshToken, ...response } = result;
|
||||
return response;
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
@UseGuards(AuthGuard('local'))
|
||||
async login(@Request() req: any, @Body() _dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||
const ua = req.headers['user-agent'];
|
||||
const result = await this.authService.login(req.user, ip, ua);
|
||||
|
||||
// MFA challenge — no cookie, just return the challenge token
|
||||
if ('mfaRequired' in result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if ('refreshToken' in result && result.refreshToken) {
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
}
|
||||
const { refreshToken: _rt, ...response } = result as any;
|
||||
return response;
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: 'Refresh access token using httpOnly cookie' })
|
||||
async refresh(@Request() req: any, @Res({ passthrough: true }) res: Response) {
|
||||
const rawToken = req.cookies?.[COOKIE_NAME];
|
||||
if (!rawToken) {
|
||||
throw new BadRequestException('No refresh token');
|
||||
}
|
||||
return this.authService.refreshAccessToken(rawToken);
|
||||
return this.authService.login(req.user, ip, ua);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@ApiOperation({ summary: 'Logout and revoke refresh token' })
|
||||
@HttpCode(200)
|
||||
async logout(@Request() req: any, @Res({ passthrough: true }) res: Response) {
|
||||
const rawToken = req.cookies?.[COOKIE_NAME];
|
||||
if (rawToken) {
|
||||
await this.authService.logout(rawToken);
|
||||
}
|
||||
clearRefreshCookie(res);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('logout-everywhere')
|
||||
@ApiOperation({ summary: 'Revoke all sessions' })
|
||||
@ApiOperation({ summary: 'Logout (invalidate current session)' })
|
||||
@HttpCode(200)
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async logoutEverywhere(@Request() req: any, @Res({ passthrough: true }) res: Response) {
|
||||
await this.authService.logoutEverywhere(req.user.sub);
|
||||
clearRefreshCookie(res);
|
||||
async logout(@Request() req: any) {
|
||||
await this.authService.logout(req.user.sub);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -145,53 +82,10 @@ export class AuthController {
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@AllowViewer()
|
||||
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto, @Res({ passthrough: true }) res: Response) {
|
||||
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||
const ua = req.headers['user-agent'];
|
||||
const result = await this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
|
||||
if (result.refreshToken) {
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
}
|
||||
const { refreshToken, ...response } = result;
|
||||
return response;
|
||||
}
|
||||
|
||||
// ─── Activation Endpoints ─────────────────────────────────────────
|
||||
|
||||
@Get('activate')
|
||||
@ApiOperation({ summary: 'Validate an activation token' })
|
||||
async validateActivation(@Query('token') token: string) {
|
||||
if (!token) throw new BadRequestException('Token required');
|
||||
return this.authService.validateInviteToken(token);
|
||||
}
|
||||
|
||||
@Post('activate')
|
||||
@ApiOperation({ summary: 'Activate user account with password' })
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
async activate(
|
||||
@Body() body: { token: string; password: string; fullName: string },
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
if (!body.token || !body.password || !body.fullName) {
|
||||
throw new BadRequestException('Token, password, and fullName are required');
|
||||
}
|
||||
if (body.password.length < 8) {
|
||||
throw new BadRequestException('Password must be at least 8 characters');
|
||||
}
|
||||
const result = await this.authService.activateUser(body.token, body.password, body.fullName);
|
||||
if (result.refreshToken) {
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
}
|
||||
const { refreshToken, ...response } = result;
|
||||
return response;
|
||||
}
|
||||
|
||||
@Post('resend-activation')
|
||||
@ApiOperation({ summary: 'Resend activation email' })
|
||||
@Throttle({ default: { limit: 2, ttl: 60000 } })
|
||||
async resendActivation(@Body() body: { email: string }) {
|
||||
// Stubbed — will be implemented when email service is ready
|
||||
return { success: true, message: 'If an account exists, a new activation link has been sent.' };
|
||||
return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
|
||||
}
|
||||
|
||||
// ─── Password Reset Flow ──────────────────────────────────────────
|
||||
|
||||
@@ -4,15 +4,8 @@ import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { MfaController } from './mfa.controller';
|
||||
import { SsoController } from './sso.controller';
|
||||
import { PasskeyController } from './passkey.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||
import { RefreshTokenService } from './refresh-token.service';
|
||||
import { MfaService } from './mfa.service';
|
||||
import { SsoService } from './sso.service';
|
||||
import { PasskeyService } from './passkey.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
@@ -32,23 +25,8 @@ import { OrganizationsModule } from '../organizations/organizations.module';
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [
|
||||
AuthController,
|
||||
AdminController,
|
||||
MfaController,
|
||||
SsoController,
|
||||
PasskeyController,
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
AdminAnalyticsService,
|
||||
RefreshTokenService,
|
||||
MfaService,
|
||||
SsoService,
|
||||
PasskeyService,
|
||||
JwtStrategy,
|
||||
LocalStrategy,
|
||||
],
|
||||
exports: [AuthService, RefreshTokenService, JwtModule],
|
||||
controllers: [AuthController, AdminController],
|
||||
providers: [AuthService, AdminAnalyticsService, JwtStrategy, LocalStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -16,12 +16,10 @@ import { UsersService } from '../users/users.service';
|
||||
import { EmailService } from '../email/email.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { RefreshTokenService } from './refresh-token.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
private readonly inviteSecret: string;
|
||||
private readonly appUrl: string;
|
||||
|
||||
constructor(
|
||||
@@ -29,10 +27,8 @@ export class AuthService {
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
private dataSource: DataSource,
|
||||
private refreshTokenService: RefreshTokenService,
|
||||
private emailService: EmailService,
|
||||
) {
|
||||
this.inviteSecret = this.configService.get<string>('INVITE_TOKEN_SECRET') || 'dev-invite-secret';
|
||||
this.appUrl = this.configService.get<string>('APP_URL') || 'http://localhost:5173';
|
||||
}
|
||||
|
||||
@@ -88,25 +84,15 @@ export class AuthService {
|
||||
// Record login in history (org_id is null at initial login)
|
||||
this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {});
|
||||
|
||||
// If MFA is enabled, return a challenge token instead of full session
|
||||
if (u.mfaEnabled && u.mfaSecret) {
|
||||
const mfaToken = this.jwtService.sign(
|
||||
{ sub: u.id, type: 'mfa_challenge' },
|
||||
{ expiresIn: '5m' },
|
||||
);
|
||||
return { mfaRequired: true, mfaToken };
|
||||
}
|
||||
|
||||
return this.generateTokenResponse(u);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete login after MFA verification — generate full session tokens.
|
||||
* Logout — currently a no-op on the server since JWT is stateless.
|
||||
* When refresh tokens are added, this should revoke the refresh token.
|
||||
*/
|
||||
async completeMfaLogin(userId: string): Promise<any> {
|
||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||
if (!user) throw new UnauthorizedException('User not found');
|
||||
return this.generateTokenResponse(user);
|
||||
async logout(_userId: string): Promise<void> {
|
||||
// Placeholder for refresh token revocation
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
@@ -119,7 +105,6 @@ export class AuthService {
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
mfaEnabled: user.mfaEnabled || false,
|
||||
organizations: user.userOrganizations?.map((uo) => ({
|
||||
id: uo.organization.id,
|
||||
name: uo.organization.name,
|
||||
@@ -159,12 +144,8 @@ export class AuthService {
|
||||
// Record org switch in login history
|
||||
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
|
||||
|
||||
// Generate new refresh token for org switch
|
||||
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
|
||||
|
||||
return {
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
refreshToken,
|
||||
organization: {
|
||||
id: membership.organization.id,
|
||||
name: membership.organization.name,
|
||||
@@ -174,145 +155,10 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token using a valid refresh token.
|
||||
*/
|
||||
async refreshAccessToken(rawRefreshToken: string) {
|
||||
const userId = await this.refreshTokenService.validateRefreshToken(rawRefreshToken);
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
||||
}
|
||||
|
||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
// Generate a new access token (keep same org context if available)
|
||||
const orgs = (user.userOrganizations || []).filter(
|
||||
(uo) => !uo.organization?.status || !['suspended', 'archived'].includes(uo.organization.status),
|
||||
);
|
||||
const defaultOrg = orgs[0];
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
isSuperadmin: user.isSuperadmin || false,
|
||||
};
|
||||
|
||||
if (defaultOrg) {
|
||||
payload.orgId = defaultOrg.organizationId;
|
||||
payload.role = defaultOrg.role;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout: revoke the refresh token.
|
||||
*/
|
||||
async logout(rawRefreshToken: string): Promise<void> {
|
||||
if (rawRefreshToken) {
|
||||
await this.refreshTokenService.revokeToken(rawRefreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout everywhere: revoke all refresh tokens for a user.
|
||||
*/
|
||||
async logoutEverywhere(userId: string): Promise<void> {
|
||||
await this.refreshTokenService.revokeAllUserTokens(userId);
|
||||
}
|
||||
|
||||
async markIntroSeen(userId: string): Promise<void> {
|
||||
await this.usersService.markIntroSeen(userId);
|
||||
}
|
||||
|
||||
// ─── Invite Token (Activation) Methods ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate an invite/activation token.
|
||||
*/
|
||||
async validateInviteToken(token: string) {
|
||||
try {
|
||||
const payload = this.jwtService.verify(token, { secret: this.inviteSecret });
|
||||
if (payload.type !== 'invite') throw new Error('Not an invite token');
|
||||
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex');
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT it.*, o.name as org_name FROM shared.invite_tokens it
|
||||
JOIN shared.organizations o ON o.id = it.organization_id
|
||||
WHERE it.token_hash = $1`,
|
||||
[tokenHash],
|
||||
);
|
||||
|
||||
if (rows.length === 0) throw new Error('Token not found');
|
||||
const row = rows[0];
|
||||
if (row.used_at) throw new BadRequestException('This activation link has already been used');
|
||||
if (new Date(row.expires_at) < new Date()) throw new BadRequestException('This activation link has expired');
|
||||
|
||||
return { valid: true, email: payload.email, orgName: row.org_name, orgId: payload.orgId, userId: payload.userId };
|
||||
} catch (err) {
|
||||
if (err instanceof BadRequestException) throw err;
|
||||
throw new BadRequestException('Invalid or expired activation link');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a user from an invite token (set password, activate, issue session).
|
||||
*/
|
||||
async activateUser(token: string, password: string, fullName: string) {
|
||||
const info = await this.validateInviteToken(token);
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const [firstName, ...rest] = fullName.trim().split(' ');
|
||||
const lastName = rest.join(' ') || '';
|
||||
|
||||
// Update user record
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.users SET password_hash = $1, first_name = $2, last_name = $3,
|
||||
is_email_verified = true, updated_at = NOW()
|
||||
WHERE id = $4`,
|
||||
[passwordHash, firstName, lastName, info.userId],
|
||||
);
|
||||
|
||||
// Mark invite token as used
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex');
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.invite_tokens SET used_at = NOW() WHERE token_hash = $1`,
|
||||
[tokenHash],
|
||||
);
|
||||
|
||||
// Issue session
|
||||
const user = await this.usersService.findByIdWithOrgs(info.userId);
|
||||
if (!user) throw new NotFoundException('User not found after activation');
|
||||
|
||||
return this.generateTokenResponse(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a signed invite token for a user/org pair.
|
||||
*/
|
||||
async generateInviteToken(userId: string, orgId: string, email: string): Promise<string> {
|
||||
const token = this.jwtService.sign(
|
||||
{ type: 'invite', userId, orgId, email },
|
||||
{ secret: this.inviteSecret, expiresIn: '72h' },
|
||||
);
|
||||
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex');
|
||||
const expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000);
|
||||
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.invite_tokens (organization_id, user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[orgId, userId, tokenHash, expiresAt],
|
||||
);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// ─── Password Reset Flow ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -429,7 +275,7 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async generateTokenResponse(user: User, impersonatedBy?: string) {
|
||||
private generateTokenResponse(user: User, impersonatedBy?: string) {
|
||||
const allOrgs = user.userOrganizations || [];
|
||||
// Filter out suspended/archived organizations
|
||||
const orgs = allOrgs.filter(
|
||||
@@ -452,12 +298,8 @@ export class AuthService {
|
||||
payload.role = defaultOrg.role;
|
||||
}
|
||||
|
||||
// Create refresh token
|
||||
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
|
||||
|
||||
return {
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -466,7 +308,6 @@ export class AuthService {
|
||||
isSuperadmin: user.isSuperadmin || false,
|
||||
isPlatformOwner: user.isPlatformOwner || false,
|
||||
hasSeenIntro: user.hasSeenIntro || false,
|
||||
mfaEnabled: user.mfaEnabled || false,
|
||||
},
|
||||
organizations: orgs.map((uo) => ({
|
||||
id: uo.organizationId,
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
Res,
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Response } from 'express';
|
||||
import { MfaService } from './mfa.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||
|
||||
const COOKIE_NAME = 'ledgeriq_rt';
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth/mfa')
|
||||
export class MfaController {
|
||||
constructor(
|
||||
private mfaService: MfaService,
|
||||
private authService: AuthService,
|
||||
private jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
@Post('setup')
|
||||
@ApiOperation({ summary: 'Generate MFA setup (QR code + secret)' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async setup(@Request() req: any) {
|
||||
return this.mfaService.generateSetup(req.user.sub);
|
||||
}
|
||||
|
||||
@Post('enable')
|
||||
@ApiOperation({ summary: 'Enable MFA after verifying TOTP code' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async enable(@Request() req: any, @Body() body: { token: string }) {
|
||||
if (!body.token) throw new BadRequestException('TOTP code required');
|
||||
return this.mfaService.enableMfa(req.user.sub, body.token);
|
||||
}
|
||||
|
||||
@Post('verify')
|
||||
@ApiOperation({ summary: 'Verify MFA during login flow' })
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
async verify(
|
||||
@Body() body: { mfaToken: string; token: string; useRecovery?: boolean },
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
if (!body.mfaToken || !body.token) {
|
||||
throw new BadRequestException('mfaToken and verification code required');
|
||||
}
|
||||
|
||||
// Decode the MFA challenge token
|
||||
let payload: any;
|
||||
try {
|
||||
payload = this.jwtService.verify(body.mfaToken);
|
||||
if (payload.type !== 'mfa_challenge') throw new Error('Wrong token type');
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired MFA challenge');
|
||||
}
|
||||
|
||||
const userId = payload.sub;
|
||||
let verified = false;
|
||||
|
||||
if (body.useRecovery) {
|
||||
verified = await this.mfaService.verifyRecoveryCode(userId, body.token);
|
||||
} else {
|
||||
verified = await this.mfaService.verifyMfa(userId, body.token);
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
throw new UnauthorizedException('Invalid verification code');
|
||||
}
|
||||
|
||||
// MFA passed — issue full session
|
||||
const result = await this.authService.completeMfaLogin(userId);
|
||||
if (result.refreshToken) {
|
||||
res.cookie(COOKIE_NAME, result.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
const { refreshToken: _rt, ...response } = result;
|
||||
return response;
|
||||
}
|
||||
|
||||
@Post('disable')
|
||||
@ApiOperation({ summary: 'Disable MFA (requires password)' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async disable(@Request() req: any, @Body() body: { password: string }) {
|
||||
if (!body.password) throw new BadRequestException('Password required to disable MFA');
|
||||
|
||||
// Verify password first
|
||||
const user = await this.authService.validateUser(req.user.email, body.password);
|
||||
if (!user) throw new UnauthorizedException('Invalid password');
|
||||
|
||||
await this.mfaService.disableMfa(req.user.sub);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Get MFA status' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@AllowViewer()
|
||||
async status(@Request() req: any) {
|
||||
return this.mfaService.getStatus(req.user.sub);
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { Injectable, Logger, BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { generateSecret, generateURI, verifySync } from 'otplib';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class MfaService {
|
||||
private readonly logger = new Logger(MfaService.name);
|
||||
|
||||
constructor(private dataSource: DataSource) {}
|
||||
|
||||
/**
|
||||
* Generate MFA setup data (secret + QR code) for a user.
|
||||
*/
|
||||
async generateSetup(userId: string): Promise<{ secret: string; qrDataUrl: string; otpauthUrl: string }> {
|
||||
const userRows = await this.dataSource.query(
|
||||
`SELECT email, mfa_enabled FROM shared.users WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
if (userRows.length === 0) throw new BadRequestException('User not found');
|
||||
|
||||
const secret = generateSecret();
|
||||
const otpauthUrl = generateURI({ secret, issuer: 'HOA LedgerIQ', label: userRows[0].email });
|
||||
const qrDataUrl = await QRCode.toDataURL(otpauthUrl);
|
||||
|
||||
// Store the secret temporarily (not verified yet)
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.users SET mfa_secret = $1, updated_at = NOW() WHERE id = $2`,
|
||||
[secret, userId],
|
||||
);
|
||||
|
||||
return { secret, qrDataUrl, otpauthUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable MFA after verifying the initial TOTP code.
|
||||
* Returns recovery codes.
|
||||
*/
|
||||
async enableMfa(userId: string, token: string): Promise<{ recoveryCodes: string[] }> {
|
||||
const userRows = await this.dataSource.query(
|
||||
`SELECT mfa_secret, mfa_enabled FROM shared.users WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
if (userRows.length === 0) throw new BadRequestException('User not found');
|
||||
if (!userRows[0].mfa_secret) throw new BadRequestException('MFA setup not initiated');
|
||||
if (userRows[0].mfa_enabled) throw new BadRequestException('MFA is already enabled');
|
||||
|
||||
// Verify the token
|
||||
const result = verifySync({ token, secret: userRows[0].mfa_secret });
|
||||
if (!result.valid) throw new BadRequestException('Invalid verification code');
|
||||
|
||||
// Generate recovery codes
|
||||
const recoveryCodes = Array.from({ length: 10 }, () =>
|
||||
randomBytes(4).toString('hex').toUpperCase(),
|
||||
);
|
||||
|
||||
// Hash recovery codes for storage
|
||||
const hashedCodes = await Promise.all(
|
||||
recoveryCodes.map((code) => bcrypt.hash(code, 10)),
|
||||
);
|
||||
|
||||
// Enable MFA
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.users SET
|
||||
mfa_enabled = true,
|
||||
totp_verified_at = NOW(),
|
||||
recovery_codes = $1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[JSON.stringify(hashedCodes), userId],
|
||||
);
|
||||
|
||||
this.logger.log(`MFA enabled for user ${userId}`);
|
||||
return { recoveryCodes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a TOTP code during login.
|
||||
*/
|
||||
async verifyMfa(userId: string, token: string): Promise<boolean> {
|
||||
const userRows = await this.dataSource.query(
|
||||
`SELECT mfa_secret, mfa_enabled FROM shared.users WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
if (userRows.length === 0 || !userRows[0].mfa_enabled) return false;
|
||||
|
||||
const result = verifySync({ token, secret: userRows[0].mfa_secret });
|
||||
return result.valid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a recovery code (consumes it on success).
|
||||
*/
|
||||
async verifyRecoveryCode(userId: string, code: string): Promise<boolean> {
|
||||
const userRows = await this.dataSource.query(
|
||||
`SELECT recovery_codes FROM shared.users WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
if (userRows.length === 0 || !userRows[0].recovery_codes) return false;
|
||||
|
||||
const hashedCodes: string[] = JSON.parse(userRows[0].recovery_codes);
|
||||
|
||||
for (let i = 0; i < hashedCodes.length; i++) {
|
||||
const match = await bcrypt.compare(code.toUpperCase(), hashedCodes[i]);
|
||||
if (match) {
|
||||
// Remove the used code
|
||||
hashedCodes.splice(i, 1);
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.users SET recovery_codes = $1, updated_at = NOW() WHERE id = $2`,
|
||||
[JSON.stringify(hashedCodes), userId],
|
||||
);
|
||||
this.logger.log(`Recovery code used for user ${userId}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable MFA (requires password verification done by caller).
|
||||
*/
|
||||
async disableMfa(userId: string): Promise<void> {
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.users SET
|
||||
mfa_enabled = false,
|
||||
mfa_secret = NULL,
|
||||
totp_verified_at = NULL,
|
||||
recovery_codes = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
this.logger.log(`MFA disabled for user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MFA status for a user.
|
||||
*/
|
||||
async getStatus(userId: string): Promise<{ enabled: boolean; hasRecoveryCodes: boolean }> {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT mfa_enabled, recovery_codes FROM shared.users WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
if (rows.length === 0) return { enabled: false, hasRecoveryCodes: false };
|
||||
|
||||
return {
|
||||
enabled: rows[0].mfa_enabled || false,
|
||||
hasRecoveryCodes: !!rows[0].recovery_codes && JSON.parse(rows[0].recovery_codes || '[]').length > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
Res,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { Response } from 'express';
|
||||
import { PasskeyService } from './passkey.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||
|
||||
const COOKIE_NAME = 'ledgeriq_rt';
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth/passkeys')
|
||||
export class PasskeyController {
|
||||
constructor(
|
||||
private passkeyService: PasskeyService,
|
||||
private authService: AuthService,
|
||||
private usersService: UsersService,
|
||||
) {}
|
||||
|
||||
@Post('register-options')
|
||||
@ApiOperation({ summary: 'Get passkey registration options' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getRegistrationOptions(@Request() req: any) {
|
||||
return this.passkeyService.generateRegistrationOptions(req.user.sub);
|
||||
}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new passkey' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async register(
|
||||
@Request() req: any,
|
||||
@Body() body: { response: any; deviceName?: string },
|
||||
) {
|
||||
if (!body.response) throw new BadRequestException('Attestation response required');
|
||||
return this.passkeyService.verifyRegistration(req.user.sub, body.response, body.deviceName);
|
||||
}
|
||||
|
||||
@Post('login-options')
|
||||
@ApiOperation({ summary: 'Get passkey login options' })
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
async getLoginOptions(@Body() body: { email?: string }) {
|
||||
return this.passkeyService.generateAuthenticationOptions(body.email);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Authenticate with passkey' })
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
async login(
|
||||
@Body() body: { response: any; challenge: string },
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
if (!body.response || !body.challenge) {
|
||||
throw new BadRequestException('Assertion response and challenge required');
|
||||
}
|
||||
|
||||
const { userId } = await this.passkeyService.verifyAuthentication(body.response, body.challenge);
|
||||
|
||||
// Get user with orgs and generate session
|
||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||
if (!user) throw new BadRequestException('User not found');
|
||||
|
||||
await this.usersService.updateLastLogin(userId);
|
||||
const result = await this.authService.generateTokenResponse(user);
|
||||
|
||||
if (result.refreshToken) {
|
||||
res.cookie(COOKIE_NAME, result.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
const { refreshToken: _rt, ...response } = result;
|
||||
return response;
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List registered passkeys' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@AllowViewer()
|
||||
async list(@Request() req: any) {
|
||||
return this.passkeyService.listPasskeys(req.user.sub);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Remove a passkey' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async remove(@Request() req: any, @Param('id') passkeyId: string) {
|
||||
await this.passkeyService.removePasskey(req.user.sub, passkeyId);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { Injectable, Logger, BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
// Use inline type aliases to avoid ESM-only @simplewebauthn/types import issue
|
||||
type RegistrationResponseJSON = any;
|
||||
type AuthenticationResponseJSON = any;
|
||||
type AuthenticatorTransportFuture = any;
|
||||
|
||||
@Injectable()
|
||||
export class PasskeyService {
|
||||
private readonly logger = new Logger(PasskeyService.name);
|
||||
private rpID: string;
|
||||
private rpName: string;
|
||||
private origin: string;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private dataSource: DataSource,
|
||||
) {
|
||||
this.rpID = this.configService.get<string>('WEBAUTHN_RP_ID') || 'localhost';
|
||||
this.rpName = 'HOA LedgerIQ';
|
||||
this.origin = this.configService.get<string>('WEBAUTHN_RP_ORIGIN') || 'http://localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate registration options for navigator.credentials.create().
|
||||
*/
|
||||
async generateRegistrationOptions(userId: string) {
|
||||
const userRows = await this.dataSource.query(
|
||||
`SELECT id, email, first_name, last_name FROM shared.users WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
if (userRows.length === 0) throw new BadRequestException('User not found');
|
||||
const user = userRows[0];
|
||||
|
||||
// Get existing passkeys for exclusion
|
||||
const existingKeys = await this.dataSource.query(
|
||||
`SELECT credential_id, transports FROM shared.user_passkeys WHERE user_id = $1`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: this.rpName,
|
||||
rpID: this.rpID,
|
||||
userID: new TextEncoder().encode(userId),
|
||||
userName: user.email,
|
||||
userDisplayName: `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: existingKeys.map((k: any) => ({
|
||||
id: k.credential_id,
|
||||
type: 'public-key' as const,
|
||||
transports: k.transports || [],
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
});
|
||||
|
||||
// Store challenge temporarily
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.users SET webauthn_challenge = $1, updated_at = NOW() WHERE id = $2`,
|
||||
[options.challenge, userId],
|
||||
);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and store a passkey registration.
|
||||
*/
|
||||
async verifyRegistration(userId: string, response: RegistrationResponseJSON, deviceName?: string) {
|
||||
const userRows = await this.dataSource.query(
|
||||
`SELECT webauthn_challenge FROM shared.users WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
if (userRows.length === 0) throw new BadRequestException('User not found');
|
||||
const expectedChallenge = userRows[0].webauthn_challenge;
|
||||
if (!expectedChallenge) throw new BadRequestException('No registration challenge found');
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin: this.origin,
|
||||
expectedRPID: this.rpID,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
throw new BadRequestException('Passkey registration verification failed');
|
||||
}
|
||||
|
||||
const { credential } = verification.registrationInfo;
|
||||
|
||||
// Store the passkey
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.user_passkeys (user_id, credential_id, public_key, counter, device_name, transports)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
userId,
|
||||
Buffer.from(credential.id).toString('base64url'),
|
||||
Buffer.from(credential.publicKey).toString('base64url'),
|
||||
credential.counter,
|
||||
deviceName || 'Passkey',
|
||||
credential.transports || [],
|
||||
],
|
||||
);
|
||||
|
||||
// Clear challenge
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.users SET webauthn_challenge = NULL WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
this.logger.log(`Passkey registered for user ${userId}`);
|
||||
return { verified: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate authentication options for navigator.credentials.get().
|
||||
*/
|
||||
async generateAuthenticationOptions(email?: string) {
|
||||
let allowCredentials: any[] | undefined;
|
||||
|
||||
if (email) {
|
||||
const userRows = await this.dataSource.query(
|
||||
`SELECT u.id FROM shared.users u WHERE u.email = $1`,
|
||||
[email],
|
||||
);
|
||||
if (userRows.length > 0) {
|
||||
const passkeys = await this.dataSource.query(
|
||||
`SELECT credential_id, transports FROM shared.user_passkeys WHERE user_id = $1`,
|
||||
[userRows[0].id],
|
||||
);
|
||||
allowCredentials = passkeys.map((k: any) => ({
|
||||
id: k.credential_id,
|
||||
type: 'public-key' as const,
|
||||
transports: k.transports || [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: this.rpID,
|
||||
allowCredentials,
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
// Store challenge — for passkey login we need a temporary storage
|
||||
// Since we don't know the user yet, store in a shared way
|
||||
// In production, use Redis/session. For now, we'll pass it back and verify client-side.
|
||||
return { ...options, challenge: options.challenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify authentication and return the user.
|
||||
*/
|
||||
async verifyAuthentication(response: AuthenticationResponseJSON, expectedChallenge: string) {
|
||||
// Find the credential
|
||||
const credId = response.id;
|
||||
const passkeys = await this.dataSource.query(
|
||||
`SELECT p.*, u.id as user_id, u.email
|
||||
FROM shared.user_passkeys p
|
||||
JOIN shared.users u ON u.id = p.user_id
|
||||
WHERE p.credential_id = $1`,
|
||||
[credId],
|
||||
);
|
||||
|
||||
if (passkeys.length === 0) {
|
||||
throw new UnauthorizedException('Passkey not recognized');
|
||||
}
|
||||
|
||||
const passkey = passkeys[0];
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin: this.origin,
|
||||
expectedRPID: this.rpID,
|
||||
credential: {
|
||||
id: passkey.credential_id,
|
||||
publicKey: Buffer.from(passkey.public_key, 'base64url'),
|
||||
counter: Number(passkey.counter),
|
||||
transports: (passkey.transports || []) as AuthenticatorTransportFuture[],
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
throw new UnauthorizedException('Passkey authentication failed');
|
||||
}
|
||||
|
||||
// Update counter and last_used_at
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.user_passkeys SET counter = $1, last_used_at = NOW() WHERE id = $2`,
|
||||
[verification.authenticationInfo.newCounter, passkey.id],
|
||||
);
|
||||
|
||||
return { userId: passkey.user_id };
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's registered passkeys.
|
||||
*/
|
||||
async listPasskeys(userId: string) {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT id, device_name, created_at, last_used_at
|
||||
FROM shared.user_passkeys
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[userId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a passkey.
|
||||
*/
|
||||
async removePasskey(userId: string, passkeyId: string): Promise<void> {
|
||||
// Check that user has password or other passkeys
|
||||
const [userRows, passkeyCount] = await Promise.all([
|
||||
this.dataSource.query(`SELECT password_hash FROM shared.users WHERE id = $1`, [userId]),
|
||||
this.dataSource.query(
|
||||
`SELECT COUNT(*) as cnt FROM shared.user_passkeys WHERE user_id = $1`,
|
||||
[userId],
|
||||
),
|
||||
]);
|
||||
|
||||
const hasPassword = !!userRows[0]?.password_hash;
|
||||
const count = parseInt(passkeyCount[0]?.cnt || '0', 10);
|
||||
|
||||
if (!hasPassword && count <= 1) {
|
||||
throw new BadRequestException('Cannot remove your only passkey without a password set');
|
||||
}
|
||||
|
||||
await this.dataSource.query(
|
||||
`DELETE FROM shared.user_passkeys WHERE id = $1 AND user_id = $2`,
|
||||
[passkeyId, userId],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class RefreshTokenService {
|
||||
private readonly logger = new Logger(RefreshTokenService.name);
|
||||
|
||||
constructor(private dataSource: DataSource) {}
|
||||
|
||||
/**
|
||||
* Create a new refresh token for a user.
|
||||
* Returns the raw (unhashed) token to be sent as an httpOnly cookie.
|
||||
*/
|
||||
async createRefreshToken(userId: string): Promise<string> {
|
||||
const rawToken = randomBytes(64).toString('base64url');
|
||||
const tokenHash = this.hashToken(rawToken);
|
||||
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.refresh_tokens (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[userId, tokenHash, expiresAt],
|
||||
);
|
||||
|
||||
return rawToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a refresh token. Returns the user_id if valid, null otherwise.
|
||||
*/
|
||||
async validateRefreshToken(rawToken: string): Promise<string | null> {
|
||||
const tokenHash = this.hashToken(rawToken);
|
||||
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT user_id, expires_at, revoked_at
|
||||
FROM shared.refresh_tokens
|
||||
WHERE token_hash = $1`,
|
||||
[tokenHash],
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const { user_id, expires_at, revoked_at } = rows[0];
|
||||
|
||||
// Check if revoked
|
||||
if (revoked_at) return null;
|
||||
|
||||
// Check if expired
|
||||
if (new Date(expires_at) < new Date()) return null;
|
||||
|
||||
return user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a single refresh token.
|
||||
*/
|
||||
async revokeToken(rawToken: string): Promise<void> {
|
||||
const tokenHash = this.hashToken(rawToken);
|
||||
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`,
|
||||
[tokenHash],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all refresh tokens for a user ("log out everywhere").
|
||||
*/
|
||||
async revokeAllUserTokens(userId: string): Promise<void> {
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.refresh_tokens SET revoked_at = NOW()
|
||||
WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||
[userId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove expired / revoked tokens older than 7 days.
|
||||
* Called periodically to keep the table clean.
|
||||
*/
|
||||
async cleanupExpired(): Promise<number> {
|
||||
const result = await this.dataSource.query(
|
||||
`DELETE FROM shared.refresh_tokens
|
||||
WHERE (expires_at < NOW() - INTERVAL '7 days')
|
||||
OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days')`,
|
||||
);
|
||||
const deleted = result?.[1] ?? 0;
|
||||
if (deleted > 0) {
|
||||
this.logger.log(`Cleaned up ${deleted} expired/revoked refresh tokens`);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private hashToken(rawToken: string): string {
|
||||
return createHash('sha256').update(rawToken).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
Res,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { SsoService } from './sso.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
const COOKIE_NAME = 'ledgeriq_rt';
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class SsoController {
|
||||
constructor(
|
||||
private ssoService: SsoService,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
@Get('sso/providers')
|
||||
@ApiOperation({ summary: 'Get available SSO providers' })
|
||||
getProviders() {
|
||||
return this.ssoService.getAvailableProviders();
|
||||
}
|
||||
|
||||
// Google OAuth routes would be:
|
||||
// GET /auth/google → passport.authenticate('google')
|
||||
// GET /auth/google/callback → passport callback
|
||||
// These are registered conditionally in auth.module.ts if env vars are set.
|
||||
// For now, we'll add the callback handler:
|
||||
|
||||
@Get('google/callback')
|
||||
@ApiOperation({ summary: 'Google OAuth callback' })
|
||||
async googleCallback(@Request() req: any, @Res() res: Response) {
|
||||
if (!req.user) {
|
||||
return res.redirect('/login?error=sso_failed');
|
||||
}
|
||||
|
||||
const result = await this.authService.generateTokenResponse(req.user);
|
||||
|
||||
// Set refresh token cookie
|
||||
if (result.refreshToken) {
|
||||
res.cookie(COOKIE_NAME, result.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect to app with access token in URL fragment (for SPA to pick up)
|
||||
return res.redirect(`/sso-callback?token=${result.accessToken}`);
|
||||
}
|
||||
|
||||
@Get('azure/callback')
|
||||
@ApiOperation({ summary: 'Azure AD OAuth callback' })
|
||||
async azureCallback(@Request() req: any, @Res() res: Response) {
|
||||
if (!req.user) {
|
||||
return res.redirect('/login?error=sso_failed');
|
||||
}
|
||||
|
||||
const result = await this.authService.generateTokenResponse(req.user);
|
||||
|
||||
if (result.refreshToken) {
|
||||
res.cookie(COOKIE_NAME, result.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
return res.redirect(`/sso-callback?token=${result.accessToken}`);
|
||||
}
|
||||
|
||||
@Post('sso/link')
|
||||
@ApiOperation({ summary: 'Link SSO provider to current user' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async linkAccount(@Request() req: any) {
|
||||
// This would typically be done via the OAuth redirect flow
|
||||
// For now, it's a placeholder
|
||||
throw new BadRequestException('Use the OAuth redirect flow to link accounts');
|
||||
}
|
||||
|
||||
@Delete('sso/unlink/:provider')
|
||||
@ApiOperation({ summary: 'Unlink SSO provider from current user' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async unlinkAccount(@Request() req: any, @Param('provider') provider: string) {
|
||||
await this.ssoService.unlinkSsoAccount(req.user.sub, provider);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
interface SsoProfile {
|
||||
provider: string;
|
||||
providerId: string;
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SsoService {
|
||||
private readonly logger = new Logger(SsoService.name);
|
||||
|
||||
constructor(
|
||||
private dataSource: DataSource,
|
||||
private usersService: UsersService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find existing user by SSO provider+id, or by email match, or create new.
|
||||
*/
|
||||
async findOrCreateSsoUser(profile: SsoProfile) {
|
||||
// 1. Try to find by provider + provider ID
|
||||
const byProvider = await this.dataSource.query(
|
||||
`SELECT * FROM shared.users WHERE oauth_provider = $1 AND oauth_provider_id = $2`,
|
||||
[profile.provider, profile.providerId],
|
||||
);
|
||||
if (byProvider.length > 0) {
|
||||
return this.usersService.findByIdWithOrgs(byProvider[0].id);
|
||||
}
|
||||
|
||||
// 2. Try to find by email match (link accounts)
|
||||
const byEmail = await this.usersService.findByEmail(profile.email);
|
||||
if (byEmail) {
|
||||
// Link the SSO provider to existing account
|
||||
await this.linkSsoAccount(byEmail.id, profile.provider, profile.providerId);
|
||||
return this.usersService.findByIdWithOrgs(byEmail.id);
|
||||
}
|
||||
|
||||
// 3. Create new user
|
||||
const newUser = await this.dataSource.query(
|
||||
`INSERT INTO shared.users (email, first_name, last_name, oauth_provider, oauth_provider_id, is_email_verified)
|
||||
VALUES ($1, $2, $3, $4, $5, true)
|
||||
RETURNING id`,
|
||||
[profile.email, profile.firstName || '', profile.lastName || '', profile.provider, profile.providerId],
|
||||
);
|
||||
|
||||
return this.usersService.findByIdWithOrgs(newUser[0].id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link an SSO provider to an existing user.
|
||||
*/
|
||||
async linkSsoAccount(userId: string, provider: string, providerId: string): Promise<void> {
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.users SET oauth_provider = $1, oauth_provider_id = $2, updated_at = NOW() WHERE id = $3`,
|
||||
[provider, providerId, userId],
|
||||
);
|
||||
this.logger.log(`Linked ${provider} SSO to user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink SSO from a user (only if they have a password set).
|
||||
*/
|
||||
async unlinkSsoAccount(userId: string, provider: string): Promise<void> {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT password_hash, oauth_provider FROM shared.users WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
if (rows.length === 0) throw new BadRequestException('User not found');
|
||||
if (!rows[0].password_hash) {
|
||||
throw new BadRequestException('Cannot unlink SSO — you must set a password first');
|
||||
}
|
||||
if (rows[0].oauth_provider !== provider) {
|
||||
throw new BadRequestException('SSO provider mismatch');
|
||||
}
|
||||
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.users SET oauth_provider = NULL, oauth_provider_id = NULL, updated_at = NOW() WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
this.logger.log(`Unlinked ${provider} SSO from user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which SSO providers are configured.
|
||||
*/
|
||||
getAvailableProviders(): { google: boolean; azure: boolean } {
|
||||
return {
|
||||
google: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
|
||||
azure: !!(process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
RawBodyRequest,
|
||||
BadRequestException,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { Request as ExpressRequest } from 'express';
|
||||
import { BillingService } from './billing.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('billing')
|
||||
@Controller()
|
||||
export class BillingController {
|
||||
constructor(private billingService: BillingService) {}
|
||||
|
||||
@Post('billing/create-checkout-session')
|
||||
@ApiOperation({ summary: 'Create a Stripe Checkout Session' })
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
async createCheckout(
|
||||
@Body() body: { planId: string; email?: string; businessName?: string },
|
||||
) {
|
||||
if (!body.planId) throw new BadRequestException('planId is required');
|
||||
return this.billingService.createCheckoutSession(body.planId, body.email, body.businessName);
|
||||
}
|
||||
|
||||
@Post('webhooks/stripe')
|
||||
@ApiOperation({ summary: 'Stripe webhook endpoint' })
|
||||
async handleWebhook(@Req() req: RawBodyRequest<ExpressRequest>) {
|
||||
const signature = req.headers['stripe-signature'] as string;
|
||||
if (!signature) throw new BadRequestException('Missing Stripe signature');
|
||||
if (!req.rawBody) throw new BadRequestException('Missing raw body');
|
||||
await this.billingService.handleWebhook(req.rawBody, signature);
|
||||
return { received: true };
|
||||
}
|
||||
|
||||
@Get('billing/status')
|
||||
@ApiOperation({ summary: 'Check provisioning status for a checkout session' })
|
||||
async getStatus(@Query('session_id') sessionId: string) {
|
||||
if (!sessionId) throw new BadRequestException('session_id required');
|
||||
return this.billingService.getProvisioningStatus(sessionId);
|
||||
}
|
||||
|
||||
@Post('billing/portal')
|
||||
@ApiOperation({ summary: 'Create Stripe Customer Portal session' })
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async createPortal(@Request() req: any) {
|
||||
// Lookup the org's stripe_customer_id
|
||||
// Only allow president or superadmin
|
||||
const orgId = req.user.orgId;
|
||||
if (!orgId) throw new BadRequestException('No organization context');
|
||||
// For now, we'd look this up from the org
|
||||
throw new BadRequestException('Portal session requires stripe_customer_id lookup — implement per org context');
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BillingService } from './billing.service';
|
||||
import { BillingController } from './billing.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DatabaseModule } from '../../database/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, DatabaseModule],
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
@@ -1,294 +0,0 @@
|
||||
import { Injectable, Logger, BadRequestException, RawBodyRequest } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import Stripe from 'stripe';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { TenantSchemaService } from '../../database/tenant-schema.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { EmailService } from '../email/email.service';
|
||||
|
||||
const PLAN_FEATURES: Record<string, { name: string; unitLimit: number }> = {
|
||||
starter: { name: 'Starter', unitLimit: 50 },
|
||||
professional: { name: 'Professional', unitLimit: 200 },
|
||||
enterprise: { name: 'Enterprise', unitLimit: 999999 },
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
private readonly logger = new Logger(BillingService.name);
|
||||
private stripe: Stripe | null = null;
|
||||
private webhookSecret: string;
|
||||
private priceMap: Record<string, string>;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private dataSource: DataSource,
|
||||
private tenantSchemaService: TenantSchemaService,
|
||||
private authService: AuthService,
|
||||
private emailService: EmailService,
|
||||
) {
|
||||
const secretKey = this.configService.get<string>('STRIPE_SECRET_KEY');
|
||||
if (secretKey && !secretKey.includes('placeholder')) {
|
||||
this.stripe = new Stripe(secretKey, { apiVersion: '2025-02-24.acacia' as any });
|
||||
this.logger.log('Stripe initialized');
|
||||
} else {
|
||||
this.logger.warn('Stripe not configured — billing endpoints will return stubs');
|
||||
}
|
||||
|
||||
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET') || '';
|
||||
this.priceMap = {
|
||||
starter: this.configService.get<string>('STRIPE_STARTER_PRICE_ID') || '',
|
||||
professional: this.configService.get<string>('STRIPE_PROFESSIONAL_PRICE_ID') || '',
|
||||
enterprise: this.configService.get<string>('STRIPE_ENTERPRISE_PRICE_ID') || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Checkout Session for a new subscription.
|
||||
*/
|
||||
async createCheckoutSession(planId: string, email?: string, businessName?: string): Promise<{ url: string }> {
|
||||
if (!this.stripe) {
|
||||
throw new BadRequestException('Stripe not configured');
|
||||
}
|
||||
|
||||
const priceId = this.priceMap[planId];
|
||||
if (!priceId || priceId.includes('placeholder')) {
|
||||
throw new BadRequestException(`Invalid plan: ${planId}`);
|
||||
}
|
||||
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${this.getAppUrl()}/onboarding/pending?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${this.getAppUrl()}/pricing`,
|
||||
customer_email: email || undefined,
|
||||
metadata: {
|
||||
plan_id: planId,
|
||||
business_name: businessName || '',
|
||||
},
|
||||
});
|
||||
|
||||
return { url: session.url! };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a Stripe webhook event.
|
||||
*/
|
||||
async handleWebhook(rawBody: Buffer, signature: string): Promise<void> {
|
||||
if (!this.stripe) throw new BadRequestException('Stripe not configured');
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Webhook signature verification failed: ${err.message}`);
|
||||
throw new BadRequestException('Invalid webhook signature');
|
||||
}
|
||||
|
||||
// Idempotency check
|
||||
const existing = await this.dataSource.query(
|
||||
`SELECT id FROM shared.stripe_events WHERE id = $1`,
|
||||
[event.id],
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
this.logger.log(`Duplicate Stripe event ${event.id}, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Record event
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.stripe_events (id, type, payload) VALUES ($1, $2, $3)`,
|
||||
[event.id, event.type, JSON.stringify(event.data)],
|
||||
);
|
||||
|
||||
// Dispatch
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
|
||||
break;
|
||||
case 'invoice.payment_succeeded':
|
||||
await this.handlePaymentSucceeded(event.data.object as Stripe.Invoice);
|
||||
break;
|
||||
case 'invoice.payment_failed':
|
||||
await this.handlePaymentFailed(event.data.object as Stripe.Invoice);
|
||||
break;
|
||||
case 'customer.subscription.deleted':
|
||||
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
||||
break;
|
||||
default:
|
||||
this.logger.log(`Unhandled Stripe event: ${event.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provisioning status for a checkout session.
|
||||
*/
|
||||
async getProvisioningStatus(sessionId: string): Promise<{ status: string; activationUrl?: string }> {
|
||||
if (!this.stripe) return { status: 'not_configured' };
|
||||
|
||||
const session = await this.stripe.checkout.sessions.retrieve(sessionId);
|
||||
const customerId = session.customer as string;
|
||||
|
||||
if (!customerId) return { status: 'pending' };
|
||||
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT id, status FROM shared.organizations WHERE stripe_customer_id = $1`,
|
||||
[customerId],
|
||||
);
|
||||
|
||||
if (rows.length === 0) return { status: 'provisioning' };
|
||||
if (rows[0].status === 'active') return { status: 'active' };
|
||||
return { status: 'provisioning' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Customer Portal session.
|
||||
*/
|
||||
async createPortalSession(customerId: string): Promise<{ url: string }> {
|
||||
if (!this.stripe) throw new BadRequestException('Stripe not configured');
|
||||
|
||||
const session = await this.stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: `${this.getAppUrl()}/settings`,
|
||||
});
|
||||
|
||||
return { url: session.url };
|
||||
}
|
||||
|
||||
// ─── Provisioning (inline, no BullMQ for now — add queue later) ─────
|
||||
|
||||
private async handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
|
||||
const customerId = session.customer as string;
|
||||
const subscriptionId = session.subscription as string;
|
||||
const email = session.customer_email || session.customer_details?.email || '';
|
||||
const planId = session.metadata?.plan_id || 'starter';
|
||||
const businessName = session.metadata?.business_name || 'My HOA';
|
||||
|
||||
this.logger.log(`Provisioning org for ${email}, plan=${planId}, customer=${customerId}`);
|
||||
|
||||
try {
|
||||
await this.provisionOrganization(customerId, subscriptionId, email, planId, businessName);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Provisioning failed: ${err.message}`, err.stack);
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaymentSucceeded(invoice: Stripe.Invoice): Promise<void> {
|
||||
const customerId = invoice.customer as string;
|
||||
// Activate tenant if it was pending
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.organizations SET status = 'active', updated_at = NOW()
|
||||
WHERE stripe_customer_id = $1 AND status != 'active'`,
|
||||
[customerId],
|
||||
);
|
||||
}
|
||||
|
||||
private async handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
|
||||
const customerId = invoice.customer as string;
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT email FROM shared.organizations WHERE stripe_customer_id = $1`,
|
||||
[customerId],
|
||||
);
|
||||
if (rows.length > 0 && rows[0].email) {
|
||||
await this.emailService.sendPaymentFailedEmail(rows[0].email, rows[0].name || 'Your organization');
|
||||
}
|
||||
this.logger.warn(`Payment failed for customer ${customerId}`);
|
||||
}
|
||||
|
||||
private async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
|
||||
const customerId = subscription.customer as string;
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.organizations SET status = 'archived', updated_at = NOW()
|
||||
WHERE stripe_customer_id = $1`,
|
||||
[customerId],
|
||||
);
|
||||
this.logger.log(`Subscription cancelled for customer ${customerId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full provisioning flow: create org, schema, user, invite token, email.
|
||||
*/
|
||||
async provisionOrganization(
|
||||
customerId: string,
|
||||
subscriptionId: string,
|
||||
email: string,
|
||||
planId: string,
|
||||
businessName: string,
|
||||
): Promise<void> {
|
||||
// 1. Create or upsert organization
|
||||
const schemaName = `tenant_${uuid().replace(/-/g, '').substring(0, 12)}`;
|
||||
|
||||
const orgRows = await this.dataSource.query(
|
||||
`INSERT INTO shared.organizations (name, schema_name, status, plan_level, stripe_customer_id, stripe_subscription_id, email)
|
||||
VALUES ($1, $2, 'active', $3, $4, $5, $6)
|
||||
ON CONFLICT (stripe_customer_id) DO UPDATE SET
|
||||
stripe_subscription_id = EXCLUDED.stripe_subscription_id,
|
||||
plan_level = EXCLUDED.plan_level,
|
||||
status = 'active',
|
||||
updated_at = NOW()
|
||||
RETURNING id, schema_name`,
|
||||
[businessName, schemaName, planId, customerId, subscriptionId, email],
|
||||
);
|
||||
|
||||
const orgId = orgRows[0].id;
|
||||
const actualSchema = orgRows[0].schema_name;
|
||||
|
||||
// 2. Create tenant schema
|
||||
try {
|
||||
await this.tenantSchemaService.createTenantSchema(actualSchema);
|
||||
this.logger.log(`Created tenant schema: ${actualSchema}`);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('already exists')) {
|
||||
this.logger.log(`Schema ${actualSchema} already exists, skipping creation`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create or find user
|
||||
let userRows = await this.dataSource.query(
|
||||
`SELECT id FROM shared.users WHERE email = $1`,
|
||||
[email],
|
||||
);
|
||||
|
||||
let userId: string;
|
||||
if (userRows.length === 0) {
|
||||
const newUser = await this.dataSource.query(
|
||||
`INSERT INTO shared.users (email, is_email_verified)
|
||||
VALUES ($1, false)
|
||||
RETURNING id`,
|
||||
[email],
|
||||
);
|
||||
userId = newUser[0].id;
|
||||
} else {
|
||||
userId = userRows[0].id;
|
||||
}
|
||||
|
||||
// 4. Create membership (president role)
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.user_organizations (user_id, organization_id, role)
|
||||
VALUES ($1, $2, 'president')
|
||||
ON CONFLICT (user_id, organization_id) DO NOTHING`,
|
||||
[userId, orgId],
|
||||
);
|
||||
|
||||
// 5. Generate invite token and "send" activation email
|
||||
const inviteToken = await this.authService.generateInviteToken(userId, orgId, email);
|
||||
const activationUrl = `${this.getAppUrl()}/activate?token=${inviteToken}`;
|
||||
await this.emailService.sendActivationEmail(email, businessName, activationUrl);
|
||||
|
||||
// 6. Initialize onboarding progress
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.onboarding_progress (organization_id) VALUES ($1) ON CONFLICT DO NOTHING`,
|
||||
[orgId],
|
||||
);
|
||||
|
||||
this.logger.log(`✅ Provisioning complete for org=${orgId}, user=${userId}`);
|
||||
}
|
||||
|
||||
private getAppUrl(): string {
|
||||
return this.configService.get<string>('APP_URL') || 'http://localhost';
|
||||
}
|
||||
}
|
||||
@@ -11,40 +11,6 @@ export class EmailService {
|
||||
|
||||
constructor(private dataSource: DataSource) {}
|
||||
|
||||
async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
|
||||
const subject = `Activate your ${businessName} account on HOA LedgerIQ`;
|
||||
const body = [
|
||||
`Welcome to HOA LedgerIQ!`,
|
||||
``,
|
||||
`Your organization "${businessName}" has been created.`,
|
||||
`Please activate your account by clicking the link below:`,
|
||||
``,
|
||||
activationUrl,
|
||||
``,
|
||||
`This link expires in 72 hours.`,
|
||||
].join('\n');
|
||||
|
||||
await this.log(email, subject, body, 'activation', { businessName, activationUrl });
|
||||
}
|
||||
|
||||
async sendWelcomeEmail(email: string, businessName: string): Promise<void> {
|
||||
const subject = `Welcome to HOA LedgerIQ — ${businessName}`;
|
||||
const body = `Your account is active. Log in at http://localhost to get started.`;
|
||||
await this.log(email, subject, body, 'welcome', { businessName });
|
||||
}
|
||||
|
||||
async sendPaymentFailedEmail(email: string, businessName: string): Promise<void> {
|
||||
const subject = `Payment failed for ${businessName} on HOA LedgerIQ`;
|
||||
const body = `We were unable to process your payment. Please update your payment method.`;
|
||||
await this.log(email, subject, body, 'payment_failed', { businessName });
|
||||
}
|
||||
|
||||
async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise<void> {
|
||||
const subject = `You've been invited to ${orgName} on HOA LedgerIQ`;
|
||||
const body = `You've been invited to join ${orgName}. Click here to accept: ${inviteUrl}`;
|
||||
await this.log(email, subject, body, 'invite_member', { orgName, inviteUrl });
|
||||
}
|
||||
|
||||
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
|
||||
const subject = 'Reset your HOA LedgerIQ password';
|
||||
const body = [
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Controller, Get, Patch, Body, UseGuards, Request, BadRequestException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
|
||||
@ApiTags('onboarding')
|
||||
@Controller('onboarding')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class OnboardingController {
|
||||
constructor(private onboardingService: OnboardingService) {}
|
||||
|
||||
@Get('progress')
|
||||
@ApiOperation({ summary: 'Get onboarding progress for current org' })
|
||||
@AllowViewer()
|
||||
async getProgress(@Request() req: any) {
|
||||
const orgId = req.user.orgId;
|
||||
if (!orgId) throw new BadRequestException('No organization context');
|
||||
return this.onboardingService.getProgress(orgId);
|
||||
}
|
||||
|
||||
@Patch('progress')
|
||||
@ApiOperation({ summary: 'Mark an onboarding step as complete' })
|
||||
async markStep(@Request() req: any, @Body() body: { step: string }) {
|
||||
const orgId = req.user.orgId;
|
||||
if (!orgId) throw new BadRequestException('No organization context');
|
||||
if (!body.step) throw new BadRequestException('step is required');
|
||||
return this.onboardingService.markStepComplete(orgId, body.step);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
import { OnboardingController } from './onboarding.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [OnboardingController],
|
||||
providers: [OnboardingService],
|
||||
exports: [OnboardingService],
|
||||
})
|
||||
export class OnboardingModule {}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
const REQUIRED_STEPS = ['profile', 'workspace', 'invite_member', 'first_workflow'];
|
||||
|
||||
@Injectable()
|
||||
export class OnboardingService {
|
||||
private readonly logger = new Logger(OnboardingService.name);
|
||||
|
||||
constructor(private dataSource: DataSource) {}
|
||||
|
||||
async getProgress(orgId: string) {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT completed_steps, completed_at, updated_at
|
||||
FROM shared.onboarding_progress
|
||||
WHERE organization_id = $1`,
|
||||
[orgId],
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
// Create a fresh record
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.onboarding_progress (organization_id)
|
||||
VALUES ($1) ON CONFLICT DO NOTHING`,
|
||||
[orgId],
|
||||
);
|
||||
return { completedSteps: [], completedAt: null, requiredSteps: REQUIRED_STEPS };
|
||||
}
|
||||
|
||||
return {
|
||||
completedSteps: rows[0].completed_steps || [],
|
||||
completedAt: rows[0].completed_at,
|
||||
requiredSteps: REQUIRED_STEPS,
|
||||
};
|
||||
}
|
||||
|
||||
async markStepComplete(orgId: string, step: string) {
|
||||
// Add step to array (using array_append with dedup)
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.onboarding_progress (organization_id, completed_steps, updated_at)
|
||||
VALUES ($1, ARRAY[$2::text], NOW())
|
||||
ON CONFLICT (organization_id)
|
||||
DO UPDATE SET
|
||||
completed_steps = CASE
|
||||
WHEN $2 = ANY(onboarding_progress.completed_steps) THEN onboarding_progress.completed_steps
|
||||
ELSE array_append(onboarding_progress.completed_steps, $2::text)
|
||||
END,
|
||||
updated_at = NOW()`,
|
||||
[orgId, step],
|
||||
);
|
||||
|
||||
// Check if all required steps are done
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT completed_steps FROM shared.onboarding_progress WHERE organization_id = $1`,
|
||||
[orgId],
|
||||
);
|
||||
|
||||
const completedSteps = rows[0]?.completed_steps || [];
|
||||
const allDone = REQUIRED_STEPS.every((s) => completedSteps.includes(s));
|
||||
|
||||
if (allDone) {
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.onboarding_progress SET completed_at = NOW() WHERE organization_id = $1 AND completed_at IS NULL`,
|
||||
[orgId],
|
||||
);
|
||||
}
|
||||
|
||||
return this.getProgress(orgId);
|
||||
}
|
||||
|
||||
async resetProgress(orgId: string) {
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.onboarding_progress SET completed_steps = '{}', completed_at = NULL, updated_at = NOW()
|
||||
WHERE organization_id = $1`,
|
||||
[orgId],
|
||||
);
|
||||
return this.getProgress(orgId);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
-- Migration 015: SaaS Onboarding + Auth (Stripe, Refresh Tokens, MFA, SSO, Passkeys)
|
||||
-- Adds tables for refresh tokens, stripe event tracking, invite tokens,
|
||||
-- onboarding progress, and WebAuthn passkeys.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Modify shared.organizations — add Stripe billing columns
|
||||
-- ============================================================================
|
||||
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255) UNIQUE;
|
||||
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255) UNIQUE;
|
||||
ALTER TABLE shared.organizations ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ;
|
||||
|
||||
-- Update plan_level CHECK constraint to include new SaaS plan tiers
|
||||
-- (Drop and re-add since ALTER CHECK is not supported in PG)
|
||||
ALTER TABLE shared.organizations DROP CONSTRAINT IF EXISTS organizations_plan_level_check;
|
||||
ALTER TABLE shared.organizations ADD CONSTRAINT organizations_plan_level_check
|
||||
CHECK (plan_level IN ('standard', 'premium', 'enterprise', 'starter', 'professional'));
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. New table: shared.refresh_tokens
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS shared.refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON shared.refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON shared.refresh_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON shared.refresh_tokens(expires_at);
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. New table: shared.stripe_events (idempotency for webhook processing)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS shared.stripe_events (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
type VARCHAR(100) NOT NULL,
|
||||
processed_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
payload JSONB
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. New table: shared.invite_tokens (magic link activation)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS shared.invite_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_tokens_hash ON shared.invite_tokens(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_tokens_user ON shared.invite_tokens(user_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. New table: shared.onboarding_progress
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS shared.onboarding_progress (
|
||||
organization_id UUID PRIMARY KEY REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||
completed_steps TEXT[] DEFAULT '{}',
|
||||
completed_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. New table: shared.user_passkeys (WebAuthn)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS shared.user_passkeys (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||
credential_id TEXT UNIQUE NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
counter BIGINT DEFAULT 0,
|
||||
device_name VARCHAR(255),
|
||||
transports TEXT[],
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user ON shared.user_passkeys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_passkeys_cred ON shared.user_passkeys(credential_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- 7. Modify shared.users — add MFA/WebAuthn columns
|
||||
-- ============================================================================
|
||||
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS totp_verified_at TIMESTAMPTZ;
|
||||
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS recovery_codes TEXT;
|
||||
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS webauthn_challenge TEXT;
|
||||
ALTER TABLE shared.users ADD COLUMN IF NOT EXISTS has_seen_intro BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- ============================================================================
|
||||
-- 8. Stubbed email log table (for development — replaces real email sends)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS shared.email_log (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
to_email VARCHAR(255) NOT NULL,
|
||||
subject VARCHAR(500) NOT NULL,
|
||||
body TEXT,
|
||||
template VARCHAR(100),
|
||||
metadata JSONB,
|
||||
sent_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -29,21 +29,6 @@ services:
|
||||
- NEW_RELIC_ENABLED=${NEW_RELIC_ENABLED:-false}
|
||||
- NEW_RELIC_LICENSE_KEY=${NEW_RELIC_LICENSE_KEY:-}
|
||||
- NEW_RELIC_APP_NAME=${NEW_RELIC_APP_NAME:-HOALedgerIQ_App}
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
||||
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
|
||||
- STRIPE_PROFESSIONAL_PRICE_ID=${STRIPE_PROFESSIONAL_PRICE_ID:-}
|
||||
- STRIPE_ENTERPRISE_PRICE_ID=${STRIPE_ENTERPRISE_PRICE_ID:-}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost/api/auth/google/callback}
|
||||
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID:-}
|
||||
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET:-}
|
||||
- AZURE_TENANT_ID=${AZURE_TENANT_ID:-}
|
||||
- AZURE_CALLBACK_URL=${AZURE_CALLBACK_URL:-http://localhost/api/auth/azure/callback}
|
||||
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-localhost}
|
||||
- WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost}
|
||||
- INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret}
|
||||
volumes:
|
||||
- ./backend/src:/app/src
|
||||
- ./backend/nest-cli.json:/app/nest-cli.json
|
||||
|
||||
11
frontend/package-lock.json
generated
11
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-frontend",
|
||||
"version": "2026.3.17",
|
||||
"version": "2026.03.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hoa-ledgeriq-frontend",
|
||||
"version": "2026.3.17",
|
||||
"version": "2026.03.10",
|
||||
"dependencies": {
|
||||
"@mantine/core": "^7.15.3",
|
||||
"@mantine/dates": "^7.15.3",
|
||||
@@ -14,7 +14,6 @@
|
||||
"@mantine/hooks": "^7.15.3",
|
||||
"@mantine/modals": "^7.15.3",
|
||||
"@mantine/notifications": "^7.15.3",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@tabler/icons-react": "^3.28.1",
|
||||
"@tanstack/react-query": "^5.64.2",
|
||||
"axios": "^1.7.9",
|
||||
@@ -1290,12 +1289,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
|
||||
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tabler/icons": {
|
||||
"version": "3.36.1",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-frontend",
|
||||
"version": "2026.3.17",
|
||||
"version": "2026.03.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -16,7 +16,6 @@
|
||||
"@mantine/hooks": "^7.15.3",
|
||||
"@mantine/modals": "^7.15.3",
|
||||
"@mantine/notifications": "^7.15.3",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@tabler/icons-react": "^3.28.1",
|
||||
"@tanstack/react-query": "^5.64.2",
|
||||
"axios": "^1.7.9",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AppLayout } from './components/layout/AppLayout';
|
||||
import { LoginPage } from './pages/auth/LoginPage';
|
||||
import { RegisterPage } from './pages/auth/RegisterPage';
|
||||
import { SelectOrgPage } from './pages/auth/SelectOrgPage';
|
||||
import { ActivatePage } from './pages/auth/ActivatePage';
|
||||
import { DashboardPage } from './pages/dashboard/DashboardPage';
|
||||
import { AccountsPage } from './pages/accounts/AccountsPage';
|
||||
import { TransactionsPage } from './pages/transactions/TransactionsPage';
|
||||
@@ -38,9 +37,6 @@ import { AssessmentScenariosPage } from './pages/board-planning/AssessmentScenar
|
||||
import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage';
|
||||
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
|
||||
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
|
||||
import { PricingPage } from './pages/pricing/PricingPage';
|
||||
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
|
||||
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
@@ -81,12 +77,6 @@ function AuthRoute({ children }: { children: React.ReactNode }) {
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Public routes (no auth required) */}
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
<Route path="/activate" element={<ActivatePage />} />
|
||||
<Route path="/onboarding/pending" element={<OnboardingPendingPage />} />
|
||||
|
||||
{/* Auth routes (redirect if already logged in) */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
@@ -111,18 +101,6 @@ export function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Onboarding (requires auth but not org selection) */}
|
||||
<Route
|
||||
path="/onboarding"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<OnboardingPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
@@ -133,8 +111,6 @@ export function App() {
|
||||
>
|
||||
<Route index element={<AdminPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Main app routes (require auth + org) */}
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
IconChartAreaLine,
|
||||
IconClipboardCheck,
|
||||
IconSparkles,
|
||||
IconHeartRateMonitor,
|
||||
IconCalculator,
|
||||
IconGitCompare,
|
||||
IconScale,
|
||||
@@ -45,6 +46,14 @@ const navSections = [
|
||||
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Transactions',
|
||||
items: [
|
||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
||||
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Board Planning',
|
||||
items: [
|
||||
@@ -58,8 +67,12 @@ const navSections = [
|
||||
{
|
||||
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
|
||||
},
|
||||
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
|
||||
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
|
||||
{
|
||||
label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning',
|
||||
children: [
|
||||
{ label: 'Investment Scenarios', path: '/board-planning/investments' },
|
||||
],
|
||||
},
|
||||
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
||||
],
|
||||
},
|
||||
@@ -69,14 +82,6 @@ const navSections = [
|
||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Transactions',
|
||||
items: [
|
||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
||||
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||
{ label: 'Payments', icon: IconCash, path: '/payments' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Reports',
|
||||
items: [
|
||||
|
||||
@@ -9,7 +9,7 @@ import '@mantine/core/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import { App } from './App';
|
||||
import { defaultTheme, compactTheme } from './theme/theme';
|
||||
import { theme } from './theme/theme';
|
||||
import { usePreferencesStore } from './stores/preferencesStore';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -24,11 +24,9 @@ const queryClient = new QueryClient({
|
||||
|
||||
function Root() {
|
||||
const colorScheme = usePreferencesStore((s) => s.colorScheme);
|
||||
const compactView = usePreferencesStore((s) => s.compactView);
|
||||
const activeTheme = compactView ? compactTheme : defaultTheme;
|
||||
|
||||
return (
|
||||
<MantineProvider theme={activeTheme} forceColorScheme={colorScheme}>
|
||||
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
|
||||
<Notifications position="top-right" />
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Container, Paper, Title, Text, TextInput, PasswordInput,
|
||||
Button, Stack, Alert, Center, Loader, Progress, Anchor,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconAlertCircle, IconCheck, IconShieldCheck } from '@tabler/icons-react';
|
||||
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import logoSrc from '../../assets/logo.png';
|
||||
|
||||
function getPasswordStrength(pw: string): number {
|
||||
let score = 0;
|
||||
if (pw.length >= 8) score += 25;
|
||||
if (pw.length >= 12) score += 15;
|
||||
if (/[A-Z]/.test(pw)) score += 20;
|
||||
if (/[a-z]/.test(pw)) score += 10;
|
||||
if (/[0-9]/.test(pw)) score += 15;
|
||||
if (/[^A-Za-z0-9]/.test(pw)) score += 15;
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
function strengthColor(s: number): string {
|
||||
if (s < 40) return 'red';
|
||||
if (s < 70) return 'orange';
|
||||
return 'green';
|
||||
}
|
||||
|
||||
export function ActivatePage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [validating, setValidating] = useState(true);
|
||||
const [tokenInfo, setTokenInfo] = useState<any>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: { fullName: '', password: '', confirmPassword: '' },
|
||||
validate: {
|
||||
fullName: (v) => (v.trim().length >= 2 ? null : 'Name is required'),
|
||||
password: (v) => (v.length >= 8 ? null : 'Password must be at least 8 characters'),
|
||||
confirmPassword: (v, values) => (v === values.password ? null : 'Passwords do not match'),
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError('No activation token provided');
|
||||
setValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
api.get(`/auth/activate?token=${token}`)
|
||||
.then(({ data }) => {
|
||||
setTokenInfo(data);
|
||||
setValidating(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.response?.data?.message || 'Invalid or expired activation link');
|
||||
setValidating(false);
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await api.post('/auth/activate', {
|
||||
token,
|
||||
password: values.password,
|
||||
fullName: values.fullName,
|
||||
});
|
||||
setAuth(data.accessToken, data.user, data.organizations);
|
||||
navigate('/onboarding');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Activation failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const passwordStrength = getPasswordStrength(form.values.password);
|
||||
|
||||
if (validating) {
|
||||
return (
|
||||
<Container size={420} my={80}>
|
||||
<Center><Loader size="lg" /></Center>
|
||||
<Text ta="center" mt="md" c="dimmed">Validating activation link...</Text>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !tokenInfo) {
|
||||
return (
|
||||
<Container size={420} my={80}>
|
||||
<Center>
|
||||
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
|
||||
</Center>
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light" mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
<Stack>
|
||||
<Anchor component={Link} to="/login" size="sm" ta="center">
|
||||
Go to Login
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size={420} my={80}>
|
||||
<Center>
|
||||
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
|
||||
</Center>
|
||||
<Text ta="center" mt={5} c="dimmed" size="sm">
|
||||
Activate your account for <strong>{tokenInfo?.orgName || 'your organization'}</strong>
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
{error && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="John Doe"
|
||||
required
|
||||
{...form.getInputProps('fullName')}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Create a strong password"
|
||||
required
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
{form.values.password && (
|
||||
<Progress
|
||||
value={passwordStrength}
|
||||
color={strengthColor(passwordStrength)}
|
||||
size="xs"
|
||||
mt={4}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Confirm Password"
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
{...form.getInputProps('confirmPassword')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
leftSection={<IconShieldCheck size={16} />}
|
||||
>
|
||||
Activate Account
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Center,
|
||||
Container,
|
||||
@@ -10,41 +10,18 @@ import {
|
||||
Anchor,
|
||||
Stack,
|
||||
Alert,
|
||||
Divider,
|
||||
Group,
|
||||
PinInput,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBrandGoogle,
|
||||
IconBrandWindows,
|
||||
IconFingerprint,
|
||||
IconShieldLock,
|
||||
} from '@tabler/icons-react';
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import api from '../../services/api';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
import logoSrc from '../../assets/logo.png';
|
||||
|
||||
type LoginState = 'credentials' | 'mfa';
|
||||
|
||||
export function LoginPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loginState, setLoginState] = useState<LoginState>('credentials');
|
||||
const [mfaToken, setMfaToken] = useState('');
|
||||
const [mfaCode, setMfaCode] = useState('');
|
||||
const [useRecovery, setUseRecovery] = useState(false);
|
||||
const [recoveryCode, setRecoveryCode] = useState('');
|
||||
const [ssoProviders, setSsoProviders] = useState<{ google: boolean; azure: boolean }>({
|
||||
google: false,
|
||||
azure: false,
|
||||
});
|
||||
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||
@@ -57,42 +34,20 @@ export function LoginPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch SSO providers & check passkey support on mount
|
||||
useEffect(() => {
|
||||
api
|
||||
.get('/auth/sso/providers')
|
||||
.then(({ data }) => setSsoProviders(data))
|
||||
.catch(() => {});
|
||||
|
||||
if (
|
||||
window.PublicKeyCredential &&
|
||||
typeof window.PublicKeyCredential === 'function'
|
||||
) {
|
||||
setPasskeySupported(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLoginSuccess = (data: any) => {
|
||||
setAuth(data.accessToken, data.user, data.organizations);
|
||||
if (data.user?.isSuperadmin && data.organizations.length === 0) {
|
||||
navigate('/admin');
|
||||
} else if (data.organizations.length >= 1) {
|
||||
navigate('/select-org');
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await api.post('/auth/login', values);
|
||||
if (data.mfaRequired) {
|
||||
setMfaToken(data.mfaToken);
|
||||
setLoginState('mfa');
|
||||
setAuth(data.accessToken, data.user, data.organizations);
|
||||
// Platform owner / superadmin with no orgs → admin panel
|
||||
if (data.user?.isSuperadmin && data.organizations.length === 0) {
|
||||
navigate('/admin');
|
||||
} else if (data.organizations.length >= 1) {
|
||||
// Always go through org selection to ensure correct JWT with orgSchema
|
||||
navigate('/select-org');
|
||||
} else {
|
||||
handleLoginSuccess(data);
|
||||
navigate('/');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Login failed');
|
||||
@@ -101,57 +56,6 @@ export function LoginPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMfaVerify = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const token = useRecovery ? recoveryCode : mfaCode;
|
||||
const { data } = await api.post('/auth/mfa/verify', {
|
||||
mfaToken,
|
||||
token,
|
||||
isRecoveryCode: useRecovery,
|
||||
});
|
||||
handleLoginSuccess(data);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'MFA verification failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasskeyLogin = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
// Get authentication options
|
||||
const { data: options } = await api.post('/auth/passkeys/login-options', {
|
||||
email: form.values.email || undefined,
|
||||
});
|
||||
|
||||
// Trigger browser WebAuthn prompt
|
||||
const credential = await startAuthentication({ optionsJSON: options });
|
||||
|
||||
// Verify with server
|
||||
const { data } = await api.post('/auth/passkeys/login', {
|
||||
response: credential,
|
||||
challenge: options.challenge,
|
||||
});
|
||||
handleLoginSuccess(data);
|
||||
} catch (err: any) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
setError('Passkey authentication was cancelled');
|
||||
} else {
|
||||
setError(err.response?.data?.message || err.message || 'Passkey login failed');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSso = ssoProviders.google || ssoProviders.azure;
|
||||
|
||||
// MFA verification screen
|
||||
if (loginState === 'mfa') {
|
||||
return (
|
||||
<Container size={420} my={80}>
|
||||
<Center>
|
||||
@@ -160,136 +64,9 @@ export function LoginPage() {
|
||||
alt="HOA LedgerIQ"
|
||||
style={{
|
||||
height: 60,
|
||||
...(isDark
|
||||
? {
|
||||
filter:
|
||||
'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<Stack>
|
||||
<Group gap="xs" justify="center">
|
||||
<IconShieldLock size={24} />
|
||||
<Text fw={600} size="lg">
|
||||
Two-Factor Authentication
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{error && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!useRecovery ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</Text>
|
||||
<Center>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
value={mfaCode}
|
||||
onChange={setMfaCode}
|
||||
oneTimeCode
|
||||
autoFocus
|
||||
size="lg"
|
||||
/>
|
||||
</Center>
|
||||
<Button
|
||||
fullWidth
|
||||
loading={loading}
|
||||
onClick={handleMfaVerify}
|
||||
disabled={mfaCode.length !== 6}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
<Anchor
|
||||
size="sm"
|
||||
ta="center"
|
||||
onClick={() => {
|
||||
setUseRecovery(true);
|
||||
setError('');
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
Use a recovery code instead
|
||||
</Anchor>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Enter one of your recovery codes
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholder="xxxxxxxx"
|
||||
value={recoveryCode}
|
||||
onChange={(e) => setRecoveryCode(e.currentTarget.value)}
|
||||
autoFocus
|
||||
ff="monospace"
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
loading={loading}
|
||||
onClick={handleMfaVerify}
|
||||
disabled={!recoveryCode.trim()}
|
||||
>
|
||||
Verify Recovery Code
|
||||
</Button>
|
||||
<Anchor
|
||||
size="sm"
|
||||
ta="center"
|
||||
onClick={() => {
|
||||
setUseRecovery(false);
|
||||
setError('');
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
Use authenticator code instead
|
||||
</Anchor>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Anchor
|
||||
size="sm"
|
||||
ta="center"
|
||||
onClick={() => {
|
||||
setLoginState('credentials');
|
||||
setMfaToken('');
|
||||
setMfaCode('');
|
||||
setRecoveryCode('');
|
||||
setError('');
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
← Back to login
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// Main login form
|
||||
return (
|
||||
<Container size={420} my={80}>
|
||||
<Center>
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt="HOA LedgerIQ"
|
||||
style={{
|
||||
height: 60,
|
||||
...(isDark
|
||||
? {
|
||||
filter:
|
||||
'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
||||
}
|
||||
: {}),
|
||||
...(isDark ? {
|
||||
filter: 'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
||||
} : {}),
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
@@ -325,53 +102,6 @@ export function LoginPage() {
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
{/* Passkey login */}
|
||||
{passkeySupported && (
|
||||
<>
|
||||
<Divider label="or" labelPosition="center" my="md" />
|
||||
<Button
|
||||
variant="light"
|
||||
fullWidth
|
||||
leftSection={<IconFingerprint size={18} />}
|
||||
onClick={handlePasskeyLogin}
|
||||
loading={loading}
|
||||
>
|
||||
Sign in with Passkey
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SSO providers */}
|
||||
{hasSso && (
|
||||
<>
|
||||
<Divider label="or continue with" labelPosition="center" my="md" />
|
||||
<Group grow>
|
||||
{ssoProviders.google && (
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconBrandGoogle size={18} color="#4285F4" />}
|
||||
onClick={() => {
|
||||
window.location.href = '/api/auth/google';
|
||||
}}
|
||||
>
|
||||
Google
|
||||
</Button>
|
||||
)}
|
||||
{ssoProviders.azure && (
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconBrandWindows size={18} color="#0078D4" />}
|
||||
onClick={() => {
|
||||
window.location.href = '/api/auth/azure';
|
||||
}}
|
||||
>
|
||||
Microsoft
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -494,6 +494,35 @@ export function DashboardPage() {
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Title order={4} mb="sm">Recent Transactions</Title>
|
||||
{(data?.recent_transactions || []).length === 0 ? (
|
||||
<Text c="dimmed" size="sm">No transactions yet. Start by entering journal entries.</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Tbody>
|
||||
{(data?.recent_transactions || []).map((tx) => (
|
||||
<Table.Tr key={tx.id}>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{new Date(tx.entry_date).toLocaleDateString()}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" lineClamp={1}>{tx.description}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" color={entryTypeColors[tx.entry_type] || 'gray'} variant="light">
|
||||
{tx.entry_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" fw={500}>
|
||||
{fmt(tx.amount)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Title order={4}>Quick Stats</Title>
|
||||
<Stack mt="sm" gap="xs">
|
||||
@@ -554,35 +583,6 @@ export function DashboardPage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card withBorder padding="lg" radius="md">
|
||||
<Title order={4} mb="sm">Recent Transactions</Title>
|
||||
{(data?.recent_transactions || []).length === 0 ? (
|
||||
<Text c="dimmed" size="sm">No transactions yet. Start by entering journal entries.</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Tbody>
|
||||
{(data?.recent_transactions || []).map((tx) => (
|
||||
<Table.Tr key={tx.id}>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{new Date(tx.entry_date).toLocaleDateString()}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" lineClamp={1}>{tx.description}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" color={entryTypeColors[tx.entry_type] || 'gray'} variant="light">
|
||||
{tx.entry_type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" fw={500}>
|
||||
{fmt(tx.amount)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container, Title, Text, Stack, Card, Group, Button, TextInput,
|
||||
Select, Stepper, ThemeIcon, Progress, Alert, Loader, Center, Anchor,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import {
|
||||
IconUser, IconBuilding, IconUserPlus, IconListDetails,
|
||||
IconCheck, IconPlayerPlay, IconConfetti,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
const STEPS = [
|
||||
{ slug: 'profile', label: 'Complete Your Profile', icon: IconUser, description: 'Set up your name and contact' },
|
||||
{ slug: 'workspace', label: 'Configure Your HOA', icon: IconBuilding, description: 'Organization name and settings' },
|
||||
{ slug: 'invite_member', label: 'Invite a Team Member', icon: IconUserPlus, description: 'Add a board member or manager' },
|
||||
{ slug: 'first_workflow', label: 'Set Up First Account', icon: IconListDetails, description: 'Create your chart of accounts' },
|
||||
];
|
||||
|
||||
export function OnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
|
||||
const { data: progress, isLoading } = useQuery({
|
||||
queryKey: ['onboarding-progress'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/onboarding/progress');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const markStep = useMutation({
|
||||
mutationFn: (step: string) => api.patch('/onboarding/progress', { step }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['onboarding-progress'] }),
|
||||
});
|
||||
|
||||
const completedSteps = progress?.completedSteps || [];
|
||||
const completedCount = completedSteps.length;
|
||||
const allDone = progress?.completedAt != null;
|
||||
|
||||
// Profile form
|
||||
const profileForm = useForm({
|
||||
initialValues: {
|
||||
firstName: user?.firstName || '',
|
||||
lastName: user?.lastName || '',
|
||||
phone: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Workspace form
|
||||
const workspaceForm = useForm({
|
||||
initialValues: { orgName: '', address: '', fiscalYearStart: '1' },
|
||||
});
|
||||
|
||||
// Invite form
|
||||
const inviteForm = useForm({
|
||||
initialValues: { email: '', role: 'treasurer' },
|
||||
validate: { email: (v) => (/\S+@\S+/.test(v) ? null : 'Valid email required') },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-advance to first incomplete step
|
||||
const firstIncomplete = STEPS.findIndex((s) => !completedSteps.includes(s.slug));
|
||||
if (firstIncomplete >= 0) setActiveStep(firstIncomplete);
|
||||
}, [completedSteps]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Center h={400}><Loader size="lg" /></Center>;
|
||||
}
|
||||
|
||||
if (allDone) {
|
||||
return (
|
||||
<Container size="sm" py={60}>
|
||||
<Center>
|
||||
<Stack align="center" gap="lg">
|
||||
<ThemeIcon size={60} radius="xl" color="green" variant="light">
|
||||
<IconConfetti size={30} />
|
||||
</ThemeIcon>
|
||||
<Title order={2}>You're all set!</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
Your workspace is ready. Let's get to work.
|
||||
</Text>
|
||||
<Button size="lg" onClick={() => navigate('/dashboard')}>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="md" py={40}>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={2}>Welcome to HOA LedgerIQ</Title>
|
||||
<Text c="dimmed" size="sm">Complete these steps to set up your workspace</Text>
|
||||
</div>
|
||||
|
||||
<Progress value={(completedCount / STEPS.length) * 100} size="lg" color="teal" />
|
||||
<Text size="sm" c="dimmed" ta="center">{completedCount} of {STEPS.length} steps complete</Text>
|
||||
|
||||
<Stepper
|
||||
active={activeStep}
|
||||
onStepClick={setActiveStep}
|
||||
orientation="vertical"
|
||||
size="sm"
|
||||
>
|
||||
{/* Step 1: Profile */}
|
||||
<Stepper.Step
|
||||
label={STEPS[0].label}
|
||||
description={STEPS[0].description}
|
||||
icon={completedSteps.includes('profile') ? <IconCheck size={16} /> : <IconUser size={16} />}
|
||||
completedIcon={<IconCheck size={16} />}
|
||||
color={completedSteps.includes('profile') ? 'green' : undefined}
|
||||
>
|
||||
<Card withBorder p="lg" mt="sm">
|
||||
<form onSubmit={profileForm.onSubmit(() => markStep.mutate('profile'))}>
|
||||
<Stack>
|
||||
<Group grow>
|
||||
<TextInput label="First Name" {...profileForm.getInputProps('firstName')} />
|
||||
<TextInput label="Last Name" {...profileForm.getInputProps('lastName')} />
|
||||
</Group>
|
||||
<TextInput label="Phone (optional)" {...profileForm.getInputProps('phone')} />
|
||||
<Button type="submit" loading={markStep.isPending}>Save & Continue</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
|
||||
{/* Step 2: Workspace */}
|
||||
<Stepper.Step
|
||||
label={STEPS[1].label}
|
||||
description={STEPS[1].description}
|
||||
icon={completedSteps.includes('workspace') ? <IconCheck size={16} /> : <IconBuilding size={16} />}
|
||||
completedIcon={<IconCheck size={16} />}
|
||||
color={completedSteps.includes('workspace') ? 'green' : undefined}
|
||||
>
|
||||
<Card withBorder p="lg" mt="sm">
|
||||
<form onSubmit={workspaceForm.onSubmit(() => markStep.mutate('workspace'))}>
|
||||
<Stack>
|
||||
<TextInput label="Organization Name" placeholder="Sunset Village HOA" {...workspaceForm.getInputProps('orgName')} />
|
||||
<TextInput label="Address" placeholder="123 Main St" {...workspaceForm.getInputProps('address')} />
|
||||
<Select
|
||||
label="Fiscal Year Start Month"
|
||||
data={[
|
||||
{ value: '1', label: 'January' },
|
||||
{ value: '4', label: 'April' },
|
||||
{ value: '7', label: 'July' },
|
||||
{ value: '10', label: 'October' },
|
||||
]}
|
||||
{...workspaceForm.getInputProps('fiscalYearStart')}
|
||||
/>
|
||||
<Button type="submit" loading={markStep.isPending}>Save & Continue</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
|
||||
{/* Step 3: Invite */}
|
||||
<Stepper.Step
|
||||
label={STEPS[2].label}
|
||||
description={STEPS[2].description}
|
||||
icon={completedSteps.includes('invite_member') ? <IconCheck size={16} /> : <IconUserPlus size={16} />}
|
||||
completedIcon={<IconCheck size={16} />}
|
||||
color={completedSteps.includes('invite_member') ? 'green' : undefined}
|
||||
>
|
||||
<Card withBorder p="lg" mt="sm">
|
||||
<form onSubmit={inviteForm.onSubmit(() => markStep.mutate('invite_member'))}>
|
||||
<Stack>
|
||||
<TextInput label="Email Address" placeholder="teammate@example.com" {...inviteForm.getInputProps('email')} />
|
||||
<Select
|
||||
label="Role"
|
||||
data={[
|
||||
{ value: 'president', label: 'President' },
|
||||
{ value: 'treasurer', label: 'Treasurer' },
|
||||
{ value: 'secretary', label: 'Secretary' },
|
||||
{ value: 'member_at_large', label: 'Member at Large' },
|
||||
{ value: 'manager', label: 'Manager' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
]}
|
||||
{...inviteForm.getInputProps('role')}
|
||||
/>
|
||||
<Group>
|
||||
<Button type="submit" loading={markStep.isPending}>Send Invite & Continue</Button>
|
||||
<Button variant="subtle" onClick={() => markStep.mutate('invite_member')}>
|
||||
Skip for now
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
|
||||
{/* Step 4: First Account */}
|
||||
<Stepper.Step
|
||||
label={STEPS[3].label}
|
||||
description={STEPS[3].description}
|
||||
icon={completedSteps.includes('first_workflow') ? <IconCheck size={16} /> : <IconListDetails size={16} />}
|
||||
completedIcon={<IconCheck size={16} />}
|
||||
color={completedSteps.includes('first_workflow') ? 'green' : undefined}
|
||||
>
|
||||
<Card withBorder p="lg" mt="sm">
|
||||
<Stack>
|
||||
<Text size="sm">
|
||||
Your chart of accounts has been pre-configured with standard HOA accounts.
|
||||
You can review and customize them now, or do it later.
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<IconListDetails size={16} />}
|
||||
onClick={() => {
|
||||
markStep.mutate('first_workflow');
|
||||
navigate('/accounts');
|
||||
}}
|
||||
>
|
||||
Review Accounts
|
||||
</Button>
|
||||
<Button variant="subtle" onClick={() => markStep.mutate('first_workflow')}>
|
||||
Use defaults & Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
|
||||
<Group justify="center" mt="md">
|
||||
<Button variant="subtle" color="gray" onClick={() => navigate('/dashboard')}>
|
||||
Finish Later
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Container, Center, Stack, Loader, Text, Title, Alert, Button } from '@mantine/core';
|
||||
import { IconCheck, IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
|
||||
export function OnboardingPendingPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const sessionId = searchParams.get('session_id');
|
||||
const [status, setStatus] = useState<string>('polling');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setError('No session ID provided');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/billing/status?session_id=${sessionId}`);
|
||||
if (cancelled) return;
|
||||
|
||||
if (data.status === 'active') {
|
||||
setStatus('complete');
|
||||
// Redirect to login page — user will get activation email
|
||||
setTimeout(() => navigate('/login'), 3000);
|
||||
} else if (data.status === 'not_configured') {
|
||||
setError('Payment system is not configured. Please contact support.');
|
||||
} else {
|
||||
// Still provisioning — poll again
|
||||
setTimeout(poll, 3000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!cancelled) {
|
||||
setError(err.response?.data?.message || 'Failed to check status');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
return () => { cancelled = true; };
|
||||
}, [sessionId, navigate]);
|
||||
|
||||
return (
|
||||
<Container size="sm" py={80}>
|
||||
<Center>
|
||||
<Stack align="center" gap="lg">
|
||||
{error ? (
|
||||
<>
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
||||
{error}
|
||||
</Alert>
|
||||
<Button variant="light" onClick={() => navigate('/pricing')}>
|
||||
Back to Pricing
|
||||
</Button>
|
||||
</>
|
||||
) : status === 'complete' ? (
|
||||
<>
|
||||
<IconCheck size={48} color="var(--mantine-color-green-6)" />
|
||||
<Title order={2}>Your account is ready!</Title>
|
||||
<Text c="dimmed" ta="center">
|
||||
Check your email for an activation link to set your password and get started.
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">Redirecting to login...</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader size="xl" />
|
||||
<Title order={2}>Setting up your account...</Title>
|
||||
<Text c="dimmed" ta="center" maw={400}>
|
||||
We're creating your HOA workspace. This usually takes just a few seconds.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
|
||||
export function UserPreferencesPage() {
|
||||
const { user, currentOrg } = useAuthStore();
|
||||
const { colorScheme, toggleColorScheme, compactView, toggleCompactView } = usePreferencesStore();
|
||||
const { colorScheme, toggleColorScheme } = usePreferencesStore();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
@@ -78,7 +78,7 @@ export function UserPreferencesPage() {
|
||||
<Text size="sm">Compact View</Text>
|
||||
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
|
||||
</div>
|
||||
<Switch checked={compactView} onChange={toggleCompactView} />
|
||||
<Switch disabled />
|
||||
</Group>
|
||||
<Divider />
|
||||
<Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge,
|
||||
Button, List, ThemeIcon, TextInput, Center, Alert,
|
||||
} from '@mantine/core';
|
||||
import { IconCheck, IconX, IconRocket, IconStar, IconCrown, IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import logoSrc from '../../assets/logo.png';
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
price: '$29',
|
||||
period: '/month',
|
||||
description: 'For small communities getting started',
|
||||
icon: IconRocket,
|
||||
color: 'blue',
|
||||
features: [
|
||||
{ text: 'Up to 50 units', included: true },
|
||||
{ text: 'Chart of Accounts', included: true },
|
||||
{ text: 'Assessment Tracking', included: true },
|
||||
{ text: 'Basic Reports', included: true },
|
||||
{ text: 'Board Planning', included: false },
|
||||
{ text: 'AI Investment Advisor', included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'professional',
|
||||
name: 'Professional',
|
||||
price: '$79',
|
||||
period: '/month',
|
||||
description: 'For growing HOAs that need full features',
|
||||
icon: IconStar,
|
||||
color: 'violet',
|
||||
popular: true,
|
||||
features: [
|
||||
{ text: 'Up to 200 units', included: true },
|
||||
{ text: 'Everything in Starter', included: true },
|
||||
{ text: 'Board Planning & Scenarios', included: true },
|
||||
{ text: 'AI Investment Advisor', included: true },
|
||||
{ text: 'Advanced Reports', included: true },
|
||||
{ text: 'Priority Support', included: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
price: '$199',
|
||||
period: '/month',
|
||||
description: 'For large communities and management firms',
|
||||
icon: IconCrown,
|
||||
color: 'orange',
|
||||
features: [
|
||||
{ text: 'Unlimited units', included: true },
|
||||
{ text: 'Everything in Professional', included: true },
|
||||
{ text: 'Priority Support', included: true },
|
||||
{ text: 'Custom Integrations', included: true },
|
||||
{ text: 'Dedicated Account Manager', included: true },
|
||||
{ text: 'SLA Guarantee', included: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function PricingPage() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [businessName, setBusinessName] = useState('');
|
||||
|
||||
const handleSelectPlan = async (planId: string) => {
|
||||
setLoading(planId);
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await api.post('/billing/create-checkout-session', {
|
||||
planId,
|
||||
email: email || undefined,
|
||||
businessName: businessName || undefined,
|
||||
});
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
setError('Unable to create checkout session');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to start checkout');
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="lg" py={60}>
|
||||
<Stack align="center" mb={40}>
|
||||
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
|
||||
<Title order={1} ta="center">
|
||||
Simple, transparent pricing
|
||||
</Title>
|
||||
<Text size="lg" c="dimmed" ta="center" maw={500}>
|
||||
Choose the plan that fits your community. All plans include a 14-day free trial.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Optional pre-capture fields */}
|
||||
<Center mb="xl">
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="HOA / Business name"
|
||||
value={businessName}
|
||||
onChange={(e) => setBusinessName(e.currentTarget.value)}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
</Group>
|
||||
</Center>
|
||||
|
||||
{error && (
|
||||
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="lg" variant="light">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{plans.map((plan) => (
|
||||
<Card
|
||||
key={plan.id}
|
||||
withBorder
|
||||
shadow={plan.popular ? 'lg' : 'sm'}
|
||||
radius="md"
|
||||
p="xl"
|
||||
style={plan.popular ? {
|
||||
border: '2px solid var(--mantine-color-violet-5)',
|
||||
position: 'relative',
|
||||
} : undefined}
|
||||
>
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
color="violet"
|
||||
variant="filled"
|
||||
style={{ position: 'absolute', top: -10, right: 20 }}
|
||||
>
|
||||
Most Popular
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Stack gap="md">
|
||||
<Group>
|
||||
<ThemeIcon size="lg" color={plan.color} variant="light" radius="md">
|
||||
<plan.icon size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} size="lg">{plan.name}</Text>
|
||||
<Text size="xs" c="dimmed">{plan.description}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group align="baseline" gap={4}>
|
||||
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: 36 }}>
|
||||
{plan.price}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">{plan.period}</Text>
|
||||
</Group>
|
||||
|
||||
<List spacing="xs" size="sm" center>
|
||||
{plan.features.map((f, i) => (
|
||||
<List.Item
|
||||
key={i}
|
||||
icon={
|
||||
<ThemeIcon
|
||||
size={20}
|
||||
radius="xl"
|
||||
color={f.included ? 'teal' : 'gray'}
|
||||
variant={f.included ? 'filled' : 'light'}
|
||||
>
|
||||
{f.included ? <IconCheck size={12} /> : <IconX size={12} />}
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
<Text c={f.included ? undefined : 'dimmed'}>{f.text}</Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
color={plan.color}
|
||||
variant={plan.popular ? 'filled' : 'light'}
|
||||
loading={loading === plan.id}
|
||||
onClick={() => handleSelectPlan(plan.id)}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Text ta="center" size="sm" c="dimmed" mt="xl">
|
||||
All plans include a 14-day free trial. No credit card required to start.
|
||||
</Text>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import {
|
||||
Card, Title, Text, Stack, Group, Button, Badge, Alert,
|
||||
} from '@mantine/core';
|
||||
import { IconBrandGoogle, IconBrandAzure, IconLink, IconLinkOff, IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import api from '../../services/api';
|
||||
|
||||
export function LinkedAccounts() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: providers } = useQuery({
|
||||
queryKey: ['sso-providers'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/auth/sso/providers');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ['auth-profile'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/auth/profile');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const unlinkMutation = useMutation({
|
||||
mutationFn: (provider: string) => api.delete(`/auth/sso/unlink/${provider}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['auth-profile'] });
|
||||
notifications.show({ message: 'Account unlinked', color: 'orange' });
|
||||
},
|
||||
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Failed to unlink', color: 'red' }),
|
||||
});
|
||||
|
||||
const noProviders = !providers?.google && !providers?.azure;
|
||||
|
||||
return (
|
||||
<Card withBorder p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={4}>Linked Accounts</Title>
|
||||
<Text size="sm" c="dimmed">Connect third-party accounts for single sign-on</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{noProviders && (
|
||||
<Alert color="gray" variant="light" icon={<IconAlertCircle size={16} />}>
|
||||
No SSO providers are configured. Contact your administrator to enable Google or Microsoft SSO.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="md">
|
||||
{providers?.google && (
|
||||
<Group justify="space-between" p="sm" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}>
|
||||
<Group>
|
||||
<IconBrandGoogle size={24} color="#4285F4" />
|
||||
<div>
|
||||
<Text fw={500}>Google</Text>
|
||||
<Text size="xs" c="dimmed">Sign in with your Google account</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<IconLink size={14} />}
|
||||
onClick={() => window.location.href = '/api/auth/google'}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{providers?.azure && (
|
||||
<Group justify="space-between" p="sm" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}>
|
||||
<Group>
|
||||
<IconBrandAzure size={24} color="#0078D4" />
|
||||
<div>
|
||||
<Text fw={500}>Microsoft</Text>
|
||||
<Text size="xs" c="dimmed">Sign in with your Microsoft account</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<IconLink size={14} />}
|
||||
onClick={() => window.location.href = '/api/auth/azure'}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Card, Title, Text, Stack, Group, Button, TextInput,
|
||||
PasswordInput, Alert, Code, SimpleGrid, Badge, Image,
|
||||
} from '@mantine/core';
|
||||
import { IconShieldCheck, IconShieldOff, IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import api from '../../services/api';
|
||||
|
||||
export function MfaSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [setupData, setSetupData] = useState<any>(null);
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
const [disablePassword, setDisablePassword] = useState('');
|
||||
const [showDisable, setShowDisable] = useState(false);
|
||||
|
||||
const { data: mfaStatus, isLoading } = useQuery({
|
||||
queryKey: ['mfa-status'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/auth/mfa/status');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: () => api.post('/auth/mfa/setup'),
|
||||
onSuccess: ({ data }) => setSetupData(data),
|
||||
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Setup failed', color: 'red' }),
|
||||
});
|
||||
|
||||
const enableMutation = useMutation({
|
||||
mutationFn: (token: string) => api.post('/auth/mfa/enable', { token }),
|
||||
onSuccess: ({ data }) => {
|
||||
setRecoveryCodes(data.recoveryCodes);
|
||||
setSetupData(null);
|
||||
setVerifyCode('');
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
||||
notifications.show({ message: 'MFA enabled successfully', color: 'green' });
|
||||
},
|
||||
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Invalid code', color: 'red' }),
|
||||
});
|
||||
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: (password: string) => api.post('/auth/mfa/disable', { password }),
|
||||
onSuccess: () => {
|
||||
setShowDisable(false);
|
||||
setDisablePassword('');
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
|
||||
notifications.show({ message: 'MFA disabled', color: 'orange' });
|
||||
},
|
||||
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Invalid password', color: 'red' }),
|
||||
});
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<Card withBorder p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={4}>Two-Factor Authentication (MFA)</Title>
|
||||
<Text size="sm" c="dimmed">Add an extra layer of security to your account</Text>
|
||||
</div>
|
||||
<Badge color={mfaStatus?.enabled ? 'green' : 'gray'} variant="light" size="lg">
|
||||
{mfaStatus?.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Recovery codes display (shown once after enable) */}
|
||||
{recoveryCodes && (
|
||||
<Alert color="orange" variant="light" mb="md" icon={<IconAlertCircle size={16} />} title="Save your recovery codes">
|
||||
<Text size="sm" mb="sm">
|
||||
These codes can be used to access your account if you lose your authenticator. Save them securely — they will not be shown again.
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="xs">
|
||||
{recoveryCodes.map((code, i) => (
|
||||
<Code key={i} block>{code}</Code>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Button variant="subtle" size="xs" mt="sm" onClick={() => setRecoveryCodes(null)}>
|
||||
I've saved my codes
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!mfaStatus?.enabled && !setupData && (
|
||||
<Button
|
||||
leftSection={<IconShieldCheck size={16} />}
|
||||
onClick={() => setupMutation.mutate()}
|
||||
loading={setupMutation.isPending}
|
||||
>
|
||||
Set Up MFA
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* QR Code Setup */}
|
||||
{setupData && (
|
||||
<Stack>
|
||||
<Text size="sm">Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.):</Text>
|
||||
<Group justify="center">
|
||||
<Image src={setupData.qrDataUrl} w={200} h={200} />
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Manual entry key: <Code>{setupData.secret}</Code>
|
||||
</Text>
|
||||
<TextInput
|
||||
label="Verification Code"
|
||||
placeholder="Enter 6-digit code"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.currentTarget.value)}
|
||||
maxLength={6}
|
||||
/>
|
||||
<Group>
|
||||
<Button
|
||||
onClick={() => enableMutation.mutate(verifyCode)}
|
||||
loading={enableMutation.isPending}
|
||||
disabled={verifyCode.length < 6}
|
||||
>
|
||||
Verify & Enable
|
||||
</Button>
|
||||
<Button variant="subtle" onClick={() => setSetupData(null)}>Cancel</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Disable MFA */}
|
||||
{mfaStatus?.enabled && !showDisable && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconShieldOff size={16} />}
|
||||
onClick={() => setShowDisable(true)}
|
||||
>
|
||||
Disable MFA
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showDisable && (
|
||||
<Stack mt="md">
|
||||
<Alert color="red" variant="light">
|
||||
Disabling MFA will make your account less secure. Enter your password to confirm.
|
||||
</Alert>
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
value={disablePassword}
|
||||
onChange={(e) => setDisablePassword(e.currentTarget.value)}
|
||||
/>
|
||||
<Group>
|
||||
<Button color="red" onClick={() => disableMutation.mutate(disablePassword)} loading={disableMutation.isPending}>
|
||||
Disable MFA
|
||||
</Button>
|
||||
<Button variant="subtle" onClick={() => setShowDisable(false)}>Cancel</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Card, Title, Text, Stack, Group, Button, TextInput,
|
||||
Table, Badge, ActionIcon, Tooltip, Alert,
|
||||
} from '@mantine/core';
|
||||
import { IconFingerprint, IconTrash, IconPlus, IconAlertCircle } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import api from '../../services/api';
|
||||
|
||||
export function PasskeySettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [deviceName, setDeviceName] = useState('');
|
||||
const [registering, setRegistering] = useState(false);
|
||||
|
||||
const { data: passkeys = [], isLoading } = useQuery({
|
||||
queryKey: ['passkeys'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/auth/passkeys');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/auth/passkeys/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
||||
notifications.show({ message: 'Passkey removed', color: 'orange' });
|
||||
},
|
||||
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Failed to remove', color: 'red' }),
|
||||
});
|
||||
|
||||
const handleRegister = async () => {
|
||||
setRegistering(true);
|
||||
try {
|
||||
// 1. Get registration options from server
|
||||
const { data: options } = await api.post('/auth/passkeys/register-options');
|
||||
|
||||
// 2. Create credential via browser WebAuthn API
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
|
||||
// 3. Send attestation to server for verification
|
||||
await api.post('/auth/passkeys/register', {
|
||||
response: credential,
|
||||
deviceName: deviceName || 'My Passkey',
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
|
||||
setDeviceName('');
|
||||
notifications.show({ message: 'Passkey registered successfully', color: 'green' });
|
||||
} catch (err: any) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
notifications.show({ message: 'Registration was cancelled', color: 'yellow' });
|
||||
} else {
|
||||
notifications.show({ message: err.response?.data?.message || err.message || 'Registration failed', color: 'red' });
|
||||
}
|
||||
} finally {
|
||||
setRegistering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const webauthnSupported = typeof window !== 'undefined' && !!window.PublicKeyCredential;
|
||||
|
||||
return (
|
||||
<Card withBorder p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={4}>Passkeys</Title>
|
||||
<Text size="sm" c="dimmed">Sign in with your fingerprint, face, or security key</Text>
|
||||
</div>
|
||||
<Badge color={passkeys.length > 0 ? 'green' : 'gray'} variant="light" size="lg">
|
||||
{passkeys.length} registered
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{!webauthnSupported && (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertCircle size={16} />} mb="md">
|
||||
Your browser doesn't support WebAuthn passkeys.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{passkeys.length > 0 && (
|
||||
<Table striped mb="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Device</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
<Table.Th>Last Used</Table.Th>
|
||||
<Table.Th w={60}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{passkeys.map((pk: any) => (
|
||||
<Table.Tr key={pk.id}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<IconFingerprint size={16} />
|
||||
<Text size="sm" fw={500}>{pk.device_name || 'Passkey'}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td><Text size="sm">{new Date(pk.created_at).toLocaleDateString()}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c={pk.last_used_at ? undefined : 'dimmed'}>
|
||||
{pk.last_used_at ? new Date(pk.last_used_at).toLocaleDateString() : 'Never'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label="Remove">
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(pk.id)}>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{webauthnSupported && (
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Device name (e.g., MacBook Pro)"
|
||||
value={deviceName}
|
||||
onChange={(e) => setDeviceName(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={handleRegister}
|
||||
loading={registering}
|
||||
>
|
||||
Register Passkey
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
|
||||
Tabs, Button, Switch,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBuilding, IconUser, IconSettings, IconShieldLock,
|
||||
IconFingerprint, IconLink, IconLogout,
|
||||
IconBuilding, IconUser, IconUsers, IconSettings, IconShieldLock,
|
||||
IconCalendar,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
import { MfaSettings } from './MfaSettings';
|
||||
import { PasskeySettings } from './PasskeySettings';
|
||||
import { LinkedAccounts } from './LinkedAccounts';
|
||||
import api from '../../services/api';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { user, currentOrg } = useAuthStore();
|
||||
const { compactView, toggleCompactView } = usePreferencesStore();
|
||||
const [loggingOutAll, setLoggingOutAll] = useState(false);
|
||||
|
||||
const handleLogoutEverywhere = async () => {
|
||||
setLoggingOutAll(true);
|
||||
try {
|
||||
await api.post('/auth/logout-everywhere');
|
||||
notifications.show({ message: 'All other sessions have been logged out', color: 'green' });
|
||||
} catch {
|
||||
notifications.show({ message: 'Failed to log out other sessions', color: 'red' });
|
||||
} finally {
|
||||
setLoggingOutAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
@@ -90,6 +68,33 @@ export function SettingsPage() {
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
<Card withBorder padding="lg">
|
||||
<Group mb="md">
|
||||
<ThemeIcon color="red" variant="light" size={40} radius="md">
|
||||
<IconShieldLock size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} size="lg">Security</Text>
|
||||
<Text c="dimmed" size="sm">Authentication and access</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Authentication</Text>
|
||||
<Badge color="green" variant="light">Active Session</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Two-Factor Auth</Text>
|
||||
<Badge color="gray" variant="light">Not Configured</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">OAuth Providers</Text>
|
||||
<Badge color="gray" variant="light">None Linked</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* System Info */}
|
||||
<Card withBorder padding="lg">
|
||||
<Group mb="md">
|
||||
@@ -108,87 +113,15 @@ export function SettingsPage() {
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Version</Text>
|
||||
<Badge variant="light">2026.03.17</Badge>
|
||||
<Badge variant="light">2026.03.10</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">API</Text>
|
||||
<Text size="sm" ff="monospace" c="dimmed">/api/docs</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="sm">Compact View</Text>
|
||||
<Text size="xs" c="dimmed">Reduce spacing in tables and lists</Text>
|
||||
</div>
|
||||
<Switch checked={compactView} onChange={toggleCompactView} />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Sessions */}
|
||||
<Card withBorder padding="lg">
|
||||
<Group mb="md">
|
||||
<ThemeIcon color="orange" variant="light" size={40} radius="md">
|
||||
<IconLogout size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} size="lg">Sessions</Text>
|
||||
<Text c="dimmed" size="sm">Manage active sessions</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Current Session</Text>
|
||||
<Badge color="green" variant="light">Active</Badge>
|
||||
</Group>
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="sm"
|
||||
leftSection={<IconLogout size={16} />}
|
||||
onClick={handleLogoutEverywhere}
|
||||
loading={loggingOutAll}
|
||||
mt="xs"
|
||||
>
|
||||
Log Out All Other Sessions
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider my="md" />
|
||||
|
||||
{/* Security Settings */}
|
||||
<div>
|
||||
<Title order={3} mb="sm">Security</Title>
|
||||
<Text c="dimmed" size="sm" mb="md">Manage authentication methods and security settings</Text>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="mfa">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="mfa" leftSection={<IconShieldLock size={16} />}>
|
||||
Two-Factor Auth
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="passkeys" leftSection={<IconFingerprint size={16} />}>
|
||||
Passkeys
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="linked" leftSection={<IconLink size={16} />}>
|
||||
Linked Accounts
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="mfa" pt="md">
|
||||
<MfaSettings />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="passkeys" pt="md">
|
||||
<PasskeySettings />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="linked" pt="md">
|
||||
<LinkedAccounts />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
withCredentials: true, // Send httpOnly cookies for refresh token
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
@@ -15,89 +14,23 @@ api.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
// ─── Silent Refresh Logic ─────────────────────────────────────────
|
||||
let isRefreshing = false;
|
||||
let pendingQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (err: any) => void;
|
||||
}> = [];
|
||||
|
||||
function processPendingQueue(error: any, token: string | null) {
|
||||
pendingQueue.forEach((p) => {
|
||||
if (error) {
|
||||
p.reject(error);
|
||||
} else {
|
||||
p.resolve(token!);
|
||||
}
|
||||
});
|
||||
pendingQueue = [];
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// If 401 and we haven't retried yet, try refreshing the token
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
originalRequest &&
|
||||
!originalRequest._retry &&
|
||||
!originalRequest.url?.includes('/auth/refresh') &&
|
||||
!originalRequest.url?.includes('/auth/login')
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
if (isRefreshing) {
|
||||
// Another request is already refreshing — queue this one
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingQueue.push({
|
||||
resolve: (token: string) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
resolve(api(originalRequest));
|
||||
},
|
||||
reject: (err: any) => reject(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true });
|
||||
const newToken = data.accessToken;
|
||||
useAuthStore.getState().setToken(newToken);
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
processPendingQueue(null, newToken);
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processPendingQueue(refreshError, null);
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-retryable 401 (e.g. refresh failed, login failed)
|
||||
if (error.response?.status === 401 && originalRequest?.url?.includes('/auth/refresh')) {
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
// Handle org suspended/archived — redirect to org selection
|
||||
const responseData = error.response?.data as any;
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
typeof responseData?.message === 'string' &&
|
||||
responseData.message.includes('has been')
|
||||
typeof error.response?.data?.message === 'string' &&
|
||||
error.response.data.message.includes('has been')
|
||||
) {
|
||||
const store = useAuthStore.getState();
|
||||
store.setCurrentOrg({ id: '', name: '', role: '' }); // Clear current org
|
||||
window.location.href = '/select-org';
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -33,7 +33,6 @@ interface AuthState {
|
||||
currentOrg: Organization | null;
|
||||
impersonationOriginal: ImpersonationOriginal | null;
|
||||
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
||||
setToken: (token: string) => void;
|
||||
setCurrentOrg: (org: Organization, token?: string) => void;
|
||||
setUserIntroSeen: () => void;
|
||||
setOrgSettings: (settings: Record<string, any>) => void;
|
||||
@@ -61,7 +60,6 @@ export const useAuthStore = create<AuthState>()(
|
||||
// Don't auto-select org — force user through SelectOrgPage
|
||||
currentOrg: null,
|
||||
}),
|
||||
setToken: (token) => set({ token }),
|
||||
setCurrentOrg: (org, token) =>
|
||||
set((state) => ({
|
||||
currentOrg: org,
|
||||
@@ -104,17 +102,14 @@ export const useAuthStore = create<AuthState>()(
|
||||
});
|
||||
}
|
||||
},
|
||||
logout: () => {
|
||||
// Fire-and-forget server-side logout to revoke refresh token cookie
|
||||
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {});
|
||||
logout: () =>
|
||||
set({
|
||||
token: null,
|
||||
user: null,
|
||||
organizations: [],
|
||||
currentOrg: null,
|
||||
impersonationOriginal: null,
|
||||
});
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'ledgeriq-auth',
|
||||
|
||||
@@ -5,26 +5,19 @@ type ColorScheme = 'light' | 'dark';
|
||||
|
||||
interface PreferencesState {
|
||||
colorScheme: ColorScheme;
|
||||
compactView: boolean;
|
||||
toggleColorScheme: () => void;
|
||||
setColorScheme: (scheme: ColorScheme) => void;
|
||||
toggleCompactView: () => void;
|
||||
setCompactView: (compact: boolean) => void;
|
||||
}
|
||||
|
||||
export const usePreferencesStore = create<PreferencesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
colorScheme: 'light',
|
||||
compactView: false,
|
||||
toggleColorScheme: () =>
|
||||
set((state) => ({
|
||||
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
|
||||
})),
|
||||
setColorScheme: (scheme) => set({ colorScheme: scheme }),
|
||||
toggleCompactView: () =>
|
||||
set((state) => ({ compactView: !state.compactView })),
|
||||
setCompactView: (compact) => set({ compactView: compact }),
|
||||
}),
|
||||
{
|
||||
name: 'ledgeriq-preferences',
|
||||
|
||||
@@ -1,57 +1,10 @@
|
||||
import { createTheme } from '@mantine/core';
|
||||
|
||||
const baseFontFamily = '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif';
|
||||
|
||||
export const defaultTheme = createTheme({
|
||||
export const theme = createTheme({
|
||||
primaryColor: 'blue',
|
||||
fontFamily: baseFontFamily,
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
|
||||
headings: {
|
||||
fontFamily: baseFontFamily,
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
|
||||
},
|
||||
defaultRadius: 'md',
|
||||
});
|
||||
|
||||
export const compactTheme = createTheme({
|
||||
primaryColor: 'blue',
|
||||
fontFamily: baseFontFamily,
|
||||
headings: {
|
||||
fontFamily: baseFontFamily,
|
||||
},
|
||||
defaultRadius: 'md',
|
||||
spacing: {
|
||||
xs: '4px',
|
||||
sm: '6px',
|
||||
md: '10px',
|
||||
lg: '12px',
|
||||
xl: '16px',
|
||||
},
|
||||
fontSizes: {
|
||||
xs: '11px',
|
||||
sm: '12px',
|
||||
md: '13px',
|
||||
lg: '15px',
|
||||
xl: '18px',
|
||||
},
|
||||
components: {
|
||||
Table: {
|
||||
defaultProps: {
|
||||
verticalSpacing: 'xs',
|
||||
horizontalSpacing: 'xs',
|
||||
fz: 'sm',
|
||||
},
|
||||
},
|
||||
Card: {
|
||||
defaultProps: {
|
||||
padding: 'sm',
|
||||
},
|
||||
},
|
||||
AppShell: {
|
||||
defaultProps: {
|
||||
padding: 'xs',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** @deprecated Use `defaultTheme` or `compactTheme` instead */
|
||||
export const theme = defaultTheme;
|
||||
|
||||
Reference in New Issue
Block a user