diff --git a/graphql/provision/provision.config.ts b/graphql/provision/provision.config.ts index 5210c65d7..95d1b48bf 100644 --- a/graphql/provision/provision.config.ts +++ b/graphql/provision/provision.config.ts @@ -26,14 +26,43 @@ export interface ProvisionRelation { }; } -export type ModuleSpec = string | [string, { scope?: string }]; +/** + * A provision module. Scoped modules MUST use tuple form + * (['permissions_module', { scope: 'app' }]) — the live provision proc rejects + * the colon-string form ('permissions_module:app') with a PROVISION-001 hard-fail. + */ +export type ModuleScope = { scope: 'app' | 'org' }; +export type ProvisionModule = string | [string, ModuleScope]; + +/** + * BASE tier default — the `auth:email` preset. Mirrors + * references/flows.json → email-password.backend.modules verbatim. + * + * NO org / memberships{org} / hierarchy / invites modules: a base scaffold + * ships no org/b2b code. B2B apps layer the org modules on top (see docs/B2B.md). + */ +export const AUTH_EMAIL_MODULES: ProvisionModule[] = [ + 'users_module', + 'membership_types_module', + ['permissions_module', { scope: 'app' }], + ['limits_module', { scope: 'app' }], + ['levels_module', { scope: 'app' }], + ['memberships_module', { scope: 'app' }], + 'sessions_module', + 'user_state_module', + 'user_credentials_module', + 'config_secrets_module', + 'emails_module', + 'rls_module', + 'user_auth_module' +]; export interface ProvisionConfig { platformAuth: string; platformApi: string; database: { name: string; - modules: ModuleSpec[]; + modules: ProvisionModule[]; bootstrapUser: boolean; }; auth: { @@ -96,7 +125,10 @@ export function defineProvisionConfig(config: ProvisionConfig): ProvisionConfig * platformApi: 'http://api.localhost:3000/graphql', * database: { * name: 'taskmanager', - * modules: ['all'], + * // BASE tier: the auth:email preset. Import AUTH_EMAIL_MODULES (above) + * // and spread it, adding only what your app needs. NEVER use ['all'] or + * // colon-string scoped modules — the provision proc rejects both. + * modules: AUTH_EMAIL_MODULES, * bootstrapUser: true, * }, * auth: { @@ -158,7 +190,10 @@ export default defineProvisionConfig({ platformApi: 'http://api.localhost:3000/graphql', database: { name: 'REPLACE_ME', - modules: ['all'] as ModuleSpec[], + // BASE tier default: the auth:email preset (see AUTH_EMAIL_MODULES above). + // Do NOT use ['all'] — the live provision proc rejects it. Layer org + // modules on top only for a b2b app (see docs/B2B.md). + modules: AUTH_EMAIL_MODULES, bootstrapUser: true, }, auth: { diff --git a/graphql/provision/provision.ts b/graphql/provision/provision.ts index 69e9fbc99..4f003a583 100644 --- a/graphql/provision/provision.ts +++ b/graphql/provision/provision.ts @@ -226,7 +226,9 @@ async function main(): Promise { ownerId: userId, subdomain: config.database.name, domain: 'localhost', - modules: config.database.modules as any, + // The generated SDK types `modules` as string[]; the server accepts the + // JSONB tuple form (['permissions_module', { scope: 'app' }]) at runtime. + modules: config.database.modules as unknown as string[], bootstrapUser: config.database.bootstrapUser, }, select: { id: true, databaseId: true, databaseName: true, status: true }, diff --git a/nextjs/constructive-app/docs/B2B.md b/nextjs/constructive-app/docs/B2B.md new file mode 100644 index 000000000..f0212a2f7 --- /dev/null +++ b/nextjs/constructive-app/docs/B2B.md @@ -0,0 +1,128 @@ +# B2B / Organizations — opt-in + +This template ships as a **BASE tier** app: the `auth:email` module set only. +Out of the box it has authentication (sign in / up / out, password reset, email +verification), an account/profile surface, and a place to build your own app +data. It ships **no** organization, members, roles, or invite code — so a base +app builds clean with zero org imports. + +Multi-tenant / team features (organizations, members, roles, org settings) are a +deliberate **opt-in**. You add them in two coordinated steps; you do **not** +hand-write the org UI — the registry org blocks already implement it. + +## 1. Provision the org modules + +The base default module set lives in `packages/provision/src/modules.ts` +(`AUTH_EMAIL_MODULES`). To go b2b, extend it with `ORG_MODULES` (also exported +from that file) when creating the database: + +```ts +// packages/provision/src/create-db.ts +import { asModules, AUTH_EMAIL_MODULES, ORG_MODULES } from './modules.js'; + +const APP_MODULES = [...AUTH_EMAIL_MODULES, ...ORG_MODULES]; +// ... +modules: asModules(APP_MODULES), +``` + +`ORG_MODULES` mirrors the `organization` flow's backend module set from the +flow catalog (`references/flows.json`): the org-scoped `permissions`, `limits`, +`levels`, `memberships`, `profiles`, `hierarchy` modules plus app- and +org-scoped `invites`. All scoped modules are tuple form +(`['memberships_module', { scope: 'org' }]`) — the colon-string form +(`'memberships_module:org'`) is rejected by the provision proc. + +After provisioning, re-run codegen (`pnpm codegen`) so the generated admin SDK +(`@sdk/admin`) gains the org / members / invites query + mutation hooks that the +org blocks consume. + +## 2. Add the registry org blocks + +Install the organization blocks from the Constructive blocks registry and mount +them on your org routes — they bring their own data hooks, wired to the +org-scoped admin SDK: + +```bash +npx shadcn@latest add org-create-card org-members-list org-roles-editor org-settings-form +``` + +| Block | Purpose | +| ------------------- | ---------------------------------------- | +| `org-create-card` | Create a new organization | +| `org-members-list` | List / manage members of an organization | +| `org-roles-editor` | Edit member roles & permissions | +| `org-settings-form` | Organization profile & settings | + +Then reintroduce the org navigation seam that the base intentionally leaves +minimal: + +- `src/lib/navigation/sidebar-config.ts` — add an `Organizations` nav entry and + an org-level nav group. +- `src/lib/navigation/use-entity-params.ts` — reintroduce the `orgId` path param + (and an org switcher) so `/orgs/[orgId]/*` routes resolve. +- `src/app-routes.ts` — add the org-scoped routes; the + `requiredPermission: 'app-admin'` gate in `RouteGuard` is already present for + app-admin surfaces to reuse. + +That's the whole opt-in: provision the modules, regenerate the SDK, drop in the +blocks, and wire the routes. No org UI is hand-written in this template. + +## 3. Prerequisite: the org-create RLS permission bit + +Creating an organization is a `createUser` with **`type = 2`** (an org is modelled +as an entity-typed user row). The `users` table is RLS-protected, and the INSERT +policy for entity-type rows requires the **acting** user to hold the +**`create_entity`** app permission. Without it the create fails with: + +``` +new row violates row-level security policy for table "users" +``` + +`create_entity` is the 5th app-permission bit defined by `initialize_permissions` +(app scope) — i.e. bit value `0x10000` (`1 << 16`) in the 64-bit app permission +mask. The actor must have it set on their **app membership** row before they call +the org-create mutation (the `org-create-card` block). + +How this template grants it: `packages/provision/src/create-db.ts` already +elevates the bootstrap admin to full permissions right after provisioning — it +resolves **this tenant's** `memberships_public` schema via the metaschema +(scoped by `database_id`, not a floating `LIKE`) and runs: + +```sql +UPDATE "_memberships_public".app_memberships + SET is_admin = true, is_owner = true, + permissions = (64 one-bits)::bit(64) -- includes the create_entity bit + WHERE actor_id = $1; +``` + +So the admin user that `create-db` registers can create orgs out of the box. For +**non-admin** users who must create orgs, grant just the `create_entity` bit on +their app membership (set bit `0x10000`, or via the admin SDK +`appMembership.update`) — do not blanket-grant `is_admin`. + +### Note: `@constructive-io/node` and `*.localhost` ("fetch failed") + +`create-db.ts` registers the org/admin user against the per-tenant auth host +`auth-.localhost` using `@constructive-io/node`'s `auth.createClient`. The +convenience `{ endpoint }` form builds a `FetchAdapter` that calls +`globalThis.fetch` directly. On many Linux/CI hosts Node cannot resolve +`*.localhost` (ENOTFOUND → **"fetch failed"**), and undici also drops a manual +`Host` header, breaking subdomain routing. (macOS resolves `*.localhost` to +127.0.0.1, so it often "just works" locally — but CI will not.) + +Workaround — pass a `NodeHttpAdapter` (exported by `@constructive-io/node`) +instead of `endpoint`; it rewrites the hostname to `localhost` and injects the +original host as the `Host` header: + +```ts +import { auth, NodeHttpAdapter } from '@constructive-io/node'; + +const dbAuthClient = auth.createClient({ + adapter: new NodeHttpAdapter(`http://auth-${databaseName}.localhost:3000/graphql`), +}); +``` + +The package's own raw-HTTP path (`helpers.rawExecute`) already routes through +`@constructive-io/fetch`'s `createFetch()`, which applies the same rewrite — so +prefer that (or `NodeHttpAdapter`) for any `*.localhost` call that errors with +"fetch failed" in CI. diff --git a/nextjs/constructive-app/graphql-codegen.config.ts b/nextjs/constructive-app/graphql-codegen.config.ts index 05bda79d5..082ba0ddb 100644 --- a/nextjs/constructive-app/graphql-codegen.config.ts +++ b/nextjs/constructive-app/graphql-codegen.config.ts @@ -44,25 +44,40 @@ if (!DB_NAME) { } // Codegen connects via subdomain-based virtual hosts. Endpoints use the -// per-DB subdomain pattern: admin-{db}.localhost, auth-{db}.localhost, -// app-{db}.localhost. The Host header controls server-side API routing. +// per-DB subdomain pattern: admin-{db}.localhost, auth-{db}.localhost, and the +// app DATA subdomain (api-{db}.localhost by default). The HTTP Host header — not +// the URL — drives server-side API routing, so a URL override alone still 404s; +// the Host must match the routed subdomain. +// +// The {db} segment is the database name with hyphens; PostGraphile maps it back +// to the physical db name by converting hyphens to underscores. Discover the +// exact per-DB domains from services_public.domains if the defaults don't match. +// +// Host overrides (used by both endpoint + Host header so they stay in sync): +// - CODEGEN_ADMIN_HOST (default: admin-{db}.localhost:3000) +// - CODEGEN_AUTH_HOST (default: auth-{db}.localhost:3000) +// - CODEGEN_APP_HOST (default: api-{db}.localhost:3000) ← app business data +const ADMIN_HOST = process.env.CODEGEN_ADMIN_HOST ?? `admin-${DB_NAME}.localhost:3000`; +const AUTH_HOST = process.env.CODEGEN_AUTH_HOST ?? `auth-${DB_NAME}.localhost:3000`; +const APP_HOST = process.env.CODEGEN_APP_HOST ?? `api-${DB_NAME}.localhost:3000`; + const config: Record = { admin: { reactQuery: true, - endpoint: process.env.CODEGEN_ADMIN_ENDPOINT ?? `http://admin-${DB_NAME}.localhost:3000/graphql`, - headers: { Host: `admin-${DB_NAME}.localhost:3000` }, + endpoint: process.env.CODEGEN_ADMIN_ENDPOINT ?? `http://${ADMIN_HOST}/graphql`, + headers: { Host: ADMIN_HOST }, output: './src/graphql/sdk/admin', }, auth: { reactQuery: true, - endpoint: process.env.CODEGEN_AUTH_ENDPOINT ?? `http://auth-${DB_NAME}.localhost:3000/graphql`, - headers: { Host: `auth-${DB_NAME}.localhost:3000` }, + endpoint: process.env.CODEGEN_AUTH_ENDPOINT ?? `http://${AUTH_HOST}/graphql`, + headers: { Host: AUTH_HOST }, output: './src/graphql/sdk/auth', }, app: { reactQuery: true, - endpoint: process.env.CODEGEN_APP_ENDPOINT ?? `http://api-${DB_NAME}.localhost:3000/graphql`, - headers: { Host: `api-${DB_NAME}.localhost:3000` }, + endpoint: process.env.CODEGEN_APP_ENDPOINT ?? `http://${APP_HOST}/graphql`, + headers: { Host: APP_HOST }, output: './src/graphql/sdk/app', }, }; diff --git a/nextjs/constructive-app/package.json b/nextjs/constructive-app/package.json index cdf295ddc..1e1de8af2 100644 --- a/nextjs/constructive-app/package.json +++ b/nextjs/constructive-app/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack --port 3011", + "dev": "next dev --turbopack --port ${PORT:-3011}", "build": "next build", - "start": "next start", + "start": "next start --port ${PORT:-3011}", "lint": "eslint .", "lint:types": "tsc --noEmit", "deps": "pnpm up -r -i -L", diff --git a/nextjs/constructive-app/packages/provision/src/config.ts b/nextjs/constructive-app/packages/provision/src/config.ts index 69b4d3a6c..3a97ade31 100644 --- a/nextjs/constructive-app/packages/provision/src/config.ts +++ b/nextjs/constructive-app/packages/provision/src/config.ts @@ -1,4 +1,23 @@ -/** config.ts — Endpoint and credential configuration */ +/** + * config.ts — Centralized configuration for constructive-app provisioning + * + * The Constructive hub is Host-routed (one cnc server on :3000, virtual hosts): + * auth.localhost:3000 → Global auth API (sign up / sign in for create-db) + * api.localhost:3000 → Metaschema READS (apis/schemas/tables/databases) + * + createApiSchema / createDatabase (schema-builder) + * modules.localhost:3000 → Provisioning MUTATIONS: createDatabaseProvisionModule, + * createBlueprint, constructBlueprint, + * createSecureTableProvision (the metaschema_modules API) + * auth-{dbName}.localhost:3000 → Per-tenant auth (tokens valid for this DB's endpoints) + * api-{dbName}.localhost:3000 → Per-tenant app DATA (the codegen `app` SDK host) + * admin-{dbName}.localhost:3000 → Per-tenant admin API (orgs, members, permissions) + * + * CRITICAL: the provisioning mutations live on `modules.localhost`, NOT `api.localhost`. + * Pointing them at api.localhost fails with "Unknown type ...Input" because those + * input types are only registered on the modules host. See helpers.createModulesClient. + * + * Override via API_ENDPOINT / MODULES_ENDPOINT / AUTH_ENDPOINT env vars. + */ import dotenv from 'dotenv'; import * as path from 'path'; @@ -7,13 +26,31 @@ import * as path from 'path'; dotenv.config({ path: path.resolve(process.cwd(), '../../.env') }); export const config = { + /** Metaschema READ endpoint — apis/schemas/tables/databases + createApiSchema/createDatabase */ apiEndpoint: process.env.API_ENDPOINT || 'http://api.localhost:3000/graphql', + + /** Auth API endpoint — global metaschema sign up (used by create-db) */ authEndpoint: process.env.AUTH_ENDPOINT || 'http://auth.localhost:3000/graphql', - modulesEndpoint: process.env.MODULES_ENDPOINT || 'http://modules.localhost:3000/graphql', + + /** + * Provisioning MUTATION endpoint — createDatabaseProvisionModule, createBlueprint, + * constructBlueprint, createSecureTableProvision (the metaschema_modules API). + * These types are ONLY on the modules host; routing them to apiEndpoint fails with + * 'Unknown type ...Input'. MODULES_ENDPOINT is the canonical override (shipped in + * .env.example); PROVISION_MODULES_ENDPOINT / PG_PROVISION_MODULES_ENDPOINT are also + * accepted for parity with the PG-prefixed env vars used elsewhere. + */ + modulesEndpoint: + process.env.MODULES_ENDPOINT || + process.env.PROVISION_MODULES_ENDPOINT || + process.env.PG_PROVISION_MODULES_ENDPOINT || + 'http://modules.localhost:3000/graphql', get dbAuthEndpoint(): string { return process.env.DB_AUTH_ENDPOINT || `http://auth-${this.databaseName}.localhost:3000/graphql`; }, + + /** App DATA endpoint — per-tenant app business data (the `api-{dbName}` host codegen targets) */ get appEndpoint(): string { return process.env.APP_ENDPOINT || `http://api-${this.databaseName}.localhost:3000/graphql`; }, diff --git a/nextjs/constructive-app/packages/provision/src/create-db.ts b/nextjs/constructive-app/packages/provision/src/create-db.ts index fd1f86f89..3eb2d82b8 100644 --- a/nextjs/constructive-app/packages/provision/src/create-db.ts +++ b/nextjs/constructive-app/packages/provision/src/create-db.ts @@ -2,43 +2,24 @@ import { auth, public_ } from '@constructive-io/node'; -// Scoped modules use JSONB tuples: ['module_name', { scope: 'app'|'org' }] -// Plain strings for scope-less modules. -const APP_MODULES: (string | [string, { scope?: string }])[] = [ - // Core - 'users_module', - 'membership_types_module', - // App-level (scope: app) - ['permissions_module', { scope: 'app' }], - ['limits_module', { scope: 'app' }], - ['memberships_module', { scope: 'app' }], - ['events_module', { scope: 'app' }], - ['profiles_module', { scope: 'app' }], - ['levels_module', { scope: 'app' }], - // Org-level (scope: org) - ['permissions_module', { scope: 'org' }], - ['limits_module', { scope: 'org' }], - ['memberships_module', { scope: 'org' }], - ['events_module', { scope: 'org' }], - ['profiles_module', { scope: 'org' }], - ['levels_module', { scope: 'org' }], - ['hierarchy_module', { scope: 'org' }], - // Auth infrastructure - 'user_state_module', - 'sessions_module', - 'session_secrets_module', - 'rate_limits_module', - 'rls_module', - 'user_credentials_module', - 'config_secrets_module', - // Contact modules - 'emails_module', - // Invites - ['invites_module', { scope: 'app' }], - ['invites_module', { scope: 'org' }], - // Auth methods - 'user_auth_module', -]; +import { asModules, AUTH_EMAIL_MODULES, type ProvisionModule } from './modules.js'; + +// BASE tier default module set: the `auth:email` preset. +// +// This is the ~13-module set that backs the email-password / profile auth +// flows (see references/flows.json → email-password.backend.modules). It is +// deliberately scoped to a single-user (app-level) auth surface: NO org / +// memberships{org} / hierarchy / invites modules. A base scaffold ships no +// org/b2b code, so it does not need those modules provisioned. +// +// B2B OPT-IN: an app that adopts the registry org blocks +// (org-create-card / org-members-list / org-roles-editor / org-settings-form) +// extends this set with the org modules — see docs/B2B.md. +// +// Scoped modules MUST use tuple form (['permissions_module', { scope: 'app' }]). +// The live provision proc REJECTS the colon-string form +// ('permissions_module:app') with a PROVISION-001 hard-fail. +const APP_MODULES: ProvisionModule[] = AUTH_EMAIL_MODULES; import * as fs from 'fs'; import * as path from 'path'; @@ -46,7 +27,7 @@ import { fileURLToPath } from 'url'; import { Pool } from 'pg'; import { config } from './config.js'; -import { withRetry } from './helpers.js'; +import { withRetry, resolvePhysicalSchemaName } from './helpers.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -120,8 +101,12 @@ async function main() { } console.log(` Signed up (ID: ${userId})`); - // Step 2: Provision database — modules endpoint hosts databaseProvisionModule - console.log('\n Provisioning database...'); + // --- Step 2: Provision database (or use existing) --- + // createDatabaseProvisionModule lives on the MODULES host (modules.localhost), + // NOT api.localhost — its input type is only registered there. Routing it to + // apiEndpoint fails with 'Unknown type CreateDatabaseProvisionModuleInput'. + console.log('\n Provisioning database (modules endpoint)...'); + console.log(` Modules: ${config.modulesEndpoint}`); const modulesClient = public_.createClient({ endpoint: config.modulesEndpoint, headers: { Authorization: `Bearer ${accessToken}`, 'X-Meta-Schema': 'true' @@ -137,7 +122,7 @@ async function main() { ownerId: userId, subdomain: databaseName, domain: 'localhost', - modules: APP_MODULES as any, + modules: asModules(APP_MODULES), bootstrapUser: true, options: {} }, @@ -236,19 +221,19 @@ async function main() { let permissionsGranted = false; if (dbAdminUserId && pgAvailable) { try { - const permPool = new Pool({ database: config.pgInternalDatabase }); - // Find the app_memberships schema for this tenant - const schemaResult = await permPool.query( - `SELECT schema_name FROM information_schema.schemata - WHERE schema_name LIKE '%memberships_public' - AND schema_name LIKE '${databaseName}%' - ORDER BY schema_name` - ); - console.log(` Found ${schemaResult.rows.length} membership schemas:`, - schemaResult.rows.map((r: any) => r.schema_name).join(', ') || '(none)'); - // app_memberships comes before org_memberships alphabetically - const membershipsSchema = schemaResult.rows[0]?.schema_name; + // Resolve THIS tenant's physical app-memberships schema via the metaschema + // (scoped by database_id), NOT a floating `LIKE '%memberships_public'` — + // on a shared hub that LIKE can match a SIBLING tenant and corrupt its + // permissions. The metaschema read uses the freshly-issued accessToken + // (config.databaseId/ACCESS_TOKEN aren't in process env until .env is written). + const membershipsSchema = await resolvePhysicalSchemaName(databaseId, 'memberships_public', { + token: accessToken, + }); + console.log(` Memberships schema (this tenant): ${membershipsSchema ?? '(none)'}`); if (membershipsSchema) { + // Connect to the internal PG database (NOT PGDATABASE which may be 'postgres'). + // Schema-based multi-tenancy stores all tenant schemas in this DB. + const permPool = new Pool({ database: config.pgInternalDatabase }); const updateResult = await permPool.query( `UPDATE "${membershipsSchema}".app_memberships SET is_admin = true, is_owner = true, @@ -258,10 +243,10 @@ async function main() { ); console.log(` Admin permissions granted (SQL): ${updateResult.rowCount} row(s) updated`); permissionsGranted = true; + await permPool.end(); } else { - console.warn(' No memberships_public schema found for permission grant'); + console.warn(' No memberships_public schema found for this tenant — will try GraphQL fallback'); } - await permPool.end(); } catch (err: any) { console.warn(` SQL permission grant failed: ${err.message?.split('\n')[0]}`); } diff --git a/nextjs/constructive-app/packages/provision/src/helpers.ts b/nextjs/constructive-app/packages/provision/src/helpers.ts index 007f11df1..47156aecb 100644 --- a/nextjs/constructive-app/packages/provision/src/helpers.ts +++ b/nextjs/constructive-app/packages/provision/src/helpers.ts @@ -65,19 +65,44 @@ export async function rawExecute( return { ok: true, data: json.data as T }; } -/** API endpoint client — database, schema, api, apiSchema, domain, etc. */ -export function createMetaschemaClient(): ReturnType { +/** + * Build the standard metaschema auth headers (bearer + X-Meta-Schema, plus + * X-Database-Id when known). Shared by the api-host and modules-host clients. + */ +function metaschemaHeaders(): Record { const token = config.accessToken; if (!token) throw new Error('ACCESS_TOKEN is required'); const headers: Record = { Authorization: `Bearer ${token}`, - 'X-Meta-Schema': 'true' + 'X-Meta-Schema': 'true', }; if (config.databaseId) { headers['X-Database-Id'] = config.databaseId; } - return public_.createClient({ endpoint: config.apiEndpoint, headers }); + return headers; +} + +/** + * Create a metaschema READ client (api.localhost). Use this for metaschema + * READS — api/schema/table/database findMany — and for createApiSchema / + * createDatabase. The provisioning MUTATIONS (blueprint / constructBlueprint / + * databaseProvisionModule / secureTableProvision) are NOT on this host; use + * {@link createModulesClient} for those. + */ +export function createMetaschemaClient(): ReturnType { + return public_.createClient({ endpoint: config.apiEndpoint, headers: metaschemaHeaders() }); +} + +/** + * Create a provisioning MUTATION client (modules.localhost). The + * metaschema_modules API hosts createDatabaseProvisionModule, createBlueprint, + * constructBlueprint and createSecureTableProvision — their input types are ONLY + * registered here, so routing them through {@link createMetaschemaClient} + * (api.localhost) fails with 'Unknown type ...Input'. + */ +export function createModulesClient(): ReturnType { + return public_.createClient({ endpoint: config.modulesEndpoint, headers: metaschemaHeaders() }); } /** Platform-level auth client (auth.localhost). Tokens NOT valid for db-scoped endpoints. */ @@ -109,6 +134,41 @@ export async function executeAuthMutation( return result.data as T; } +/** + * Resolve the PHYSICAL postgres schema name for a tenant from its LOGICAL + * metaschema name (e.g. 'memberships_public' → 'myapp_memberships_public' or + * 'myapp--memberships-public', depending on the server naming mode). + * + * This anchors raw SQL to THIS tenant's schema via the metaschema (scoped by + * database_id) instead of a floating `LIKE '%memberships_public'`, which on a + * shared hub can land on a SIBLING tenant's schema — a data-corruption risk. + * + * Returns undefined if the database has no schema with that logical name. + * `endpoint`/`token` default to the metaschema READ host (api.localhost) and the + * configured access token, but are overridable for create-db (which holds a + * freshly-issued token before .env is written). + */ +export async function resolvePhysicalSchemaName( + databaseId: string, + logicalName: string, + opts: { endpoint?: string; token?: string } = {} +): Promise { + const token = opts.token ?? config.accessToken; + if (!token) throw new Error('ACCESS_TOKEN is required to resolve schema names'); + const client = public_.createClient({ + endpoint: opts.endpoint ?? config.apiEndpoint, + headers: { Authorization: `Bearer ${token}`, 'X-Meta-Schema': 'true', 'X-Database-Id': databaseId }, + }); + const result = await client.schema + .findMany({ + where: { databaseId: { equalTo: databaseId }, name: { equalTo: logicalName } }, + select: { id: true, name: true, schemaName: true }, + }) + .unwrap(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (result as any)?.schemas?.nodes?.[0]?.schemaName; +} + /** Require DATABASE_ID from config, exit if missing. */ export function requireDatabaseId(): string { const id = config.databaseId; diff --git a/nextjs/constructive-app/packages/provision/src/modules.ts b/nextjs/constructive-app/packages/provision/src/modules.ts new file mode 100644 index 000000000..c5ec01b59 --- /dev/null +++ b/nextjs/constructive-app/packages/provision/src/modules.ts @@ -0,0 +1,72 @@ +/** + * modules.ts — Provision module presets for the constructive-app. + * + * Source of truth: references/flows.json (the agentic-flow flow catalog). + * The tuples below mirror `email-password.backend.modules` / `profile.backend.modules` + * exactly — those entries are already provisioning-ready. + * + * IMPORTANT — module shape: + * - Unscoped modules are plain strings: 'users_module' + * - Scoped modules MUST use tuple form: ['permissions_module', { scope: 'app' }] + * + * The live `databaseProvisionModule` proc REJECTS the legacy colon-string form + * ('permissions_module:app') with a PROVISION-001 hard-fail. The generated SDK + * types `modules` as `string[]` because the underlying column is JSONB; pass + * `asModules(...)` at the call site to satisfy the SDK boundary. + */ + +export type ModuleScope = { scope: 'app' | 'org' }; +export type ProvisionModule = string | [string, ModuleScope]; + +/** + * BASE tier — the `auth:email` preset. + * + * The ~13-module single-user auth surface backing the email-password / profile + * flows. NO org / memberships{org} / hierarchy / invites modules — a base + * scaffold ships no org/b2b code, so it does not provision those modules. + * + * Mirrors references/flows.json → email-password.backend.modules verbatim. + */ +export const AUTH_EMAIL_MODULES: ProvisionModule[] = [ + 'users_module', + 'membership_types_module', + ['permissions_module', { scope: 'app' }], + ['limits_module', { scope: 'app' }], + ['levels_module', { scope: 'app' }], + ['memberships_module', { scope: 'app' }], + 'sessions_module', + 'user_state_module', + 'user_credentials_module', + 'config_secrets_module', + 'emails_module', + 'rls_module', + 'user_auth_module' +]; + +/** + * B2B OPT-IN — the org modules layered on top of {@link AUTH_EMAIL_MODULES}. + * + * An app that adopts the registry org blocks (org-create-card, + * org-members-list, org-roles-editor, org-settings-form) provisions these in + * addition to the base set. Mirrors references/flows.json → + * organization.backend.modules (the org-scoped half). See docs/B2B.md. + */ +export const ORG_MODULES: ProvisionModule[] = [ + ['permissions_module', { scope: 'org' }], + ['limits_module', { scope: 'org' }], + ['levels_module', { scope: 'org' }], + ['memberships_module', { scope: 'org' }], + ['profiles_module', { scope: 'org' }], + ['hierarchy_module', { scope: 'org' }], + ['invites_module', { scope: 'app' }], + ['invites_module', { scope: 'org' }] +]; + +/** + * Cast a typed module preset to the `string[]` shape the generated SDK expects. + * The server accepts the JSONB tuple form at runtime; this only bridges the + * narrow generated TypeScript signature. + */ +export function asModules(modules: ProvisionModule[]): string[] { + return modules as unknown as string[]; +} diff --git a/nextjs/constructive-app/packages/provision/src/provision.ts b/nextjs/constructive-app/packages/provision/src/provision.ts index aa453b36b..9758dddf2 100644 --- a/nextjs/constructive-app/packages/provision/src/provision.ts +++ b/nextjs/constructive-app/packages/provision/src/provision.ts @@ -1,14 +1,305 @@ -/** provision.ts — Attach schemas to app API, set membership defaults. Usage: pnpm run provision */ +/** + * provision.ts — Post-creation setup for the constructive-app + * + * Reads DATABASE_ID, ACCESS_TOKEN, DATABASE_NAME from .env (set by create-db) + * and: + * 1. Attaches entity schemas to the app API + * 2. Grants users self-update (control-plane: AuthzDirectOwner self_update) + * 3. Sets app membership defaults (auto-approve new users) + * 4. Provisions the migrate API for export (ddl_audit_public.sql_actions), + * so `pnpm export:graphql` can read the DB module's migration SQL + * + * App-table provisioning: the agent declares its own tables by calling + * `provisionAppTable()` (a baked-in, owner-scoped blueprint helper — see below) + * from this script after the schema-attach step. The helper already uses + * object-form grants and the AuthzDirectOwner default, so a base auth:email app + * doesn't have to re-derive the grant/policy shape on every build. + * + * Usage: pnpm run provision + */ import * as fs from 'fs'; import * as path from 'path'; import { Pool } from 'pg'; import { config } from './config.js'; -import { withRetry, createMetaschemaClient, requireDatabaseId } from './helpers.js'; +import { + withRetry, + createMetaschemaClient, + createModulesClient, + resolvePhysicalSchemaName, + requireDatabaseId, +} from './helpers.js'; + +// `read` client = api.localhost (apis/schemas/tables/databases + createApiSchema). +// `modules` client = modules.localhost (blueprint / constructBlueprint / +// secureTableProvision). The two hosts expose disjoint type sets, so each call +// MUST go to the right one. +type MetaschemaClient = ReturnType; +type ModulesClient = ReturnType; const PG_DATABASE = config.pgInternalDatabase; +// --------------------------------------------------------------------------- +// PROVEN RECIPE (a): users-table self-update control-plane step +// +// The `users` table lives in the `users_public` schema, provisioned by the +// `users_module`. Out of the box it has no policy that lets an authenticated +// user UPDATE their own row, so account/profile edits (display name, etc.) +// fail RLS. This adds an owner-scoped self-update policy keyed on the row's own +// `id` — i.e. `auth.user_id() = users.id`. +// +// Expressed via createSecureTableProvision on the platform (metaschema) +// endpoint using the blueprint-style `policies` array the SDK accepts. +// --------------------------------------------------------------------------- +async function grantUsersSelfUpdate( + readClient: MetaschemaClient, + modulesClient: ModulesClient +): Promise { + console.log('\n Granting users self-update (AuthzDirectOwner self_update)...'); + + // Resolve the users_public schema for this database (READ — api host). + const schemaResult = await withRetry(() => + readClient.schema.findMany({ + where: { databaseId: { equalTo: config.databaseId }, name: { equalTo: 'users_public' } }, + select: { id: true, name: true, schemaName: true }, + }).unwrap() + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const usersSchema = (schemaResult as any)?.schemas?.nodes?.[0]; + if (!usersSchema) { + console.warn(' users_public schema not found — skipping self-update grant'); + return; + } + + // Resolve the users table inside that schema (READ — api host). + const tableResult = await withRetry(() => + readClient.table.findMany({ + where: { + databaseId: { equalTo: config.databaseId }, + schemaId: { equalTo: usersSchema.id }, + name: { equalTo: 'users' }, + }, + select: { id: true, name: true }, + }).unwrap() + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const usersTable = (tableResult as any)?.tables?.nodes?.[0]; + if (!usersTable) { + console.warn(' users table not found in users_public — skipping self-update grant'); + return; + } + + try { + await withRetry(() => + // createSecureTableProvision lives on the MODULES host. + modulesClient.secureTableProvision.create({ + data: { + databaseId: config.databaseId!, + schemaId: usersSchema.id, + tableId: usersTable.id, + useRls: true, + // AuthzDirectOwner / self_update on UPDATE, owner field = the row's own id. + policies: [ + { + $type: 'AuthzDirectOwner', + permissive: true, + privileges: ['update'], + policy_name: 'self_update', + data: { entity_field: 'id' }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + select: { id: true } as any, + }).unwrap() + ); + console.log(' users.self_update policy applied (entity_field: id)'); + } catch (err: any) { + if (err.message?.includes('already exists') || err.message?.includes('duplicate')) { + console.log(' users.self_update policy already present'); + } else { + console.warn(` Could not apply users self-update policy: ${err.message}`); + } + } +} + +// --------------------------------------------------------------------------- +// PROVEN RECIPE (b): app-table blueprint helper (object-form grants + +// AuthzDirectOwner owner-scoped default) +// +// Provisions a single app table via the server-side constructBlueprint +// mutation. Defaults are the proven shape so the agent only supplies the +// table name + fields: +// - object-form grants: [{ roles: ['authenticated'], privileges: [...] }] +// - AuthzDirectOwner owner-scoped policy (each user CRUDs their own rows). +// +// The agent calls this from main() for each app table, e.g.: +// await provisionAppTable(metaschemaClient, { +// tableName: 'todos', +// fields: [ +// { name: 'title', type: 'text', is_required: true }, +// { name: 'is_done', type: 'boolean', default: 'false' }, +// ], +// }); +// +// For org-scoped (b2b) tables, pass policyType: 'AuthzEntityMembership' with +// data: { entity_field: 'entity_id', membership_type: 2 } and a +// DataOwnershipInEntity / DataEntityMembership node — but that is a b2b +// concern; a base auth:email app uses the AuthzDirectOwner default below. +// --------------------------------------------------------------------------- +/** + * Author-facing field spec. The agent writes the ergonomic bare-string form + * (`type: 'text'`, `default: 'false'`); provisionAppTable converts it to the + * OBJECT shapes the blueprint validator requires before sending: + * - FieldType: { name: 'text' } (NOT the bare string 'text') + * - FieldDefault: { value: false } | { value: 'foo' } (discriminated union) + * Passing bare strings makes constructBlueprint fail with BAD_FIELD_INPUT and the + * table is never created. You may also pass the object forms directly to escape + * the convenience mapping (e.g. a function default `{ function: 'now' }`). + */ +export interface AppTableField { + name: string; + /** Bare SQL type ('text','boolean',...) or a full FieldType object ({ name, args?, ... }). */ + type: string | Record; + /** Literal ('false','hello'), or a FieldDefault object ({ value }, { function }, ...). */ + default?: string | number | boolean | Record; + is_required?: boolean; + index?: boolean; +} + +export interface AppTableSpec { + tableName: string; + fields?: AppTableField[]; + /** + * Defaults to owner-scoped with a PK: ['DataId', 'DataDirectOwner', 'DataTimestamps']. + * DataId MUST come first — it creates the UUID primary key. Without a PK the + * table has no id column, so per-row UPDATE/DELETE (and owner RLS) can't target rows. + */ + nodes?: unknown[]; + /** Defaults to AuthzDirectOwner (owner CRUDs own rows). */ + policyType?: string; + /** Defaults to { entity_field: 'owner_id' } — the key AuthzDirectOwner reads. */ + policyData?: Record; + /** Defaults to full CRUD for 'authenticated'. */ + grants?: { roles: string[]; privileges: [string, string][] }[]; +} + +/** Coerce an author field type (bare string or object) into a FieldType object: { name, ... }. */ +function toFieldType(type: string | Record): Record { + return typeof type === 'string' ? { name: type } : type; +} + +/** Coerce an author default (literal or object) into a FieldDefault object: { value } | passthrough. */ +function toFieldDefault( + def: string | number | boolean | Record +): Record { + return typeof def === 'object' && def !== null ? def : { value: def }; +} + +export async function provisionAppTable( + readClient: MetaschemaClient, + modulesClient: ModulesClient, + spec: AppTableSpec +): Promise { + // 1. Resolve the database owner_id (READ — api host; `database` is not on modules). + const dbResult = await withRetry(() => + readClient.database.findOne({ id: requireDatabaseId(), select: { ownerId: true } }).unwrap() + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ownerId = (dbResult as any)?.database?.ownerId; + if (!ownerId) throw new Error('Could not resolve database owner_id'); + + // 2. Build the blueprint table with the proven owner-scoped defaults. + // DataId FIRST so the table gets a UUID primary key — without it there is no + // id column and per-row update/delete + owner RLS have nothing to target. + const nodes = spec.nodes ?? ['DataId', 'DataDirectOwner', 'DataTimestamps']; + // Object-form grants — the shape the platform validator expects. + const grants = spec.grants ?? [ + { + roles: ['authenticated'], + privileges: [ + ['select', '*'], + ['insert', '*'], + ['update', '*'], + ['delete', '*'], + ] as [string, string][], + }, + ]; + // AuthzDirectOwner owner-scoped default: every authenticated user CRUDs only + // their own rows, matched on owner_id. The policy reads `data.entity_field` + // (NOT owner_field) — owner_field is silently ignored, leaving the table + // effectively locked. + const policies = [ + { + $type: spec.policyType ?? 'AuthzDirectOwner', + permissive: true, + privileges: ['select', 'insert', 'update', 'delete'], + data: spec.policyData ?? { entity_field: 'owner_id' }, + }, + ]; + + // Convert author field specs to the validator's OBJECT shapes: + // type -> { name: 'text' }, default -> { value: false }. + const fields = (spec.fields ?? []).map((f) => { + const out: Record = { name: f.name, type: toFieldType(f.type) }; + if (f.default !== undefined) out.default = toFieldDefault(f.default); + if (f.is_required !== undefined) out.is_required = f.is_required; + if (f.index !== undefined) out.index = f.index; + return out; + }); + + const definition = { + tables: [ + { + table_name: spec.tableName, + nodes, + fields, + grants, + policies, + }, + ], + relations: [], + indexes: [], + full_text_searches: [], + }; + + // 3. Create the draft blueprint record (MUTATION — modules host). + const blueprintName = `app_${spec.tableName.toLowerCase().replace(/[^a-z0-9]+/g, '_')}_${Date.now()}`; + const bpResult = await withRetry(() => + modulesClient.blueprint.create({ + data: { + ownerId, + databaseId: requireDatabaseId(), + name: blueprintName, + displayName: spec.tableName, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + definition: definition as any, + }, + select: { id: true }, + }).unwrap() + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const blueprintId = (bpResult as any)?.createBlueprint?.blueprint?.id; + if (!blueprintId) throw new Error(`Failed to create blueprint record for ${spec.tableName}`); + + // 4. Execute all phases server-side in one transaction (MUTATION — modules host). + const constructResult = await withRetry(() => + modulesClient.mutation.constructBlueprint( + { input: { blueprintId } }, + { select: { result: true } } + ).unwrap() + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const refMap = (constructResult as any)?.constructBlueprint?.result; + if (!refMap) { + throw new Error(`constructBlueprint failed for ${spec.tableName} — check blueprint_construction status`); + } + console.log(` Provisioned app table: ${spec.tableName}`); + return blueprintId; +} + async function main() { console.log('\n Constructive App — Schema Provisioning\n'); console.log(` Database: ${config.databaseName}`); @@ -23,14 +314,31 @@ async function main() { const pgAvailable = !!process.env.PGHOST; - // Attach entity schemas to the app API so codegen can see them + // ------------------------------------------------------------------------- + // Attach entity-related schemas to the app-data API + // + // When a database is provisioned with modules (invites, memberships, etc.), + // the platform creates schemas like {prefix}_public, {prefix}_memberships_public, + // {prefix}_invites_public, etc. The app-data API (served on api-{dbName}.localhost, + // named 'api' in services_public.apis — NOT 'app') needs these schemas attached + // so the endpoint exposes the tables for codegen. + // + // NOTE: constructBlueprint already auto-attaches each new app table's schema to + // the 'api' API, so this step is a best-effort top-up for module schemas — it is + // non-fatal and fully idempotent. + // ------------------------------------------------------------------------- console.log(`\n${'='.repeat(60)}`); - console.log(' Attaching schemas to app API'); + console.log(' Attaching schemas to app-data API (api-' + config.databaseName + ')'); console.log('='.repeat(60)); + // READ client (api.localhost) for apis/schemas/tables + createApiSchema. const metaschemaClient = createMetaschemaClient(); + // MUTATION client (modules.localhost) for blueprint / constructBlueprint / secureTableProvision. + const modulesClient = createModulesClient(); - // Find the 'api' API + // Find the app-data API for this database. The hub's base provisioning names it + // 'api' (services_public.apis ... name = 'api'); the earlier 'app' lookup never + // matched, so the schema-attach + table seam silently no-op'd. const apisResult = await withRetry(() => metaschemaClient.api.findMany({ where: { databaseId: { equalTo: config.databaseId }, name: { equalTo: 'api' } }, @@ -41,7 +349,7 @@ async function main() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const appApi = (apisResult as any)?.apis?.nodes?.[0]; if (!appApi) { - console.warn(' App API not found — skipping schema attachment'); + console.warn(" 'api' app-data API not found — skipping schema attachment (constructBlueprint auto-attaches anyway)"); } else { const appApiId: string = appApi.id; const existingSchemaNames = new Set( @@ -93,29 +401,48 @@ async function main() { } } - // Set membership defaults so new users are auto-approved + // ------------------------------------------------------------------------- + // PROVEN RECIPE (a): grant users self-update so account/profile edits pass RLS + // (READS via api host, secureTableProvision via modules host) + // ------------------------------------------------------------------------- + await grantUsersSelfUpdate(metaschemaClient, modulesClient); + + // ------------------------------------------------------------------------- + // App tables: declare them here via provisionAppTable(read, modules, spec). + // The base scaffold ships a `todos` table (owner-scoped: each user CRUDs only + // their own rows). Add your own following the same pattern. Author fields in + // the ergonomic bare-string form — provisionAppTable converts type/default to + // the FieldType/FieldDefault objects the blueprint validator requires. + // ------------------------------------------------------------------------- + await provisionAppTable(metaschemaClient, modulesClient, { + tableName: 'todos', + fields: [ + { name: 'title', type: 'text', is_required: true }, + { name: 'is_done', type: 'boolean', default: false }, + ], + }); + + // ------------------------------------------------------------------------- + // Set app membership defaults so new users are auto-approved. + // Anchor to THIS tenant's physical memberships schema (resolved by database_id + // via the metaschema) — a floating `LIKE '%memberships_public' DESC LIMIT 1` + // has no db filter and on a shared hub would mutate a SIBLING tenant. + // ------------------------------------------------------------------------- if (pgAvailable) { console.log('\n Setting membership defaults...'); - const defaultsPool = new Pool({ database: PG_DATABASE }); - - const schemaResult = await defaultsPool.query( - `SELECT schema_name FROM information_schema.schemata - WHERE (schema_name LIKE '%memberships-public' OR schema_name LIKE '%memberships_public') - ORDER BY schema_name DESC LIMIT 1` - ); - if (schemaResult.rows.length > 0) { - const membershipsSchema = schemaResult.rows[0].schema_name; + const membershipsSchema = await resolvePhysicalSchemaName(config.databaseId!, 'memberships_public'); + if (membershipsSchema) { + const defaultsPool = new Pool({ database: PG_DATABASE }); await defaultsPool.query( `UPDATE "${membershipsSchema}".app_membership_defaults SET is_approved = TRUE, is_verified = TRUE` ); - console.log(` schema: ${membershipsSchema}`); + await defaultsPool.end(); + console.log(` schema: ${membershipsSchema} (this tenant)`); console.log(' is_approved = TRUE, is_verified = TRUE'); } else { - console.log(' No memberships schema found - skipping defaults'); + console.log(' No memberships_public schema for this tenant - skipping defaults'); } - - await defaultsPool.end(); } // ===================================================================== @@ -491,19 +818,14 @@ async function main() { } } - // Write SEED_SCHEMA to .env + // Write SEED_SCHEMA to .env so seeding just works. + // Resolve THIS tenant's physical app schema (logical name 'app_public') via the + // metaschema, NOT a floating LIKE that could pick a sibling tenant's app schema. if (pgAvailable) { - const detectPool = new Pool({ database: PG_DATABASE }); - const appSchemaResult = await detectPool.query( - `SELECT schema_name FROM information_schema.schemata - WHERE (schema_name LIKE '%app-public' OR schema_name LIKE '%app_public') - ORDER BY schema_name DESC LIMIT 1` - ); - await detectPool.end(); + const seedSchema = await resolvePhysicalSchemaName(config.databaseId!, 'app_public'); - if (appSchemaResult.rows.length > 0) { - const seedSchema = appSchemaResult.rows[0].schema_name; - console.log(`\n Detected app schema: ${seedSchema}`); + if (seedSchema) { + console.log(`\n Detected app schema (this tenant): ${seedSchema}`); const envPath = path.resolve(process.cwd(), '../../.env'); try { diff --git a/nextjs/constructive-app/packages/provision/src/seed/index.ts b/nextjs/constructive-app/packages/provision/src/seed/index.ts index 1eddef02d..d59084705 100644 --- a/nextjs/constructive-app/packages/provision/src/seed/index.ts +++ b/nextjs/constructive-app/packages/provision/src/seed/index.ts @@ -1,7 +1,25 @@ -/** seed/index.ts — Create test users and orgs. Usage: pnpm run seed */ +/** + * seed/index.ts — Seed entry point for the constructive-app (BASE tier) + * + * Creates a couple of test users so you can sign in immediately after + * provisioning. The base auth:email app has no organizations, so this seed + * does NOT create orgs or org memberships. + * + * B2B OPT-IN: once you provision the org modules and add the registry org + * blocks (see docs/B2B.md), extend this seed to create organizations and + * org memberships (org users via the auth endpoint with the admin token, then + * org_memberships rows in the {prefix}_memberships_public schema). + * + * Usage: + * pnpm run seed + * + * Prerequisites: + * - pnpm run create-db && pnpm run provision (sets up database + .env) + * - .env must contain DATABASE_ID, ACCESS_TOKEN + */ import { config } from '../config.js'; -import { createDbAuthClient, withRetry, executeAuthMutation } from '../helpers.js'; +import { createDbAuthClient, withRetry } from '../helpers.js'; // Seed fixtures @@ -10,21 +28,11 @@ const SEED_USERS = [ email: 'alice@example.com', password: 'Password123!', displayName: 'Alice Chen', - role: 'founder' as const, }, { email: 'bob@example.com', password: 'Password123!', displayName: 'Bob Martinez', - role: 'member' as const, - }, -]; - -const SEED_ORGS = [ - { - name: 'Acme Corp', - username: 'acme_corp', - ownerIdIndex: 0, // Alice }, ]; @@ -47,14 +55,16 @@ async function main() { process.exit(1); } - // Step 1: Seed users + // ----------------------------------------------------------------------- + // Seed users + // ----------------------------------------------------------------------- console.log(`\n${'─'.repeat(50)}`); console.log(' Seeding users...'); const userResults: SeedUserResult[] = []; for (const user of SEED_USERS) { - console.log(` ${user.email} (${user.role})`); + console.log(` ${user.email}`); const authClient = createDbAuthClient(); let userId: string | undefined; @@ -101,192 +111,14 @@ async function main() { console.log(` ✓ ${userId}`); } - // Step 2: Get admin token - console.log(`\n${'─'.repeat(50)}`); - console.log(' Getting admin token...'); - - const adminAuthClient = createDbAuthClient(); - const adminSignInData = await adminAuthClient.mutation.signIn( - { input: { email: config.adminEmail, password: config.adminPassword } }, - { select: { result: { select: { accessToken: true, userId: true } } } } - ).unwrap(); - - const adminResult = (adminSignInData as Record>>) - ?.signIn?.result; - const adminToken = adminResult?.accessToken; - const adminUserId = adminResult?.userId; - - if (!adminToken) { - console.error(' Failed to get admin token'); - process.exit(1); - } - console.log(` Admin token acquired`); - - // Step 3: Seed orgs via auth endpoint - console.log(`\n${'─'.repeat(50)}`); - console.log(' Seeding orgs...'); - - const orgEntityIds: { name: string; entityId: string }[] = []; - - for (const org of SEED_ORGS) { - console.log(` ${org.name} (${org.username})`); - - try { - // signUp + SQL pattern: INSERT on users via GraphQL is restricted, - // but signUp uses a SECURITY DEFINER function that bypasses GRANTs. - // After signUp, set type=2 (Organization) via direct SQL. - const signUpData = await withRetry(() => - executeAuthMutation( - adminToken, - `mutation SignUp($input: SignUpInput!) { - signUp(input: $input) { - result { - userId - } - } - }`, - { - input: { - email: `${org.username}@org.seed.local`, - password: crypto.randomUUID(), - } - } - ) - ); - - const signUpRaw = signUpData as any; - const orgUserId: string | undefined = signUpRaw?.signUp?.result?.userId; - - if (!orgUserId) { - console.error(` ✗ Failed to sign up org user for ${org.name}`); - continue; - } - - // Set display name and username via updateUser (those columns are in UPDATE GRANT) - await withRetry(() => - executeAuthMutation( - adminToken, - `mutation UpdateUser($input: UpdateUserInput!) { - updateUser(input: $input) { - user { id } - } - }`, - { - input: { - id: orgUserId, - userPatch: { - username: org.username, - displayName: org.name, - } - } - } - ) - ); - - // Set type=2 (Organization) via direct SQL — the `type` column - // is only in the INSERT GRANT, not UPDATE, and INSERT GRANTs may - // not be applied to tenant schemas. - if (!!process.env.PGHOST) { - try { - const { Pool } = await import('pg'); - const pool = new Pool({ database: config.pgInternalDatabase }); - const usersSchema = await pool.query( - `SELECT schema_name FROM information_schema.schemata - WHERE schema_name LIKE '%users_public' AND schema_name LIKE '${config.databaseName}%' - ORDER BY schema_name LIMIT 1` - ); - const schemaName = usersSchema.rows[0]?.schema_name; - if (schemaName) { - await pool.query( - `UPDATE "${schemaName}".users SET type = 2, display_name = $2, username = $3 WHERE id = $1`, - [orgUserId, org.name, org.username] - ); - console.log(` ✓ type=2 set via SQL`); - } - await pool.end(); - } catch (sqlErr: any) { - console.warn(` ⚠ SQL type update failed: ${sqlErr.message}`); - } - } - - orgEntityIds.push({ name: org.name, entityId: orgUserId }); - console.log(` ✓ ${orgUserId}`); - } catch (err: any) { - if (err.message?.includes('already exists') || err.message?.includes('duplicate') || err.message?.includes('ACCOUNT_EXISTS')) { - console.log(` ⚠ ${org.name} already exists — skipping`); - } else { - console.error(` ✗ Failed to create org ${org.name}: ${err.message}`); - } - } - } - - // Step 4: Add org memberships via SQL - const pgAvailable = !!process.env.PGHOST; - if (pgAvailable && orgEntityIds.length > 0) { - console.log(`\n${'─'.repeat(50)}`); - console.log(' Adding org memberships...'); - - const { Pool } = await import('pg'); - const pool = new Pool({ database: config.pgInternalDatabase }); - - try { - const schemaRes = await pool.query( - `SELECT schema_name FROM information_schema.schemata - WHERE schema_name LIKE '%memberships_public' LIMIT 1` - ); - const membershipsSchema = schemaRes.rows[0]?.schema_name; - if (!membershipsSchema) { - console.warn(' Memberships schema not found — skipping org memberships'); - } else { - for (const org of orgEntityIds) { - // Add seed users to org - for (const user of userResults) { - const isOwner = SEED_ORGS.find(o => o.name === org.name)?.ownerIdIndex === SEED_USERS.indexOf(user.fixture); - try { - await pool.query( - `INSERT INTO "${membershipsSchema}".org_memberships - (actor_id, entity_id, is_owner, is_admin, is_approved, is_active) - VALUES ($1, $2, $3, $3, true, true) - ON CONFLICT DO NOTHING`, - [user.userId, org.entityId, isOwner] - ); - const role = isOwner ? 'org owner' : 'org member'; - console.log(` ✓ ${user.fixture.displayName} → ${role} of ${org.name}`); - } catch (err: any) { - console.warn(` ⚠ Org membership failed: ${err.message}`); - } - } - - // Add admin as org owner - if (adminUserId) { - try { - await pool.query( - `INSERT INTO "${membershipsSchema}".org_memberships - (actor_id, entity_id, is_owner, is_admin, is_approved, is_active) - VALUES ($1, $2, true, true, true, true) - ON CONFLICT DO NOTHING`, - [adminUserId, org.entityId] - ); - console.log(` ✓ admin → org owner of ${org.name}`); - } catch (err: any) { - console.warn(` ⚠ Admin org membership failed: ${err.message}`); - } - } - } - } - } finally { - await pool.end(); - } - } - + // ----------------------------------------------------------------------- // Done console.log(`\n${'═'.repeat(50)}`); console.log(' Seed complete!'); console.log(`${'═'.repeat(50)}\n`); console.log(' Summary:'); - console.log(` Users: ${userResults.length}`); - console.log(` Orgs: ${orgEntityIds.length}\n`); + console.log(` Users: ${userResults.length}\n`); } main().catch((err) => { diff --git a/nextjs/constructive-app/src/app-routes.ts b/nextjs/constructive-app/src/app-routes.ts index 753e6c85a..2879d6b57 100644 --- a/nextjs/constructive-app/src/app-routes.ts +++ b/nextjs/constructive-app/src/app-routes.ts @@ -1,5 +1,5 @@ import type { Route } from 'next'; -import { parseAsArrayOf, parseAsInteger, parseAsString } from 'nuqs'; +import { parseAsString } from 'nuqs'; import type { SchemaContext } from '@/lib/runtime/config-core'; @@ -36,10 +36,14 @@ export interface RouteConfig { } // ============================================================================= -// ROUTE CONFIGURATION - Single source of truth for all routes +// ROUTE CONFIGURATION - Single source of truth for all routes (BASE tier) // ============================================================================= -// Entity Hierarchy: Organizations -// Organization management is scoped under /orgs/[orgId] +// The base auth:email app has a single navigation level: the app root, plus +// the auth + account surfaces. Organization-scoped routes (/orgs/[orgId]/*, +// /organizations) and the app-admin routes (/users, /invites, /settings) are a +// b2b opt-in delivered with the registry org blocks — see docs/B2B.md. The +// `requiredPermission: 'app-admin'` gate (RouteGuard) still exists for b2b apps +// to reuse, but no base route uses it. // ============================================================================= export const APP_ROUTES = { @@ -53,54 +57,6 @@ export const APP_ROUTES = { context: 'admin' as SchemaContext, }, - // ========================================================================== - // ORGANIZATION-SCOPED ROUTES - Organization management under /orgs/[orgId] - // ========================================================================== - - /** Organization members */ - ORG_MEMBERS: { - path: '/orgs/[orgId]/members' as Route, - searchParams: { - search: parseAsString, - page: parseAsInteger.withDefault(1), - limit: parseAsInteger.withDefault(20), - }, - access: 'protected' as RouteAccessType, - context: 'admin' as SchemaContext, - }, - - /** Organization invites */ - ORG_INVITES: { - path: '/orgs/[orgId]/invites' as Route, - searchParams: {}, - access: 'protected' as RouteAccessType, - context: 'admin' as SchemaContext, - }, - - /** Organization settings */ - ORG_SETTINGS: { - path: '/orgs/[orgId]/settings' as Route, - searchParams: { - tab: parseAsString.withDefault('general'), - }, - access: 'protected' as RouteAccessType, - context: 'admin' as SchemaContext, - }, - - // ========================================================================== - // ORGANIZATIONS ROUTES - Organization listing and management - // ========================================================================== - ORGANIZATIONS: { - path: '/organizations' as Route, - searchParams: { - search: parseAsString, - page: parseAsInteger.withDefault(1), - limit: parseAsInteger.withDefault(20), - }, - access: 'protected' as RouteAccessType, - context: 'admin' as SchemaContext, - }, - // ========================================================================== // ACCOUNT ROUTES - User account management // ========================================================================== @@ -113,46 +69,6 @@ export const APP_ROUTES = { context: 'admin' as SchemaContext, }, - // ========================================================================== - // APP-LEVEL ROUTES - Platform administration (requires app admin permission) - // These routes are for managing the platform itself, above organizations - // ========================================================================== - APP_USERS: { - path: '/users' as Route, - searchParams: { - search: parseAsString, - status: parseAsString, - page: parseAsInteger.withDefault(1), - limit: parseAsInteger.withDefault(50), - }, - access: 'protected' as RouteAccessType, - context: 'admin' as SchemaContext, - requiredPermission: 'app-admin' as RequiredPermission, - }, - - APP_INVITES: { - path: '/invites' as Route, - searchParams: { - search: parseAsString, - status: parseAsString, - page: parseAsInteger.withDefault(1), - limit: parseAsInteger.withDefault(50), - }, - access: 'protected' as RouteAccessType, - context: 'admin' as SchemaContext, - requiredPermission: 'app-admin' as RequiredPermission, - }, - - APP_SETTINGS: { - path: '/settings' as Route, - searchParams: { - tab: parseAsString.withDefault('general'), - }, - access: 'protected' as RouteAccessType, - context: 'admin' as SchemaContext, - requiredPermission: 'app-admin' as RequiredPermission, - }, - // ========================================================================== // GUEST-ONLY ROUTES - Only accessible when NOT authenticated // ========================================================================== @@ -194,12 +110,6 @@ export const APP_ROUTES = { }, access: 'public' as RouteAccessType, }, - - INVITE: { - path: '/invite' as Route, - searchParams: { invite_token: parseAsString, type: parseAsString }, - access: 'public' as RouteAccessType, - }, } as const; // Extract route keys for type safety @@ -234,43 +144,6 @@ export function buildRoute( return url.pathname + url.search; } -/** - * Build an organization-scoped route URL with the given org ID. - * Replaces [orgId] placeholder with the actual org ID. - * - * @param routeKey - The organization route key from APP_ROUTES - * @param orgId - The organization ID - * @param searchParams - Optional query parameters - * @returns A typed Route string - * - * @example - * buildOrgRoute('ORG_MEMBERS', 'org-123') // '/orgs/org-123/members' - */ -export function buildOrgRoute( - routeKey: 'ORG_MEMBERS' | 'ORG_INVITES' | 'ORG_SETTINGS', - orgId: string, - searchParams?: Record, -): Route { - const route = APP_ROUTES[routeKey]; - let path = route.path.replace('[orgId]', orgId); - - if (searchParams) { - const url = new URL(path, 'http://localhost'); - Object.entries(searchParams).forEach(([key, value]) => { - if (value !== null && value !== undefined) { - if (Array.isArray(value)) { - value.forEach((v) => url.searchParams.append(key, String(v))); - } else { - url.searchParams.set(key, String(value)); - } - } - }); - path = url.pathname + url.search; - } - - return path as Route; -} - // Cache for compiled route patterns - avoids recreating RegExp on every call const routePatternCache = new Map(); @@ -391,8 +264,12 @@ export function isProtectedRoute(pathname: string): boolean { */ export function getRouteRequiredPermission(pathname: string): RequiredPermission | null { const config = getRouteConfig(pathname); - if (config && 'requiredPermission' in config && config.requiredPermission) { - return config.requiredPermission; + // No BASE route declares `requiredPermission`, so the inferred config union + // has no such member; b2b routes (see docs/B2B.md) reintroduce it. Read it + // defensively via an index lookup so the helper stays valid for both tiers. + const required = (config as Record | null)?.requiredPermission; + if (required) { + return required as RequiredPermission; } return null; } @@ -425,18 +302,8 @@ export const navigationUtils = { // Export route constants for easy access export const ROUTE_PATHS = { ROOT: APP_ROUTES.ROOT.path, - // Organization-scoped routes - ORG_MEMBERS: APP_ROUTES.ORG_MEMBERS.path, - ORG_INVITES: APP_ROUTES.ORG_INVITES.path, - ORG_SETTINGS: APP_ROUTES.ORG_SETTINGS.path, - // Organizations routes - ORGANIZATIONS: APP_ROUTES.ORGANIZATIONS.path, // Account routes ACCOUNT_SETTINGS: APP_ROUTES.ACCOUNT_SETTINGS.path, - // App-level routes (admin only) - APP_USERS: APP_ROUTES.APP_USERS.path, - APP_INVITES: APP_ROUTES.APP_INVITES.path, - APP_SETTINGS: APP_ROUTES.APP_SETTINGS.path, // Guest-only routes LOGIN: APP_ROUTES.LOGIN.path, REGISTER: APP_ROUTES.REGISTER.path, @@ -444,5 +311,4 @@ export const ROUTE_PATHS = { RESET_PASSWORD: APP_ROUTES.RESET_PASSWORD.path, CHECK_EMAIL: APP_ROUTES.CHECK_EMAIL.path, VERIFY_EMAIL: APP_ROUTES.VERIFY_EMAIL.path, - INVITE: APP_ROUTES.INVITE.path, } as const; diff --git a/nextjs/constructive-app/src/app/invite/page.tsx b/nextjs/constructive-app/src/app/invite/page.tsx deleted file mode 100644 index 675c301dc..000000000 --- a/nextjs/constructive-app/src/app/invite/page.tsx +++ /dev/null @@ -1,157 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { Suspense } from 'react'; -import type { Route } from 'next'; -import { useRouter, useSearchParams } from 'next/navigation'; - -import { useAuthContext } from '@/lib/auth/auth-context'; -import { DataError } from '@/lib/gql/error-handler'; -import { useSubmitInviteCode } from '@/lib/gql/hooks/auth'; -import { useSubmitOrgInviteCode } from '@/lib/gql/hooks/auth'; -import { useAccountProfile } from '@/lib/gql/hooks/admin/account/use-account-profile'; -import { AuthScreenLayout } from '@/components/auth/auth-screen-layout'; -import { InviteScreen, type InviteScreenState } from '@/components/auth/screens/invite-screen'; - -// Query parameter keys - exported for reuse across components -export const INVITE_QUERY_PARAMS = { - INVITE_TOKEN: 'invite_token', - EMAIL: 'email', - TYPE: 'type', -} as const; - -/** - * Utility function to build query string from URLSearchParams - * @param searchParams - URLSearchParams object - * @returns Query string with leading '?' or empty string - */ -export function buildQueryString(searchParams: URLSearchParams | null): string { - if (!searchParams) return ''; - const params = new URLSearchParams(); - searchParams.forEach((value, key) => { - params.set(key, value); - }); - return params.toString() ? `?${params.toString()}` : ''; -} - -type InviteType = 'org' | 'app'; - -const ERROR_MESSAGES: Record = { - OBJECT_NOT_FOUND: 'Your session has expired. Please sign in again.', - INVITE_NOT_FOUND: 'This invite is invalid or has expired.', - INVITE_LIMIT: 'This invite has reached its usage limit.', - INVITE_EMAIL_NOT_FOUND: 'This invite was sent to a different email address.', -}; - -function getSafeInviteType(value: string | null): InviteType { - return value === 'app' ? 'app' : 'org'; -} - -function getInviteErrorCode(error: unknown): string { - if (error instanceof DataError) { - return error.code || error.message; - } - if (error instanceof Error) { - return error.message; - } - return 'UNKNOWN'; -} - -function InvitePageContent() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { isAuthenticated, isLoading: isAuthLoading, user } = useAuthContext(); - - const submitInviteCode = useSubmitInviteCode(); - const submitOrgInviteCode = useSubmitOrgInviteCode(); - - // Fetch user profile to get email - const { profile, isLoading: isProfileLoading } = useAccountProfile({ - userId: user?.id || '', - enabled: isAuthenticated && !!user?.id, - }); - - const [tokenAndType, setTokenAndType] = React.useState<{ token: string; type: InviteType } | null>(null); - const [state, setState] = React.useState({ status: 'loading' }); - - React.useEffect(() => { - const token = searchParams?.get(INVITE_QUERY_PARAMS.INVITE_TOKEN) || ''; - const type = getSafeInviteType(searchParams?.get(INVITE_QUERY_PARAMS.TYPE)); - const inviteEmail = searchParams?.get(INVITE_QUERY_PARAMS.EMAIL) || ''; - - if (!token) { - setTokenAndType(null); - setState({ status: 'no-token' }); - return; - } - - setTokenAndType({ token, type }); - - if (isAuthLoading) { - setState({ status: 'loading' }); - return; - } - if (!isAuthenticated) { - setState({ status: 'needs-auth' }); - return; - } - - // Wait for user object and profile to be loaded before proceeding - if (!user || isProfileLoading) { - setState({ status: 'loading' }); - return; - } - - const userEmail = profile?.primaryEmail?.email; - - // Check if invite email matches current user's email (only if both emails are present) - if (inviteEmail && userEmail && inviteEmail.toLowerCase() !== userEmail.toLowerCase()) { - setState({ status: 'email-mismatch', inviteEmail, currentEmail: userEmail }); - return; - } - - setState({ status: 'ready' }); - }, [searchParams, isAuthenticated, isAuthLoading, user, isProfileLoading, profile?.primaryEmail?.email]); - - async function handleAccept() { - if (!tokenAndType) return; - setState({ status: 'submitting' }); - - try { - if (tokenAndType.type === 'org') { - await submitOrgInviteCode.mutateAsync(tokenAndType.token); - } else { - await submitInviteCode.mutateAsync(tokenAndType.token); - } - - setState({ status: 'success' }); - } catch (err) { - const code = getInviteErrorCode(err); - setState({ status: 'error', message: ERROR_MESSAGES[code] || 'Something went wrong. Please try again.' }); - } - } - - function handleCancel() { - router.push('/' as Route); - } - - function handleBackToHome() { - router.push('/' as Route); - } - - return ; -} - -export default function InvitePage() { - return ( - -
Loading...
- - } - > - -
- ); -} diff --git a/nextjs/constructive-app/src/app/invites/page.tsx b/nextjs/constructive-app/src/app/invites/page.tsx deleted file mode 100644 index 630ed9459..000000000 --- a/nextjs/constructive-app/src/app/invites/page.tsx +++ /dev/null @@ -1,144 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { useCardStack } from '@/components/ui/stack'; -import { toast } from '@/components/ui/toast'; -import { Mail, Send } from 'lucide-react'; - -import { - useAppClaimedInvites, - useAppInvites, - useCancelAppInvite, - useExtendAppInvite, - useSendAppInvite, - type AppInvite, -} from '@/lib/gql/hooks/admin'; -import { ActiveInvitesTable } from '@/components/invites/active-invites-table'; -import { ClaimedInvitesTable } from '@/components/invites/claimed-invites-table'; -import { InvitesPagination } from '@/components/invites/invites-pagination'; -import { SendInviteCard } from '@/components/invites/send-invite-card'; -import { PageHeaderWithIcon } from '@/components/shared/page-header-with-icon'; - -const ITEMS_PER_PAGE = 5; - -export default function AppInvitesPage() { - const stack = useCardStack(); - const [activePage, setActivePage] = useState(1); - const [claimedPage, setClaimedPage] = useState(1); - - const activeOffset = (activePage - 1) * ITEMS_PER_PAGE; - const claimedOffset = (claimedPage - 1) * ITEMS_PER_PAGE; - - const { - invites: activeInvites, - totalCount: activeTotalCount, - isLoading: isActiveLoading, - error: activeError, - } = useAppInvites({ first: ITEMS_PER_PAGE, offset: activeOffset }); - - const { - claimedInvites, - totalCount: claimedTotalCount, - isLoading: isClaimedLoading, - error: claimedError, - } = useAppClaimedInvites({ first: ITEMS_PER_PAGE, offset: claimedOffset }); - - const { sendInvite, isSending } = useSendAppInvite(); - const { cancelInvite, isCancelling } = useCancelAppInvite(); - const { extendInvite, isExtending } = useExtendAppInvite(); - const isBusy = isCancelling || isExtending; - - const activeTotalPages = Math.max(1, Math.ceil(activeTotalCount / ITEMS_PER_PAGE)); - const claimedTotalPages = Math.max(1, Math.ceil(claimedTotalCount / ITEMS_PER_PAGE)); - - const handleExtend = async (invite: AppInvite) => { - try { - const currentExpiryTime = Date.parse(invite.expiresAt); - const baseTime = Number.isFinite(currentExpiryTime) ? currentExpiryTime : Date.now(); - const expiresAt = new Date(baseTime + 7 * 24 * 60 * 60 * 1000).toISOString(); - await extendInvite({ inviteId: invite.id, expiresAt }); - toast.success({ message: 'Invite extended by 7 days', description: invite.email || 'Invite' }); - } catch (e) { - toast.error({ message: 'Failed to extend invite', description: (e as Error)?.message }); - } - }; - - const handleCancel = async (invite: AppInvite) => { - try { - await cancelInvite({ inviteId: invite.id }); - toast.success({ message: 'Invite cancelled', description: invite.email || 'Invite' }); - } catch (e) { - toast.error({ message: 'Failed to cancel invite', description: (e as Error)?.message }); - } - }; - - const headerDescription = `${activeTotalCount} active invite${activeTotalCount !== 1 ? 's' : ''}, ${claimedTotalCount} claimed`; - - return ( -
-
- { - stack.push({ - id: 'send-app-invite', - title: 'Send Invite', - description: 'Send an invitation to join your organization', - Component: SendInviteCard, - props: { - enabled: true, - isSending, - onSendInvite: async ({ email, role, expiresAt, message }) => { - await sendInvite({ email, role, expiresAt, message }); - setActivePage(1); - }, - }, - width: 480, - }); - }} - > - - Send Invite - - } - /> - -
-
-

Active Invites

- - -
- -
-

Claimed Invites

- - -
-
- -
-
-
- ); -} diff --git a/nextjs/constructive-app/src/app/organizations/page.tsx b/nextjs/constructive-app/src/app/organizations/page.tsx deleted file mode 100644 index 8c836f015..000000000 --- a/nextjs/constructive-app/src/app/organizations/page.tsx +++ /dev/null @@ -1,275 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import type { Route } from 'next'; -import { useRouter } from 'next/navigation'; -import { Button } from '@/components/ui/button'; -import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'; -import { useCardStack } from '@/components/ui/stack'; -import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Building2, LayoutGrid, List, Plus, Search } from 'lucide-react'; - -import { useOrganizations, type OrganizationWithRole } from '@/lib/gql/hooks/admin'; -import { useEntityParams } from '@/lib/navigation'; -import { - DeleteOrganizationDialog, - OrgCard, - OrgCardSkeleton, - OrgListRow, - OrgListRowSkeleton, -} from '@/components/organizations'; -import { CreateOrganizationCard } from '@/components/organizations/create-organization-card'; -import { EditOrganizationCard } from '@/components/organizations/edit-organization-card'; -import { PageHeaderWithIcon } from '@/components/shared/page-header-with-icon'; - -export default function OrganizationsPage() { - const router = useRouter(); - const stack = useCardStack(); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [selectedOrg, setSelectedOrg] = useState(null); - const [searchValue, setSearchValue] = useState(''); - const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); - - const { availableOrgs: organizations, isLoading: isOrgsLoading } = useEntityParams(); - const { refetch: refetchOrganizations } = useOrganizations(); - - const handleCreateClick = () => { - stack.push({ - id: 'org-create', - title: 'Create Organization', - description: 'Create a new organization to collaborate with your team.', - Component: CreateOrganizationCard, - props: { - onSuccess: () => refetchOrganizations(), - }, - width: 480, - }); - }; - - const handleEditClick = (org: (typeof organizations)[0]) => { - const rawOrg = org._raw as OrganizationWithRole; - stack.push({ - id: `org-edit-${org.id}`, - title: 'Edit Organization', - description: "Update the organization's name, username, and other details.", - Component: EditOrganizationCard, - props: { - organization: rawOrg, - onSuccess: () => refetchOrganizations(), - }, - width: 480, - }); - }; - - const handleDeleteClick = (org: (typeof organizations)[0]) => { - setSelectedOrg(org._raw as OrganizationWithRole); - setDeleteDialogOpen(true); - }; - - const filteredOrganizations = organizations.filter((org) => - org.name.toLowerCase().includes(searchValue.toLowerCase()), - ); - - const handleRowClick = (orgId: string) => { - router.push(`/orgs/${orgId}/members` as Route); - }; - - return ( -
-
- - - New Organization - - } - /> - - {/* Filters section */} -
-
- {/* Search and count */} -
- - - - - setSearchValue(e.target.value)} - data-testid='orgs-search-input' - /> - - {!isOrgsLoading && ( - - {filteredOrganizations.length} {filteredOrganizations.length === 1 ? 'organization' : 'organizations'} - - )} -
- - {/* View mode toggle */} -
- - -
-
-
- - {/* Organizations content */} -
- {isOrgsLoading ? ( - viewMode === 'grid' ? ( -
- {Array.from({ length: 6 }).map((_, index) => ( - - ))} -
- ) : ( -
- - - - Organization - Role - Members - Actions - - - - {Array.from({ length: 5 }).map((_, i) => ( - - ))} - -
-
- ) - ) : filteredOrganizations.length === 0 ? ( -
-
- -
-

- {searchValue ? 'No organizations found' : 'No Organizations Yet'} -

-

- {searchValue - ? 'Try adjusting your search query' - : 'Create your first organization to start managing projects and databases.'} -

- {!searchValue && ( - - )} -
- ) : viewMode === 'grid' ? ( -
- {filteredOrganizations.map((org, index) => ( - handleRowClick(org.id)} - onEdit={org.role === 'owner' ? () => handleEditClick(org) : undefined} - onDelete={() => handleDeleteClick(org)} - index={index} - /> - ))} -
- ) : ( -
- - - - Organization - Role - Members - Actions - - - - {filteredOrganizations.map((org, index) => ( - handleRowClick(org.id)} - onEdit={org.role === 'owner' ? () => handleEditClick(org) : undefined} - onDelete={() => handleDeleteClick(org)} - index={index} - /> - ))} - -
-
- )} -
- - {/* Bottom spacing */} -
-
- - {/* Delete Organization Dialog */} - refetchOrganizations()} - /> -
- ); -} diff --git a/nextjs/constructive-app/src/app/orgs/[orgId]/activity/page.tsx b/nextjs/constructive-app/src/app/orgs/[orgId]/activity/page.tsx deleted file mode 100644 index a87c1aeef..000000000 --- a/nextjs/constructive-app/src/app/orgs/[orgId]/activity/page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -'use client'; - -import { Activity } from 'lucide-react'; - -import { useEntityParams } from '@/lib/navigation'; -import { PageHeaderWithIcon } from '@/components/shared/page-header-with-icon'; - -export default function OrgActivityPage() { - const { orgId, organization } = useEntityParams(); - - if (!orgId || !organization?._raw) { - return null; - } - - return ( -
-
- - Coming Soon - - } - /> - -
- {/* Empty state with subtle animation */} -
- {/* Subtle animated rings */} -
-
-
-
- -
-
- -

Activity tracking is on its way

-

- Soon you'll be able to monitor all changes, track user actions, and review audit logs for your - organization. -

- - {/* Feature preview pills */} -
- {['Change history', 'User actions', 'Audit logs', 'Export reports'].map((feature, index) => ( - - {feature} - - ))} -
-
-
- -
-
-
- ); -} diff --git a/nextjs/constructive-app/src/app/orgs/[orgId]/invites/page.tsx b/nextjs/constructive-app/src/app/orgs/[orgId]/invites/page.tsx deleted file mode 100644 index a8465d9a5..000000000 --- a/nextjs/constructive-app/src/app/orgs/[orgId]/invites/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -'use client'; - -import { useEntityParams } from '@/lib/navigation'; -import { InvitesRoute } from '@/components/invites/invites-route'; - -export default function OrgInvitesPage() { - const { orgId, organization } = useEntityParams(); - - if (!orgId || !organization?._raw) { - return null; - } - - return ; -} diff --git a/nextjs/constructive-app/src/app/orgs/[orgId]/layout.tsx b/nextjs/constructive-app/src/app/orgs/[orgId]/layout.tsx deleted file mode 100644 index 957e17511..000000000 --- a/nextjs/constructive-app/src/app/orgs/[orgId]/layout.tsx +++ /dev/null @@ -1,43 +0,0 @@ -'use client'; - -import { ReactNode, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; - -import { useEntityParams } from '@/lib/navigation'; - -interface OrgLayoutProps { - children: ReactNode; -} - -/** - * Organization layout - validates org from URL and renders children. - * - * URL is the source of truth for entity selection. - * This layout validates that the org exists and redirects if not found. - * - * Loading Strategy: - * - Always render children during loading - page components handle their own skeletons - * - Redirect on validation failure without showing intermediate messages - * - This eliminates flash of "Redirecting..." text - * - * Entity hierarchy: App (root) → Org → Database - */ -export default function OrgLayout({ children }: OrgLayoutProps) { - const router = useRouter(); - const { orgId, organization, isLoading } = useEntityParams(); - - // Handle redirect in useEffect to avoid React render-during-render issues - // and to prevent flash of redirect message - useEffect(() => { - if (isLoading) return; - - // Org not found after loading - redirect to home - if (orgId && !organization) { - router.replace('/'); - } - }, [isLoading, orgId, organization, router]); - - // Always render children - they handle their own loading states - // Redirect happens in useEffect without blocking render - return <>{children}; -} diff --git a/nextjs/constructive-app/src/app/orgs/[orgId]/members/page.tsx b/nextjs/constructive-app/src/app/orgs/[orgId]/members/page.tsx deleted file mode 100644 index bb46f89b4..000000000 --- a/nextjs/constructive-app/src/app/orgs/[orgId]/members/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -'use client'; - -import { useEntityParams } from '@/lib/navigation'; -import { MembersRoute } from '@/components/members/members-route'; - -export default function OrgMembersPage() { - const { orgId, organization } = useEntityParams(); - - if (!orgId || !organization?._raw) { - return null; - } - - return ; -} diff --git a/nextjs/constructive-app/src/app/orgs/[orgId]/settings/page.tsx b/nextjs/constructive-app/src/app/orgs/[orgId]/settings/page.tsx deleted file mode 100644 index d0945c40d..000000000 --- a/nextjs/constructive-app/src/app/orgs/[orgId]/settings/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -'use client'; - -import { useEntityParams } from '@/lib/navigation'; -import { OrgSettingsRoute } from '@/components/settings/org-settings-route'; - -export default function OrgSettingsPage() { - const { orgId, organization } = useEntityParams(); - - if (!orgId || !organization?._raw) { - return null; - } - - return ; -} diff --git a/nextjs/constructive-app/src/app/page.tsx b/nextjs/constructive-app/src/app/page.tsx index 009eef4bb..04fba7335 100644 --- a/nextjs/constructive-app/src/app/page.tsx +++ b/nextjs/constructive-app/src/app/page.tsx @@ -71,10 +71,6 @@ export default function HomePage() { {dbName}
-
- @sdk/admin - orgs, members, permissions -
@sdk/auth users, authentication diff --git a/nextjs/constructive-app/src/app/settings/layout.tsx b/nextjs/constructive-app/src/app/settings/layout.tsx deleted file mode 100644 index 088ff66fb..000000000 --- a/nextjs/constructive-app/src/app/settings/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function AppSettingsLayout({ children }: { children: React.ReactNode }) { - return <>{children}; -} diff --git a/nextjs/constructive-app/src/app/settings/page.tsx b/nextjs/constructive-app/src/app/settings/page.tsx deleted file mode 100644 index 57e367b39..000000000 --- a/nextjs/constructive-app/src/app/settings/page.tsx +++ /dev/null @@ -1,278 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Switch } from '@/components/ui/switch'; -import { showErrorToast, showSuccessToast } from '@/components/ui/toast'; -import { AlertCircle, ArrowRight, Check, Loader2, Settings2, Shield, UserCheck, UserPlus } from 'lucide-react'; - -import { useAppSettings, useUpdateAppSettings } from '@/lib/gql/hooks/admin/app'; -import { cn } from '@/lib/utils'; -import { PageHeaderWithIcon } from '@/components/shared/page-header-with-icon'; - -export default function AppSettingsPage() { - const { settings, isLoading, error } = useAppSettings(); - const { updateSettings, isUpdating } = useUpdateAppSettings(); - - const [isApproved, setIsApproved] = useState(true); - const [hasChanges, setHasChanges] = useState(false); - - useEffect(() => { - if (settings) { - setIsApproved(settings.isApproved); - } - }, [settings]); - - useEffect(() => { - if (settings) { - const changed = isApproved !== settings.isApproved; - setHasChanges(changed); - } else { - setHasChanges(true); - } - }, [isApproved, settings]); - - const handleSave = async () => { - try { - await updateSettings({ - isApproved, - }); - showSuccessToast({ - message: 'Settings saved', - description: 'Member permission settings have been updated.', - }); - setHasChanges(false); - } catch (err) { - showErrorToast({ - message: 'Failed to save settings', - description: err instanceof Error ? err.message : 'An error occurred', - }); - } - }; - - const settingsConfig = [ - { - id: 'members-approved', - label: 'Members approved', - description: 'Auto-approve new members. When off, an admin must approve each request.', - icon: UserCheck, - checked: isApproved, - onChange: setIsApproved, - }, - ]; - - if (error) { - return ( -
-
-
-
- -
-
-

Failed to load settings

-

{error.message}

-
-
-
-
- ); - } - - return ( -
-
- - - {/* Member Permissions Section */} -
- {/* Section header */} -
-
-
- -
-
-

Member Permissions

-

- Default security settings for new members joining the platform -

-
-
-
- - {/* Settings grid */} -
- {isLoading - ? Array.from({ length: 1 }).map((_, index) => ) - : settingsConfig.map((setting, index) => ( - - ))} -
-
- - {/* Save button */} -
-
- {hasChanges && !isLoading ? ( - <> -
- Unsaved changes - - ) : ( - <> - - All changes saved - - )} -
- -
- - {/* Bottom spacing */} -
-
-
- ); -} - -interface SettingCardProps { - id: string; - label: string; - description: string; - icon: React.ComponentType<{ className?: string }>; - checked: boolean; - onChange: (checked: boolean) => void; - disabled?: boolean; - index: number; -} - -function SettingCard({ id, label, description, icon: Icon, checked, onChange, disabled, index }: SettingCardProps) { - return ( -
- {/* Status indicator */} -
- {checked ? :
} -
- - {/* Icon */} -
-
- -
-
- - {/* Content */} -
- -

{description}

-
- - {/* Switch control */} -
- - {checked ? 'Enabled' : 'Disabled'} - - -
-
- ); -} - -function LoadingSkeleton({ index }: { index: number }) { - return ( -
- - -
- - - -
-
- - -
-
- ); -} diff --git a/nextjs/constructive-app/src/app/users/layout.tsx b/nextjs/constructive-app/src/app/users/layout.tsx deleted file mode 100644 index 4f63c3fa5..000000000 --- a/nextjs/constructive-app/src/app/users/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function AppUsersLayout({ children }: { children: React.ReactNode }) { - return <>{children}; -} diff --git a/nextjs/constructive-app/src/app/users/page.tsx b/nextjs/constructive-app/src/app/users/page.tsx deleted file mode 100644 index 123fdc316..000000000 --- a/nextjs/constructive-app/src/app/users/page.tsx +++ /dev/null @@ -1,384 +0,0 @@ -'use client'; - -import { useMemo, useState } from 'react'; -import { AlertCircle, Ban, Crown, Filter, MoreHorizontal, Search, Shield, UserCheck, Users, UserX } from 'lucide-react'; - -import { useAppUsers, useUpdateAppUser, type AppUser } from '@/lib/gql/hooks/admin/app'; -import { cn } from '@/lib/utils'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'; -import { Skeleton } from '@/components/ui/skeleton'; -import { showErrorToast, showSuccessToast } from '@/components/ui/toast'; -import { getRoleFromFlags, RoleBadge } from '@/components/members/role-badge'; -import { PageHeaderWithIcon } from '@/components/shared/page-header-with-icon'; - -function UserStatusBadge({ user }: { user: AppUser }) { - if (user.isBanned) { - return ( - - - Banned - - ); - } - if (user.isDisabled) { - return ( - - - Disabled - - ); - } - if (!user.isActive) { - return ( - - - Inactive - - ); - } - return ( - - - Active - - ); -} - -function UserRoleBadge({ user }: { user: AppUser }) { - return ; -} - -function UsersTableSkeleton() { - return ( -
- {[...Array(5)].map((_, i) => ( -
- -
- - -
- - - -
- ))} -
- ); -} - -function UserRow({ - user, - onToggleAdmin, - onToggleActive, - isUpdating, - index, -}: { - user: AppUser; - onToggleAdmin: () => void; - onToggleActive: () => void; - isUpdating: boolean; - index: number; -}) { - return ( -
- {/* Avatar */} -
-
- {user.isOwner ? ( - - ) : ( - {user.actor?.displayName?.charAt(0)?.toUpperCase() || '?'} - )} -
- {/* Online indicator dot */} - {user.isActive && !user.isBanned && ( -
- )} -
- - {/* User info */} -
-

- {user.actor?.displayName || 'Unknown User'} -

-

@{user.actor?.username || 'unknown'}

-
- - {/* Role badge */} - - - {/* Status badge */} - - - {/* Actions */} - - - - - - {!user.isOwner && ( - <> - - - {user.isAdmin ? 'Remove Admin' : 'Make Admin'} - - - - )} - - {user.isActive ? ( - <> - - Deactivate User - - ) : ( - <> - - Activate User - - )} - - - -
- ); -} - -export default function AppUsersPage() { - const [search, setSearch] = useState(''); - const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive' | 'banned'>('all'); - - const { users, totalCount, isLoading, error, refetch } = useAppUsers({ - filter: - statusFilter === 'all' - ? undefined - : statusFilter === 'active' - ? { isActive: true, isBanned: false } - : statusFilter === 'inactive' - ? { isActive: false } - : { isBanned: true }, - }); - - const { updateUser, isUpdating } = useUpdateAppUser(); - - const filteredUsers = useMemo(() => { - if (!search.trim()) return users; - const searchLower = search.toLowerCase(); - return users.filter( - (user) => - user.actor?.displayName?.toLowerCase().includes(searchLower) || - user.actor?.username?.toLowerCase().includes(searchLower), - ); - }, [users, search]); - - const handleToggleAdmin = async (user: AppUser) => { - const newIsAdmin = !user.isAdmin; - const displayName = user.actor?.displayName || 'User'; - try { - await updateUser({ - id: user.id, - patch: { isAdmin: newIsAdmin }, - }); - refetch(); - showSuccessToast({ - message: newIsAdmin ? 'Admin granted' : 'Admin revoked', - description: `${displayName} is ${newIsAdmin ? 'now an admin' : 'no longer an admin'}.`, - }); - } catch (err) { - showErrorToast({ - message: 'Failed to update role', - description: err instanceof Error ? err.message : 'An unexpected error occurred.', - }); - } - }; - - const handleToggleActive = async (user: AppUser) => { - const newIsActive = !user.isActive; - const displayName = user.actor?.displayName || 'User'; - try { - await updateUser({ - id: user.id, - patch: { isActive: newIsActive }, - }); - refetch(); - showSuccessToast({ - message: newIsActive ? 'User activated' : 'User deactivated', - description: `${displayName} has been ${newIsActive ? 'activated' : 'deactivated'}.`, - }); - } catch (err) { - showErrorToast({ - message: newIsActive ? 'Failed to activate user' : 'Failed to deactivate user', - description: err instanceof Error ? err.message : 'An unexpected error occurred.', - }); - } - }; - - const filterLabels = { - all: 'All Users', - active: 'Active', - inactive: 'Inactive', - banned: 'Banned', - }; - - if (error) { - return ( -
-
-
-
- -
-
-

Failed to load users

-

{error.message}

-
-
-
-
- ); - } - - return ( -
-
- - - {/* Filters section */} -
-
- {/* Search */} - - - - - setSearch(e.target.value)} - /> - - - {/* Filter + count */} -
- {!isLoading && ( - - {totalCount} {totalCount === 1 ? 'user' : 'users'} - - )} - - - - - - setStatusFilter('all')}>All Users - setStatusFilter('active')}>Active - setStatusFilter('inactive')}>Inactive - setStatusFilter('banned')}>Banned - - -
-
-
- - {/* Users list */} -
- {isLoading ? ( - - ) : filteredUsers.length === 0 ? ( -
-
- -
-

No users found

-

- {search ? 'Try adjusting your search query' : 'No users match the current filter'} -

-
- ) : ( -
- {filteredUsers.map((user, index) => ( - handleToggleAdmin(user)} - onToggleActive={() => handleToggleActive(user)} - isUpdating={isUpdating} - index={index} - /> - ))} -
- )} -
- - {/* Bottom spacing */} -
-
-
- ); -} diff --git a/nextjs/constructive-app/src/components/app-shell/app-shell.types.ts b/nextjs/constructive-app/src/components/app-shell/app-shell.types.ts index defd4e670..198641328 100644 --- a/nextjs/constructive-app/src/components/app-shell/app-shell.types.ts +++ b/nextjs/constructive-app/src/components/app-shell/app-shell.types.ts @@ -59,9 +59,10 @@ export interface TopBarConfig { breadcrumbPrefix?: React.ReactNode; /** - * Entity hierarchy levels (e.g., organization) + * Entity hierarchy levels (e.g., organization). Optional — the base + * auth:email app has no entity hierarchy; b2b apps populate this. */ - entityLevels: EntityLevel[]; + entityLevels?: EntityLevel[]; /** * Status badge configuration diff --git a/nextjs/constructive-app/src/components/app-shell/top-bar.tsx b/nextjs/constructive-app/src/components/app-shell/top-bar.tsx index 089748ca8..6b00d9a97 100644 --- a/nextjs/constructive-app/src/components/app-shell/top-bar.tsx +++ b/nextjs/constructive-app/src/components/app-shell/top-bar.tsx @@ -28,10 +28,11 @@ export function TopBar({ config, sidebarWidth = 56, className }: TopBarProps) { const { sidebarLogo, entityLevels, status, search, actions } = config; // Filter to only show levels that have an active entity or are the first level - const visibleLevels = entityLevels.filter((level, index) => { + const levels = entityLevels ?? []; + const visibleLevels = levels.filter((level, index) => { if (index === 0) return true; // Show level if previous level has an active entity - const prevLevel = entityLevels[index - 1]; + const prevLevel = levels[index - 1]; return prevLevel?.activeEntityId != null; }); diff --git a/nextjs/constructive-app/src/components/auth/index.ts b/nextjs/constructive-app/src/components/auth/index.ts index bd5ad430f..24a58e497 100644 --- a/nextjs/constructive-app/src/components/auth/index.ts +++ b/nextjs/constructive-app/src/components/auth/index.ts @@ -41,4 +41,3 @@ export { RegisterScreen } from './screens/register-screen'; export { ForgotPasswordScreen } from './screens/forgot-password-screen'; export { ResetPasswordScreen } from './screens/reset-password-screen'; export { VerifyEmailScreen } from './screens/verify-email-screen'; -export { InviteScreen } from './screens/invite-screen'; diff --git a/nextjs/constructive-app/src/components/auth/login-form-view.tsx b/nextjs/constructive-app/src/components/auth/login-form-view.tsx index 3c35fc8c6..ed5e8ca8e 100644 --- a/nextjs/constructive-app/src/components/auth/login-form-view.tsx +++ b/nextjs/constructive-app/src/components/auth/login-form-view.tsx @@ -11,7 +11,7 @@ import type { LoginFormData } from '@/lib/auth/schemas'; import { loginSchema } from '@/lib/auth/schemas'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; -import { buildQueryString, INVITE_QUERY_PARAMS } from '@/app/invite/page'; +import { AUTH_QUERY_PARAMS, buildQueryString } from '@/lib/navigation/query-string'; import { AuthErrorAlert } from './auth-error-alert'; import { AuthLoadingButton } from './auth-loading-button'; @@ -29,7 +29,7 @@ export function LoginFormView({ onLogin, onShowForgot, onShowRegister }: LoginFo const searchParams = useSearchParams(); const passwordInputRef = useRef(null); - const emailFromQuery = searchParams?.get(INVITE_QUERY_PARAMS.EMAIL) || ''; + const emailFromQuery = searchParams?.get(AUTH_QUERY_PARAMS.EMAIL) || ''; const form = useForm({ defaultValues: { diff --git a/nextjs/constructive-app/src/components/auth/register-form-view.tsx b/nextjs/constructive-app/src/components/auth/register-form-view.tsx index 4103900bf..eaf424893 100644 --- a/nextjs/constructive-app/src/components/auth/register-form-view.tsx +++ b/nextjs/constructive-app/src/components/auth/register-form-view.tsx @@ -11,7 +11,7 @@ import type { RegisterFormData } from '@/lib/auth/schemas'; import { registerSchema } from '@/lib/auth/schemas'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; -import { buildQueryString, INVITE_QUERY_PARAMS } from '@/app/invite/page'; +import { AUTH_QUERY_PARAMS, buildQueryString } from '@/lib/navigation/query-string'; import { AuthErrorAlert } from './auth-error-alert'; import { AuthLoadingButton } from './auth-loading-button'; @@ -28,7 +28,7 @@ export function RegisterFormView({ onRegister, onShowLogin }: RegisterFormViewPr const searchParams = useSearchParams(); const passwordInputRef = useRef(null); - const emailFromQuery = searchParams?.get(INVITE_QUERY_PARAMS.EMAIL) || ''; + const emailFromQuery = searchParams?.get(AUTH_QUERY_PARAMS.EMAIL) || ''; const form = useForm({ defaultValues: { diff --git a/nextjs/constructive-app/src/components/auth/screens/invite-screen.tsx b/nextjs/constructive-app/src/components/auth/screens/invite-screen.tsx deleted file mode 100644 index 756236ad4..000000000 --- a/nextjs/constructive-app/src/components/auth/screens/invite-screen.tsx +++ /dev/null @@ -1,197 +0,0 @@ -'use client'; - -import * as React from 'react'; -import type { Route } from 'next'; -import Link from 'next/link'; -import { Check, X } from 'lucide-react'; - -import { Button } from '@/components/ui/button'; -import { BrandLogo } from '@/components/brand-logo'; - -import { AuthScreenHeader, type AuthBrandingProps } from '../auth-screen-header'; -import { AuthScreenLayout } from '../auth-screen-layout'; - -export type InviteScreenState = - | { status: 'loading' } - | { status: 'no-token' } - | { status: 'needs-auth' } - | { status: 'email-mismatch'; inviteEmail: string; currentEmail: string } - | { status: 'ready' } - | { status: 'submitting' } - | { status: 'success' } - | { status: 'error'; message: string }; - -export interface InviteScreenProps extends AuthBrandingProps { - state: InviteScreenState; - onAccept?: () => void; - onCancel?: () => void; - onBackToHome?: () => void; - homeHref?: Route; -} - -export function InviteScreen({ - state, - onAccept, - onCancel, - onBackToHome, - homeHref = '/' as Route, - logo, - appName, - showLogo = false, -}: InviteScreenProps) { - const [countdown, setCountdown] = React.useState(10); - - // Countdown timer for success state - React.useEffect(() => { - if (state.status === 'success') { - if (countdown > 0) { - const timer = setTimeout(() => setCountdown(countdown - 1), 1000); - return () => clearTimeout(timer); - } else { - onBackToHome?.(); - } - } - }, [state.status, countdown, onBackToHome]); - - return ( - - {state.status === 'loading' ? ( - <> -
- -
- -
Please wait…
- - ) : null} - - {state.status === 'no-token' ? ( - <> -
-
- -
-
- - - - ) : null} - - {state.status === 'needs-auth' ? ( - <> -
- -
- - - - ) : null} - - {state.status === 'email-mismatch' ? ( - <> -
-
- -
-
- -
-

- Please sign in with the correct account to accept this invitation. -

-

- You're currently signed in as {state.currentEmail} -

-
- - - ) : null} - - {state.status === 'ready' || state.status === 'submitting' ? ( - <> -
- -
- -
- - -
- - ) : null} - - {state.status === 'success' ? ( - <> -
-
- -
-
- - - - ) : null} - - {state.status === 'error' ? ( - <> -
-
- -
-
- - - - ) : null} -
- ); -} diff --git a/nextjs/constructive-app/src/components/header.tsx b/nextjs/constructive-app/src/components/header.tsx deleted file mode 100644 index 4942667f2..000000000 --- a/nextjs/constructive-app/src/components/header.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Link from 'next/link'; - -import { ROUTE_PATHS } from '@/app-routes'; - -export function Header() { - return ( -
-
-
- - Constructive - - -
-
-
- ); -} diff --git a/nextjs/constructive-app/src/components/invites/active-invites-table.tsx b/nextjs/constructive-app/src/components/invites/active-invites-table.tsx deleted file mode 100644 index 0e85b266e..000000000 --- a/nextjs/constructive-app/src/components/invites/active-invites-table.tsx +++ /dev/null @@ -1,162 +0,0 @@ -'use client'; - -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { CalendarPlus, CheckCircle2, Clock, Send, X, XCircle } from 'lucide-react'; - -import type { OrgInvite, OrgInviteStatus } from '@/lib/gql/hooks/admin'; -import { RoleBadge } from '@/components/members/role-badge'; - -import { InviterAvatar } from './inviter-avatar'; -import { formatDate } from './invites.utils'; - -function StatusBadge({ status }: { status: OrgInviteStatus }) { - if (status === 'pending') { - return ( - - - Pending - - ); - } - if (status === 'expired') { - return ( - - - Expired - - ); - } - return ( - - - Claimed - - ); -} - -interface ActiveInvitesTableProps { - invites: OrgInvite[]; - isLoading: boolean; - error: Error | null; - canManageInvites: boolean; - isBusy: boolean; - onExtend: (invite: OrgInvite) => void; - onCancel: (invite: OrgInvite) => void; -} - -export function ActiveInvitesTable({ - invites, - isLoading, - error, - canManageInvites, - isBusy, - onExtend, - onCancel, -}: ActiveInvitesTableProps) { - return ( - <> - {error && ( -
- Failed to load invites: {error.message} -
- )} -
- - - - Email - Role - Invited By - Status - Expires - Actions - - - - {isLoading ? ( - - - Loading invites... - - - ) : invites.length === 0 ? ( - - - No active invites - - - ) : ( - invites.map((invite) => ( - - -
-
- -
- {invite.email || '—'} -
-
- - - - - - - - - - {formatDate(invite.expiresAt)} - - -
- - - - - -

Extend invite by 7 days

-
-
- {invite.status !== 'expired' && canManageInvites && ( - - - - - -

Cancel invite

-
-
- )} -
-
-
-
- )) - )} -
-
-
- - ); -} diff --git a/nextjs/constructive-app/src/components/invites/claimed-invites-table.tsx b/nextjs/constructive-app/src/components/invites/claimed-invites-table.tsx deleted file mode 100644 index ac045aafa..000000000 --- a/nextjs/constructive-app/src/components/invites/claimed-invites-table.tsx +++ /dev/null @@ -1,75 +0,0 @@ -'use client'; - -import { Check } from 'lucide-react'; - -import type { OrgClaimedInvite } from '@/lib/gql/hooks/admin'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { RoleBadge } from '@/components/members/role-badge'; - -import { InviterAvatar } from './inviter-avatar'; -import { formatDate } from './invites.utils'; - -interface ClaimedInvitesTableProps { - claimedInvites: OrgClaimedInvite[]; - isLoading: boolean; - error: Error | null; -} - -export function ClaimedInvitesTable({ claimedInvites, isLoading, error }: ClaimedInvitesTableProps) { - return ( - <> - {error && ( -
- Failed to load claimed invites: {error.message} -
- )} -
- - - - Email - Role - Invited By - Claimed - - - - {isLoading ? ( - - - Loading claimed invites... - - - ) : claimedInvites.length === 0 ? ( - - - No claimed invites - - - ) : ( - claimedInvites.map((invite) => ( - - -
-
- -
- {invite.email || '—'} -
-
- - - - - - - {formatDate(invite.createdAt)} -
- )) - )} -
-
-
- - ); -} diff --git a/nextjs/constructive-app/src/components/invites/inviter-avatar.tsx b/nextjs/constructive-app/src/components/invites/inviter-avatar.tsx deleted file mode 100644 index 2794f9a67..000000000 --- a/nextjs/constructive-app/src/components/invites/inviter-avatar.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client'; - -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; - -function getInitials(input: string | null | undefined): string { - if (!input) return '--'; - const parts = input.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) return '--'; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return (parts[0][0] + parts[1][0]).toUpperCase(); -} - -interface InviterInfo { - id: string; - displayName: string | null; - username: string | null; -} - -interface InviterAvatarProps { - inviter: InviterInfo | null; -} - -export function InviterAvatar({ inviter }: InviterAvatarProps) { - if (!inviter) return ; - - return ( -
- - - {getInitials(inviter.displayName || inviter.username)} - - - - {inviter.displayName || inviter.username || inviter.id} - -
- ); -} diff --git a/nextjs/constructive-app/src/components/invites/invites-pagination.tsx b/nextjs/constructive-app/src/components/invites/invites-pagination.tsx deleted file mode 100644 index 5c9f6c6e2..000000000 --- a/nextjs/constructive-app/src/components/invites/invites-pagination.tsx +++ /dev/null @@ -1,50 +0,0 @@ -'use client'; - -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from '@/components/ui/pagination'; - -interface InvitesPaginationProps { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; -} - -export function InvitesPagination({ currentPage, totalPages, onPageChange }: InvitesPaginationProps) { - if (totalPages <= 1) return null; - - return ( - - - - onPageChange(currentPage - 1)} - className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'} - /> - - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( - - onPageChange(page)} - isActive={currentPage === page} - className='cursor-pointer' - > - {page} - - - ))} - - onPageChange(currentPage + 1)} - className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'} - /> - - - - ); -} diff --git a/nextjs/constructive-app/src/components/invites/invites-route.tsx b/nextjs/constructive-app/src/components/invites/invites-route.tsx deleted file mode 100644 index dc9a72ead..000000000 --- a/nextjs/constructive-app/src/components/invites/invites-route.tsx +++ /dev/null @@ -1,177 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { useCardStack } from '@/components/ui/stack'; -import { toast } from '@/components/ui/toast'; -import { Mail, Send } from 'lucide-react'; - -import { - useCancelOrgInvite, - useExtendOrgInvite, - useOrgClaimedInvites, - useOrgInvites, - useSendOrgInvite, - type OrganizationWithRole, - type OrgInvite, -} from '@/lib/gql/hooks/admin'; -import { PageHeaderWithIcon } from '@/components/shared/page-header-with-icon'; - -import { ActiveInvitesTable } from './active-invites-table'; -import { ClaimedInvitesTable } from './claimed-invites-table'; -import { InvitesPagination } from './invites-pagination'; -import { SendInviteCard } from './send-invite-card'; - -const ITEMS_PER_PAGE = 5; - -interface InvitesRouteProps { - orgId: string; - orgName?: string; - organization: OrganizationWithRole; -} - -export function InvitesRoute({ orgId, orgName = 'Organization', organization }: InvitesRouteProps) { - const stack = useCardStack(); - const [activePage, setActivePage] = useState(1); - const [claimedPage, setClaimedPage] = useState(1); - - const isSelfOrg = organization.isSelfOrg; - const canViewInvites = organization.role === 'owner' || organization.role === 'admin'; - const canManageInvites = canViewInvites; - - const activeOffset = (activePage - 1) * ITEMS_PER_PAGE; - const claimedOffset = (claimedPage - 1) * ITEMS_PER_PAGE; - - const { - invites: activeInvites, - totalCount: activeTotalCount, - isLoading: isActiveLoading, - error: activeError, - } = useOrgInvites({ orgId, first: ITEMS_PER_PAGE, offset: activeOffset, enabled: canViewInvites }); - - const { - claimedInvites, - totalCount: claimedTotalCount, - isLoading: isClaimedLoading, - error: claimedError, - } = useOrgClaimedInvites({ orgId, first: ITEMS_PER_PAGE, offset: claimedOffset, enabled: canViewInvites }); - - const { sendInvite, isSending } = useSendOrgInvite(); - const { cancelInvite, isCancelling } = useCancelOrgInvite(); - const { extendInvite, isExtending } = useExtendOrgInvite(); - const isBusy = isCancelling || isExtending; - - const activeTotalPages = Math.max(1, Math.ceil(activeTotalCount / ITEMS_PER_PAGE)); - const claimedTotalPages = Math.max(1, Math.ceil(claimedTotalCount / ITEMS_PER_PAGE)); - - const handleExtend = async (invite: OrgInvite) => { - try { - const currentExpiryTime = Date.parse(invite.expiresAt); - const baseTime = Number.isFinite(currentExpiryTime) ? currentExpiryTime : Date.now(); - const expiresAt = new Date(baseTime + 7 * 24 * 60 * 60 * 1000).toISOString(); - await extendInvite({ orgId, inviteId: invite.id, expiresAt }); - toast.success({ message: 'Invite extended by 7 days', description: invite.email || 'Invite' }); - } catch (e) { - toast.error({ message: 'Failed to extend invite', description: (e as Error)?.message }); - } - }; - - const handleCancel = async (invite: OrgInvite) => { - try { - await cancelInvite({ orgId, inviteId: invite.id }); - toast.success({ message: 'Invite cancelled', description: invite.email || 'Invite' }); - } catch (e) { - toast.error({ message: 'Failed to cancel invite', description: (e as Error)?.message }); - } - }; - - const headerDescription = isSelfOrg - ? `Personal workspace for ${orgName}` - : `${activeTotalCount} active invite${activeTotalCount !== 1 ? 's' : ''}, ${claimedTotalCount} claimed`; - - return ( -
-
- { - stack.push({ - id: 'send-invite', - title: 'Send Invite', - description: 'Send an invitation to join your organization', - Component: SendInviteCard, - props: { - entityId: orgId, - enabled: canManageInvites, - isSending, - onSendInvite: async ({ entityId, email, expiresAt }) => { - await sendInvite({ orgId: entityId!, email, expiresAt }); - setActivePage(1); - }, - }, - width: 480, - }); - }} - disabled={!canManageInvites} - > - - Send Invite - - } - /> - - {!canViewInvites && !isSelfOrg && ( -
-
- You do not have permission to view invites for this organization. -
-
- )} - - {canViewInvites && ( -
-
-

Active Invites

- - -
- -
-

Claimed Invites

- - -
-
- )} - -
-
-
- ); -} diff --git a/nextjs/constructive-app/src/components/invites/invites.types.ts b/nextjs/constructive-app/src/components/invites/invites.types.ts deleted file mode 100644 index 0f1c4da96..000000000 --- a/nextjs/constructive-app/src/components/invites/invites.types.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type InviteStatus = 'pending' | 'expired' | 'claimed'; - -export interface Invite { - id: string; - email: string; - role: 'admin' | 'member'; - invitedBy: { - name: string; - initials: string; - color: string; - }; - status: InviteStatus; - sentAt: string; - expiresAt?: string; - acceptedAt?: string; -} - -export const EXPIRY_OPTIONS = [ - { value: '1', label: '1 day' }, - { value: '3', label: '3 days' }, - { value: '7', label: '7 days' }, - { value: '14', label: '14 days' }, - { value: '30', label: '30 days' }, -]; - -export const ROLE_OPTIONS = [ - { value: 'member', label: 'Member', description: 'Members can create and edit projects' }, - { value: 'admin', label: 'Admin', description: 'Admins have full access to all settings' }, -]; diff --git a/nextjs/constructive-app/src/components/invites/invites.utils.tsx b/nextjs/constructive-app/src/components/invites/invites.utils.tsx deleted file mode 100644 index 47b058477..000000000 --- a/nextjs/constructive-app/src/components/invites/invites.utils.tsx +++ /dev/null @@ -1,6 +0,0 @@ -export function formatDate(iso: string | null | undefined): string { - if (!iso) return '—'; - const t = Date.parse(iso); - if (!Number.isFinite(t)) return '—'; - return new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); -} diff --git a/nextjs/constructive-app/src/components/invites/send-invite-card.tsx b/nextjs/constructive-app/src/components/invites/send-invite-card.tsx deleted file mode 100644 index ed8cc4fec..000000000 --- a/nextjs/constructive-app/src/components/invites/send-invite-card.tsx +++ /dev/null @@ -1,153 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Button } from '@/components/ui/button'; -import { Field } from '@/components/ui/field'; -import { InputGroup, InputGroupInput, InputGroupTextarea } from '@/components/ui/input-group'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import type { CardComponent } from '@/components/ui/stack'; -import { toast } from '@/components/ui/toast'; -import { Info } from 'lucide-react'; - -import type { OrgInviteRole } from '@/lib/gql/hooks/admin'; - -import { EXPIRY_OPTIONS, ROLE_OPTIONS } from './invites.types'; - -export type SendInviteCardProps = { - entityId?: string; - enabled?: boolean; - isSending: boolean; - onSendInvite: (params: { - entityId?: string; - email: string; - role: OrgInviteRole; - expiresAt: string; - message?: string; - }) => Promise; -}; - -export const SendInviteCard: CardComponent = ({ - entityId, - enabled = true, - isSending, - onSendInvite, - card, -}) => { - const [email, setEmail] = useState(''); - const [role, setRole] = useState('member'); - const [expiresIn, setExpiresIn] = useState('7'); - const [message, setMessage] = useState(''); - - const selectedRole = ROLE_OPTIONS.find((r) => r.value === role); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!enabled) { - toast.error({ message: 'You do not have permission to send invites' }); - return; - } - - if (!email.trim()) { - toast.error({ message: 'Email is required' }); - return; - } - - const days = Number(expiresIn); - const expiresAt = new Date(Date.now() + (Number.isFinite(days) ? days : 7) * 24 * 60 * 60 * 1000).toISOString(); - - try { - await onSendInvite({ - ...(entityId && { entityId }), - email: email.trim(), - role, - expiresAt, - message: message.trim() || undefined, - }); - toast.success({ - message: 'Invite sent', - description: `An invitation has been created for ${email.trim()}`, - }); - card.close(); - } catch (err) { - toast.error({ message: 'Failed to send invite', description: (err as Error)?.message }); - } - }; - - return ( -
-
- - - setEmail(e.target.value)} - required - /> - - - - - - - - - - - - - - setMessage(e.target.value)} - rows={3} - /> - - - - - - - The invitee will receive an email with a link to join your organization. They will have {expiresIn} day - {expiresIn !== '1' ? 's' : ''} to accept the invitation. - - -
- -
- - -
-
- ); -}; diff --git a/nextjs/constructive-app/src/components/layouts/authenticated-shell.tsx b/nextjs/constructive-app/src/components/layouts/authenticated-shell.tsx index d87c3cc18..96406b618 100644 --- a/nextjs/constructive-app/src/components/layouts/authenticated-shell.tsx +++ b/nextjs/constructive-app/src/components/layouts/authenticated-shell.tsx @@ -3,39 +3,33 @@ import * as React from 'react'; import type { Route } from 'next'; import Link from 'next/link'; -import { usePathname, useRouter } from 'next/navigation'; -import { useCardStack } from '@/components/ui/stack'; +import { usePathname } from 'next/navigation'; import { useLogout } from '@/lib/gql/hooks/auth'; -import { useOrganizations } from '@/lib/gql/hooks/admin'; -import { useCurrentUserAppMembership } from '@/lib/gql/hooks/admin/app'; -import { useEntityParams, useSidebarNavigation } from '@/lib/navigation'; +import { useSidebarNavigation } from '@/lib/navigation'; import { cn } from '@/lib/utils'; import { useAuth, useSidebarPinned, useSidebarPinnedActions } from '@/store/app-store'; import { AppShell } from '@/components/app-shell/app-shell'; -import type { EntityLevel, TopBarConfig } from '@/components/app-shell/app-shell.types'; +import type { TopBarConfig } from '@/components/app-shell/app-shell.types'; import { UserDropdown } from '@/components/app-shell/user-dropdown'; import { BrandLogo } from '@/components/brand-logo'; import { branding } from '@/config/branding'; -import { CreateOrganizationCard } from '@/components/organizations/create-organization-card'; import { ShellContentFallback, ShellFrameSkeleton } from '@/components/skeletons'; import { ThemeSwitcher } from '@/components/theme-switcher'; -import { buildOrgRoute, getRouteAccessType } from '@/app-routes'; +import { getRouteAccessType } from '@/app-routes'; export interface AuthenticatedShellProps { children: React.ReactNode; } /** - * Shell wrapper that conditionally renders the AppShell based on Tier 1 (admin) auth state. + * Shell wrapper that conditionally renders the AppShell based on auth state. * Lives in root layout so sidebar state is preserved across route navigations. * * Loading Strategy: * - Protected routes show shell frame immediately with content skeleton * - Auth check happens in parallel, not blocking shell structure * - Page components handle their own loading states once shell is visible - * - * Auth: Admin-context auth controls shell visibility. */ export function AuthenticatedShell({ children }: AuthenticatedShellProps) { const pathname = usePathname(); @@ -43,7 +37,6 @@ export function AuthenticatedShell({ children }: AuthenticatedShellProps) { const accessType = getRouteAccessType(pathname); const isProtectedRoute = accessType === 'protected'; - const isInviteRoute = pathname === '/invite'; // During loading, assume not authenticated to avoid hydration mismatches const isAuthenticatedReady = isLoading ? false : isAuthenticated; @@ -71,79 +64,29 @@ export function AuthenticatedShell({ children }: AuthenticatedShellProps) { } // Authenticated: render full shell - return {children}; + return {children}; } /** * Inner component that handles the authenticated shell with dynamic navigation. * Separated to ensure hooks are called only when authenticated. */ -function AuthenticatedShellInner({ children, hideSidebar }: AuthenticatedShellProps & { hideSidebar?: boolean }) { - const pathname = usePathname(); - const router = useRouter(); - const stack = useCardStack(); +function AuthenticatedShellInner({ children }: AuthenticatedShellProps) { const logoutMutation = useLogout(); - const { isAppAdmin } = useCurrentUserAppMembership(); - const { refetch: refetchOrganizations } = useOrganizations(); // Sidebar pinned state from Zustand (persisted) const sidebarPinned = useSidebarPinned(); const { toggleSidebarPinned } = useSidebarPinnedActions(); - // Get entity state from URL params (source of truth) - const { orgId, availableOrgs } = useEntityParams(); - const handleLogout = React.useCallback(() => { logoutMutation.mutate(); }, [logoutMutation]); - // Get dynamic navigation based on URL params (source of truth) + // Base tier navigation: Home + Account (single app-root level). const { navigation } = useSidebarNavigation({ - isAppAdmin, onLogout: handleLogout, }); - // Handle org selection - navigates to org members route (URL is source of truth) - const handleOrgChange = React.useCallback( - (newOrgId: string) => { - router.push(buildOrgRoute('ORG_MEMBERS', newOrgId)); - }, - [router], - ); - - // Build entity levels for breadcrumbs based on URL params - const entityLevels = React.useMemo((): EntityLevel[] => { - const levels: EntityLevel[] = []; - - // Organization level - only show if orgId is in URL - if (orgId) { - levels.push({ - id: 'organization', - label: 'Organization', - labelPlural: 'Organizations', - entities: availableOrgs, - activeEntityId: orgId, - onEntityChange: handleOrgChange, - onCreateNew: () => { - stack.push({ - id: 'org-create', - title: 'Create Organization', - description: 'Create a new organization to collaborate with your team.', - Component: CreateOrganizationCard, - props: { - onSuccess: () => refetchOrganizations(), - }, - width: 480, - }); - }, - createLabel: 'Create organization', - viewAllHref: '/', - }); - } - - return levels; - }, [orgId, availableOrgs, handleOrgChange, stack, refetchOrganizations]); - const topBarConfig: TopBarConfig = { sidebarLogo: ( ), - entityLevels, actions: ( <> @@ -173,7 +115,6 @@ function AuthenticatedShellInner({ children, hideSidebar }: AuthenticatedShellPr diff --git a/nextjs/constructive-app/src/components/layouts/navigation-data.ts b/nextjs/constructive-app/src/components/layouts/navigation-data.ts deleted file mode 100644 index 61b7c5504..000000000 --- a/nextjs/constructive-app/src/components/layouts/navigation-data.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type NavigationContextType = 'app' | 'org' | 'account'; - -export interface Organization { - id: string; - name: string; - memberCount: number; - role: 'owner' | 'admin' | 'member'; - createdAt: string; -} - -export interface Project { - id: string; - name: string; - orgId: string; -} - -export const FAKE_ORGANIZATIONS: Organization[] = [ - { id: 'org-1', name: 'Acme Corporation', memberCount: 24, role: 'owner', createdAt: 'Jan 14, 2024' }, - { id: 'org-2', name: 'Tech Startup Inc', memberCount: 12, role: 'admin', createdAt: 'Mar 21, 2024' }, - { id: 'org-3', name: 'Design Agency', memberCount: 8, role: 'member', createdAt: 'Jun 9, 2024' }, -]; - -export const FAKE_PROJECTS: Project[] = [ - { id: 'proj-1', name: 'Main App', orgId: 'org-1' }, - { id: 'proj-2', name: 'Marketing Site', orgId: 'org-1' }, - { id: 'proj-3', name: 'API Gateway', orgId: 'org-1' }, - { id: 'proj-4', name: 'Mobile App', orgId: 'org-2' }, - { id: 'proj-5', name: 'Dashboard', orgId: 'org-2' }, - { id: 'proj-6', name: 'Portfolio', orgId: 'org-3' }, - { id: 'proj-7', name: 'Client Portal', orgId: 'org-3' }, -]; - diff --git a/nextjs/constructive-app/src/components/layouts/sidebar-config.ts b/nextjs/constructive-app/src/components/layouts/sidebar-config.ts deleted file mode 100644 index 7b862efa8..000000000 --- a/nextjs/constructive-app/src/components/layouts/sidebar-config.ts +++ /dev/null @@ -1,159 +0,0 @@ -'use client'; - -import { - RiBuilding2Line, - RiGroupLine, - RiLogoutBoxLine, - RiMailLine, - RiSettings3Line, - RiUserLine, -} from '@remixicon/react'; - -import type { NavGroup } from '@/components/app-shell/app-shell.types'; - -export type NavigationContextType = 'app' | 'org' | 'account'; - -export interface SidebarConfigParams { - context: NavigationContextType; - orgId: string | null; - pathname: string; - onLogout: () => void; - isLogoutPending: boolean; - settingsRender?: React.ReactNode; - isAppAdmin?: boolean; -} - -function isActive(pathname: string, href: string, exact = false): boolean { - if (href === pathname) return true; - if (!exact && href !== '/' && pathname.startsWith(href + '/')) return true; - return false; -} - -export function getSidebarNavigation(params: SidebarConfigParams): NavGroup[] { - const { context, orgId, pathname, onLogout, isLogoutPending, settingsRender, isAppAdmin } = params; - - const footerItems: NavGroup = { - id: 'footer', - position: 'bottom', - items: [ - ...(settingsRender - ? [ - { - id: 'settings-dialog', - label: 'Settings', - icon: RiSettings3Line, - render: settingsRender, - }, - ] - : []), - { - id: 'logout', - label: 'Sign Out', - icon: RiLogoutBoxLine, - onClick: onLogout, - disabled: isLogoutPending, - }, - ], - }; - - if (context === 'app') { - const appItems = [ - { - id: 'organizations', - label: 'Organizations', - icon: RiBuilding2Line, - href: '/organizations', - isActive: isActive(pathname, '/organizations'), - }, - ]; - - if (isAppAdmin) { - appItems.push( - { - id: 'invites', - label: 'Invites', - icon: RiMailLine, - href: '/invites', - isActive: isActive(pathname, '/invites'), - }, - { - id: 'users', - label: 'Users', - icon: RiUserLine, - href: '/users', - isActive: isActive(pathname, '/users'), - }, - { - id: 'settings', - label: 'Settings', - icon: RiSettings3Line, - href: '/settings', - isActive: isActive(pathname, '/settings'), - }, - ); - } - - return [ - { - id: 'main', - position: 'top', - items: appItems, - }, - footerItems, - ]; - } - - if (context === 'org' && orgId) { - return [ - { - id: 'main', - position: 'top', - items: [ - { - id: 'members', - label: 'Members', - icon: RiGroupLine, - href: `/orgs/${orgId}/members`, - isActive: isActive(pathname, `/orgs/${orgId}/members`), - }, - { - id: 'invites', - label: 'Invites', - icon: RiMailLine, - href: `/orgs/${orgId}/invites`, - isActive: isActive(pathname, `/orgs/${orgId}/invites`), - }, - { - id: 'settings', - label: 'Settings', - icon: RiSettings3Line, - href: `/orgs/${orgId}/settings`, - isActive: isActive(pathname, `/orgs/${orgId}/settings`), - }, - ], - }, - footerItems, - ]; - } - - if (context === 'account') { - return [ - { - id: 'main', - position: 'top', - items: [ - { - id: 'settings', - label: 'Settings', - icon: RiSettings3Line, - href: '/account/settings', - isActive: isActive(pathname, '/account/settings'), - }, - ], - }, - footerItems, - ]; - } - - return [footerItems]; -} diff --git a/nextjs/constructive-app/src/components/members/members-data.ts b/nextjs/constructive-app/src/components/members/members-data.ts deleted file mode 100644 index caa14e862..000000000 --- a/nextjs/constructive-app/src/components/members/members-data.ts +++ /dev/null @@ -1,86 +0,0 @@ -export type MemberStatus = 'active' | 'unverified' | 'pending_approval' | 'disabled'; -export type MemberRole = 'owner' | 'admin' | 'member'; - -export interface Member { - id: string; - name: string; - email: string; - initials: string; - color: string; - role: MemberRole; - status: MemberStatus; - joinedAt: string; -} - -export const FAKE_MEMBERS: Member[] = [ - { - id: 'm1', - name: 'Sarah Johnson', - email: 'sarah.johnson@company.com', - initials: 'SJ', - color: 'bg-violet-500', - role: 'owner', - status: 'active', - joinedAt: 'Jan 2024', - }, - { - id: 'm2', - name: 'Michael Chen', - email: 'michael.chen@company.com', - initials: 'MC', - color: 'bg-emerald-500', - role: 'admin', - status: 'active', - joinedAt: 'Feb 2024', - }, - { - id: 'm3', - name: 'Emily Rodriguez', - email: 'emily.rodriguez@company.com', - initials: 'ER', - color: 'bg-amber-500', - role: 'member', - status: 'active', - joinedAt: 'Mar 2024', - }, - { - id: 'm4', - name: 'David Kim', - email: 'david.kim@company.com', - initials: 'DK', - color: 'bg-sky-500', - role: 'member', - status: 'unverified', - joinedAt: 'Apr 2024', - }, - { - id: 'm5', - name: 'Lisa Anderson', - email: 'lisa.anderson@company.com', - initials: 'LA', - color: 'bg-pink-500', - role: 'admin', - status: 'active', - joinedAt: 'May 2024', - }, - { - id: 'm6', - name: 'John Doe', - email: 'john.doe@company.com', - initials: 'JD', - color: 'bg-indigo-500', - role: 'member', - status: 'pending_approval', - joinedAt: 'Nov 2024', - }, - { - id: 'm7', - name: 'Alex Smith', - email: 'alex.smith@company.com', - initials: 'AS', - color: 'bg-slate-500', - role: 'member', - status: 'disabled', - joinedAt: 'Oct 2024', - }, -]; diff --git a/nextjs/constructive-app/src/components/members/members-route.tsx b/nextjs/constructive-app/src/components/members/members-route.tsx deleted file mode 100644 index 1362873eb..000000000 --- a/nextjs/constructive-app/src/components/members/members-route.tsx +++ /dev/null @@ -1,354 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { RiUserAddLine } from '@remixicon/react'; -import { AlertCircle, AtSign, CheckCircle2, Clock, MoreVertical, Slash, Users, XCircle } from 'lucide-react'; -import { useRouter } from 'next/navigation'; - -import { PageHeaderWithIcon } from '@/components/shared/page-header-with-icon'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from '@/components/ui/pagination'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { toast } from '@/components/ui/toast'; -import { - useOrgMembers, - type OrgMember, - type OrgMemberStatus, - type OrganizationWithRole, -} from '@/lib/gql/hooks/admin'; -import { - useDeleteOrgMembershipMutation, - useUpdateOrgMembershipMutation, -} from '@sdk/admin'; -import { useAppStore } from '@/store/app-store'; - -import { RoleBadge } from './role-badge'; - -const ITEMS_PER_PAGE = 10; - -function getInitials(input: string | null | undefined): string { - if (!input) return '--'; - const parts = input.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) return '--'; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return (parts[0][0] + parts[1][0]).toUpperCase(); -} - -function StatusBadge({ status }: { status: OrgMemberStatus }) { - switch (status) { - case 'active': - return ( -
- - Active -
- ); - case 'inactive': - return ( -
- - Inactive -
- ); - case 'pending_approval': - return ( -
- - Pending Approval -
- ); - case 'pending_approval': - return ( -
- - Pending Approval -
- ); - case 'banned': - return ( -
- - Banned -
- ); - case 'disabled': - return ( -
- - Disabled -
- ); - } -} - -function PaginationControls({ - currentPage, - totalPages, - onPageChange, -}: { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; -}) { - if (totalPages <= 1) return null; - - return ( - - - - onPageChange(currentPage - 1)} - className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'} - /> - - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( - - onPageChange(page)} - isActive={currentPage === page} - className='cursor-pointer' - > - {page} - - - ))} - - onPageChange(currentPage + 1)} - className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'} - /> - - - - ); -} - -function MemberActions({ - member, - canManage, - isCurrentUser, - onToggleAdmin, - onRemove, -}: { - member: OrgMember; - canManage: boolean; - isCurrentUser: boolean; - onToggleAdmin: (member: OrgMember) => void; - onRemove: (member: OrgMember) => void; -}) { - if (!canManage || isCurrentUser || member.flags.isOwner) return null; - - const isAdmin = member.flags.isAdmin; - - return ( - - - - - - onToggleAdmin(member)}> - {isAdmin ? 'Remove admin' : 'Make admin'} - - - onRemove(member)}> - Remove member - - - - ); -} - -interface MembersRouteProps { - orgId: string; - orgName?: string; - organization: OrganizationWithRole; -} - -export function MembersRoute({ orgId, orgName = 'Organization', organization }: MembersRouteProps) { - const router = useRouter(); - const isSelfOrg = organization.isSelfOrg; - const canManageMembers = organization.role === 'owner' || organization.role === 'admin'; - - const actorId = useAppStore( - (state) => state.auth.user?.id || state.auth.token?.userId || null, - ); - - const [currentPage, setCurrentPage] = useState(1); - const offset = (currentPage - 1) * ITEMS_PER_PAGE; - - const { members, totalCount, isLoading, error } = useOrgMembers({ orgId, first: ITEMS_PER_PAGE, offset }); - const { mutateAsync: updateMember, isPending: isUpdating } = useUpdateOrgMembershipMutation({ - selection: { fields: { id: true } }, - }); - const { mutateAsync: removeMember, isPending: isRemoving } = useDeleteOrgMembershipMutation({ - selection: { fields: { id: true } }, - }); - - const totalPages = Math.max(1, Math.ceil(totalCount / ITEMS_PER_PAGE)); - - const goToPage = (page: number) => { - if (page >= 1 && page <= totalPages) { - setCurrentPage(page); - } - }; - - const onInviteClick = () => { - router.push(`/orgs/${orgId}/invites`); - }; - - const onToggleAdmin = async (member: OrgMember) => { - try { - await updateMember({ - id: member.membershipId, orgMembershipPatch: { isAdmin: !member.flags.isAdmin }, - }); - toast.success({ - message: member.flags.isAdmin ? 'Removed admin role' : 'Granted admin role', - description: member.displayName || member.username || 'Member', - }); - } catch (e) { - toast.error({ message: 'Failed to update member', description: (e as Error)?.message }); - } - }; - - const onRemove = async (member: OrgMember) => { - try { - await removeMember({ id: member.membershipId }); - toast.success({ - message: 'Removed member', - description: member.displayName || member.username || 'Member', - }); - } catch (e) { - toast.error({ message: 'Failed to remove member', description: (e as Error)?.message }); - } - }; - - const isBusy = isUpdating || isRemoving; - - const headerDescription = isSelfOrg - ? `Personal workspace for ${orgName}` - : `${totalCount} member${totalCount !== 1 ? 's' : ''} in your organization`; - - return ( -
-
- - - Invite Member - - } - /> - - {error && ( -
-
- Failed to load members: {error.message} -
-
- )} - -
-
- - - - Member - Role - Status - Account - Actions - - - - {isLoading ? ( - - - Loading members... - - - ) : members.length === 0 ? ( - - - No members found - - - ) : ( - members.map((member) => ( - - -
- - - {getInitials(member.displayName || member.username)} - - -
-

- {member.displayName || member.username || member.actorId} -

-
- - {member.username || '—'} -
-
-
-
- - - - - - - - {member.actorId.slice(0, 8)} - - - - -
- )) - )} -
-
-
- -
- -
-
-
- ); -} diff --git a/nextjs/constructive-app/src/components/members/role-badge.tsx b/nextjs/constructive-app/src/components/members/role-badge.tsx deleted file mode 100644 index 9fc1580da..000000000 --- a/nextjs/constructive-app/src/components/members/role-badge.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Crown, Shield, User } from 'lucide-react'; - -import { cn } from '@/lib/utils'; -import { Badge } from '@/components/ui/badge'; - -export type Role = 'owner' | 'admin' | 'member'; -export type RoleBadgeSize = 'sm' | 'md' | 'lg'; - -interface RoleBadgeProps { - role: Role; - size?: RoleBadgeSize; - showIcon?: boolean; - className?: string; -} - -const roleConfig: Record = { - owner: { icon: Crown, label: 'Owner' }, - admin: { icon: Shield, label: 'Admin' }, - member: { icon: User, label: 'Member' }, -}; - -const sizeStyles: Record = { - sm: { badge: 'px-1.5 py-0 gap-0.5', icon: 'size-2.5', text: 'text-[10px]' }, - md: { badge: 'px-2 py-0.5 gap-1', icon: 'size-3', text: 'text-xs' }, - lg: { badge: 'px-3 py-1 gap-1.5', icon: 'size-3.5', text: 'text-sm' }, -}; - -const roleStyles: Record = { - owner: 'bg-amber-500/10 text-amber-600 dark:bg-amber-400/10 dark:text-amber-400', - admin: 'bg-blue-500/10 text-blue-600 dark:bg-blue-400/10 dark:text-blue-400', - member: 'bg-muted/50 text-muted-foreground', -}; - -export function RoleBadge({ role, size = 'md', showIcon = true, className }: RoleBadgeProps) { - const config = roleConfig[role]; - const sizeStyle = sizeStyles[size]; - const roleStyle = roleStyles[role]; - const Icon = config.icon; - - return ( - - {showIcon && } - {config.label} - - ); -} - -export function getRoleFromFlags(isOwner: boolean, isAdmin: boolean): Role { - if (isOwner) return 'owner'; - if (isAdmin) return 'admin'; - return 'member'; -} diff --git a/nextjs/constructive-app/src/components/organizations/create-organization-card.tsx b/nextjs/constructive-app/src/components/organizations/create-organization-card.tsx deleted file mode 100644 index 4c7c87659..000000000 --- a/nextjs/constructive-app/src/components/organizations/create-organization-card.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Field } from '@/components/ui/field'; -import { InputGroup, InputGroupInput } from '@/components/ui/input-group'; -import type { CardComponent } from '@/components/ui/stack'; -import { showErrorToast, showSuccessToast } from '@/components/ui/toast'; -import { BuildingIcon, Loader2Icon } from 'lucide-react'; - -import { useCreateOrganization } from '@/lib/gql/hooks/admin'; - -export type CreateOrganizationCardProps = { - onSuccess?: () => void; -}; - -export const CreateOrganizationCard: CardComponent = ({ onSuccess, card }) => { - const [displayName, setDisplayName] = useState(''); - const [username, setUsername] = useState(''); - - const { createOrganization, isCreating } = useCreateOrganization({ - onSuccess: (result) => { - showSuccessToast({ - message: 'Organization created', - description: `"${result.organization.displayName}" has been created successfully.`, - }); - onSuccess?.(); - card.close(); - }, - onError: (error) => { - showErrorToast({ - message: 'Failed to create organization', - description: error.message, - }); - }, - }); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!displayName.trim()) { - showErrorToast({ - message: 'Name is required', - description: 'Please enter a name for the organization.', - }); - return; - } - - await createOrganization({ - displayName: displayName.trim(), - username: username.trim() || undefined, - }); - }; - - return ( -
-
-
- {/* Required Fields */} -
- - - setDisplayName(e.target.value)} - disabled={isCreating} - autoComplete='off' - autoFocus - data-testid='orgs-create-name' - /> - - - - - - setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} - disabled={isCreating} - autoComplete='off' - data-testid='orgs-create-username' - /> - - -
-
- -
- - -
-
-
- ); -}; diff --git a/nextjs/constructive-app/src/components/organizations/delete-organization-dialog.tsx b/nextjs/constructive-app/src/components/organizations/delete-organization-dialog.tsx deleted file mode 100644 index e5f11671b..000000000 --- a/nextjs/constructive-app/src/components/organizations/delete-organization-dialog.tsx +++ /dev/null @@ -1,170 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Loader2Icon } from 'lucide-react'; - -import { useDeleteOrganization, type OrganizationWithRole } from '@/lib/gql/hooks/admin'; -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { showErrorToast } from '@/components/ui/toast'; -import { showSuccessToast } from '@/components/ui/toast'; - -interface DeleteOrganizationDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - organization: OrganizationWithRole | null; - onSuccess?: () => void; -} - -/** - * Dialog for confirming organization deletion - * - * Only owners should be able to access this dialog. - * The parent component is responsible for checking permissions. - * - * Requires typing the organization name to confirm deletion. - */ -export function DeleteOrganizationDialog({ open, onOpenChange, organization, onSuccess }: DeleteOrganizationDialogProps) { - const [confirmName, setConfirmName] = useState(''); - - const { deleteOrganization, isDeleting } = useDeleteOrganization({ - onSuccess: (result) => { - showSuccessToast({ - message: 'Organization deleted', - description: `"${result.deletedOrgName}" has been permanently deleted.`, - }); - setConfirmName(''); - onOpenChange(false); - onSuccess?.(); - }, - onError: (error) => { - showErrorToast({ - message: 'Failed to delete organization', - description: error.message, - }); - }, - }); - - const orgName = organization?.displayName || organization?.username || ''; - const canDelete = confirmName === orgName; - - const handleDelete = async () => { - if (!organization || !canDelete) return; - - await deleteOrganization({ - orgId: organization.id, - confirmName, - }); - }; - - const handleOpenChange = (newOpen: boolean) => { - if (!isDeleting) { - onOpenChange(newOpen); - if (!newOpen) { - setConfirmName(''); - } - } - }; - - if (!organization) return null; - - return ( - - - {/* Header */} - - - Delete organization - - - This will permanently delete{' '} - {orgName}{' '} - and cannot be undone. - - - - {/* Info section */} -
-
-

- The following will be removed: -

-
    -
  • - - Organization settings and configuration -
  • -
  • - - All member relationships -
  • -
  • - - Associated resources and permissions -
  • -
-
-
- - {/* Form with confirmation input */} -
{ - e.preventDefault(); - if (canDelete && !isDeleting) { - handleDelete(); - } - }} - > -
- - setConfirmName(e.target.value)} - disabled={isDeleting} - autoComplete='off' - autoFocus - className='h-10' - /> -
- - {/* Footer */} - - - - -
-
-
- ); -} diff --git a/nextjs/constructive-app/src/components/organizations/edit-organization-card.tsx b/nextjs/constructive-app/src/components/organizations/edit-organization-card.tsx deleted file mode 100644 index dc6c8110d..000000000 --- a/nextjs/constructive-app/src/components/organizations/edit-organization-card.tsx +++ /dev/null @@ -1,137 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import type { CardComponent } from '@/components/ui/stack'; -import { showErrorToast, showSuccessToast } from '@/components/ui/toast'; -import { Loader2Icon, SettingsIcon } from 'lucide-react'; - -import { useUpdateOrganization, type OrganizationWithRole } from '@/lib/gql/hooks/admin'; - -export type EditOrganizationCardProps = { - organization: OrganizationWithRole; - onSuccess?: () => void; -}; - -export const EditOrganizationCard: CardComponent = ({ organization, onSuccess, card }) => { - const [displayName, setDisplayName] = useState(''); - const [username, setUsername] = useState(''); - - const { updateOrganization, isUpdating } = useUpdateOrganization({ - onSuccess: () => { - showSuccessToast({ - message: 'Organization updated', - description: 'Your changes have been saved successfully.', - }); - onSuccess?.(); - card.close(); - }, - onError: (error) => { - showErrorToast({ - message: 'Failed to update organization', - description: error.message, - }); - }, - }); - - // Populate form when organization changes - use specific fields to avoid object reference changes - useEffect(() => { - if (organization) { - setDisplayName(organization.displayName || ''); - setUsername(organization.username || ''); - } - }, [organization?.id, organization?.displayName, organization?.username]); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!organization) return; - - if (!displayName.trim()) { - showErrorToast({ - message: 'Name is required', - description: 'Please enter a name for the organization.', - }); - return; - } - - // Determine what changed - const userChanges: { displayName?: string; username?: string } = {}; - - if (displayName.trim() !== (organization.displayName || '')) { - userChanges.displayName = displayName.trim(); - } - if (username.trim() !== (organization.username || '')) { - userChanges.username = username.trim() || undefined; - } - - // Only update if there are changes - const hasUserChanges = Object.keys(userChanges).length > 0; - - if (!hasUserChanges) { - card.close(); - return; - } - - await updateOrganization({ - orgId: organization.id, - settingsId: organization.settings?.id, - user: userChanges, - }); - }; - - return ( -
-
-
- {/* Basic Fields */} -
-
- - setDisplayName(e.target.value)} - disabled={isUpdating} - autoComplete='off' - autoFocus - /> -
- -
- - setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))} - disabled={isUpdating} - autoComplete='off' - /> -

- URL-friendly identifier. Letters, numbers, and hyphens only. -

-
-
-
- -
- - -
-
-
- ); -}; diff --git a/nextjs/constructive-app/src/components/organizations/index.ts b/nextjs/constructive-app/src/components/organizations/index.ts deleted file mode 100644 index cbe06d22a..000000000 --- a/nextjs/constructive-app/src/components/organizations/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Organization Components - * - * UI components for organization CRUD operations. - */ - -export { CreateOrganizationCard, type CreateOrganizationCardProps } from './create-organization-card'; -export { EditOrganizationCard, type EditOrganizationCardProps } from './edit-organization-card'; -export { DeleteOrganizationDialog } from './delete-organization-dialog'; -export { OrganizationCard, OrganizationGrid } from './organization-card'; -export { - OrgCard, - OrgCardSkeleton, - OrgListRow, - OrgListRowSkeleton, - type OrgCardProps, - type OrgListRowProps, - type OrgListItemData, -} from './org-list-views'; diff --git a/nextjs/constructive-app/src/components/organizations/org-list-views.tsx b/nextjs/constructive-app/src/components/organizations/org-list-views.tsx deleted file mode 100644 index c45157663..000000000 --- a/nextjs/constructive-app/src/components/organizations/org-list-views.tsx +++ /dev/null @@ -1,364 +0,0 @@ -'use client'; - -import { - ArrowRight, - Building2, - Calendar, - Crown, - MoreHorizontal, - Pencil, - Shield, - Trash2, - User as UserIcon, - UserCircle, - Users, -} from 'lucide-react'; - -import type { OrgRole } from '@/lib/gql/hooks/admin'; -import { cn } from '@/lib/utils'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Skeleton } from '@/components/ui/skeleton'; -import { TableCell, TableRow } from '@/components/ui/table'; - -type DisplayRole = OrgRole | 'personal'; - -function getDisplayRole(role: OrgRole, isSelfOrg?: boolean): DisplayRole { - if (isSelfOrg) return 'personal'; - return role; -} - -function getRoleIcon(displayRole: DisplayRole) { - switch (displayRole) { - case 'personal': - return UserCircle; - case 'owner': - return Crown; - case 'admin': - return Shield; - default: - return UserIcon; - } -} - -function getRoleStyles(displayRole: DisplayRole) { - switch (displayRole) { - case 'personal': - return { - bg: 'bg-emerald-500/15', - text: 'text-emerald-600 dark:text-emerald-400', - label: 'Personal', - }; - case 'owner': - return { - bg: 'bg-amber-500/15', - text: 'text-amber-600 dark:text-amber-400', - label: 'Owner', - }; - case 'admin': - return { - bg: 'bg-primary/15', - text: 'text-primary', - label: 'Admin', - }; - default: - return { - bg: 'bg-muted', - text: 'text-muted-foreground', - label: 'Member', - }; - } -} - -export interface OrgListItemData { - id: string; - name: string; - description?: string; - memberCount?: number; - role: OrgRole; - isSelfOrg?: boolean; - settings?: { createdAt?: string | null }; -} - -export interface OrgCardProps { - org: OrgListItemData; - onClick: () => void; - onEdit?: () => void; - onDelete: () => void; - index: number; -} - -export function OrgCard({ org, onClick, onEdit, onDelete, index }: OrgCardProps) { - const displayRole = getDisplayRole(org.role, org.isSelfOrg); - const RoleIcon = getRoleIcon(displayRole); - const roleStyles = getRoleStyles(displayRole); - - const createdAt = org.settings?.createdAt - ? new Date(org.settings.createdAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }) - : null; - - return ( -
- {/* Main clickable area */} - - - {/* Actions menu - top right */} - {org.role === 'owner' && ( -
- - - - - - {onEdit && ( - <> - { - e.preventDefault(); - e.stopPropagation(); - onEdit(); - }} - > - - Edit organization - - - - )} - { - e.preventDefault(); - e.stopPropagation(); - onDelete(); - }} - > - - Delete organization - - - -
- )} -
- ); -} - -export function OrgCardSkeleton({ index }: { index: number }) { - return ( -
- -
- - -
-
- - -
-
- ); -} - -export interface OrgListRowProps { - org: OrgListItemData; - onClick: () => void; - onEdit?: () => void; - onDelete: () => void; - index: number; -} - -export function OrgListRow({ org, onClick, onEdit, onDelete, index }: OrgListRowProps) { - const displayRole = getDisplayRole(org.role, org.isSelfOrg); - const RoleIcon = getRoleIcon(displayRole); - const roleStyles = getRoleStyles(displayRole); - - return ( - - -
-
- -
-
-

{org.name || 'Unnamed Organization'}

- {org.description && ( -

{org.description}

- )} -
-
-
- -
- - {roleStyles.label} -
-
- {org.memberCount ?? 0} - - {org.role === 'owner' && ( - - - - - - { - e.preventDefault(); - e.stopPropagation(); - onClick(); - }} - > - View organization - - {onEdit && ( - { - e.preventDefault(); - e.stopPropagation(); - onEdit(); - }} - > - - Edit organization - - )} - - { - e.preventDefault(); - e.stopPropagation(); - onDelete(); - }} - > - - Delete organization - - - - )} - -
- ); -} - -export function OrgListRowSkeleton() { - return ( - - -
- -
- - -
-
-
- - - - - - - - - -
- ); -} diff --git a/nextjs/constructive-app/src/components/organizations/organization-card.tsx b/nextjs/constructive-app/src/components/organizations/organization-card.tsx deleted file mode 100644 index 283afacf3..000000000 --- a/nextjs/constructive-app/src/components/organizations/organization-card.tsx +++ /dev/null @@ -1,171 +0,0 @@ -'use client'; - -import * as React from 'react'; -import Link from 'next/link'; -import type { Route } from 'next'; -import { MoreHorizontalIcon, ExternalLinkIcon, Trash2Icon, CrownIcon, ShieldIcon, UserIcon } from 'lucide-react'; - -import { cn } from '@/lib/utils'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import type { OrgRole } from '@/lib/gql/hooks/admin'; - -/** - * Role configuration with distinct visual treatments - */ -const ROLE_CONFIG: Record = { - owner: { - label: 'Owner', - icon: CrownIcon, - className: 'bg-amber-500/10 text-amber-600 dark:bg-amber-500/15 dark:text-amber-400 border-amber-500/20', - }, - admin: { - label: 'Admin', - icon: ShieldIcon, - className: 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/15 dark:text-blue-400 border-blue-500/20', - }, - member: { - label: 'Member', - icon: UserIcon, - className: 'bg-muted text-muted-foreground border-border/60', - }, -}; - -export interface OrganizationCardProps { - id: string; - name: string; - description?: string; - memberCount?: number; - role: OrgRole; - href: string; - onDelete?: () => void; - className?: string; -} - -export function OrganizationCard({ - id, - name, - description, - memberCount, - role, - href, - onDelete, - className, -}: OrganizationCardProps) { - const roleConfig = ROLE_CONFIG[role]; - const RoleIcon = roleConfig.icon; - const canDelete = role === 'owner'; - - return ( -
- {/* Main clickable area */} - - {/* Header */} -
-
-

- {name} -

- {description && ( -

{description}

- )} -
-
- - {/* Footer with role and member count */} -
- - - {roleConfig.label} - - - {memberCount !== undefined && ( - - {memberCount} {memberCount === 1 ? 'member' : 'members'} - - )} -
- - - {/* Action menu - positioned absolutely */} -
- - - - - - - - - View organization - - - {canDelete && ( - <> - - { - e.preventDefault(); - onDelete?.(); - }} - > - - Delete organization - - - )} - - -
-
- ); -} - -export interface OrganizationGridProps { - children: React.ReactNode; - className?: string; -} - -export function OrganizationGrid({ children, className }: OrganizationGridProps) { - return ( -
- {children} -
- ); -} diff --git a/nextjs/constructive-app/src/components/settings/org-settings-route.tsx b/nextjs/constructive-app/src/components/settings/org-settings-route.tsx deleted file mode 100644 index 65d710e20..000000000 --- a/nextjs/constructive-app/src/components/settings/org-settings-route.tsx +++ /dev/null @@ -1,777 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { AlertCircle, ArrowRight, Check, Loader2Icon, Settings2 } from 'lucide-react'; - -import { - useCreateMembershipPermissionDefault, - useCreateOrgMembershipDefault, - useDeleteOrganization, - useMembershipPermissionDefault, - useOrgMembershipDefault, - useUpdateMembershipPermissionDefault, - useUpdateOrganization, - useUpdateOrgMembershipDefault, - type OrganizationWithRole, -} from '@/lib/gql/hooks/admin'; -import { usePermissions } from '@/lib/gql/hooks/admin/policies/use-permissions'; -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Switch } from '@/components/ui/switch'; -import { showErrorToast, showSuccessToast } from '@/components/ui/toast'; -import { PageHeaderWithIcon } from '@/components/shared/page-header-with-icon'; - -const parseBitmask = (bitmask: string): Set => { - const bits = new Set(); - for (let i = 0; i < bitmask.length; i++) { - if (bitmask[bitmask.length - 1 - i] === '1') { - bits.add(i + 1); - } - } - return bits; -}; - -const createBitmask = (bits: Set): string => { - const bitmask = new Array(24).fill('0'); - bits.forEach((bit) => { - if (bit >= 1 && bit <= 24) { - const position = 24 - bit; - bitmask[position] = '1'; - } - }); - return bitmask.join(''); -}; - -interface OrgSettingsRouteProps { - orgId: string; - orgName?: string; - organization: OrganizationWithRole; -} - -export function OrgSettingsRoute({ orgId, orgName = 'Organization', organization }: OrgSettingsRouteProps) { - const router = useRouter(); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deleteConfirmation, setDeleteConfirmation] = useState(''); - - const isSelfOrg = organization.isSelfOrg; - const canEdit = organization.role === 'owner' || organization.role === 'admin' || isSelfOrg; - const canDelete = organization.role === 'owner' && !isSelfOrg; - - const [displayName, setDisplayName] = useState(organization.displayName || orgName); - const [legalName, setLegalName] = useState(organization.settings?.legalName || ''); - const [addressLineOne, setAddressLineOne] = useState(organization.settings?.addressLineOne || ''); - const [addressLineTwo, setAddressLineTwo] = useState(organization.settings?.addressLineTwo || ''); - const [city, setCity] = useState(organization.settings?.city || ''); - const [state, setState] = useState(organization.settings?.state || ''); - - const hasGeneralChanges = - displayName !== (organization.displayName || orgName) || - (!isSelfOrg && - ((legalName || '') !== (organization.settings?.legalName || '') || - (addressLineOne || '') !== (organization.settings?.addressLineOne || '') || - (addressLineTwo || '') !== (organization.settings?.addressLineTwo || '') || - (city || '') !== (organization.settings?.city || '') || - (state || '') !== (organization.settings?.state || ''))); - - const { - membershipDefault, - isLoading: isDefaultsLoading, - error: defaultsError, - } = useOrgMembershipDefault({ - orgId, - enabled: canEdit && !isSelfOrg, - }); - - const [requireAdminApproval, setRequireAdminApproval] = useState(false); - const [hasMemberChanges, setHasMemberChanges] = useState(false); - - const { data: permissionsData, isLoading: isLoadingPermissions } = usePermissions({ enabled: canEdit && !isSelfOrg }); - const membershipPermissions = permissionsData?.membershipPermissions ?? []; - - const { - membershipPermissionDefault, - isLoading: isLoadingPermissionDefault, - error: permissionDefaultError, - } = useMembershipPermissionDefault({ - entityId: orgId, - enabled: canEdit && !isSelfOrg, - }); - - const initialPermissions = membershipPermissionDefault?.permissions - ? parseBitmask(membershipPermissionDefault.permissions) - : new Set(); - const [selectedPermissions, setSelectedPermissions] = useState>(initialPermissions); - - const { updateMembershipPermissionDefault, isUpdating: isUpdatingPermissionDefault } = - useUpdateMembershipPermissionDefault(); - const { createMembershipPermissionDefault, isCreating: isCreatingPermissionDefault } = - useCreateMembershipPermissionDefault(); - const isSavingPermissionDefaults = isUpdatingPermissionDefault || isCreatingPermissionDefault; - - useEffect(() => { - setDisplayName(organization.displayName || orgName); - setLegalName(organization.settings?.legalName || ''); - setAddressLineOne(organization.settings?.addressLineOne || ''); - setAddressLineTwo(organization.settings?.addressLineTwo || ''); - setCity(organization.settings?.city || ''); - setState(organization.settings?.state || ''); - }, [ - organization.displayName, - organization.settings?.legalName, - organization.settings?.addressLineOne, - organization.settings?.addressLineTwo, - organization.settings?.city, - organization.settings?.state, - orgName, - ]); - - useEffect(() => { - if (!membershipDefault) return; - setRequireAdminApproval(!membershipDefault.isApproved); - }, [membershipDefault?.id, membershipDefault?.isApproved]); - - useEffect(() => { - if (!membershipPermissionDefault?.permissions) return; - const permissionBits = parseBitmask(membershipPermissionDefault.permissions); - setSelectedPermissions(permissionBits); - }, [membershipPermissionDefault?.id]); - - const hasPermissionChangesComputed = (() => { - if (!canEdit || isSelfOrg || isLoadingPermissionDefault) return false; - if (!membershipPermissionDefault) return selectedPermissions.size > 0; - const currentBitmask = createBitmask(selectedPermissions); - return currentBitmask !== membershipPermissionDefault.permissions; - })(); - - useEffect(() => { - if (!canEdit || isSelfOrg) { - setHasMemberChanges(false); - return; - } - - if (isDefaultsLoading) return; - - if (!membershipDefault) { - setHasMemberChanges(true); - return; - } - - const currentIsApproved = !requireAdminApproval; - const changed = currentIsApproved !== membershipDefault.isApproved; - setHasMemberChanges(changed); - }, [canEdit, isDefaultsLoading, isSelfOrg, membershipDefault, requireAdminApproval]); - - const { updateOrganization, isUpdating: isUpdatingOrg } = useUpdateOrganization({ - onSuccess: () => { - showSuccessToast({ message: 'Settings saved' }); - }, - onError: (error) => { - showErrorToast({ message: 'Failed to save settings', description: error.message }); - }, - }); - - const { updateMembershipDefault, isUpdating: isUpdatingDefaults } = useUpdateOrgMembershipDefault(); - const { createMembershipDefault, isCreating: isCreatingDefaults } = useCreateOrgMembershipDefault(); - const isSavingMemberDefaults = isUpdatingDefaults || isCreatingDefaults; - - const { deleteOrganization, isDeleting } = useDeleteOrganization({ - onSuccess: () => { - showSuccessToast({ message: 'Organization deleted' }); - router.push('/'); - }, - onError: (error) => { - showErrorToast({ message: 'Failed to delete organization', description: error.message }); - }, - }); - - const togglePermission = (bitnum: number) => { - if (!bitnum) return; - setSelectedPermissions((prev) => { - const newSet = new Set(prev); - if (newSet.has(bitnum)) { - newSet.delete(bitnum); - } else { - newSet.add(bitnum); - } - return newSet; - }); - }; - - const saveGeneral = async () => { - if (!canEdit) return; - - await updateOrganization({ - orgId, - settingsId: organization.settings?.id, - user: { displayName: displayName.trim() || undefined }, - settings: !isSelfOrg - ? { - legalName: legalName.trim() || null, - addressLineOne: addressLineOne.trim() || null, - addressLineTwo: addressLineTwo.trim() || null, - city: city.trim() || null, - state: state.trim() || null, - } - : undefined, - }); - }; - - const saveMemberDefaults = async () => { - if (!canEdit || isSelfOrg) return; - - try { - const isApproved = !requireAdminApproval; - - if (membershipDefault) { - await updateMembershipDefault({ id: membershipDefault.id, orgId, patch: { isApproved } }); - showSuccessToast({ message: 'Member settings updated' }); - return; - } - - await createMembershipDefault({ orgId, isApproved }); - showSuccessToast({ message: 'Member settings created' }); - } catch (error) { - showErrorToast({ - message: 'Failed to save member settings', - description: error instanceof Error ? error.message : 'An unexpected error occurred.', - }); - } - }; - - const savePermissionDefaults = async () => { - if (!canEdit || isSelfOrg) return; - - try { - const permissions = createBitmask(selectedPermissions); - - if (membershipPermissionDefault) { - await updateMembershipPermissionDefault({ - id: membershipPermissionDefault.id, - patch: { permissions, entityId: orgId }, - }); - showSuccessToast({ message: 'Default permissions updated' }); - return; - } - - await createMembershipPermissionDefault({ entityId: orgId, permissions }); - showSuccessToast({ message: 'Default permissions created' }); - } catch (error) { - showErrorToast({ - message: 'Failed to save default permissions', - description: error instanceof Error ? error.message : 'An unexpected error occurred.', - }); - } - }; - - const memberSettingsConfig = [ - { - id: 'members-approved', - label: 'Auto-approve members', - description: 'New members are approved automatically. Turn off to require admin approval.', - checked: !requireAdminApproval, - onChange: (checked: boolean) => setRequireAdminApproval(!checked), - }, - ]; - - return ( -
-
- - -
- {/* General Section */} -
-
-

General

-

Organization identity and profile information

-
- -
-
- - setDisplayName(e.target.value)} - disabled={!canEdit} - placeholder='Enter organization name' - /> -

How your organization appears across the platform

-
- - {!isSelfOrg && ( - <> -
- - setLegalName(e.target.value)} - disabled={!canEdit} - placeholder='Enter legal business name' - /> -

- Official registered name for invoices and legal documents -

-
- -
- - setAddressLineOne(e.target.value)} - disabled={!canEdit} - placeholder='Enter street address' - /> -

Street address or P.O. Box

-
- -
- - setAddressLineTwo(e.target.value)} - disabled={!canEdit} - placeholder='Apartment, suite, unit, building, floor, etc.' - /> -

(Optional)

-
- -
-
- - setCity(e.target.value)} - disabled={!canEdit} - placeholder='Enter city' - /> -
- -
- - setState(e.target.value)} - disabled={!canEdit} - placeholder='Enter state' - /> -
-
- - )} - -
-
- {hasGeneralChanges ? ( - <> - - - - - Unsaved changes - - ) : ( - <> - - All changes saved - - )} -
- -
-
-
- - {/* Member Settings Section */} - {!isSelfOrg && ( -
-
-

Member Settings

-

- Default settings for new members joining this organization -

-
- - {defaultsError && ( -
-
- -
-

Failed to load member settings

-

{defaultsError.message}

-
-
-
- )} - -
- {isDefaultsLoading - ? Array.from({ length: 2 }).map((_, index) => ( - - )) - : memberSettingsConfig.map((setting, index) => ( - - ))} -
- -
-
- {hasMemberChanges && !isDefaultsLoading ? ( - <> - - - - - Unsaved changes - - ) : ( - <> - - All changes saved - - )} -
- -
-
- )} - - {/* Default Permissions Section */} - {!isSelfOrg && ( -
-
-

Default Permissions

-

- Select which permissions new members should have by default -

-
- - {permissionDefaultError && ( -
-
- -
-

Failed to load permission defaults

-

{permissionDefaultError.message}

-
-
-
- )} - -
- {isLoadingPermissions || isLoadingPermissionDefault - ? Array.from({ length: 3 }).map((_, index) => ) - : membershipPermissions.map((permission, index) => ( - togglePermission(permission.bitnum ?? 0)} - disabled={!canEdit || isSavingPermissionDefaults} - index={index} - /> - ))} -
- -
-
- {hasPermissionChangesComputed && !isLoadingPermissionDefault ? ( - <> - - - - - Unsaved changes - - ) : ( - <> - - All changes saved - - )} -
- -
-
- )} - - {/* Danger Zone Section */} - {canDelete && ( -
-
-

Danger Zone

-

Irreversible and destructive actions

-
- -
-
-
-

Delete organization

-

- Permanently delete this organization and all associated data. This action cannot be undone. -

-
- -
-
-
- )} -
- - { - setDeleteDialogOpen(open); - if (!open) setDeleteConfirmation(''); - }} - > - - - Delete organization - - This action cannot be undone. This will permanently delete the organization and all associated data. - - -
- - setDeleteConfirmation(e.target.value)} - placeholder={orgName} - disabled={isDeleting} - /> -
- - Cancel - - -
-
- -
-
-
- ); -} - -interface MemberSettingCardProps { - id: string; - label: string; - description: string; - checked: boolean; - onChange: (checked: boolean) => void; - disabled?: boolean; - index: number; -} - -function MemberSettingCard({ id, label, description, checked, onChange, disabled, index }: MemberSettingCardProps) { - return ( -
-
- -

{description}

-
- -
- ); -} - -function MemberSettingLoadingSkeleton({ index }: { index: number }) { - return ( -
-
- - -
- -
- ); -} - -interface PermissionCheckboxCardProps { - permission: { - id: string; - name?: string | null | undefined; - description?: string | null | undefined; - bitnum?: number | null | undefined; - bitstr?: string | null | undefined; - }; - checked: boolean; - onToggle: () => void; - disabled?: boolean; - index: number; -} - -function PermissionCheckboxCard({ permission, checked, onToggle, disabled, index }: PermissionCheckboxCardProps) { - return ( -
-
- - {permission.description &&

{permission.description}

} -
- -
- ); -} - -function PermissionLoadingSkeleton({ index }: { index: number }) { - return ( -
-
- - -
- -
- ); -} diff --git a/nextjs/constructive-app/src/lib/auth/route-guards.tsx b/nextjs/constructive-app/src/lib/auth/route-guards.tsx index b96794751..4c38c96a3 100644 --- a/nextjs/constructive-app/src/lib/auth/route-guards.tsx +++ b/nextjs/constructive-app/src/lib/auth/route-guards.tsx @@ -15,7 +15,6 @@ import { getRouteRequiredPermission, ROUTE_PATHS, } from '@/app-routes'; -import { buildQueryString, INVITE_QUERY_PARAMS } from '@/app/invite/page'; import { useAuthContext } from './auth-context'; import { TokenManager } from './token-manager'; @@ -95,13 +94,6 @@ export function RouteGuard({ children }: { children: React.ReactNode }) { // Handle guest-only routes - only redirect if definitely authenticated if (accessType === 'guest-only' && isAuthenticated) { - // Check for invite_token in query params - prioritize invite flow - const inviteToken = searchParams?.get(INVITE_QUERY_PARAMS.INVITE_TOKEN); - if (inviteToken) { - // Preserve all query params when redirecting to invite page - router.replace(`/invite${buildQueryString(searchParams)}` as Route); - return; - } const target = redirectParam || getHomePath(ctx); router.replace(target as Route); return; @@ -147,20 +139,6 @@ export function RouteGuard({ children }: { children: React.ReactNode }) { // For guest-only routes, optimize loading state if (accessType === 'guest-only') { - // Check for invite token - if present, show loading while checking auth or if authenticated - const inviteToken = searchParams?.get(INVITE_QUERY_PARAMS.INVITE_TOKEN); - if (inviteToken) { - // If still loading auth state, show loading to prevent flash - if (isLoading) { - return ; - } - // If authenticated, show loading while redirecting - if (isAuthenticated) { - return ; - } - // If not authenticated and not loading, allow register page to show - } - // If there's no token at all, render immediately without waiting for auth loading if (isLoading && !TokenManager.hasToken(ctx)) { return <>{children}; diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/app/index.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/app/index.ts index 42fad61f5..1a51f4f80 100644 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/app/index.ts +++ b/nextjs/constructive-app/src/lib/gql/hooks/admin/app/index.ts @@ -1,6 +1,10 @@ /** - * App-level hooks for platform administration - * These hooks handle app-level context that exists above organizations + * App-level hooks (BASE tier) + * + * App-level context that sits above any per-app data: the current user and + * their app membership (used for the `app-admin` route gate). The b2b app-admin + * surfaces (app users / app settings / app invites) ship as the registry org + * blocks + the org modules — see docs/B2B.md — not as hand-written hooks here. */ export { @@ -18,41 +22,3 @@ export { type UseCurrentUserOptions, type UseCurrentUserResult, } from './use-current-user'; - -export { - useAppUsers, - useUpdateAppUser, - appUsersQueryKeys, - type AppUser, - type UseAppUsersOptions, - type UseAppUsersResult, - type UpdateAppUserData, -} from './use-app-users'; - -export { - useAppSettings, - useUpdateAppSettings, - appSettingsQueryKeys, - type AppMembershipDefaultSettings, - type UseAppSettingsOptions, - type UseAppSettingsResult, - type UpdateAppSettingsData, - type UseUpdateAppSettingsResult, -} from './use-app-settings'; - -export { - useAppInvites, - useAppClaimedInvites, - useSendAppInvite, - useCancelAppInvite, - useExtendAppInvite, - appInvitesQueryKeys, - type AppInvite, - type AppInviteRole, - type AppInviteStatus, - type AppClaimedInvite, - type UseAppInvitesOptions, - type SendAppInviteInput, - type CancelAppInviteInput, - type ExtendAppInviteInput, -} from './use-app-invites'; diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/app/use-app-invites.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/app/use-app-invites.ts deleted file mode 100644 index 8fbb8bffc..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/app/use-app-invites.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * Hook for fetching app invites - * Tier 4 wrapper: Uses SDK hooks + composition for sender data - */ -import { useQuery, useQueryClient } from '@tanstack/react-query'; - -import { useAppStore } from '@/store/app-store'; -import type { SchemaContext } from '@/app-config'; -import { - fetchAppClaimedInvitesQuery, - fetchAppInvitesQuery, - useCreateAppInviteMutation, - useDeleteAppInviteMutation, - useUpdateAppInviteMutation, -} from '@sdk/admin'; -import { fetchUsersQuery } from '@sdk/auth'; - -import { - transformActiveInvite, - transformClaimedInvite, - type BaseClaimedInvite, - type BaseInvite, - type ClaimedInviteNode, - type InviteNode, - type InviteRole, - type InviteStatus, -} from '../invites-shared-utils'; - -export type AppInviteRole = InviteRole; -export type AppInviteStatus = InviteStatus; -export type AppInvite = BaseInvite; -export type AppClaimedInvite = BaseClaimedInvite; - -interface UserNode { - id: string; - displayName: string | null; - username: string | null; -} - -export const appInvitesQueryKeys = { - all: ['app-invites'] as const, - byContext: (context: SchemaContext) => [...appInvitesQueryKeys.all, { context }] as const, - active: (context: SchemaContext, params?: { first?: number; offset?: number }) => - params - ? ([...appInvitesQueryKeys.byContext(context), 'active', params] as const) - : ([...appInvitesQueryKeys.byContext(context), 'active'] as const), - claimed: (context: SchemaContext, params?: { first?: number; offset?: number }) => - params - ? ([...appInvitesQueryKeys.byContext(context), 'claimed', params] as const) - : ([...appInvitesQueryKeys.byContext(context), 'claimed'] as const), -}; - -export interface UseAppInvitesOptions { - enabled?: boolean; - first?: number; - offset?: number; - context?: SchemaContext; -} - -interface AppInvitesQueryResult { - invites: InviteNode[]; - totalCount: number; - pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean }; -} - -export function useAppInvites(options: UseAppInvitesOptions = {}) { - const { enabled = true, first = 20, offset = 0, context = 'admin' } = options; - void context; // Context handled by SDK execute-adapter - - const isAuthenticated = useAppStore((state) => state.auth.isAuthenticated); - - const { data, isLoading, error, refetch } = useQuery({ - queryKey: appInvitesQueryKeys.active(context, { first, offset }), - queryFn: async (): Promise => { - // Step 1: Fetch app invites - const invitesResult = await fetchAppInvitesQuery({ - selection: { - fields: { - id: true, - email: true, - data: true, - createdAt: true, - expiresAt: true, - inviteValid: true, - inviteToken: true, - inviteCount: true, - inviteLimit: true, - senderId: true, - }, - first, - offset, - orderBy: ['CREATED_AT_DESC'], - }, - }); - - const rawInvites = invitesResult.appInvites?.nodes ?? []; - const totalCount = invitesResult.appInvites?.totalCount ?? 0; - const pageInfo = invitesResult.appInvites?.pageInfo ?? { - hasNextPage: false, - hasPreviousPage: false, - }; - - if (rawInvites.length === 0) { - return { invites: [], totalCount: 0, pageInfo: { hasNextPage: false, hasPreviousPage: false } }; - } - - // Step 2: Fetch senders (users) for all invites - const senderIds = [...new Set(rawInvites.map((i: any) => i.senderId).filter((id: any): id is string => !!id))]; - let senderMap = new Map(); - - if (senderIds.length > 0) { - const usersResult = await fetchUsersQuery({ - selection: { - fields: { - id: true, - displayName: true, - username: true, - }, - where: { id: { in: senderIds } }, - }, - }); - for (const user of usersResult.users?.nodes ?? []) { - if (user.id) { - senderMap.set(user.id, { - id: user.id, - displayName: user.displayName ?? null, - username: user.username ?? null, - }); - } - } - } - - // Step 3: Build invite nodes with sender info - const invites: InviteNode[] = rawInvites.map((i: any) => ({ - id: i.id ?? '', - email: (i.email as string | null) ?? null, - data: i.data, - createdAt: i.createdAt ?? '', - expiresAt: i.expiresAt ?? '', - inviteValid: i.inviteValid ?? false, - inviteToken: i.inviteToken ?? '', - inviteCount: i.inviteCount ?? 0, - inviteLimit: i.inviteLimit ?? 1, - sender: i.senderId ? (senderMap.get(i.senderId) ?? null) : null, - })); - - return { invites, totalCount, pageInfo }; - }, - enabled: enabled && isAuthenticated, - staleTime: 30 * 1000, - refetchOnMount: true, - }); - - const invites = (data?.invites ?? []).map((node) => transformActiveInvite(node)); - const totalCount = data?.totalCount ?? 0; - const pageInfo = data?.pageInfo ?? { hasNextPage: false, hasPreviousPage: false }; - - return { invites, totalCount, pageInfo, isLoading, error, refetch }; -} - -interface AppClaimedInvitesQueryResult { - claimedInvites: ClaimedInviteNode[]; - totalCount: number; - pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean }; -} - -export function useAppClaimedInvites(options: UseAppInvitesOptions = {}) { - const { enabled = true, first = 20, offset = 0, context = 'admin' } = options; - void context; // Context handled by SDK execute-adapter - - const isAuthenticated = useAppStore((state) => state.auth.isAuthenticated); - - const { data, isLoading, error, refetch } = useQuery({ - queryKey: appInvitesQueryKeys.claimed(context, { first, offset }), - queryFn: async (): Promise => { - // Step 1: Fetch app claimed invites - const claimedResult = await fetchAppClaimedInvitesQuery({ - selection: { - fields: { - id: true, - data: true, - senderId: true, - receiverId: true, - createdAt: true, - }, - first, - offset, - orderBy: ['CREATED_AT_DESC'], - }, - }); - - const rawClaimed = claimedResult.appClaimedInvites?.nodes ?? []; - const totalCount = claimedResult.appClaimedInvites?.totalCount ?? 0; - const pageInfo = claimedResult.appClaimedInvites?.pageInfo ?? { - hasNextPage: false, - hasPreviousPage: false, - }; - - if (rawClaimed.length === 0) { - return { claimedInvites: [], totalCount: 0, pageInfo: { hasNextPage: false, hasPreviousPage: false } }; - } - - // Step 2: Fetch senders and receivers (users) for all claimed invites - const userIds = [ - ...new Set([ - ...rawClaimed.map((c: any) => c.senderId).filter((id: any): id is string => !!id), - ...rawClaimed.map((c: any) => c.receiverId).filter((id: any): id is string => !!id), - ]), - ]; - let userMap = new Map(); - - if (userIds.length > 0) { - const usersResult = await fetchUsersQuery({ - selection: { - fields: { - id: true, - displayName: true, - username: true, - }, - where: { id: { in: userIds } }, - }, - }); - for (const user of usersResult.users?.nodes ?? []) { - if (user.id) { - userMap.set(user.id, { - id: user.id, - displayName: user.displayName ?? null, - username: user.username ?? null, - }); - } - } - } - - // Step 3: Build claimed invite nodes with sender/receiver info - const claimedInvites: ClaimedInviteNode[] = rawClaimed.map((c: any) => ({ - id: c.id ?? '', - createdAt: c.createdAt ?? '', - data: c.data, - sender: c.senderId ? (userMap.get(c.senderId) ?? null) : null, - receiver: c.receiverId ? (userMap.get(c.receiverId) ?? null) : null, - })); - - return { claimedInvites, totalCount, pageInfo }; - }, - enabled: enabled && isAuthenticated, - staleTime: 30 * 1000, - refetchOnMount: true, - }); - - const claimedInvites = (data?.claimedInvites ?? []).map((node) => transformClaimedInvite(node)); - const totalCount = data?.totalCount ?? 0; - const pageInfo = data?.pageInfo ?? { hasNextPage: false, hasPreviousPage: false }; - - return { claimedInvites, totalCount, pageInfo, isLoading, error, refetch }; -} - -export interface SendAppInviteInput { - email: string; - role: AppInviteRole; - expiresAt: string; - message?: string; - context?: SchemaContext; -} - -export function useSendAppInvite(defaultContext: SchemaContext = 'admin') { - void defaultContext; // Context handled by SDK execute-adapter - const queryClient = useQueryClient(); - const createMutation = useCreateAppInviteMutation({ selection: { fields: { id: true } } }); - - return { - sendInvite: async (input: SendAppInviteInput) => { - const ctx = input.context ?? defaultContext; - const senderId = useAppStore.getState().auth.user?.id || useAppStore.getState().auth.token?.userId; - if (!senderId) throw new Error('No authenticated user found'); - const result = await createMutation.mutateAsync({ - senderId, - email: input.email, - expiresAt: input.expiresAt, - // TODO: fix permission denied for table invites - // data: { - // email: input.email, - // role: input.role, - // message: input.message || null, - // }, - }); - queryClient.invalidateQueries({ queryKey: appInvitesQueryKeys.byContext(ctx) }); - return result.createAppInvite?.appInvite ?? null; - }, - isSending: createMutation.isPending, - error: createMutation.error, - reset: createMutation.reset, - }; -} - -export interface CancelAppInviteInput { - inviteId: string; - context?: SchemaContext; -} - -export function useCancelAppInvite(defaultContext: SchemaContext = 'admin') { - void defaultContext; // Context handled by SDK execute-adapter - const queryClient = useQueryClient(); - const deleteMutation = useDeleteAppInviteMutation({ selection: { fields: { id: true } } }); - - return { - cancelInvite: async (input: CancelAppInviteInput) => { - const ctx = input.context ?? defaultContext; - await deleteMutation.mutateAsync({ id: input.inviteId }); - queryClient.invalidateQueries({ queryKey: appInvitesQueryKeys.byContext(ctx) }); - return input.inviteId; - }, - isCancelling: deleteMutation.isPending, - error: deleteMutation.error, - reset: deleteMutation.reset, - }; -} - -export interface ExtendAppInviteInput { - inviteId: string; - expiresAt: string; - context?: SchemaContext; -} - -export function useExtendAppInvite(defaultContext: SchemaContext = 'admin') { - void defaultContext; // Context handled by SDK execute-adapter - const queryClient = useQueryClient(); - const updateMutation = useUpdateAppInviteMutation({ selection: { fields: { id: true } } }); - - return { - extendInvite: async (input: ExtendAppInviteInput) => { - const ctx = input.context ?? defaultContext; - const result = await updateMutation.mutateAsync({ - id: input.inviteId, - appInvitePatch: { - expiresAt: input.expiresAt, - }, - }); - queryClient.invalidateQueries({ queryKey: appInvitesQueryKeys.byContext(ctx) }); - return result.updateAppInvite?.appInvite ?? null; - }, - isExtending: updateMutation.isPending, - error: updateMutation.error, - reset: updateMutation.reset, - }; -} diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/app/use-app-settings.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/app/use-app-settings.ts deleted file mode 100644 index d03d65303..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/app/use-app-settings.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Hook for fetching and updating app-level settings - * Manages the AppMembershipDefault which controls default member permissions - * Tier 4 wrapper: Uses SDK hooks + cache invalidation - */ -import { useQueryClient } from '@tanstack/react-query'; - -import type { SchemaContext } from '@/app-config'; -import { - appMembershipDefaultsQueryKey, - useAppMembershipDefaultsQuery, - useCreateAppMembershipDefaultMutation, - useUpdateAppMembershipDefaultMutation, -} from '@sdk/admin'; -import { useAppStore } from '@/store/app-store'; - -export interface AppMembershipDefaultSettings { - id: string; - isApproved: boolean; - isVerified: boolean; -} - -export interface UseAppSettingsOptions { - enabled?: boolean; - context?: SchemaContext; -} - -export interface UseAppSettingsResult { - settings: AppMembershipDefaultSettings | null; - isLoading: boolean; - error: Error | null; - refetch: () => Promise; -} - -/** - * Hook for fetching app-level settings (member defaults) - * - * @example - * ```tsx - * const { settings, isLoading } = useAppSettings(); - * - * if (settings) { - * console.log(settings.isApproved); // Whether new members require approval - * console.log(settings.isVerified); // Whether new members require email verification - * } - * ``` - */ -export function useAppSettings(options: UseAppSettingsOptions = {}): UseAppSettingsResult { - const { enabled = true, context = 'admin' } = options; - - const isAuthenticated = useAppStore((state) => state.auth.isAuthenticated); - - const { data, isLoading, error, refetch } = useAppMembershipDefaultsQuery({ - selection: { - fields: { - id: true, - isApproved: true, - isVerified: true, - }, - first: 1, - }, - enabled: enabled && isAuthenticated, - staleTime: 5 * 60 * 1000, // 5 minutes - refetchOnMount: true, - }); - - const node = data?.appMembershipDefaults?.nodes?.[0]; - const settings: AppMembershipDefaultSettings | null = node?.id - ? { - id: node.id, - isApproved: node.isApproved ?? false, - isVerified: node.isVerified ?? false, - } - : null; - - return { - settings, - isLoading, - error: error ?? null, - refetch, - }; -} - -export interface UpdateAppSettingsData { - isApproved?: boolean; - isVerified?: boolean; -} - -export interface UseUpdateAppSettingsResult { - updateSettings: (data: UpdateAppSettingsData) => Promise; - isUpdating: boolean; - error: Error | null; -} - -/** - * Hook for updating app-level settings - * - * @example - * ```tsx - * const { updateSettings, isUpdating } = useUpdateAppSettings(); - * const { settings } = useAppSettings(); - * - * await updateSettings({ - * isApproved: true, // Require admin approval for new members - * isVerified: true, // Require email verification - * }); - * ``` - */ -export function useUpdateAppSettings(_context: SchemaContext = 'admin'): UseUpdateAppSettingsResult { - const queryClient = useQueryClient(); - const { settings } = useAppSettings({ context: _context }); - const updateMutation = useUpdateAppMembershipDefaultMutation({ selection: { fields: { id: true, isApproved: true, isVerified: true } } }); - const createMutation = useCreateAppMembershipDefaultMutation({ selection: { fields: { id: true, isApproved: true, isVerified: true } } }); - - const updateSettings = async (data: UpdateAppSettingsData) => { - let result: AppMembershipDefaultSettings | null = null; - - if (settings?.id) { - const updateResult = await updateMutation.mutateAsync({ - id: settings.id, appMembershipDefaultPatch: data, - }); - const node = updateResult.updateAppMembershipDefault?.appMembershipDefault; - result = node?.id - ? { id: node.id, isApproved: node.isApproved ?? false, isVerified: node.isVerified ?? false } - : null; - } else { - const createResult = await createMutation.mutateAsync({ - isApproved: data.isApproved ?? false, - isVerified: data.isVerified ?? true, - }); - const node = createResult.createAppMembershipDefault?.appMembershipDefault; - result = node?.id - ? { id: node.id, isApproved: node.isApproved ?? false, isVerified: node.isVerified ?? false } - : null; - } - - // Invalidate cache - queryClient.invalidateQueries({ queryKey: appSettingsQueryKeys.all }); - return result; - }; - - return { - updateSettings, - isUpdating: updateMutation.isPending || createMutation.isPending, - error: updateMutation.error || createMutation.error, - }; -} - -/** @deprecated Use appMembershipDefaultsQueryKey from SDK instead */ -export const appSettingsQueryKeys = { - all: ['app-settings'] as const, - settings: (context: SchemaContext) => [...appSettingsQueryKeys.all, { context }] as const, -}; - -// Re-export SDK query key for consumers to migrate -export { appMembershipDefaultsQueryKey }; diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/app/use-app-users.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/app/use-app-users.ts deleted file mode 100644 index abef0c079..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/app/use-app-users.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * Hook for fetching all app users (admin only) - * Lists all users with app memberships for admin management - * Tier 4 wrapper: Uses SDK hooks + cache invalidation - */ -import { useQuery, useQueryClient } from '@tanstack/react-query'; - -import { useAppStore } from '@/store/app-store'; -import type { SchemaContext } from '@/app-config'; -import { - fetchAppMembershipsQuery, - useUpdateAppMembershipMutation, -} from '@sdk/admin'; -import { fetchUsersQuery } from '@sdk/auth'; - -interface ActorNode { - id: string; - displayName: string | null; - username: string | null; - profilePicture: unknown | null; -} - -interface AppUserNode { - id: string; - actorId: string; - isAdmin: boolean; - isOwner: boolean; - isActive: boolean; - isApproved: boolean; - isBanned: boolean; - isDisabled: boolean; - isVerified: boolean; - permissions: unknown; - granted: unknown; - actor: ActorNode | null; -} - -interface QueryResult { - appMemberships: { - nodes: AppUserNode[]; - totalCount: number; - pageInfo: { - hasNextPage: boolean; - hasPreviousPage: boolean; - }; - } | null; -} - - -/** AppMembershipsOrderBy enum values */ -export const AppMembershipsOrderBy = { - ActorIdAsc: 'ACTOR_ID_ASC', - ActorIdDesc: 'ACTOR_ID_DESC', - IsAdminAsc: 'IS_ADMIN_ASC', - IsAdminDesc: 'IS_ADMIN_DESC', - IsOwnerAsc: 'IS_OWNER_ASC', - IsOwnerDesc: 'IS_OWNER_DESC', -} as const; - -export type AppMembershipsOrderByType = (typeof AppMembershipsOrderBy)[keyof typeof AppMembershipsOrderBy]; - -export type AppUser = AppUserNode; - -export interface UseAppUsersOptions { - /** Enable/disable the query */ - enabled?: boolean; - /** Page size */ - first?: number; - /** Offset for pagination */ - offset?: number; - /** Filter options */ - filter?: { - isActive?: boolean; - isAdmin?: boolean; - isBanned?: boolean; - }; - /** Sort order */ - orderBy?: AppMembershipsOrderByType[]; - /** Override schema context (defaults to admin) */ - context?: SchemaContext; -} - -export interface UseAppUsersResult { - /** Array of app user objects */ - users: AppUser[]; - /** Total count of all users */ - totalCount: number; - /** Loading state */ - isLoading: boolean; - /** Error state */ - error: Error | null; - /** Pagination info */ - pageInfo: { - hasNextPage: boolean; - hasPreviousPage: boolean; - }; - /** Refetch function */ - refetch: () => Promise; -} - -/** - * Hook for fetching all app users (admin only) - * - * @example - * ```tsx - * const { users, totalCount, isLoading } = useAppUsers(); - * - * // With filters - * const { users } = useAppUsers({ - * filter: { isActive: true }, - * first: 20, - * offset: 0 - * }); - * ``` - */ -export function useAppUsers(options: UseAppUsersOptions = {}): UseAppUsersResult { - const { - enabled = true, - first = 50, - offset = 0, - filter, - orderBy = [AppMembershipsOrderBy.ActorIdAsc as AppMembershipsOrderByType], - context = 'admin', - } = options; - - const isAuthenticated = useAppStore((state) => state.auth.isAuthenticated); - - // Build filter object for GraphQL - const graphqlFilter = filter - ? { - ...(filter.isActive !== undefined && { isActive: { equalTo: filter.isActive } }), - ...(filter.isAdmin !== undefined && { isAdmin: { equalTo: filter.isAdmin } }), - ...(filter.isBanned !== undefined && { isBanned: { equalTo: filter.isBanned } }), - } - : undefined; - - const { data, isLoading, error, refetch } = useQuery({ - queryKey: appUsersQueryKeys.list(context, { first, offset, filter, orderBy }), - queryFn: async (): Promise => { - // Step 1: Fetch app memberships - const membershipsResult = await fetchAppMembershipsQuery({ - selection: { - fields: { - id: true, - actorId: true, - isAdmin: true, - isOwner: true, - isActive: true, - isApproved: true, - isBanned: true, - isDisabled: true, - isVerified: true, - permissions: true, - granted: true, - }, - first, - offset, - where: graphqlFilter, - orderBy: orderBy as AppMembershipsOrderByType[], - }, - }); - - const memberships = membershipsResult.appMemberships?.nodes ?? []; - const totalCount = membershipsResult.appMemberships?.totalCount ?? 0; - const pageInfo = membershipsResult.appMemberships?.pageInfo ?? { - hasNextPage: false, - hasPreviousPage: false, - }; - - if (memberships.length === 0) { - return { - appMemberships: { nodes: [], totalCount: 0, pageInfo: { hasNextPage: false, hasPreviousPage: false } }, - }; - } - - // Step 2: Fetch actors (users) for all memberships - const actorIds = [...new Set(memberships.map((m: any) => m.actorId).filter((id: any): id is string => !!id))]; - const usersResult = await fetchUsersQuery({ - selection: { - fields: { - id: true, - displayName: true, - username: true, - profilePicture: true, - }, - where: { id: { in: actorIds } }, - }, - }); - - // Build a map of actorId -> user for fast lookup - const actorMap = new Map(); - for (const user of usersResult.users?.nodes ?? []) { - if (user.id) { - actorMap.set(user.id, { - id: user.id, - displayName: user.displayName ?? null, - username: user.username ?? null, - profilePicture: user.profilePicture ?? null, - }); - } - } - - // Step 3: Join memberships with actors - const nodes: AppUserNode[] = memberships.map((m: any) => ({ - id: m.id ?? '', - actorId: m.actorId ?? '', - isAdmin: m.isAdmin ?? false, - isOwner: m.isOwner ?? false, - isActive: m.isActive ?? false, - isApproved: m.isApproved ?? false, - isBanned: m.isBanned ?? false, - isDisabled: m.isDisabled ?? false, - isVerified: m.isVerified ?? false, - permissions: m.permissions ?? null, - granted: m.granted ?? null, - actor: m.actorId ? actorMap.get(m.actorId) ?? null : null, - })); - - return { - appMemberships: { nodes, totalCount, pageInfo }, - }; - }, - enabled: enabled && isAuthenticated, - staleTime: 2 * 60 * 1000, // 2 minutes - refetchOnMount: true, - }); - - const users = data?.appMemberships?.nodes || []; - const totalCount = data?.appMemberships?.totalCount || 0; - const pageInfo = data?.appMemberships?.pageInfo || { - hasNextPage: false, - hasPreviousPage: false, - }; - - return { - users, - totalCount, - isLoading, - error, - pageInfo, - refetch, - }; -} - -export interface UpdateAppUserData { - id: string; - patch: { - isAdmin?: boolean; - isActive?: boolean; - isBanned?: boolean; - isDisabled?: boolean; - }; -} - -/** - * Hook for updating an app user's membership - * - * @example - * ```tsx - * const { updateUser, isUpdating } = useUpdateAppUser(); - * - * await updateUser({ - * id: 'membership-id', - * patch: { isAdmin: true } - * }); - * ``` - */ -export function useUpdateAppUser(context: SchemaContext = 'admin') { - void context; // Context handled by SDK execute-adapter - const queryClient = useQueryClient(); - const updateMutation = useUpdateAppMembershipMutation({ selection: { fields: { id: true } } }); - - return { - updateUser: async (data: UpdateAppUserData) => { - const result = await updateMutation.mutateAsync({ - id: data.id, appMembershipPatch: data.patch, - }); - // Invalidate app users cache - queryClient.invalidateQueries({ queryKey: appUsersQueryKeys.all }); - return result.updateAppMembership?.appMembership ?? null; - }, - isUpdating: updateMutation.isPending, - error: updateMutation.error, - }; -} - -/** - * Generate query keys for consistent cache management - */ -export const appUsersQueryKeys = { - all: ['app-users'] as const, - byContext: (context: SchemaContext) => [...appUsersQueryKeys.all, { context }] as const, - list: ( - context: SchemaContext, - params: { first?: number; offset?: number; filter?: unknown; orderBy?: string[] }, - ) => [...appUsersQueryKeys.byContext(context), params] as const, -}; diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/index.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/index.ts index 16e81c153..b3ac9289f 100644 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/index.ts +++ b/nextjs/constructive-app/src/lib/gql/hooks/admin/index.ts @@ -1,114 +1,26 @@ /** - * Admin hooks - * Centralized exports for organization, permissions, and admin-related hooks + * Admin hooks (BASE tier) + * + * The base auth:email app exposes only account-scoped admin hooks (current + * user, app membership, account profile/email/delete). Organization, + * members, and invite management are a b2b opt-in delivered via the registry + * org blocks + the org modules — see docs/B2B.md — so none of those hooks + * exist in the base. */ -// App-level hooks (platform administration) +// App-level hooks (current user + app membership) export { useCurrentUserAppMembership, - useAppUsers, - useUpdateAppUser, - useAppInvites, - useAppClaimedInvites, - useSendAppInvite, - useCancelAppInvite, - useExtendAppInvite, + useCurrentUser, appMembershipQueryKeys, - appUsersQueryKeys, - appInvitesQueryKeys, + currentUserQueryKeys, type AppMembership, - type AppUser, - type AppInvite, - type AppInviteRole, - type AppInviteStatus, - type AppClaimedInvite, + type CurrentUser, type UseCurrentUserAppMembershipOptions, type UseCurrentUserAppMembershipResult, - type UseAppUsersOptions, - type UseAppUsersResult, - type UpdateAppUserData, - type UseAppInvitesOptions, - type SendAppInviteInput, - type CancelAppInviteInput, - type ExtendAppInviteInput, + type UseCurrentUserOptions, + type UseCurrentUserResult, } from './app'; -// Organization hooks -export { - // Types - type Organization, - type OrganizationWithRole, - type OrganizationSettings, - type OrganizationDetail, - type OrganizationMember, - type OrgRole, - ROLE_TYPE, - ORG_ROLE_CONFIG, - deriveOrgRole, - // Hooks - useOrganizations, - useOrganization, - useCreateOrganization, - useUpdateOrganization, - useDeleteOrganization, - useOrgMembers, - useOrgInvites, - useOrgClaimedInvites, - useSendOrgInvite, - useCancelOrgInvite, - useExtendOrgInvite, - useOrgMembershipDefault, - useUpdateOrgMembershipDefault, - useCreateOrgMembershipDefault, - useMembershipPermissionDefault, - useCreateMembershipPermissionDefault, - useUpdateMembershipPermissionDefault, - // Query keys - organizationsQueryKeys, - orgInvitesQueryKeys, - orgMembershipDefaultQueryKeys, - membershipPermissionDefaultQueryKeys, - // Hook types - type UseOrganizationsOptions, - type UseOrganizationsResult, - type UseOrganizationOptions, - type UseOrganizationResult, - type CreateOrganizationInput, - type CreateOrganizationResult, - type UseCreateOrganizationOptions, - type UseCreateOrganizationResult, - type UpdateOrganizationInput, - type UpdateOrganizationResult, - type UseUpdateOrganizationOptions, - type UseUpdateOrganizationResult, - type DeleteOrganizationInput, - type DeleteOrganizationResult, - type OrgMember, - type OrgMemberStatus, - type UseOrgMembersOptions, - type UseOrgMembersResult, - type OrgInvite, - type OrgInviteRole, - type OrgInviteStatus, - type OrgClaimedInvite, - type UseOrgInvitesOptions, - type SendOrgInviteInput, - type CancelOrgInviteInput, - type ExtendOrgInviteInput, - type OrgMembershipDefault, - type UseOrgMembershipDefaultOptions, - type UpdateOrgMembershipDefaultInput, - type CreateOrgMembershipDefaultInput, - type MembershipPermissionDefault, - type UseMembershipPermissionDefaultOptions, - type CreateMembershipPermissionDefaultInput, - type UpdateMembershipPermissionDefaultInput, - type UseDeleteOrganizationOptions, - type UseDeleteOrganizationResult, -} from './organizations'; - // Account hooks export * from './account'; - -// Shared invite utilities -export * from './invites-shared-utils'; diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/invites-shared-utils.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/invites-shared-utils.ts deleted file mode 100644 index 4d02a90db..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/invites-shared-utils.ts +++ /dev/null @@ -1,161 +0,0 @@ -export type InviteRole = 'admin' | 'member'; -export type InviteStatus = 'pending' | 'expired' | 'claimed'; - -export interface BaseInvite { - id: string; - email: string | null; - role: InviteRole; - status: InviteStatus; - createdAt: string; - expiresAt: string; - inviteValid: boolean; - inviteToken: string; - inviteCount: number; - inviteLimit: number; - sender: { - id: string; - displayName: string | null; - username: string | null; - } | null; - data: unknown; -} - -export interface BaseClaimedInvite { - id: string; - createdAt: string; - role: InviteRole; - email: string | null; - sender: { - id: string; - displayName: string | null; - username: string | null; - } | null; - receiver: { - id: string; - displayName: string | null; - username: string | null; - } | null; - data: unknown; -} - -export function parseInviteRole(data: unknown): InviteRole { - if (!data || typeof data !== 'object') return 'member'; - const role = (data as { role?: unknown }).role; - return role === 'admin' || role === 'member' ? role : 'member'; -} - -export function parseInviteEmailFallback(data: unknown): string | null { - if (!data || typeof data !== 'object') return null; - const email = (data as { email?: unknown }).email; - return typeof email === 'string' ? email : null; -} - -export function deriveInviteStatus(inviteValid: boolean, expiresAt: string): InviteStatus { - if (!inviteValid) { - const expiresAtTime = Date.parse(expiresAt); - if (Number.isFinite(expiresAtTime) && expiresAtTime > Date.now()) { - return 'claimed'; - } - } - - if (inviteValid) { - const expiresAtTime = Date.parse(expiresAt); - if (Number.isFinite(expiresAtTime) && expiresAtTime <= Date.now()) { - return 'expired'; - } - } - - return 'pending'; -} - -export interface InviteNode { - id: string; - email?: string | null | undefined; - data?: unknown; - createdAt: string; - expiresAt: string; - inviteValid: boolean; - inviteToken: string; - inviteCount: number; - inviteLimit: number; - sender?: - | { - id: string; - displayName?: string | null | undefined; - username?: string | null | undefined; - } - | null - | undefined; -} - -export interface ClaimedInviteNode { - id: string; - createdAt: string; - data?: unknown; - sender?: - | { - id: string; - displayName?: string | null | undefined; - username?: string | null | undefined; - } - | null - | undefined; - receiver?: - | { - id: string; - displayName?: string | null | undefined; - username?: string | null | undefined; - } - | null - | undefined; -} - -export function transformActiveInvite(node: InviteNode): BaseInvite { - const email = node.email ?? parseInviteEmailFallback(node.data) ?? null; - const role = parseInviteRole(node.data); - - return { - id: node.id, - email, - role, - status: deriveInviteStatus(node.inviteValid, node.expiresAt), - createdAt: node.createdAt, - expiresAt: node.expiresAt, - inviteValid: node.inviteValid, - inviteToken: node.inviteToken, - inviteCount: node.inviteCount, - inviteLimit: node.inviteLimit, - sender: node.sender - ? { - id: node.sender.id, - displayName: node.sender.displayName ?? null, - username: node.sender.username ?? null, - } - : null, - data: node.data ?? null, - }; -} - -export function transformClaimedInvite(node: ClaimedInviteNode): BaseClaimedInvite { - return { - id: node.id, - createdAt: node.createdAt, - role: parseInviteRole(node.data), - email: parseInviteEmailFallback(node.data) ?? null, - sender: node.sender - ? { - id: node.sender.id, - displayName: node.sender.displayName ?? null, - username: node.sender.username ?? null, - } - : null, - receiver: node.receiver - ? { - id: node.receiver.id, - displayName: node.receiver.displayName ?? null, - username: node.receiver.username ?? null, - } - : null, - data: node.data ?? null, - }; -} diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/index.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/index.ts deleted file mode 100644 index f4b7d22e1..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/index.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Organizations Hooks - * - * GraphQL hooks for organization CRUD operations. - * Organizations are implemented as Users with type=1 (Organization role type). - */ - -// Types -export { - type Organization, - type OrganizationWithRole, - type OrganizationSettings, - type OrgRole, - ROLE_TYPE, - ORG_ROLE_CONFIG, - deriveOrgRole, -} from './organization.types'; - -// List organizations (with current user's role) -export { - useOrganizations, - organizationsQueryKeys, - type UseOrganizationsOptions, - type UseOrganizationsResult, -} from './use-organizations'; - -// Get single organization -export { - useOrganization, - type OrganizationDetail, - type OrganizationMember, - type UseOrganizationOptions, - type UseOrganizationResult, -} from './use-organization'; - -// Create organization -export { - useCreateOrganization, - type CreateOrganizationInput, - type CreateOrganizationResult, - type UseCreateOrganizationOptions, - type UseCreateOrganizationResult, -} from './use-create-organization'; - -// Update organization -export { - useUpdateOrganization, - type UpdateOrganizationInput, - type UpdateOrganizationResult, - type UseUpdateOrganizationOptions, - type UseUpdateOrganizationResult, -} from './use-update-organization'; - -// Delete organization -export { - useDeleteOrganization, - type DeleteOrganizationInput, - type DeleteOrganizationResult, - type UseDeleteOrganizationOptions, - type UseDeleteOrganizationResult, -} from './use-delete-organization'; - -// Organization members -export { - useOrgMembers, - type OrgMember, - type OrgMemberStatus, - type UseOrgMembersOptions, - type UseOrgMembersResult, -} from './use-org-members'; - -// Organization invites -export { - useOrgInvites, - useOrgClaimedInvites, - orgInvitesQueryKeys, - type OrgInvite, - type OrgInviteRole, - type OrgInviteStatus, - type OrgClaimedInvite, - type UseOrgInvitesOptions, -} from './use-org-invites'; - -export { - useSendOrgInvite, - useCancelOrgInvite, - useExtendOrgInvite, - type SendOrgInviteInput, - type CancelOrgInviteInput, - type ExtendOrgInviteInput, -} from './use-org-invite-mutations'; - -// Organization membership defaults (join/approval workflow) -export { - useOrgMembershipDefault, - useUpdateOrgMembershipDefault, - useCreateOrgMembershipDefault, - orgMembershipDefaultQueryKeys, - type OrgMembershipDefault, - type UseOrgMembershipDefaultOptions, - type UpdateOrgMembershipDefaultInput, - type CreateOrgMembershipDefaultInput, -} from './use-org-membership-default'; - -// Organization membership permission defaults -export { - useMembershipPermissionDefault, - useCreateMembershipPermissionDefault, - useUpdateMembershipPermissionDefault, - membershipPermissionDefaultQueryKeys, - type MembershipPermissionDefault, - type UseMembershipPermissionDefaultOptions, - type CreateMembershipPermissionDefaultInput, - type UpdateMembershipPermissionDefaultInput, -} from './use-membership-permission-default'; diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/organization.types.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/organization.types.ts deleted file mode 100644 index 6b0e132fb..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/organization.types.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Organization Types - * - * Type definitions for organization-related data structures. - * Organizations are implemented as Users with type=2 (Organization role type). - */ - -import { ROLE_TYPE } from '@/lib/constants/role-types'; - -export { ROLE_TYPE }; - -/** - * User's role within an organization - * Derived from membership flags: isOwner, isAdmin - */ -export type OrgRole = 'owner' | 'admin' | 'member'; - -/** - * Organization settings from organization_settings table - */ -export interface OrganizationSettings { - id: string; - legalName: string | null; - addressLineOne: string | null; - addressLineTwo: string | null; - city: string | null; - state: string | null; - createdAt: string | null; - updatedAt: string | null; -} - -/** - * Organization entity (User with type=1) - */ -export interface Organization { - id: string; - displayName: string | null; - username: string | null; - profilePicture: unknown | null; - settings: OrganizationSettings | null; - memberCount: number; -} - -/** - * Organization with current user's membership details - * Used in list views to show role badges - */ -export interface OrganizationWithRole extends Organization { - /** Current user's role in this organization */ - role: OrgRole; - /** Membership ID for the current user */ - membershipId: string; - /** When the current user joined */ - memberSince: string | null; - /** True if this is the user's self-org (entity_id === actor_id, type=0) */ - isSelfOrg: boolean; -} - -/** - * Derive user's role from membership flags - */ -export function deriveOrgRole(membership: { - isOwner: boolean; - isAdmin: boolean; -}): OrgRole { - if (membership.isOwner) return 'owner'; - if (membership.isAdmin) return 'admin'; - return 'member'; -} - -/** - * Role display configuration for UI - */ -export const ORG_ROLE_CONFIG = { - owner: { - label: 'Owner', - variant: 'warning' as const, - icon: 'crown', - }, - admin: { - label: 'Admin', - variant: 'default' as const, - icon: 'shield', - }, - member: { - label: 'Member', - variant: 'secondary' as const, - icon: 'user', - }, -} as const; diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-create-organization.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-create-organization.ts deleted file mode 100644 index 7f06cff45..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-create-organization.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * Hook for creating a new organization - * Tier 4 wrapper: Uses SDK hooks + cache invalidation - * - * Creating an organization is a single-step flow: - * 1. createUser with type=2 (Organization) - * → The `type` column is in the INSERT GRANT, so it can be set - * at creation time but never updated afterwards. - * → A database trigger `membership_mbr_create` automatically - * creates the owner membership using jwt_public.current_user_id() - * as the owner. - * 2. Fetch the auto-created membership by entityId - * - * No "convert" step, no direct SQL, no SECURITY DEFINER — the regular - * PostGraphile `createUser` mutation accepts `type` as part of its input - * because INSERT grants include the column (only UPDATE grants exclude it). - */ -import { useQueryClient } from '@tanstack/react-query'; - -import type { SchemaContext } from '@/app-config'; -import { useCreateUserMutation } from '@sdk/auth'; -import { fetchOrgMembershipsQuery } from '@sdk/admin'; - -import { ROLE_TYPE, type Organization } from './organization.types'; -import { organizationsQueryKeys } from './use-organizations'; - -// TODO: Backend migration removed OrganizationSetting mutations -// These need backend consultation for proper replacement - -/** - * Input for creating a new organization - */ -export interface CreateOrganizationInput { - /** Organization display name (required) */ - displayName: string; - /** Organization username/slug (optional, for URL-friendly identifier) */ - username?: string; - /** Organization settings (optional) */ - settings?: { - legalName?: string; - addressLineOne?: string; - addressLineTwo?: string; - city?: string; - state?: string; - }; -} - -/** - * Result of organization creation - */ -export interface CreateOrganizationResult { - organization: Organization; - membershipId: string; -} - -export interface UseCreateOrganizationOptions { - /** Override schema context (defaults to admin) */ - context?: SchemaContext; - /** Callback on successful creation */ - onSuccess?: (result: CreateOrganizationResult) => void; - /** Callback on error */ - onError?: (error: Error) => void; -} - -export interface UseCreateOrganizationResult { - /** Function to create an organization */ - createOrganization: (input: CreateOrganizationInput) => Promise; - /** Whether creation is in progress */ - isCreating: boolean; - /** Error from last creation attempt */ - error: Error | null; - /** Reset error state */ - reset: () => void; -} - -/** - * Hook for creating a new organization - * - * @example - * ```tsx - * const { createOrganization, isCreating, error } = useCreateOrganization({ - * onSuccess: (result) => { - * toast.success(`Created ${result.organization.displayName}`); - * router.push(`/orgs/${result.organization.id}/databases`); - * }, - * }); - * - * const handleSubmit = async (data: FormData) => { - * await createOrganization({ - * displayName: data.name, - * settings: { - * legalName: data.legalName, - * city: data.city, - * state: data.state, - * }, - * }); - * }; - * ``` - */ -export function useCreateOrganization(options: UseCreateOrganizationOptions = {}): UseCreateOrganizationResult { - const { context = 'admin', onSuccess, onError } = options; - void context; // Context is handled by SDK execute-adapter - const queryClient = useQueryClient(); - const createUserMutation = useCreateUserMutation({ - selection: { fields: { id: true, displayName: true, username: true } }, - }); - - const createOrganization = async (input: CreateOrganizationInput): Promise => { - // Step 1: Create User as Organization (type=2) - // The `type` column is in the INSERT GRANT, so it can be set at creation. - // The `membership_mbr_create` trigger fires on insert and creates the - // owner membership automatically, using jwt_public.current_user_id() - // as the owner (i.e. the caller). - const userInput: { displayName: string; username?: string; type: number } = { - displayName: input.displayName, - type: ROLE_TYPE.ORGANIZATION, - }; - if (input.username) { - userInput.username = input.username; - } - const userResult = await createUserMutation.mutateAsync(userInput); - - const newOrg = userResult?.createUser?.user; - if (!newOrg?.id) { - throw new Error('Failed to create organization user'); - } - - // Step 2: Fetch the auto-created membership from the trigger - let membershipId = ''; - try { - const membershipsResult = await fetchOrgMembershipsQuery({ - selection: { - fields: { id: true }, - where: { entityId: { equalTo: newOrg.id }, isOwner: { equalTo: true } }, - first: 1, - }, - }); - membershipId = membershipsResult?.orgMemberships?.nodes?.[0]?.id ?? ''; - } catch { - // Membership fetch failed, but org was created - continue - console.warn('Failed to fetch auto-created membership, org creation succeeded'); - } - - // TODO: OrganizationSetting creation removed from schema - // Settings creation needs backend consultation for proper replacement - if (input.settings) { - console.warn('useCreateOrganization: OrganizationSetting mutations removed from schema'); - } - - // Invalidate organizations list cache - queryClient.invalidateQueries({ queryKey: organizationsQueryKeys.all }); - - const organization: Organization = { - id: newOrg.id, - displayName: newOrg.displayName ?? null, - username: newOrg.username ?? null, - profilePicture: null, - settings: null, // TODO: OrganizationSetting removed - memberCount: 1, // Just the owner - }; - const result: CreateOrganizationResult = { organization, membershipId }; - onSuccess?.(result); - return result; - }; - - return { - createOrganization: async (input: CreateOrganizationInput) => { - try { - return await createOrganization(input); - } catch (error) { - onError?.(error as Error); - throw error; - } - }, - isCreating: createUserMutation.isPending, - error: createUserMutation.error, - reset: createUserMutation.reset, - }; -} diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-delete-organization.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-delete-organization.ts deleted file mode 100644 index 6b3f277c2..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-delete-organization.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Hook for deleting an organization - * Tier 4 wrapper: Uses SDK hooks + cache invalidation - * - * Deleting an organization removes: - * - The User entity (type=1) - * - Associated OrganizationSetting (cascaded by database) - * - Associated Memberships (cascaded by database) - * - * Only organization owners can delete an organization. - */ -import { useQueryClient } from '@tanstack/react-query'; - -import type { SchemaContext } from '@/app-config'; -import { useDeleteUserMutation } from '@sdk/auth'; - -import { organizationsQueryKeys } from './use-organizations'; - -/** - * Input for deleting an organization - */ -export interface DeleteOrganizationInput { - /** Organization ID to delete */ - orgId: string; - /** Organization name for confirmation (optional, for UI validation) */ - confirmName?: string; -} - -/** - * Result of organization deletion - */ -export interface DeleteOrganizationResult { - /** The deleted organization ID */ - deletedOrgId: string; - /** The deleted organization name */ - deletedOrgName: string | null; -} - -export interface UseDeleteOrganizationOptions { - /** Override schema context (defaults to admin) */ - context?: SchemaContext; - /** Callback on successful deletion */ - onSuccess?: (result: DeleteOrganizationResult) => void; - /** Callback on error */ - onError?: (error: Error) => void; -} - -export interface UseDeleteOrganizationResult { - /** Function to delete an organization */ - deleteOrganization: (input: DeleteOrganizationInput) => Promise; - /** Whether deletion is in progress */ - isDeleting: boolean; - /** Error from last deletion attempt */ - error: Error | null; - /** Reset error state */ - reset: () => void; -} - -/** - * Hook for deleting an organization - * - * Only owners can delete an organization. The UI should verify - * ownership before showing the delete option. - * - * @example - * ```tsx - * const { deleteOrganization, isDeleting } = useDeleteOrganization({ - * onSuccess: () => { - * toast.success('Organization deleted'); - * router.push('/'); - * }, - * }); - * - * // In delete confirmation dialog - * const handleConfirmDelete = async () => { - * await deleteOrganization({ orgId: organization.id }); - * }; - * ``` - */ -export function useDeleteOrganization( - options: UseDeleteOrganizationOptions = {} -): UseDeleteOrganizationResult { - const { context = 'admin', onSuccess, onError } = options; - const queryClient = useQueryClient(); - const deleteUserMutation = useDeleteUserMutation({ selection: { fields: { id: true } } }); - - const deleteOrganization = async (input: DeleteOrganizationInput): Promise => { - const { orgId } = input; - - await deleteUserMutation.mutateAsync({ id: orgId }); - - // Invalidate all organization caches - queryClient.invalidateQueries({ queryKey: organizationsQueryKeys.all }); - // Remove the specific organization from cache - queryClient.removeQueries({ - queryKey: organizationsQueryKeys.detail(context, orgId), - }); - - const deleteResult: DeleteOrganizationResult = { - deletedOrgId: orgId, - deletedOrgName: null, // SDK mutation doesn't return displayName - }; - - onSuccess?.(deleteResult); - return deleteResult; - }; - - return { - deleteOrganization: async (input: DeleteOrganizationInput) => { - try { - return await deleteOrganization(input); - } catch (error) { - onError?.(error as Error); - throw error; - } - }, - isDeleting: deleteUserMutation.isPending, - error: deleteUserMutation.error, - reset: deleteUserMutation.reset, - }; -} diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-membership-permission-default.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-membership-permission-default.ts deleted file mode 100644 index 408277680..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-membership-permission-default.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Hooks for managing membership permission defaults - * Tier 4 wrapper: Uses SDK hooks + cache invalidation - */ -import { useQueryClient } from '@tanstack/react-query'; - -import type { SchemaContext } from '@/app-config'; -import { - orgPermissionDefaultsQueryKey, - useCreateOrgPermissionDefaultMutation, - useOrgPermissionDefaultsQuery, - useUpdateOrgPermissionDefaultMutation, -} from '@sdk/admin'; -import { useAppStore } from '@/store/app-store'; - -export interface MembershipPermissionDefault { - id: string; - entityId: string; - permissions: string; -} - -/** @deprecated Use orgPermissionDefaultsQueryKey from SDK instead */ -export const membershipPermissionDefaultQueryKeys = { - all: ['membership-permission-default'] as const, - byContext: (context: SchemaContext) => [...membershipPermissionDefaultQueryKeys.all, { context }] as const, - byId: (context: SchemaContext, id: string) => - [...membershipPermissionDefaultQueryKeys.byContext(context), { id }] as const, -}; - -export interface UseMembershipPermissionDefaultOptions { - entityId: string; - enabled?: boolean; - context?: SchemaContext; -} - -export function useMembershipPermissionDefault(options: UseMembershipPermissionDefaultOptions) { - const { entityId, enabled = true, context = 'admin' } = options; - - const isAuthenticated = useAppStore((state) => state.auth.isAuthenticated); - - const { data, isLoading, error, refetch } = useOrgPermissionDefaultsQuery({ - selection: { - fields: { - id: true, - entityId: true, - permissions: true, - }, - where: { entityId: { equalTo: entityId } }, - }, - enabled: enabled && isAuthenticated && !!entityId, - staleTime: 30 * 1000, - refetchOnMount: true, - }); - - const firstNode = data?.orgPermissionDefaults?.nodes?.[0]; - const membershipPermissionDefault: MembershipPermissionDefault | null = firstNode?.id - ? { - id: firstNode.id, - entityId: firstNode.entityId ?? entityId, - permissions: firstNode.permissions ?? '', - } - : null; - - return { - membershipPermissionDefault, - isLoading, - error: error ?? null, - refetch, - }; -} - -export interface CreateMembershipPermissionDefaultInput { - entityId: string; - permissions: string; - context?: SchemaContext; -} - -export function useCreateMembershipPermissionDefault(_defaultContext: SchemaContext = 'admin') { - const queryClient = useQueryClient(); - const createMutation = useCreateOrgPermissionDefaultMutation({ selection: { fields: { id: true } } }); - - return { - createMembershipPermissionDefault: async (input: CreateMembershipPermissionDefaultInput) => { - const result = await createMutation.mutateAsync({ - entityId: input.entityId, - permissions: input.permissions, - }); - // Invalidate cache - queryClient.invalidateQueries({ - queryKey: orgPermissionDefaultsQueryKey({ where: { entityId: { equalTo: input.entityId } } }), - }); - return result.createOrgPermissionDefault?.orgPermissionDefault ?? null; - }, - isCreating: createMutation.isPending, - error: createMutation.error, - reset: createMutation.reset, - }; -} - -export interface UpdateMembershipPermissionDefaultInput { - id: string; - patch: { entityId?: string; permissions?: string }; - context?: SchemaContext; -} - -export function useUpdateMembershipPermissionDefault(_defaultContext: SchemaContext = 'admin') { - const queryClient = useQueryClient(); - const updateMutation = useUpdateOrgPermissionDefaultMutation({ selection: { fields: { id: true, entityId: true } } }); - - return { - updateMembershipPermissionDefault: async (input: UpdateMembershipPermissionDefaultInput) => { - const result = await updateMutation.mutateAsync({ - id: input.id, - orgPermissionDefaultPatch: input.patch, - }); - // Invalidate related caches - const entityId = input.patch.entityId ?? result.updateOrgPermissionDefault?.orgPermissionDefault?.entityId; - if (entityId) { - queryClient.invalidateQueries({ - queryKey: orgPermissionDefaultsQueryKey({ where: { entityId: { equalTo: entityId } } }), - }); - } - return result.updateOrgPermissionDefault?.orgPermissionDefault ?? null; - }, - isUpdating: updateMutation.isPending, - error: updateMutation.error, - reset: updateMutation.reset, - }; -} - -// Re-export SDK query key for consumers to migrate -export { orgPermissionDefaultsQueryKey }; diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-org-invite-mutations.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-org-invite-mutations.ts deleted file mode 100644 index 9adc894a1..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-org-invite-mutations.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query'; - -import type { SchemaContext } from '@/app-config'; -import { - useCreateOrgInviteMutation, - useDeleteOrgInviteMutation, - useUpdateOrgInviteMutation, -} from '@sdk/admin'; - -import { orgInvitesQueryKeys } from './use-org-invites'; - -export interface SendOrgInviteInput { - orgId: string; - email: string; - expiresAt: string; - context?: SchemaContext; -} - -export function useSendOrgInvite(defaultContext: SchemaContext = 'admin') { - const queryClient = useQueryClient(); - const createMutation = useCreateOrgInviteMutation({ selection: { fields: { id: true } } }); - - return { - sendInvite: async (input: SendOrgInviteInput) => { - const ctx = input.context ?? defaultContext; - const result = await createMutation.mutateAsync({ - entityId: input.orgId, - email: input.email, - expiresAt: input.expiresAt, - }); - queryClient.invalidateQueries({ queryKey: orgInvitesQueryKeys.active(ctx, input.orgId) }); - queryClient.invalidateQueries({ queryKey: orgInvitesQueryKeys.claimed(ctx, input.orgId) }); - return result.createOrgInvite?.orgInvite ?? null; - }, - isSending: createMutation.isPending, - error: createMutation.error, - reset: createMutation.reset, - }; -} - -export interface CancelOrgInviteInput { - orgId: string; - inviteId: string; - context?: SchemaContext; -} - -export function useCancelOrgInvite(defaultContext: SchemaContext = 'admin') { - void defaultContext; - const queryClient = useQueryClient(); - const deleteMutation = useDeleteOrgInviteMutation({ selection: { fields: { id: true } } }); - - return { - cancelInvite: async (input: CancelOrgInviteInput) => { - const ctx = input.context ?? defaultContext; - await deleteMutation.mutateAsync({ id: input.inviteId }); - queryClient.invalidateQueries({ queryKey: orgInvitesQueryKeys.active(ctx, input.orgId) }); - return input.inviteId; - }, - isCancelling: deleteMutation.isPending, - error: deleteMutation.error, - reset: deleteMutation.reset, - }; -} - -export interface ExtendOrgInviteInput { - orgId: string; - inviteId: string; - expiresAt: string; - context?: SchemaContext; -} - -export function useExtendOrgInvite(defaultContext: SchemaContext = 'admin') { - void defaultContext; - const queryClient = useQueryClient(); - const updateMutation = useUpdateOrgInviteMutation({ selection: { fields: { id: true } } }); - - return { - extendInvite: async (input: ExtendOrgInviteInput) => { - const ctx = input.context ?? defaultContext; - const result = await updateMutation.mutateAsync({ - id: input.inviteId, - orgInvitePatch: { - expiresAt: input.expiresAt, - }, - }); - queryClient.invalidateQueries({ queryKey: orgInvitesQueryKeys.active(ctx, input.orgId) }); - queryClient.invalidateQueries({ queryKey: orgInvitesQueryKeys.claimed(ctx, input.orgId) }); - return result.updateOrgInvite?.orgInvite ?? null; - }, - isExtending: updateMutation.isPending, - error: updateMutation.error, - reset: updateMutation.reset, - }; -} diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-org-invites.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-org-invites.ts deleted file mode 100644 index aeaa543c8..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-org-invites.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** - * Hook for fetching organization invites - * Tier 4 wrapper: Uses SDK hooks + composition for sender data - */ -import { useQuery } from '@tanstack/react-query'; - -import { useAppStore } from '@/store/app-store'; -import type { SchemaContext } from '@/app-config'; -import { - fetchOrgClaimedInvitesQuery, - fetchOrgInvitesQuery, -} from '@sdk/admin'; -import { fetchUsersQuery } from '@sdk/auth'; - -import { - transformActiveInvite, - transformClaimedInvite, - type BaseClaimedInvite, - type BaseInvite, - type ClaimedInviteNode, - type InviteNode, - type InviteRole, - type InviteStatus, -} from '../invites-shared-utils'; - -export type OrgInviteRole = InviteRole; -export type OrgInviteStatus = InviteStatus; -export type OrgInvite = BaseInvite; -export type OrgClaimedInvite = BaseClaimedInvite; - -interface UserNode { - id: string; - displayName: string | null; - username: string | null; -} - -export const orgInvitesQueryKeys = { - all: ['org-invites'] as const, - byContext: (context: SchemaContext) => [...orgInvitesQueryKeys.all, { context }] as const, - active: (context: SchemaContext, orgId: string, params?: { first?: number; offset?: number }) => - params - ? ([...orgInvitesQueryKeys.byContext(context), 'active', { orgId }, params] as const) - : ([...orgInvitesQueryKeys.byContext(context), 'active', { orgId }] as const), - claimed: (context: SchemaContext, orgId: string, params?: { first?: number; offset?: number }) => - params - ? ([...orgInvitesQueryKeys.byContext(context), 'claimed', { orgId }, params] as const) - : ([...orgInvitesQueryKeys.byContext(context), 'claimed', { orgId }] as const), -}; - -export interface UseOrgInvitesOptions { - orgId: string; - enabled?: boolean; - first?: number; - offset?: number; - context?: SchemaContext; -} - -interface OrgInvitesQueryResult { - invites: InviteNode[]; - totalCount: number; - pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean }; -} - -export function useOrgInvites(options: UseOrgInvitesOptions) { - const { orgId, enabled = true, first = 20, offset = 0, context = 'admin' } = options; - void context; // Context handled by SDK execute-adapter - - const isAuthenticated = useAppStore((state) => state.auth.isAuthenticated); - - const { data, isLoading, error, refetch } = useQuery({ - queryKey: orgInvitesQueryKeys.active(context, orgId, { first, offset }), - queryFn: async (): Promise => { - // Step 1: Fetch org invites - const invitesResult = await fetchOrgInvitesQuery({ - selection: { - fields: { - id: true, - email: true, - data: true, - createdAt: true, - expiresAt: true, - inviteValid: true, - inviteToken: true, - inviteCount: true, - inviteLimit: true, - senderId: true, - }, - where: { entityId: { equalTo: orgId } }, - first, - offset, - orderBy: ['CREATED_AT_DESC'], - }, - }); - - const rawInvites = invitesResult.orgInvites?.nodes ?? []; - const totalCount = invitesResult.orgInvites?.totalCount ?? 0; - const pageInfo = invitesResult.orgInvites?.pageInfo ?? { - hasNextPage: false, - hasPreviousPage: false, - }; - - if (rawInvites.length === 0) { - return { invites: [], totalCount: 0, pageInfo: { hasNextPage: false, hasPreviousPage: false } }; - } - - // Step 2: Fetch senders (users) for all invites - const senderIds = [...new Set(rawInvites.map((i: any) => i.senderId).filter((id: any): id is string => !!id))]; - let senderMap = new Map(); - - if (senderIds.length > 0) { - const usersResult = await fetchUsersQuery({ - selection: { - fields: { - id: true, - displayName: true, - username: true, - }, - where: { id: { in: senderIds } }, - }, - }); - for (const user of usersResult.users?.nodes ?? []) { - if (user.id) { - senderMap.set(user.id, { - id: user.id, - displayName: user.displayName ?? null, - username: user.username ?? null, - }); - } - } - } - - // Step 3: Build invite nodes with sender info - const invites: InviteNode[] = rawInvites.map((i: any) => ({ - id: i.id ?? '', - email: (i.email as string | null) ?? null, - data: i.data, - createdAt: i.createdAt ?? '', - expiresAt: i.expiresAt ?? '', - inviteValid: i.inviteValid ?? false, - inviteToken: i.inviteToken ?? '', - inviteCount: i.inviteCount ?? 0, - inviteLimit: i.inviteLimit ?? 1, - sender: i.senderId ? senderMap.get(i.senderId) ?? null : null, - })); - - return { invites, totalCount, pageInfo }; - }, - enabled: enabled && isAuthenticated && !!orgId, - staleTime: 30 * 1000, - refetchOnMount: true, - }); - - const invites = (data?.invites ?? []).map((node: any) => transformActiveInvite(node)); - const totalCount = data?.totalCount ?? 0; - const pageInfo = data?.pageInfo ?? { hasNextPage: false, hasPreviousPage: false }; - - return { invites, totalCount, pageInfo, isLoading, error, refetch }; -} - -interface OrgClaimedInvitesQueryResult { - claimedInvites: ClaimedInviteNode[]; - totalCount: number; - pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean }; -} - -export function useOrgClaimedInvites(options: UseOrgInvitesOptions) { - const { orgId, enabled = true, first = 20, offset = 0, context = 'admin' } = options; - void context; // Context handled by SDK execute-adapter - - const isAuthenticated = useAppStore((state) => state.auth.isAuthenticated); - - const { data, isLoading, error, refetch } = useQuery({ - queryKey: orgInvitesQueryKeys.claimed(context, orgId, { first, offset }), - queryFn: async (): Promise => { - // Step 1: Fetch org claimed invites - const claimedResult = await fetchOrgClaimedInvitesQuery({ - selection: { - fields: { - id: true, - data: true, - senderId: true, - receiverId: true, - createdAt: true, - }, - where: { entityId: { equalTo: orgId } }, - first, - offset, - orderBy: ['CREATED_AT_DESC'], - }, - }); - - const rawClaimed = claimedResult.orgClaimedInvites?.nodes ?? []; - const totalCount = claimedResult.orgClaimedInvites?.totalCount ?? 0; - const pageInfo = claimedResult.orgClaimedInvites?.pageInfo ?? { - hasNextPage: false, - hasPreviousPage: false, - }; - - if (rawClaimed.length === 0) { - return { claimedInvites: [], totalCount: 0, pageInfo: { hasNextPage: false, hasPreviousPage: false } }; - } - - // Step 2: Fetch senders and receivers (users) for all claimed invites - const userIds = [ - ...new Set([ - ...rawClaimed.map((c: any) => c.senderId).filter((id: any): id is string => !!id), - ...rawClaimed.map((c: any) => c.receiverId).filter((id: any): id is string => !!id), - ]), - ]; - let userMap = new Map(); - - if (userIds.length > 0) { - const usersResult = await fetchUsersQuery({ - selection: { - fields: { - id: true, - displayName: true, - username: true, - }, - where: { id: { in: userIds } }, - }, - }); - for (const user of usersResult.users?.nodes ?? []) { - if (user.id) { - userMap.set(user.id, { - id: user.id, - displayName: user.displayName ?? null, - username: user.username ?? null, - }); - } - } - } - - // Step 3: Build claimed invite nodes with sender/receiver info - const claimedInvites: ClaimedInviteNode[] = rawClaimed.map((c: any) => ({ - id: c.id ?? '', - createdAt: c.createdAt ?? '', - data: c.data, - sender: c.senderId ? userMap.get(c.senderId) ?? null : null, - receiver: c.receiverId ? userMap.get(c.receiverId) ?? null : null, - })); - - return { claimedInvites, totalCount, pageInfo }; - }, - enabled: enabled && isAuthenticated && !!orgId, - staleTime: 30 * 1000, - refetchOnMount: true, - }); - - const claimedInvites = (data?.claimedInvites ?? []).map((node: any) => transformClaimedInvite(node)); - const totalCount = data?.totalCount ?? 0; - const pageInfo = data?.pageInfo ?? { hasNextPage: false, hasPreviousPage: false }; - - return { claimedInvites, totalCount, pageInfo, isLoading, error, refetch }; -} diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-org-members.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-org-members.ts deleted file mode 100644 index 262e49c0b..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-org-members.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Hook for fetching organization members - * Tier 4 wrapper: Uses SDK hooks + cache invalidation - */ -import { useQuery } from '@tanstack/react-query'; - -import { useAppStore } from '@/store/app-store'; -import type { SchemaContext } from '@/app-config'; -import { fetchOrgMembershipsQuery } from '@sdk/admin'; -import { fetchUsersQuery } from '@sdk/auth'; - -import type { OrgRole } from './organization.types'; -import { deriveOrgRole } from './organization.types'; -import { organizationsQueryKeys } from './use-organizations'; - -interface ActorNode { - id: string; - displayName: string | null; - username: string | null; - profilePicture: unknown | null; -} - -interface MembershipNode { - id: string; - actorId: string; - isOwner: boolean; - isAdmin: boolean; - isActive: boolean; - isApproved: boolean; - isBanned: boolean; - isDisabled: boolean; - actor: ActorNode | null; -} - -export type OrgMemberStatus = - | 'active' - | 'inactive' - | 'pending_approval' - | 'banned' - | 'disabled'; - -export interface OrgMember { - membershipId: string; - actorId: string; - displayName: string | null; - username: string | null; - profilePicture: unknown | null; - role: OrgRole; - status: OrgMemberStatus; - flags: { - isOwner: boolean; - isAdmin: boolean; - isActive: boolean; - isApproved: boolean; - isBanned: boolean; - isDisabled: boolean; - }; -} - -function deriveMemberStatus(m: Pick): OrgMemberStatus { - if (m.isBanned) return 'banned'; - if (m.isDisabled) return 'disabled'; - if (!m.isApproved) return 'pending_approval'; - return m.isActive ? 'active' : 'inactive'; -} - -function transformMember(node: MembershipNode): OrgMember { - const flags = { - isOwner: node.isOwner, - isAdmin: node.isAdmin, - isActive: node.isActive, - isApproved: node.isApproved, - isBanned: node.isBanned, - isDisabled: node.isDisabled, - }; - - return { - membershipId: node.id, - actorId: node.actorId, - displayName: node.actor?.displayName ?? null, - username: node.actor?.username ?? null, - profilePicture: node.actor?.profilePicture ?? null, - role: deriveOrgRole(node), - status: deriveMemberStatus(flags), - flags, - }; -} - -export interface UseOrgMembersOptions { - orgId: string; - enabled?: boolean; - first?: number; - offset?: number; - context?: SchemaContext; -} - -export interface UseOrgMembersResult { - members: OrgMember[]; - totalCount: number; - isLoading: boolean; - error: Error | null; - pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean }; - refetch: () => Promise; -} - -interface QueryResult { - members: OrgMember[]; - totalCount: number; - pageInfo: { hasNextPage: boolean; hasPreviousPage: boolean }; -} - -export function useOrgMembers(options: UseOrgMembersOptions): UseOrgMembersResult { - const { orgId, enabled = true, first = 20, offset = 0, context = 'admin' } = options; - void context; // Context handled by SDK execute-adapter - - const isAuthenticated = useAppStore((state) => state.auth.isAuthenticated); - - const { data, isLoading, error, refetch } = useQuery({ - queryKey: organizationsQueryKeys.members(context, orgId, { first, offset }), - queryFn: async () => { - // Step 1: Fetch org memberships - const membershipsResult = await fetchOrgMembershipsQuery({ - selection: { - fields: { - id: true, - actorId: true, - isOwner: true, - isAdmin: true, - isActive: true, - isApproved: true, - isBanned: true, - isDisabled: true, - }, - where: { entityId: { equalTo: orgId } }, - first, - offset, - orderBy: ['IS_OWNER_DESC', 'IS_ADMIN_DESC'], - }, - }); - - const memberships = membershipsResult.orgMemberships?.nodes ?? []; - const totalCount = membershipsResult.orgMemberships?.totalCount ?? 0; - const pageInfo = membershipsResult.orgMemberships?.pageInfo ?? { - hasNextPage: false, - hasPreviousPage: false, - }; - - if (memberships.length === 0) { - return { members: [], totalCount: 0, pageInfo: { hasNextPage: false, hasPreviousPage: false } }; - } - - // Step 2: Fetch actors (users) for all memberships - const actorIds = [...new Set(memberships.map((m: any) => m.actorId).filter((id: any): id is string => !!id))]; - const usersResult = await fetchUsersQuery({ - selection: { - fields: { - id: true, - displayName: true, - username: true, - profilePicture: true, - }, - where: { id: { in: actorIds } }, - }, - }); - - // Build a map of actorId -> user for fast lookup - const actorMap = new Map(); - for (const user of usersResult.users?.nodes ?? []) { - if (user.id) { - actorMap.set(user.id, { - id: user.id, - displayName: user.displayName ?? null, - username: user.username ?? null, - profilePicture: user.profilePicture ?? null, - }); - } - } - - // Step 3: Transform memberships with actors - const members = memberships.map((m: any): OrgMember => { - const membershipNode: MembershipNode = { - id: m.id ?? '', - actorId: m.actorId ?? '', - isOwner: m.isOwner ?? false, - isAdmin: m.isAdmin ?? false, - isActive: m.isActive ?? false, - isApproved: m.isApproved ?? false, - isBanned: m.isBanned ?? false, - isDisabled: m.isDisabled ?? false, - actor: m.actorId ? actorMap.get(m.actorId) ?? null : null, - }; - return transformMember(membershipNode); - }); - - return { members, totalCount, pageInfo }; - }, - enabled: enabled && isAuthenticated && !!orgId, - staleTime: 30 * 1000, - refetchOnMount: true, - }); - - return { - members: data?.members ?? [], - totalCount: data?.totalCount ?? 0, - isLoading, - error, - pageInfo: data?.pageInfo ?? { hasNextPage: false, hasPreviousPage: false }, - refetch, - }; -} diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-org-membership-default.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-org-membership-default.ts deleted file mode 100644 index 46cbe392c..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-org-membership-default.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Hooks for managing organization membership defaults - * Tier 4 wrapper: Uses SDK hooks + cache invalidation - */ -import { useQueryClient } from '@tanstack/react-query'; - -import type { SchemaContext } from '@/app-config'; -import { - orgMembershipDefaultsQueryKey, - useCreateOrgMembershipDefaultMutation, - useOrgMembershipDefaultsQuery, - useUpdateOrgMembershipDefaultMutation, -} from '@sdk/admin'; -import { useAppStore } from '@/store/app-store'; - -export interface OrgMembershipDefault { - id: string; - entityId: string; - isApproved: boolean; -} - -/** @deprecated Use orgMembershipDefaultsQueryKey from SDK instead */ -export const orgMembershipDefaultQueryKeys = { - all: ['org-membership-default'] as const, - byContext: (context: SchemaContext) => [...orgMembershipDefaultQueryKeys.all, { context }] as const, - byOrg: (context: SchemaContext, orgId: string) => - [...orgMembershipDefaultQueryKeys.byContext(context), { orgId }] as const, -}; - -export interface UseOrgMembershipDefaultOptions { - orgId: string; - enabled?: boolean; - context?: SchemaContext; -} - -export function useOrgMembershipDefault(options: UseOrgMembershipDefaultOptions) { - const { orgId, enabled = true, context = 'admin' } = options; - - const isAuthenticated = useAppStore((state) => state.auth.isAuthenticated); - - const { data, isLoading, error, refetch } = useOrgMembershipDefaultsQuery({ - selection: { - fields: { - id: true, - entityId: true, - isApproved: true, - }, - where: { entityId: { equalTo: orgId } }, - first: 1, - }, - enabled: enabled && isAuthenticated && !!orgId, - staleTime: 30 * 1000, - refetchOnMount: true, - }); - - const node = data?.orgMembershipDefaults?.nodes?.[0]; - const membershipDefault: OrgMembershipDefault | null = node?.id - ? { - id: node.id, - entityId: node.entityId ?? orgId, - isApproved: node.isApproved ?? false, - } - : null; - - return { - membershipDefault, - isLoading, - error: error ?? null, - refetch, - }; -} - -export interface UpdateOrgMembershipDefaultInput { - orgId: string; - patch: { isApproved?: boolean }; - context?: SchemaContext; -} - -export function useUpdateOrgMembershipDefault(_defaultContext: SchemaContext = 'admin') { - const queryClient = useQueryClient(); - const updateMutation = useUpdateOrgMembershipDefaultMutation({ - selection: { fields: { id: true } }, - }); - - return { - updateMembershipDefault: async (input: UpdateOrgMembershipDefaultInput & { id: string }) => { - const result = await updateMutation.mutateAsync({ - id: input.id, - orgMembershipDefaultPatch: input.patch, - }); - // Invalidate cache - queryClient.invalidateQueries({ - queryKey: orgMembershipDefaultsQueryKey({ where: { entityId: { equalTo: input.orgId } } }), - }); - return result.updateOrgMembershipDefault?.orgMembershipDefault ?? null; - }, - isUpdating: updateMutation.isPending, - error: updateMutation.error, - reset: updateMutation.reset, - }; -} - -export interface CreateOrgMembershipDefaultInput { - orgId: string; - isApproved: boolean; - context?: SchemaContext; -} - -export function useCreateOrgMembershipDefault(_defaultContext: SchemaContext = 'admin') { - const queryClient = useQueryClient(); - const createMutation = useCreateOrgMembershipDefaultMutation({ selection: { fields: { id: true } } }); - - return { - createMembershipDefault: async (input: CreateOrgMembershipDefaultInput) => { - const result = await createMutation.mutateAsync({ - entityId: input.orgId, - isApproved: input.isApproved, - }); - // Invalidate cache - queryClient.invalidateQueries({ - queryKey: orgMembershipDefaultsQueryKey({ where: { entityId: { equalTo: input.orgId } } }), - }); - return result.createOrgMembershipDefault?.orgMembershipDefault ?? null; - }, - isCreating: createMutation.isPending, - error: createMutation.error, - reset: createMutation.reset, - }; -} - -// Re-export SDK query key for consumers to migrate -export { orgMembershipDefaultsQueryKey }; diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-organization.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-organization.ts deleted file mode 100644 index 8a991fa9c..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-organization.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * Hook for fetching a single organization's details - * Tier 4 wrapper: Uses SDK hooks + cache invalidation - * - * Returns full organization data including settings and member preview. - */ -import { useQuery } from '@tanstack/react-query'; - -import type { SchemaContext } from '@/app-config'; -import { fetchOrgMembershipsQuery } from '@sdk/admin'; -import { fetchUserQuery, fetchUsersQuery } from '@sdk/auth'; -import { useAppStore } from '@/store/app-store'; - -import { - type Organization, - type OrgRole, - ROLE_TYPE, - deriveOrgRole, -} from './organization.types'; -import { organizationsQueryKeys } from './use-organizations'; - -// TODO: Backend migration removed organizationSettingByOrganizationId from User -// Settings field needs backend consultation for proper replacement - -interface ActorNode { - id: string; - displayName: string | null; - username: string | null; - profilePicture: unknown | null; -} - -interface MemberNode { - id: string; - actorId: string; - isOwner: boolean; - isAdmin: boolean; - isActive: boolean; - actor: ActorNode | null; -} - -interface UserNode { - id: string; - displayName: string | null; - username: string | null; - profilePicture: unknown | null; - type: number; - membersPreview: MemberNode[]; - memberCount: number; -} - -interface CurrentMembershipNode { - id: string; - isOwner: boolean; - isAdmin: boolean; - isActive: boolean; -} - -interface QueryResult { - user: UserNode | null; - currentUserMembership: CurrentMembershipNode | null; -} - -/** - * Member preview for organization detail view - */ -export interface OrganizationMember { - id: string; - membershipId: string; - displayName: string | null; - username: string | null; - profilePicture: unknown | null; - role: OrgRole; - memberSince: string | null; -} - -/** - * Full organization detail with members preview - */ -export interface OrganizationDetail extends Organization { - /** Current user's role in this organization */ - currentUserRole: OrgRole | null; - /** Current user's membership ID (null if not a member) */ - currentUserMembershipId: string | null; - /** Preview of organization members (owners/admins first) */ - membersPreview: OrganizationMember[]; -} - -export interface UseOrganizationOptions { - /** Organization ID to fetch */ - orgId: string; - /** Enable/disable the query */ - enabled?: boolean; - /** Override schema context (defaults to admin) */ - context?: SchemaContext; -} - -export interface UseOrganizationResult { - /** Organization details (null if not found or not a member) */ - organization: OrganizationDetail | null; - /** Loading state */ - isLoading: boolean; - /** Error state */ - error: Error | null; - /** Whether the current user is a member of this org */ - isMember: boolean; - /** Whether the current user can edit this org (owner or admin) */ - canEdit: boolean; - /** Whether the current user can delete this org (owner only) */ - canDelete: boolean; - /** Refetch function */ - refetch: () => Promise; -} - -/** - * Transform member node to OrganizationMember - */ -function transformMember(member: MemberNode): OrganizationMember { - return { - id: member.actor?.id ?? '', - membershipId: member.id, - displayName: member.actor?.displayName ?? null, - username: member.actor?.username ?? null, - profilePicture: member.actor?.profilePicture ?? null, - role: deriveOrgRole(member), - memberSince: null, // Membership doesn't have createdAt in schema - }; -} - -/** - * Hook for fetching a single organization's details - * - * @example - * ```tsx - * const { organization, canEdit, isLoading } = useOrganization({ orgId }); - * - * if (organization) { - * return ( - *
- *

{organization.displayName}

- *

Your role: {organization.currentUserRole}

- * {canEdit && } - *
- * ); - * } - * ``` - */ -export function useOrganization(options: UseOrganizationOptions): UseOrganizationResult { - const { orgId, enabled = true, context = 'admin' } = options; - - const { user, token } = useAppStore((state) => ({ - user: state.auth.user, - token: state.auth.token, - })); - - const actorId = user?.id || token?.userId; - - const { data, isLoading, error, refetch } = useQuery({ - queryKey: organizationsQueryKeys.detail(context, orgId), - queryFn: async (): Promise => { - if (!actorId) { - throw new Error('No authenticated user found'); - } - - // Step 1: Fetch the organization (user entity) by ID - const userResult = await fetchUserQuery({ - id: orgId, - selection: { - fields: { - id: true, - displayName: true, - username: true, - profilePicture: true, - type: true, - }, - }, - }); - const userData = userResult.user; - - if (!userData) { - return { user: null, currentUserMembership: null }; - } - - // Step 2: Fetch current user's membership in this org - const currentMembershipResult = await fetchOrgMembershipsQuery({ - selection: { - fields: { - id: true, - isOwner: true, - isAdmin: true, - isActive: true, - actorId: true, - entityId: true, - }, - where: { entityId: { equalTo: orgId }, actorId: { equalTo: actorId } }, - first: 1, - }, - }); - const currentUserMembership = currentMembershipResult.orgMemberships?.nodes?.[0] ?? null; - - // Step 3: Fetch member preview (owners/admins first) - const membersResult = await fetchOrgMembershipsQuery({ - selection: { - fields: { - id: true, - isOwner: true, - isAdmin: true, - isActive: true, - actorId: true, - entityId: true, - }, - where: { entityId: { equalTo: orgId } }, - first: 5, - orderBy: ['IS_OWNER_DESC', 'IS_ADMIN_DESC'], - }, - }); - const memberships = membersResult.orgMemberships?.nodes ?? []; - const memberCount = membersResult.orgMemberships?.totalCount ?? 0; - - // Step 4: Fetch actors for member preview - const actorIds = [...new Set(memberships.map((m: any) => m.actorId).filter((id: any): id is string => !!id))]; - let actorMap = new Map(); - - if (actorIds.length > 0) { - const usersResult = await fetchUsersQuery({ - selection: { - fields: { - id: true, - displayName: true, - username: true, - profilePicture: true, - }, - where: { id: { in: actorIds } }, - }, - }); - for (const user of usersResult.users?.nodes ?? []) { - if (user.id) { - actorMap.set(user.id, { - id: user.id, - displayName: user.displayName ?? null, - username: user.username ?? null, - profilePicture: user.profilePicture ?? null, - }); - } - } - } - - // Build user node with members preview - const userNode: UserNode = { - id: userData.id ?? '', - displayName: userData.displayName ?? null, - username: userData.username ?? null, - profilePicture: userData.profilePicture ?? null, - type: userData.type ?? 0, - membersPreview: memberships.map((m: any) => ({ - id: m.id ?? '', - actorId: m.actorId ?? '', - isOwner: m.isOwner ?? false, - isAdmin: m.isAdmin ?? false, - isActive: m.isActive ?? false, - actor: m.actorId ? actorMap.get(m.actorId) ?? null : null, - })), - memberCount, - }; - - // Build current membership node - const currentMembershipNode: CurrentMembershipNode | null = currentUserMembership - ? { - id: currentUserMembership.id ?? '', - isOwner: currentUserMembership.isOwner ?? false, - isAdmin: currentUserMembership.isAdmin ?? false, - isActive: currentUserMembership.isActive ?? false, - } - : null; - - return { user: userNode, currentUserMembership: currentMembershipNode }; - }, - enabled: enabled && !!actorId && !!orgId, - staleTime: 2 * 60 * 1000, - refetchOnMount: true, - }); - - // Process organization data - const userData = data?.user; - const currentUserMembership = data?.currentUserMembership; - - // Validate this is an organization - const isOrganization = userData?.type === ROLE_TYPE.ORGANIZATION; - - let organization: OrganizationDetail | null = null; - - if (userData && isOrganization) { - // TODO: organizationSettingByOrganizationId removed from schema - // Settings field needs backend consultation for proper replacement - - organization = { - id: userData.id, - displayName: userData.displayName ?? null, - username: userData.username ?? null, - profilePicture: userData.profilePicture ?? null, - settings: null, // TODO: OrganizationSetting removed - memberCount: userData.memberCount, - currentUserRole: currentUserMembership ? deriveOrgRole(currentUserMembership) : null, - currentUserMembershipId: currentUserMembership?.id ?? null, - membersPreview: userData.membersPreview.map(transformMember), - }; - } - - const isMember = !!currentUserMembership; - const currentRole = currentUserMembership ? deriveOrgRole(currentUserMembership) : null; - const canEdit = currentRole === 'owner' || currentRole === 'admin'; - const canDelete = currentRole === 'owner'; - - return { - organization, - isLoading, - error, - isMember, - canEdit, - canDelete, - refetch, - }; -} diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-organizations.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-organizations.ts deleted file mode 100644 index 3c1c3dd68..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-organizations.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * Hook for fetching organizations the current user belongs to - * Tier 4 wrapper: Uses SDK hooks + cache invalidation - * - * Returns organizations with the user's role (owner/admin/member) for each. - * Organizations are Users with type=1 in the database. - */ -import { useQuery } from '@tanstack/react-query'; - -import type { SchemaContext } from '@/app-config'; -import { fetchOrgMembershipsQuery } from '@sdk/admin'; -import { fetchUsersQuery } from '@sdk/auth'; -import { useAppStore } from '@/store/app-store'; - -import { - type OrganizationWithRole, - ROLE_TYPE, - deriveOrgRole, -} from './organization.types'; - -// TODO: Backend migration removed organizationSettingByOrganizationId from User -// Settings field needs backend consultation for proper replacement - -interface EntityNode { - id: string; - displayName: string | null; - username: string | null; - profilePicture: unknown | null; - type: number; - memberCount: number; -} - -interface MembershipNode { - id: string; - isOwner: boolean; - isAdmin: boolean; - isActive: boolean; - isApproved: boolean; - entity: EntityNode | null; -} - -interface QueryResult { - memberships: MembershipNode[]; - totalCount: number; - pageInfo: { - hasNextPage: boolean; - hasPreviousPage: boolean; - }; -} - -export interface UseOrganizationsOptions { - /** Enable/disable the query */ - enabled?: boolean; - /** Number of organizations to fetch */ - first?: number; - /** Offset for pagination */ - offset?: number; - /** Override schema context (defaults to admin) */ - context?: SchemaContext; -} - -export interface UseOrganizationsResult { - /** List of organizations with current user's role */ - organizations: OrganizationWithRole[]; - /** Total count of organizations the user belongs to */ - totalCount: number; - /** Loading state */ - isLoading: boolean; - /** Error state */ - error: Error | null; - /** Pagination info */ - pageInfo: { - hasNextPage: boolean; - hasPreviousPage: boolean; - }; - /** Refetch function */ - refetch: () => Promise; -} - -/** - * Transform raw membership data into OrganizationWithRole - * @param membership - The membership node - * @param actorId - The current user's ID to detect self-org - */ -function transformMembershipToOrg(membership: MembershipNode, actorId: string): OrganizationWithRole | null { - const entity = membership.entity; - if (!entity) return null; - - // Self-org: entity_id === actor_id (type=1, User) - const isSelfOrg = entity.id === actorId && entity.type === ROLE_TYPE.USER; - - // Include: self-org (type=1) OR organization (type=2) - if (!isSelfOrg && entity.type !== ROLE_TYPE.ORGANIZATION) { - return null; - } - - // TODO: organizationSettingByOrganizationId removed from schema - // Settings field needs backend consultation for proper replacement - - return { - id: entity.id, - displayName: entity.displayName ?? null, - username: entity.username ?? null, - profilePicture: entity.profilePicture ?? null, - settings: null, // TODO: OrganizationSetting removed - memberCount: entity.memberCount, - role: deriveOrgRole(membership), - membershipId: membership.id, - memberSince: null, // Membership doesn't have createdAt in schema - isSelfOrg, - }; -} - -/** - * Hook for fetching organizations the current user belongs to - * - * @example - * ```tsx - * const { organizations, isLoading } = useOrganizations(); - * - * organizations.map(org => ( - * - * )); - * ``` - */ -export function useOrganizations(options: UseOrganizationsOptions = {}): UseOrganizationsResult { - const { enabled = true, first = 50, offset = 0, context = 'admin' } = options; - - const { user, token, isAuthLoading } = useAppStore((state) => ({ - user: state.auth.user, - token: state.auth.token, - isAuthLoading: state.auth.isLoading, - })); - - const actorId = user?.id || token?.userId; - - // Auth is ready when we have a user/token to query with - const isAuthReady = !!actorId; - - const { data, isLoading: isQueryLoading, error, refetch } = useQuery({ - queryKey: organizationsQueryKeys.list(context, actorId || '', { first, offset }), - queryFn: async () => { - if (!actorId) { - throw new Error('No authenticated user found'); - } - - // Step 1: Fetch memberships for current user - const membershipsResult = await fetchOrgMembershipsQuery({ - selection: { - fields: { - id: true, - isOwner: true, - isAdmin: true, - isActive: true, - isApproved: true, - actorId: true, - entityId: true, - }, - where: { actorId: { equalTo: actorId }, isActive: { equalTo: true } }, - first, - offset, - }, - }); - - const rawMemberships = membershipsResult.orgMemberships?.nodes ?? []; - const totalCount = membershipsResult.orgMemberships?.totalCount ?? 0; - const pageInfo = membershipsResult.orgMemberships?.pageInfo ?? { - hasNextPage: false, - hasPreviousPage: false, - }; - - if (rawMemberships.length === 0) { - return { memberships: [], totalCount: 0, pageInfo: { hasNextPage: false, hasPreviousPage: false } }; - } - - // Step 2: Get unique entity IDs and fetch user data - const entityIds = [...new Set(rawMemberships.map((m: any) => m.entityId).filter((id: any): id is string => !!id))]; - const usersResult = await fetchUsersQuery({ - selection: { - fields: { - id: true, - displayName: true, - username: true, - profilePicture: true, - type: true, - }, - where: { id: { in: entityIds } }, - }, - }); - - // Build entity map - const entityMap = new Map(); - for (const user of usersResult.users?.nodes ?? []) { - if (user.id) { - entityMap.set(user.id, { - id: user.id, - displayName: user.displayName ?? null, - username: user.username ?? null, - profilePicture: user.profilePicture ?? null, - type: user.type ?? 0, - }); - } - } - - // Step 3: Fetch member counts for each entity (batch query) - const memberCountMap = new Map(); - if (entityIds.length > 0) { - const countsResult = await fetchOrgMembershipsQuery({ - selection: { - fields: { id: true, entityId: true }, - where: { entityId: { in: entityIds } }, - }, - }); - // Count memberships per entity - for (const m of countsResult.orgMemberships?.nodes ?? []) { - if (m.entityId) { - memberCountMap.set(m.entityId, (memberCountMap.get(m.entityId) ?? 0) + 1); - } - } - } - - // Step 4: Build membership nodes with entities - const memberships: MembershipNode[] = rawMemberships.map((m: any) => { - const entity = m.entityId ? entityMap.get(m.entityId) : null; - return { - id: m.id ?? '', - isOwner: m.isOwner ?? false, - isAdmin: m.isAdmin ?? false, - isActive: m.isActive ?? false, - isApproved: m.isApproved ?? false, - entity: entity ? { - ...entity, - memberCount: memberCountMap.get(entity.id) ?? 0, - } : null, - }; - }); - - return { memberships, totalCount, pageInfo }; - }, - enabled: enabled && isAuthReady, - staleTime: 2 * 60 * 1000, // 2 minutes - refetchOnMount: true, - }); - - // Transform and filter memberships to organizations (including self-org) - const organizations: OrganizationWithRole[] = (data?.memberships ?? []) - .map((m: any) => transformMembershipToOrg(m, actorId!)) - .filter((org): org is OrganizationWithRole => org !== null); - - // Note: totalCount from API includes all memberships (not just orgs) - // We report the filtered count for accuracy - const totalCount = organizations.length; - - const pageInfo = data?.pageInfo ?? { - hasNextPage: false, - hasPreviousPage: false, - }; - - // Loading state logic: - // - If auth is still initializing (isAuthLoading=true), report loading - // - If auth is done but user NOT authenticated (!isAuthReady), report NOT loading - // (this allows route guards to properly redirect) - // - If auth is done and user authenticated, check query loading state - const isLoading = isAuthLoading || (isAuthReady && isQueryLoading); - - return { - organizations, - totalCount, - isLoading, - error, - pageInfo, - refetch, - }; -} - -/** - * Query keys for organizations cache management - */ -export const organizationsQueryKeys = { - all: ['organizations'] as const, - byContext: (context: SchemaContext) => [...organizationsQueryKeys.all, { context }] as const, - list: (context: SchemaContext, actorId: string, params: { first?: number; offset?: number }) => - [...organizationsQueryKeys.byContext(context), 'list', { actorId, ...params }] as const, - detail: (context: SchemaContext, orgId: string) => - [...organizationsQueryKeys.byContext(context), 'detail', { orgId }] as const, - members: (context: SchemaContext, orgId: string, params?: { first?: number; offset?: number }) => - params - ? ([...organizationsQueryKeys.byContext(context), 'members', { orgId }, params] as const) - : ([...organizationsQueryKeys.byContext(context), 'members', { orgId }] as const), -}; diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-update-organization.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-update-organization.ts deleted file mode 100644 index 458f3564f..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/organizations/use-update-organization.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Hook for updating an organization - * Tier 4 wrapper: adds cache invalidation - * - * Updates can include: - * - User fields (displayName, username, profilePicture) - * - OrganizationSetting fields (legalName, address, etc.) - */ -import { useMutation, useQueryClient } from '@tanstack/react-query'; - -import type { SchemaContext } from '@/app-config'; -import { useUpdateUserMutation } from '@sdk/auth'; - -import type { OrganizationSettings } from './organization.types'; -import { organizationsQueryKeys } from './use-organizations'; - -// TODO: Backend migration removed OrganizationSetting mutations -// These need backend consultation for proper replacement -// const UPDATE_ORG_SETTINGS_MUTATION = ... -// const CREATE_ORG_SETTINGS_MUTATION = ... - -/** - * Input for updating an organization - */ -export interface UpdateOrganizationInput { - /** Organization ID (User ID) */ - orgId: string; - /** Organization settings ID (required if updating settings) */ - settingsId?: string; - /** User field updates */ - user?: { - displayName?: string; - username?: string; - }; - /** Organization settings updates */ - settings?: { - legalName?: string | null; - addressLineOne?: string | null; - addressLineTwo?: string | null; - city?: string | null; - state?: string | null; - }; -} - -/** - * Result of organization update - */ -export interface UpdateOrganizationResult { - organization: { - id: string; - displayName: string | null; - username: string | null; - profilePicture: unknown | null; - }; - settings: Omit | null; -} - -export interface UseUpdateOrganizationOptions { - /** Override schema context (defaults to admin) */ - context?: SchemaContext; - /** Callback on successful update */ - onSuccess?: (result: UpdateOrganizationResult) => void; - /** Callback on error */ - onError?: (error: Error) => void; -} - -export interface UseUpdateOrganizationResult { - /** Function to update an organization */ - updateOrganization: (input: UpdateOrganizationInput) => Promise; - /** Whether update is in progress */ - isUpdating: boolean; - /** Error from last update attempt */ - error: Error | null; - /** Reset error state */ - reset: () => void; -} - -/** - * Hook for updating an organization - * - * @example - * ```tsx - * const { updateOrganization, isUpdating } = useUpdateOrganization({ - * onSuccess: () => toast.success('Organization updated'), - * }); - * - * await updateOrganization({ - * orgId: 'org-123', - * settingsId: 'settings-456', - * user: { displayName: 'New Name' }, - * settings: { city: 'San Francisco', state: 'CA' }, - * }); - * ``` - */ -export function useUpdateOrganization(options: UseUpdateOrganizationOptions = {}): UseUpdateOrganizationResult { - const { context = 'admin', onSuccess, onError } = options; - const queryClient = useQueryClient(); - const updateUserMutation = useUpdateUserMutation({ selection: { fields: { id: true, displayName: true, username: true, profilePicture: true } } }); - - const mutation = useMutation({ - mutationFn: async (input: UpdateOrganizationInput): Promise => { - const { orgId, user, settings } = input; - - let updatedUserId: string | null = null; - let updatedDisplayName: string | null = null; - let updatedUsername: string | null = null; - let updatedProfilePicture: unknown | null = null; - - // Update User fields if provided - if (user && Object.keys(user).length > 0) { - const patch: Record = {}; - if (user.displayName !== undefined) patch.displayName = user.displayName; - if (user.username !== undefined) patch.username = user.username; - - if (Object.keys(patch).length > 0) { - const userResult = await updateUserMutation.mutateAsync({ id: orgId, userPatch: patch }); - const resultUser = userResult.updateUser?.user; - if (resultUser) { - updatedUserId = resultUser.id ?? null; - updatedDisplayName = resultUser.displayName ?? null; - updatedUsername = resultUser.username ?? null; - updatedProfilePicture = resultUser.profilePicture ?? null; - } - } - } - - // TODO: OrganizationSetting mutations removed from schema - // Settings updates need backend consultation for proper replacement - if (settings && Object.keys(settings).length > 0) { - console.warn('useUpdateOrganization: OrganizationSetting mutations removed from schema'); - } - - return { - organization: updatedUserId - ? { - id: updatedUserId, - displayName: updatedDisplayName, - username: updatedUsername, - profilePicture: updatedProfilePicture, - } - : { - id: orgId, - displayName: null, - username: null, - profilePicture: null, - }, - settings: null, // TODO: OrganizationSetting removed - }; - }, - onSuccess: (result, variables) => { - // Invalidate relevant caches - queryClient.invalidateQueries({ queryKey: organizationsQueryKeys.all }); - queryClient.invalidateQueries({ - queryKey: organizationsQueryKeys.detail(context, variables.orgId), - }); - onSuccess?.(result); - }, - onError: (error: Error) => { - onError?.(error); - }, - }); - - return { - updateOrganization: mutation.mutateAsync, - isUpdating: mutation.isPending, - error: mutation.error, - reset: mutation.reset, - }; -} diff --git a/nextjs/constructive-app/src/lib/gql/hooks/admin/policies/use-permissions.ts b/nextjs/constructive-app/src/lib/gql/hooks/admin/policies/use-permissions.ts deleted file mode 100644 index 140f68c92..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/admin/policies/use-permissions.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Hook for fetching app and membership permissions - * Tier 4 wrapper: Uses SDK hooks + cache invalidation - */ -import { useQuery } from '@tanstack/react-query'; - -import { - fetchAppPermissionsQuery, - fetchOrgPermissionsQuery, -} from '@sdk/admin'; - -export interface PermissionNode { - bitnum: number | null; - bitstr: string | null; - description: string | null; - id: string; - name: string; -} - -export type AppPermission = PermissionNode; -export type MembershipPermission = PermissionNode; - -export const permissionsQueryKeys = { - all: ['permissions'] as const, -}; - -export interface UsePermissionsOptions { - enabled?: boolean; -} - -export function usePermissions(options: UsePermissionsOptions = {}) { - const isEnabled = options.enabled !== false; - - return useQuery<{ appPermissions: AppPermission[]; membershipPermissions: MembershipPermission[] }>({ - queryKey: permissionsQueryKeys.all, - queryFn: async () => { - // Fetch both permission types in parallel using SDK fetch functions - const [appResult, orgResult] = await Promise.all([ - fetchAppPermissionsQuery({ - selection: { - fields: { - id: true, - name: true, - bitnum: true, - bitstr: true, - description: true, - }, - orderBy: ['NAME_ASC'], - }, - }), - fetchOrgPermissionsQuery({ - selection: { - fields: { - id: true, - name: true, - bitnum: true, - bitstr: true, - description: true, - }, - orderBy: ['NAME_ASC'], - }, - }), - ]); - - const appPermissions: AppPermission[] = (appResult.appPermissions?.nodes ?? []).map((node: any) => ({ - id: node.id ?? '', - name: node.name ?? '', - bitnum: node.bitnum ?? null, - bitstr: node.bitstr ?? null, - description: node.description ?? null, - })); - - const membershipPermissions: MembershipPermission[] = (orgResult.orgPermissions?.nodes ?? []).map((node: any) => ({ - id: node.id ?? '', - name: node.name ?? '', - bitnum: node.bitnum ?? null, - bitstr: node.bitstr ?? null, - description: node.description ?? null, - })); - - return { - appPermissions, - membershipPermissions, - }; - }, - enabled: isEnabled, - staleTime: 5 * 60 * 1000, - refetchOnMount: isEnabled, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }); -} diff --git a/nextjs/constructive-app/src/lib/gql/hooks/auth/index.ts b/nextjs/constructive-app/src/lib/gql/hooks/auth/index.ts index 110b1049a..aacaffcdb 100644 --- a/nextjs/constructive-app/src/lib/gql/hooks/auth/index.ts +++ b/nextjs/constructive-app/src/lib/gql/hooks/auth/index.ts @@ -12,5 +12,3 @@ export { useForgotPassword } from './use-forgot-password'; export { useResetPassword } from './use-reset-password'; export { useSendVerificationEmail } from './use-send-verification-email'; export { useVerifyEmail } from './use-verify-email'; -export { useSubmitInviteCode } from './use-submit-invite-code'; -export { useSubmitOrgInviteCode } from './use-submit-org-invite-code'; diff --git a/nextjs/constructive-app/src/lib/gql/hooks/auth/use-submit-invite-code.ts b/nextjs/constructive-app/src/lib/gql/hooks/auth/use-submit-invite-code.ts deleted file mode 100644 index fc7ff1df6..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/auth/use-submit-invite-code.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; - -import { useSubmitAppInviteCodeMutation } from '@sdk/admin'; - -/** - * Submit invite code hook using SDK-generated mutation - */ -export function useSubmitInviteCode() { - const submitInviteMutation = useSubmitAppInviteCodeMutation({ selection: { fields: { result: true } } }); - - return useMutation({ - mutationKey: ['invite', 'submit-invite-code'], - mutationFn: async (token: string) => { - const result = await submitInviteMutation.mutateAsync({ - input: { token }, - }); - if (!result.submitAppInviteCode?.result) { - throw new Error('Invite could not be accepted.'); - } - return { success: true }; - }, - }); -} diff --git a/nextjs/constructive-app/src/lib/gql/hooks/auth/use-submit-org-invite-code.ts b/nextjs/constructive-app/src/lib/gql/hooks/auth/use-submit-org-invite-code.ts deleted file mode 100644 index 9fbfb2138..000000000 --- a/nextjs/constructive-app/src/lib/gql/hooks/auth/use-submit-org-invite-code.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; - -import { useSubmitOrgInviteCodeMutation } from '@sdk/admin'; - -/** - * Submit org invite code hook using SDK-generated mutation - */ -export function useSubmitOrgInviteCode() { - const submitOrgInviteMutation = useSubmitOrgInviteCodeMutation({ selection: { fields: { result: true } } }); - - return useMutation({ - mutationKey: ['invite', 'submit-org-invite-code'], - mutationFn: async (token: string) => { - const result = await submitOrgInviteMutation.mutateAsync({ - input: { token }, - }); - if (!result.submitOrgInviteCode?.result) { - throw new Error('Invite could not be accepted.'); - } - return { success: true }; - }, - }); -} diff --git a/nextjs/constructive-app/src/lib/gql/hooks/index.ts b/nextjs/constructive-app/src/lib/gql/hooks/index.ts index a70e5ab98..2af957eaf 100644 --- a/nextjs/constructive-app/src/lib/gql/hooks/index.ts +++ b/nextjs/constructive-app/src/lib/gql/hooks/index.ts @@ -8,9 +8,7 @@ export { useResetPassword, useSendVerificationEmail, useVerifyEmail, - useSubmitInviteCode, - useSubmitOrgInviteCode, } from './auth'; -// ==== Admin hooks (organizations, permissions, etc.) +// ==== Admin hooks (account + app membership) export * from './admin'; diff --git a/nextjs/constructive-app/src/lib/navigation/index.ts b/nextjs/constructive-app/src/lib/navigation/index.ts index 1dcbddbc7..e78f60290 100644 --- a/nextjs/constructive-app/src/lib/navigation/index.ts +++ b/nextjs/constructive-app/src/lib/navigation/index.ts @@ -1,14 +1,11 @@ export { getSidebarConfig, type NavigationLevel, type SidebarConfigOptions } from './sidebar-config'; export { useSidebarNavigation, type UseSidebarNavigationOptions, type UseSidebarNavigationResult } from './use-sidebar-navigation'; -// URL-based entity state management -export { - useEntityParams, - type Organization, - type OrgRole, - type EntityValidation, - type UseEntityParamsResult, -} from './use-entity-params'; +// URL-based navigation level +export { useEntityParams, type UseEntityParamsResult } from './use-entity-params'; + +// Query-string helpers shared by the auth surface +export { AUTH_QUERY_PARAMS, buildQueryString } from './query-string'; // nuqs-based URL state hooks export { diff --git a/nextjs/constructive-app/src/lib/navigation/query-string.ts b/nextjs/constructive-app/src/lib/navigation/query-string.ts new file mode 100644 index 000000000..dc8804f8f --- /dev/null +++ b/nextjs/constructive-app/src/lib/navigation/query-string.ts @@ -0,0 +1,26 @@ +/** + * query-string.ts — small URL query-param helpers shared by the auth surface. + * + * (Previously these lived in the now-removed invite page. They are generic + * query-string utilities — the auth sign-in / sign-up links use them to carry + * query params, e.g. a prefilled `email`, across navigation.) + */ + +/** Well-known query parameter keys used across the auth surface. */ +export const AUTH_QUERY_PARAMS = { + EMAIL: 'email', + REDIRECT: 'redirect', +} as const; + +/** + * Build a query string from a URLSearchParams object. + * @returns a string with a leading '?' or an empty string when there are none. + */ +export function buildQueryString(searchParams: URLSearchParams | null): string { + if (!searchParams) return ''; + const params = new URLSearchParams(); + searchParams.forEach((value, key) => { + params.set(key, value); + }); + return params.toString() ? `?${params.toString()}` : ''; +} diff --git a/nextjs/constructive-app/src/lib/navigation/sidebar-config.ts b/nextjs/constructive-app/src/lib/navigation/sidebar-config.ts index c298eabbd..b466e47db 100644 --- a/nextjs/constructive-app/src/lib/navigation/sidebar-config.ts +++ b/nextjs/constructive-app/src/lib/navigation/sidebar-config.ts @@ -1,48 +1,33 @@ -import { - RiAccountCircleLine, - RiHome4Line, - RiMailLine, - RiOrganizationChart, - RiSettings3Line, - RiTeamLine, - RiUserLine, - RiUserSettingsLine, -} from '@remixicon/react'; +import { RiHome4Line, RiUserSettingsLine } from '@remixicon/react'; import type { NavGroup, NavItem } from '@/components/app-shell/app-shell.types'; import { APP_ROUTES } from '@/app-routes'; /** - * Navigation level based on entity context - * - root: No organization selected (app-level view) - * - org: Organization selected (org-level view) + * Navigation level (BASE tier). + * The base auth:email app has a single level: the app root. */ -export type NavigationLevel = 'root' | 'org'; +export type NavigationLevel = 'root'; export interface SidebarConfigOptions { /** Current pathname for active state detection */ pathname: string; - /** Whether the current user is an app admin */ - isAppAdmin?: boolean; /** Logout handler */ onLogout?: () => void; /** Custom render for settings (e.g., RuntimeEndpointsDialog) */ settingsRender?: React.ReactNode; - /** Active organization ID (for building org-specific routes) */ - activeOrgId?: string; /** Function to check if a route is active */ isRouteActive?: (routeKey: string) => boolean; } /** - * Get navigation items for the ROOT level (no org selected) - * Shows: Organizations, App items (Invites, Users, Settings - admin only) + * Root-level navigation: Home + Account. * - * At root level, app-level navigation items are only visible to app admins. - * Account actions (Profile, Settings, Sign Out) are in the top bar UserDropdown. + * B2B OPT-IN: a b2b app adds an Organizations entry and an org-level group + * here, wired to the registry org blocks. See docs/B2B.md. */ function getRootNavigation(options: SidebarConfigOptions): NavGroup[] { - const { isRouteActive, isAppAdmin } = options; + const { isRouteActive } = options; const mainItems: NavItem[] = [ { @@ -52,13 +37,6 @@ function getRootNavigation(options: SidebarConfigOptions): NavGroup[] { href: '/', isActive: isRouteActive?.('ROOT'), }, - { - id: 'organizations', - label: 'Organizations', - icon: RiOrganizationChart, - href: '/organizations', - isActive: isRouteActive?.('ORGANIZATIONS'), - }, ]; const groups: NavGroup[] = [ @@ -69,38 +47,6 @@ function getRootNavigation(options: SidebarConfigOptions): NavGroup[] { }, ]; - // App-level admin items - only shown to app admins - // These are for managing the application itself (users, invites, settings) - if (isAppAdmin) { - groups.push({ - id: 'app', - position: 'top', - items: [ - { - id: 'app-invites', - label: 'Invites', - icon: RiMailLine, - href: APP_ROUTES.APP_INVITES.path, - isActive: isRouteActive?.('APP_INVITES'), - }, - { - id: 'app-users', - label: 'Users', - icon: RiUserLine, - href: APP_ROUTES.APP_USERS.path, - isActive: isRouteActive?.('APP_USERS'), - }, - { - id: 'app-settings', - label: 'Settings', - icon: RiSettings3Line, - href: APP_ROUTES.APP_SETTINGS.path, - isActive: isRouteActive?.('APP_SETTINGS'), - }, - ], - }); - } - // Account items groups.push({ id: 'account', @@ -120,62 +66,8 @@ function getRootNavigation(options: SidebarConfigOptions): NavGroup[] { } /** - * Get navigation items for the ORG level (org selected) - * Shows: Members, Invites, Settings - * Account actions are in the top bar UserDropdown. - */ -function getOrgNavigation(options: SidebarConfigOptions): NavGroup[] { - const { activeOrgId, isRouteActive } = options; - - // Build org-specific paths - const orgBasePath = activeOrgId ? `/orgs/${activeOrgId}` : '/orgs'; - - const mainItems: NavItem[] = [ - { - id: 'members', - label: 'Members', - icon: RiTeamLine, - href: `${orgBasePath}/members`, - isActive: isRouteActive?.('ORG_MEMBERS'), - }, - { - id: 'invites', - label: 'Invites', - icon: RiMailLine, - href: `${orgBasePath}/invites`, - isActive: isRouteActive?.('ORG_INVITES'), - }, - { - id: 'settings', - label: 'Settings', - icon: RiSettings3Line, - href: `${orgBasePath}/settings`, - isActive: isRouteActive?.('ORG_SETTINGS'), - }, - ]; - - return [ - { - id: 'main', - position: 'top', - items: mainItems, - }, - ]; -} - -/** - * Get sidebar navigation configuration based on the current level - * Default case returns root navigation as a safe fallback + * Get sidebar navigation configuration for the current level. */ -export function getSidebarConfig(level: NavigationLevel, options: SidebarConfigOptions): NavGroup[] { - switch (level) { - case 'root': - return getRootNavigation(options); - case 'org': - return getOrgNavigation(options); - default: - // Default to root navigation as the safest fallback - // This ensures users see the organizations list if level is somehow undefined - return getRootNavigation(options); - } +export function getSidebarConfig(_level: NavigationLevel, options: SidebarConfigOptions): NavGroup[] { + return getRootNavigation(options); } diff --git a/nextjs/constructive-app/src/lib/navigation/use-entity-params.ts b/nextjs/constructive-app/src/lib/navigation/use-entity-params.ts index 1318769e3..891bf601d 100644 --- a/nextjs/constructive-app/src/lib/navigation/use-entity-params.ts +++ b/nextjs/constructive-app/src/lib/navigation/use-entity-params.ts @@ -1,196 +1,40 @@ /** - * Entity Params Hook - URL-based entity state management + * Entity Params Hook — URL-based navigation level (BASE tier) * - * This hook provides a clean interface for reading entity IDs from URL path params - * and validating them against available data. It serves as the source of truth - * for entity selection, replacing Zustand-based entity state. + * The base auth:email app has a single navigation level: the app root. There + * is no organization hierarchy, so this hook is intentionally minimal. * - * Benefits: - * - URL is shareable and bookmarkable - * - Browser back/forward works naturally - * - No stale persisted state issues - * - Type-safe with validation - * - * Entity Hierarchy: App (root) → Org - * - * Routes: - * - `/` - Root level (organizations list) - * - `/orgs/[orgId]/*` - Organization level + * B2B OPT-IN: a b2b app re-introduces the org level (an `orgId` path param, + * an org switcher, `/orgs/[orgId]/*` routes) alongside the registry org blocks. + * See docs/B2B.md. */ 'use client'; -import { useMemo } from 'react'; -import { useParams } from 'next/navigation'; - -import { - useOrganizations, - type OrganizationWithRole, - type OrgRole, -} from '@/lib/gql/hooks/admin'; - -/** - * Organization type for entity params - * Maps from OrganizationWithRole for backward compatibility - */ -export interface Organization { - id: string; - name: string; - description?: string; - memberCount?: number; - /** Current user's role in this organization */ - role: OrgRole; - /** Original organization data with full details */ - _raw?: OrganizationWithRole; -} - -// Re-export OrgRole for consumers -export type { OrgRole }; - /** - * Navigation level based on URL path params + * Navigation level based on URL path params. + * Base tier only ever sits at the app root. */ -export type NavigationLevel = 'root' | 'org'; +export type NavigationLevel = 'root'; /** - * Entity validation result - */ -export interface EntityValidation { - isValid: boolean; - redirectTo?: string; - error?: string; -} - -/** - * Result from useEntityParams hook + * Result from useEntityParams hook. */ export interface UseEntityParamsResult { - // Current entity IDs from URL (null if not in URL) - orgId: string | null; - - // Validated entity data (null if ID not in URL or invalid) - organization: Organization | null; - - // Navigation level derived from URL + /** Navigation level derived from the URL (always 'root' in the base tier). */ level: NavigationLevel; - - // Available entities for current context - availableOrgs: Organization[]; - - // Validation state - validation: EntityValidation; + /** Whether navigation context is still loading. */ isLoading: boolean; - - // Helper to check if an entity ID is valid - isValidOrgId: (id: string) => boolean; } /** - * Hook to read and validate entity IDs from URL path params. + * Hook to read the current navigation level. * - * This is the primary source of truth for entity selection. - * Use this hook in layouts to determine navigation context. - * - * @example - * ```tsx - * // In org layout - * const { orgId, organization, validation, isLoading } = useEntityParams(); - * - * if (isLoading) return ; - * if (!validation.isValid) { - * router.replace(validation.redirectTo ?? '/'); - * return null; - * } - * - * return <>{children}; - * ``` + * In the base tier this is constant (`root`); it exists as a stable seam so a + * b2b app can reintroduce org-aware logic without rewiring callers. */ - -/** - * Transform OrganizationWithRole to the local Organization type - */ -function transformOrganization(org: OrganizationWithRole): Organization { - return { - id: org.id, - name: org.displayName || org.username || 'Unnamed Organization', - description: org.settings?.legalName || undefined, - memberCount: org.memberCount, - role: org.role, - _raw: org, - }; -} - export function useEntityParams(): UseEntityParamsResult { - const params = useParams(); - - // Extract entity IDs from URL path params - const orgId = (params?.orgId as string) ?? null; - - // Get organizations from GraphQL - const { organizations: rawOrganizations, isLoading: isOrgsLoading } = useOrganizations(); - - // Transform organizations to local format - const availableOrgs = useMemo(() => rawOrganizations.map(transformOrganization), [rawOrganizations]); - - // Validate organization - const organization = useMemo(() => { - if (!orgId) return null; - return availableOrgs.find((org) => org.id === orgId) ?? null; - }, [orgId, availableOrgs]); - - - // Determine navigation level from URL - const level = useMemo((): NavigationLevel => { - if (orgId) return 'org'; - return 'root'; - }, [orgId]); - - // Validation helper functions - const isValidOrgId = useMemo(() => (id: string) => availableOrgs.some((org) => org.id === id), [availableOrgs]); - - // Combined loading state - const isLoading = isOrgsLoading; - - // Compute validation result - const validation = useMemo((): EntityValidation => { - // If no entity IDs in URL, we're at root level - always valid - if (!orgId) { - return { isValid: true }; - } - - // Validate org route - if (orgId) { - // Wait for orgs to load before validating - if (!isOrgsLoading && !organization) { - return { - isValid: false, - redirectTo: '/', - error: `Organization "${orgId}" not found`, - }; - } - return { isValid: true }; - } - - return { isValid: true }; - }, [orgId, organization, isOrgsLoading]); - return { - // Entity IDs from URL - orgId, - - // Validated entities - organization, - - // Navigation level - level, - - // Available entities - availableOrgs, - - // Validation - validation, - isLoading, - - // Helpers - isValidOrgId, + level: 'root', + isLoading: false, }; } diff --git a/nextjs/constructive-app/src/lib/navigation/use-sidebar-navigation.ts b/nextjs/constructive-app/src/lib/navigation/use-sidebar-navigation.ts index 0b9142b0b..b5150a368 100644 --- a/nextjs/constructive-app/src/lib/navigation/use-sidebar-navigation.ts +++ b/nextjs/constructive-app/src/lib/navigation/use-sidebar-navigation.ts @@ -10,8 +10,6 @@ import { getSidebarConfig, type NavigationLevel, type SidebarConfigOptions } fro import { useEntityParams } from './use-entity-params'; export interface UseSidebarNavigationOptions { - /** Whether the current user is an app admin */ - isAppAdmin?: boolean; /** Logout handler */ onLogout?: () => void; /** Custom render for settings (e.g., RuntimeEndpointsDialog) */ @@ -23,27 +21,19 @@ export interface UseSidebarNavigationResult { level: NavigationLevel; /** Navigation groups for the current level */ navigation: NavGroup[]; - /** Active organization ID from URL params */ - activeOrgId: string | null; } /** - * Hook to determine the current navigation level and return appropriate sidebar config + * Hook to build the sidebar navigation for the current URL. * - * Level detection is URL-BASED for robust, shareable state: - * - Entity hierarchy: App (root) → Org - * - If orgId in URL → 'org' level - * - Else → 'root' level - * - * This ensures the sidebar reflects the current URL context. - * URLs are the source of truth for entity selection. + * Base tier sits at the app root (Home + Account). B2B apps reintroduce an + * org level here alongside the registry org blocks — see docs/B2B.md. */ export function useSidebarNavigation(options: UseSidebarNavigationOptions = {}): UseSidebarNavigationResult { const pathname = usePathname(); - const { isAppAdmin, onLogout, settingsRender } = options; + const { onLogout, settingsRender } = options; - // Get entity IDs and level from URL params (source of truth) - const { orgId, level } = useEntityParams(); + const { level } = useEntityParams(); // Create the route active checker const checkRouteActive = useMemo( @@ -55,19 +45,16 @@ export function useSidebarNavigation(options: UseSidebarNavigationOptions = {}): const navigation = useMemo(() => { const configOptions: SidebarConfigOptions = { pathname, - isAppAdmin, onLogout, settingsRender, - activeOrgId: orgId ?? undefined, isRouteActive: checkRouteActive, }; return getSidebarConfig(level, configOptions); - }, [level, pathname, isAppAdmin, onLogout, settingsRender, orgId, checkRouteActive]); + }, [level, pathname, onLogout, settingsRender, checkRouteActive]); return { level, navigation, - activeOrgId: orgId, }; } diff --git a/nextjs/constructive-app/src/lib/permissions/app-permissions.ts b/nextjs/constructive-app/src/lib/permissions/app-permissions.ts index 1aa3b0f99..e826bfd3c 100644 --- a/nextjs/constructive-app/src/lib/permissions/app-permissions.ts +++ b/nextjs/constructive-app/src/lib/permissions/app-permissions.ts @@ -21,10 +21,6 @@ export const APP_PERMISSIONS = { // Settings management VIEW_SETTINGS: 'app:settings:view', MANAGE_SETTINGS: 'app:settings:manage', - - // Organization management - VIEW_ORGANIZATIONS: 'app:organizations:view', - MANAGE_ORGANIZATIONS: 'app:organizations:manage', } as const; export type AppPermission = (typeof APP_PERMISSIONS)[keyof typeof APP_PERMISSIONS]; diff --git a/nextjs/constructive-app/src/lib/runtime/config-core.ts b/nextjs/constructive-app/src/lib/runtime/config-core.ts index f53e1d5a1..8e487938c 100644 --- a/nextjs/constructive-app/src/lib/runtime/config-core.ts +++ b/nextjs/constructive-app/src/lib/runtime/config-core.ts @@ -80,8 +80,15 @@ export function getAuthEndpoint(): string { * Get the app endpoint. * Used for: your business data (boards, cards, etc.) * - * Note: "api" maps to the PostgreSQL schema "app_public" via - * PostGraphile's virtual-host routing (hyphens → underscores). + * The app DATA API is served on the `api-{db}` virtual host (NOT + * `app-public-{db}`). The browser sends the Host header from this URL's + * hostname, and that Host header — not the URL — is what drives server-side + * routing. PostGraphile maps the `{db}` segment back to the physical database + * by converting hyphens to underscores (so "api" maps to the PostgreSQL + * schema "app_public"). + * + * Override with NEXT_PUBLIC_APP_ENDPOINT to point at a different host + * (e.g. when the per-DB domain in services_public.domains differs). */ export function getAppEndpoint(): string { const override = getRuntimeConfig('NEXT_PUBLIC_APP_ENDPOINT'); diff --git a/nextjs/constructive-app/tsconfig.json b/nextjs/constructive-app/tsconfig.json index 7d8c19cb1..a0e7460d0 100644 --- a/nextjs/constructive-app/tsconfig.json +++ b/nextjs/constructive-app/tsconfig.json @@ -25,9 +25,10 @@ } ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], + "include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], "exclude": [ "node_modules", + "packages", "src/**/*.test.ts", "src/**/*.test.tsx", "src/**/__tests__/**/*"