From 5e29f47721485df107b87da0a2840fbf2a5164a2 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmad Date: Mon, 21 Sep 2026 18:46:41 +0500 Subject: [PATCH 01/13] =?UTF-8?q?feat(auth):=20team=20context=20foundation?= =?UTF-8?q?=20=E2=80=94=20active=20team,=20area=20metadata,=20invitation?= =?UTF-8?q?=20near=20account=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration: session.active_team_id, team.metadata, invitation.near_account_id - Better Auth additionalFields for team metadata and invitation nearAccountId - disable default team creation and allow removing the last team - getContext reports organization.teams (id, name, areas) and a membership-validated organization.activeTeamId - setActiveTeam and listUserTeams plugin routes; team routes expose areas - toORPCError maps Better Auth APIError statusCode instead of the string status, so auth errors no longer surface as 500s Co-Authored-By: Claude Opus 5 (1M context) --- plugins/auth/src/auth-instance.ts | 14 + plugins/auth/src/contract.ts | 24 + .../db/migrations/0003_teams_workspaces.sql | 4 + .../src/db/migrations/meta/0003_snapshot.json | 1476 +++++++++++++++++ .../auth/src/db/migrations/meta/_journal.json | 7 + plugins/auth/src/db/schema.ts | 4 + plugins/auth/src/handlers/organizations.ts | 3 +- plugins/auth/src/handlers/session.ts | 31 +- plugins/auth/src/handlers/teams.ts | 92 +- plugins/auth/src/utils.ts | 30 +- .../tests/integration/team-context.test.ts | 196 +++ 11 files changed, 1841 insertions(+), 40 deletions(-) create mode 100644 plugins/auth/src/db/migrations/0003_teams_workspaces.sql create mode 100644 plugins/auth/src/db/migrations/meta/0003_snapshot.json create mode 100644 plugins/auth/tests/integration/team-context.test.ts diff --git a/plugins/auth/src/auth-instance.ts b/plugins/auth/src/auth-instance.ts index 3b4f48aa6..c8ccea76b 100644 --- a/plugins/auth/src/auth-instance.ts +++ b/plugins/auth/src/auth-instance.ts @@ -285,6 +285,20 @@ export function createAuthInstance( roles: orgRoles, teams: { enabled: true, + defaultTeam: { enabled: false }, + allowRemovingAllTeams: true, + }, + schema: { + team: { + additionalFields: { + metadata: { type: "string", required: false, input: true }, + }, + }, + invitation: { + additionalFields: { + nearAccountId: { type: "string", required: false, input: true }, + }, + }, }, async sendInvitationEmail(data) { const inviteLink = `${config.baseUrl}/accept-invitation/${data.id}`; diff --git a/plugins/auth/src/contract.ts b/plugins/auth/src/contract.ts index 0969ff6c4..8368d4efe 100644 --- a/plugins/auth/src/contract.ts +++ b/plugins/auth/src/contract.ts @@ -48,12 +48,20 @@ const organizationInfoSchema = z.object({ metadata: z.record(z.string(), z.unknown()).nullable().optional(), }); +const teamContextSchema = z.object({ + id: z.string(), + name: z.string(), + areas: z.array(z.string()), +}); + const organizationContextSchema = z.object({ activeOrganizationId: z.string().nullable(), organization: organizationInfoSchema.nullable(), member: organizationMemberSchema.nullable(), isPersonal: z.boolean(), hasOrganization: z.boolean(), + teams: z.array(teamContextSchema), + activeTeamId: z.string().nullable(), }); const sessionUserSchema = z.object({ @@ -189,6 +197,7 @@ const teamSchema = z.object({ id: z.string(), name: z.string(), organizationId: z.string(), + areas: z.array(z.string()), createdAt: z.date(), updatedAt: z.date(), }); @@ -579,6 +588,7 @@ export const contract = oc.router({ z.object({ name: z.string(), organizationId: z.string().optional(), + areas: z.array(z.string()).optional(), }), ) .output(teamSchema) @@ -589,8 +599,10 @@ export const contract = oc.router({ .input( z.object({ teamId: z.string(), + organizationId: z.string().optional(), data: z.object({ name: z.string().optional(), + areas: z.array(z.string()).optional(), }), }), ) @@ -618,6 +630,18 @@ export const contract = oc.router({ .output(z.array(teamSchema)) .errors(Errors), + setActiveTeam: oc + .route({ method: "POST", path: "/v1/auth/teams/set-active" }) + .input(z.object({ teamId: z.string().nullable() })) + .output(teamSchema.nullable()) + .errors(Errors), + + listUserTeams: oc + .route({ method: "GET", path: "/v1/auth/teams/user" }) + .input(z.object({ organizationId: z.string().optional() }).optional()) + .output(z.array(teamSchema)) + .errors(Errors), + listTeamMembers: oc .route({ method: "GET", path: "/v1/auth/teams/members" }) .input(z.object({ teamId: z.string() })) diff --git a/plugins/auth/src/db/migrations/0003_teams_workspaces.sql b/plugins/auth/src/db/migrations/0003_teams_workspaces.sql new file mode 100644 index 000000000..abe83cc0a --- /dev/null +++ b/plugins/auth/src/db/migrations/0003_teams_workspaces.sql @@ -0,0 +1,4 @@ +ALTER TABLE "invitation" ADD COLUMN "near_account_id" text;--> statement-breakpoint +ALTER TABLE "session" ADD COLUMN "active_team_id" text;--> statement-breakpoint +ALTER TABLE "team" ADD COLUMN "metadata" text;--> statement-breakpoint +CREATE INDEX "invitation_nearAccountId_idx" ON "invitation" USING btree ("near_account_id"); \ No newline at end of file diff --git a/plugins/auth/src/db/migrations/meta/0003_snapshot.json b/plugins/auth/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 000000000..b0907e8c4 --- /dev/null +++ b/plugins/auth/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,1476 @@ +{ + "id": "33fb8645-a6dd-4ed5-897a-bff9ec264bb3", + "prevId": "cff12f4a-5cd4-4b57-a5b2-9f55b34586fb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_configId_idx": { + "name": "apikey_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_referenceId_idx": { + "name": "apikey_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "near_account_id": { + "name": "near_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_teamId_idx": { + "name": "invitation_teamId_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_nearAccountId_idx": { + "name": "invitation_nearAccountId_idx", + "columns": [ + { + "expression": "near_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.near_account": { + "name": "near_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "near_account_user_id_user_id_fk": { + "name": "near_account_user_id_user_id_fk", + "tableFrom": "near_account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.relayed_transaction": { + "name": "relayed_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receiver_id": { + "name": "receiver_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gas_used": { + "name": "gas_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "relayedTransaction_txHash_idx": { + "name": "relayedTransaction_txHash_idx", + "columns": [ + { + "expression": "tx_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "relayedTransaction_userId_idx": { + "name": "relayedTransaction_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "relayed_transaction_user_id_user_id_fk": { + "name": "relayed_transaction_user_id_user_id_fk", + "tableFrom": "relayed_transaction", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.relayer_key": { + "name": "relayer_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_private_key": { + "name": "encrypted_private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_organizationId_idx": { + "name": "team_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_organization_id_organization_id_fk": { + "name": "team_organization_id_organization_id_fk", + "tableFrom": "team", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teamMember_teamId_idx": { + "name": "teamMember_teamId_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teamMember_userId_idx": { + "name": "teamMember_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number_verified": { + "name": "phone_number_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_phone_number_unique": { + "name": "user_phone_number_unique", + "nullsNotDistinct": false, + "columns": ["phone_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/plugins/auth/src/db/migrations/meta/_journal.json b/plugins/auth/src/db/migrations/meta/_journal.json index 374cccac7..a02bc44e6 100644 --- a/plugins/auth/src/db/migrations/meta/_journal.json +++ b/plugins/auth/src/db/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1786026390221, "tag": "0002_third_captain_stacy", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1789998141585, + "tag": "0003_teams_workspaces", + "breakpoints": true } ] } diff --git a/plugins/auth/src/db/schema.ts b/plugins/auth/src/db/schema.ts index 6d95dcfb7..6f5566305 100644 --- a/plugins/auth/src/db/schema.ts +++ b/plugins/auth/src/db/schema.ts @@ -44,6 +44,7 @@ export const session = pgTable( .references(() => user.id, { onDelete: "cascade" }), impersonatedBy: text("impersonated_by"), activeOrganizationId: text("active_organization_id"), + activeTeamId: text("active_team_id"), }, (table) => [index("session_userId_idx").on(table.userId)], ); @@ -172,11 +173,13 @@ export const invitation = pgTable( .notNull() .references(() => user.id, { onDelete: "cascade" }), teamId: text("team_id"), + nearAccountId: text("near_account_id"), }, (table) => [ index("invitation_organizationId_idx").on(table.organizationId), index("invitation_email_idx").on(table.email), index("invitation_teamId_idx").on(table.teamId), + index("invitation_nearAccountId_idx").on(table.nearAccountId), ], ); @@ -188,6 +191,7 @@ export const team = pgTable( organizationId: text("organization_id") .notNull() .references(() => organization.id, { onDelete: "cascade" }), + metadata: text("metadata"), createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), }, diff --git a/plugins/auth/src/handlers/organizations.ts b/plugins/auth/src/handlers/organizations.ts index 0212cd13b..622e553df 100644 --- a/plugins/auth/src/handlers/organizations.ts +++ b/plugins/auth/src/handlers/organizations.ts @@ -3,7 +3,7 @@ import { eq } from "drizzle-orm"; import { Context } from "effect"; import * as schema from "../db/schema"; import { AuthServicesTag } from "../service-types"; -import { createHeaders, safeAuthApi, tryJsonParse } from "../utils"; +import { createHeaders, parseTeamAreas, safeAuthApi, tryJsonParse } from "../utils"; function toOrganizationInfo(organization: { id: string; @@ -80,6 +80,7 @@ export function createOrganizationHandlers(builder: any, requireAuth: any) { id: t.id, name: t.name, organizationId: t.organizationId, + areas: parseTeamAreas(t.metadata), createdAt: t.createdAt instanceof Date ? t.createdAt : new Date(t.createdAt), updatedAt: t.updatedAt instanceof Date ? t.updatedAt : new Date(t.updatedAt), })) diff --git a/plugins/auth/src/handlers/session.ts b/plugins/auth/src/handlers/session.ts index 88ad1aafd..560957366 100644 --- a/plugins/auth/src/handlers/session.ts +++ b/plugins/auth/src/handlers/session.ts @@ -1,9 +1,28 @@ -import { eq } from "drizzle-orm"; +import { and, asc, eq } from "drizzle-orm"; import { Context } from "effect"; import { API_KEY_CONFIG_IDS } from "../config-schemas"; +import type { Database } from "../db"; import * as schema from "../db/schema"; import { AuthServicesTag } from "../service-types"; -import { createHeaders, getActiveOrganizationId, tryJsonParse } from "../utils"; +import { + createHeaders, + getActiveOrganizationId, + getActiveTeamId, + parseTeamAreas, + tryJsonParse, +} from "../utils"; + +async function listMemberTeams(db: Database, userId: string, organizationId: string) { + const rows = await db + .select({ id: schema.team.id, name: schema.team.name, metadata: schema.team.metadata }) + .from(schema.teamMember) + .innerJoin(schema.team, eq(schema.teamMember.teamId, schema.team.id)) + .where( + and(eq(schema.teamMember.userId, userId), eq(schema.team.organizationId, organizationId)), + ) + .orderBy(asc(schema.team.name)); + return rows.map((row) => ({ id: row.id, name: row.name, areas: parseTeamAreas(row.metadata) })); +} export function createSessionHandlers(builder: any) { return { @@ -233,6 +252,8 @@ export function createSessionHandlers(builder: any) { member: null as { id: string; role: string } | null, isPersonal: false, hasOrganization: false, + teams: [] as Array<{ id: string; name: string; areas: string[] }>, + activeTeamId: null as string | null, }; const organizations: Array<{ @@ -260,6 +281,8 @@ export function createSessionHandlers(builder: any) { member: null, isPersonal: false, hasOrganization: true, + teams: [], + activeTeamId: null, }; } } else if (user?.id) { @@ -286,6 +309,8 @@ export function createSessionHandlers(builder: any) { if (activeMembership?.organization) { const org = activeMembership.organization; + const teams = await listMemberTeams(services.db, user.id, org.id); + const sessionTeamId = getActiveTeamId(session?.session); organizationContext = { activeOrganizationId: activeOrgId, organization: { @@ -301,6 +326,8 @@ export function createSessionHandlers(builder: any) { }, isPersonal: org.slug === user.id, hasOrganization: true, + teams, + activeTeamId: teams.some((team) => team.id === sessionTeamId) ? sessionTeamId : null, }; } } diff --git a/plugins/auth/src/handlers/teams.ts b/plugins/auth/src/handlers/teams.ts index ccd7e6f1d..a0b23de76 100644 --- a/plugins/auth/src/handlers/teams.ts +++ b/plugins/auth/src/handlers/teams.ts @@ -1,9 +1,58 @@ import { Context } from "effect"; import { AuthServicesTag } from "../service-types"; -import { createHeaders, safeAuthApi } from "../utils"; +import { + createHeaders, + getActiveOrganizationId, + parseTeamAreas, + safeAuthApi, + serializeTeamAreas, +} from "../utils"; + +function toDate(value: unknown): Date { + if (value instanceof Date) return value; + return value ? new Date(value as string) : new Date(); +} + +function toTeam(team: any) { + return { + id: team.id, + name: team.name, + organizationId: team.organizationId, + areas: parseTeamAreas(team.metadata), + createdAt: toDate(team.createdAt), + updatedAt: toDate(team.updatedAt), + }; +} export function createTeamHandlers(builder: any, requireAuth: any) { return { + setActiveTeam: builder.setActiveTeam + .use(requireAuth) + .handler(async ({ input, context }: { input: any; context: any }) => { + const services = Context.get(context["effect/context"], AuthServicesTag); + const result = await safeAuthApi(() => + services.auth.api.setActiveTeam({ + headers: createHeaders(context.reqHeaders), + body: { teamId: input.teamId }, + }), + ); + return result ? toTeam(result) : null; + }), + + listUserTeams: builder.listUserTeams + .use(requireAuth) + .handler(async ({ input, context }: { input: any; context: any }) => { + const services = Context.get(context["effect/context"], AuthServicesTag); + const headers = createHeaders(context.reqHeaders); + const organizationId = + input?.organizationId ?? + getActiveOrganizationId((await services.auth.api.getSession({ headers }))?.session); + const result = await safeAuthApi(() => services.auth.api.listUserTeams({ headers })); + return (result ?? []) + .filter((team: any) => !organizationId || team.organizationId === organizationId) + .map(toTeam); + }), + createTeam: builder.createTeam .use(requireAuth) .handler(async ({ input, context }: { input: any; context: any }) => { @@ -14,22 +63,11 @@ export function createTeamHandlers(builder: any, requireAuth: any) { body: { name: input.name, organizationId: input.organizationId, + ...(input.areas ? { metadata: serializeTeamAreas(input.areas) } : {}), }, }), ); - return { - id: result.id, - name: result.name, - organizationId: result.organizationId, - createdAt: - result.createdAt instanceof Date ? result.createdAt : new Date(result.createdAt as any), - updatedAt: - result.updatedAt instanceof Date - ? result.updatedAt - : result.updatedAt - ? new Date(result.updatedAt as any) - : new Date(), - }; + return toTeam(result); }), updateTeam: builder.updateTeam @@ -42,7 +80,9 @@ export function createTeamHandlers(builder: any, requireAuth: any) { body: { teamId: input.teamId, data: { - name: input.data.name, + ...(input.organizationId ? { organizationId: input.organizationId } : {}), + ...(input.data.name !== undefined ? { name: input.data.name } : {}), + ...(input.data.areas ? { metadata: serializeTeamAreas(input.data.areas) } : {}), }, }, }), @@ -50,19 +90,7 @@ export function createTeamHandlers(builder: any, requireAuth: any) { if (!result) { throw new Error("Team not found"); } - return { - id: result.id, - name: result.name, - organizationId: result.organizationId, - createdAt: - result.createdAt instanceof Date ? result.createdAt : new Date(result.createdAt as any), - updatedAt: - result.updatedAt instanceof Date - ? result.updatedAt - : result.updatedAt - ? new Date(result.updatedAt as any) - : new Date(), - }; + return toTeam(result); }), deleteTeam: builder.deleteTeam @@ -93,13 +121,7 @@ export function createTeamHandlers(builder: any, requireAuth: any) { }, }), ); - return (result ?? []).map((t: any) => ({ - id: t.id, - name: t.name, - organizationId: t.organizationId, - createdAt: t.createdAt instanceof Date ? t.createdAt : new Date(t.createdAt), - updatedAt: t.updatedAt instanceof Date ? t.updatedAt : new Date(t.updatedAt), - })); + return (result ?? []).map(toTeam); }), listTeamMembers: builder.listTeamMembers diff --git a/plugins/auth/src/utils.ts b/plugins/auth/src/utils.ts index a00b49dc7..7134ce399 100644 --- a/plugins/auth/src/utils.ts +++ b/plugins/auth/src/utils.ts @@ -32,16 +32,28 @@ export function getActiveOrganizationId(session: unknown): string | null { return null; } +export function getActiveTeamId(session: unknown): string | null { + if (session && typeof session === "object" && "activeTeamId" in session) { + return (session as { activeTeamId: string | null }).activeTeamId ?? null; + } + return null; +} + export function toORPCError(error: unknown) { if (error instanceof ORPCError) return error; if (error && typeof error === "object") { const apiError = error as { - status?: number; + status?: number | string; statusCode?: number; message?: string; code?: string; }; - const status = apiError.status ?? apiError.statusCode; + const status = + typeof apiError.statusCode === "number" + ? apiError.statusCode + : typeof apiError.status === "number" + ? apiError.status + : undefined; if (status) { const statusMap: Record = { 400: "BAD_REQUEST", @@ -68,3 +80,17 @@ export async function safeAuthApi(fn: () => Promise): Promise { throw toORPCError(error); } } + +export function parseTeamAreas(metadata: unknown): string[] { + const parsed = + typeof metadata === "string" ? tryJsonParse<{ areas?: unknown }>(metadata) : metadata; + if (!parsed || typeof parsed !== "object") return []; + const areas = (parsed as { areas?: unknown }).areas; + return Array.isArray(areas) + ? areas.filter((area): area is string => typeof area === "string") + : []; +} + +export function serializeTeamAreas(areas: string[]): string { + return JSON.stringify({ areas: [...new Set(areas)] }); +} diff --git a/plugins/auth/tests/integration/team-context.test.ts b/plugins/auth/tests/integration/team-context.test.ts new file mode 100644 index 000000000..f6de0b948 --- /dev/null +++ b/plugins/auth/tests/integration/team-context.test.ts @@ -0,0 +1,196 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + addTestMember, + createTestHandlers, + createTestOrg, + createTestServices, + createTestUser, +} from "../helpers"; + +let services: Awaited>; + +beforeAll(async () => { + services = await createTestServices(); +}, 30000); + +afterAll(async () => { + await services.driver.close(); +}, 30000); + +async function orgWithTeamMember(areas: string[] = ["node-operations"]) { + const owner = await createTestUser(services.services); + const member = await createTestUser(services.services); + const org = await createTestOrg(services.services, owner.userId); + await addTestMember(services.services, org.id, member.userId, "member"); + const handlers = createTestHandlers(services.services); + const team = await handlers.teams.createTeam({ + input: { name: "Node Operator", organizationId: org.id, areas }, + context: { reqHeaders: owner.reqHeaders }, + }); + await handlers.teams.addTeamMember({ + input: { teamId: team.id, userId: member.userId, organizationId: org.id }, + context: { reqHeaders: owner.reqHeaders }, + }); + await handlers.organizations.setActiveOrganization({ + input: { organizationId: org.id }, + context: { reqHeaders: member.reqHeaders }, + }); + return { owner, member, org, team, handlers }; +} + +describe("team context", () => { + it("reports the member's teams and the active team after setting it", async () => { + const { member, team, handlers } = await orgWithTeamMember(["node-operations", "finance"]); + + await handlers.teams.setActiveTeam({ + input: { teamId: team.id }, + context: { reqHeaders: member.reqHeaders }, + }); + + const context = await handlers.session.getContext({ + context: { reqHeaders: member.reqHeaders }, + }); + + expect(context.organization.teams).toEqual([ + { id: team.id, name: "Node Operator", areas: ["node-operations", "finance"] }, + ]); + expect(context.organization.activeTeamId).toBe(team.id); + }); + + it("drops an active team the member was removed from", async () => { + const { owner, member, org, team, handlers } = await orgWithTeamMember(); + await handlers.teams.setActiveTeam({ + input: { teamId: team.id }, + context: { reqHeaders: member.reqHeaders }, + }); + + await handlers.teams.removeTeamMember({ + input: { teamId: team.id, userId: member.userId, organizationId: org.id }, + context: { reqHeaders: owner.reqHeaders }, + }); + + const context = await handlers.session.getContext({ + context: { reqHeaders: member.reqHeaders }, + }); + expect(context.organization.teams).toEqual([]); + expect(context.organization.activeTeamId).toBeNull(); + }); + + it("drops an active team that belongs to a different organization", async () => { + const { member, team, handlers } = await orgWithTeamMember(); + await handlers.teams.setActiveTeam({ + input: { teamId: team.id }, + context: { reqHeaders: member.reqHeaders }, + }); + const otherOrg = await createTestOrg(services.services, member.userId); + + await handlers.organizations.setActiveOrganization({ + input: { organizationId: otherOrg.id }, + context: { reqHeaders: member.reqHeaders }, + }); + + const context = await handlers.session.getContext({ + context: { reqHeaders: member.reqHeaders }, + }); + expect(context.organization.activeOrganizationId).toBe(otherOrg.id); + expect(context.organization.teams).toEqual([]); + expect(context.organization.activeTeamId).toBeNull(); + }); +}); + +describe("setActiveTeam", () => { + it("rejects a team the user is not a member of", async () => { + const { owner, org, handlers } = await orgWithTeamMember(); + const outsider = await createTestUser(services.services); + await addTestMember(services.services, org.id, outsider.userId, "member"); + await handlers.organizations.setActiveOrganization({ + input: { organizationId: org.id }, + context: { reqHeaders: outsider.reqHeaders }, + }); + const finance = await handlers.teams.createTeam({ + input: { name: "Finance", organizationId: org.id, areas: ["finance"] }, + context: { reqHeaders: owner.reqHeaders }, + }); + + await expect( + handlers.teams.setActiveTeam({ + input: { teamId: finance.id }, + context: { reqHeaders: outsider.reqHeaders }, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("unsets the active team when given null", async () => { + const { member, team, handlers } = await orgWithTeamMember(); + await handlers.teams.setActiveTeam({ + input: { teamId: team.id }, + context: { reqHeaders: member.reqHeaders }, + }); + + const result = await handlers.teams.setActiveTeam({ + input: { teamId: null }, + context: { reqHeaders: member.reqHeaders }, + }); + + expect(result).toBeNull(); + const context = await handlers.session.getContext({ + context: { reqHeaders: member.reqHeaders }, + }); + expect(context.organization.activeTeamId).toBeNull(); + expect(context.organization.teams).toHaveLength(1); + }); + + it("requires authentication", async () => { + const { team, handlers } = await orgWithTeamMember(); + + await expect( + handlers.teams.setActiveTeam({ input: { teamId: team.id }, context: {} }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); +}); + +describe("listUserTeams", () => { + it("lists only the user's teams in the active organization", async () => { + const { owner, member, org, team, handlers } = await orgWithTeamMember(); + await handlers.teams.createTeam({ + input: { name: "Finance", organizationId: org.id, areas: ["finance"] }, + context: { reqHeaders: owner.reqHeaders }, + }); + const otherOrg = await createTestOrg(services.services, owner.userId); + await addTestMember(services.services, otherOrg.id, member.userId, "member"); + const otherTeam = await handlers.teams.createTeam({ + input: { name: "Elsewhere", organizationId: otherOrg.id }, + context: { reqHeaders: owner.reqHeaders }, + }); + await handlers.teams.addTeamMember({ + input: { teamId: otherTeam.id, userId: member.userId, organizationId: otherOrg.id }, + context: { reqHeaders: owner.reqHeaders }, + }); + + const teams = await handlers.teams.listUserTeams({ + input: undefined, + context: { reqHeaders: member.reqHeaders }, + }); + + expect(teams.map((t: { id: string }) => t.id)).toEqual([team.id]); + expect(teams[0]).toMatchObject({ name: "Node Operator", areas: ["node-operations"] }); + }); +}); + +describe("team area grants", () => { + it("round-trips areas through create, update and list", async () => { + const { owner, org, team, handlers } = await orgWithTeamMember(["things"]); + + const updated = await handlers.teams.updateTeam({ + input: { teamId: team.id, organizationId: org.id, data: { areas: ["stake", "finance"] } }, + context: { reqHeaders: owner.reqHeaders }, + }); + + expect(updated).toMatchObject({ name: "Node Operator", areas: ["stake", "finance"] }); + const listed = await handlers.teams.listTeams({ + input: { organizationId: org.id }, + context: { reqHeaders: owner.reqHeaders }, + }); + expect(listed).toEqual([expect.objectContaining({ id: team.id, areas: ["stake", "finance"] })]); + }); +}); From 300b92e6b59daf64bf4a8e6c371be10b565d2633 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmad Date: Mon, 21 Sep 2026 18:51:04 +0500 Subject: [PATCH 02/13] feat(api): team feature-area gating for node operations (#83) - FEATURE_AREAS vocabulary (node-operations, finance, things, stake) exported from api/feature-areas for API and UI - requireTeam and requireTeamArea middlewares with bypass for platform admins and organization owners/admins; no active team keeps pre-teams access - node and validator mutation routes require node-operations Co-Authored-By: Claude Opus 5 (1M context) --- api/package.json | 4 + api/src/feature-areas.ts | 14 +++ api/src/index.ts | 76 +++++++------ api/src/lib/team-auth.ts | 87 +++++++++++++++ api/tests/integration/team-gating.test.ts | 127 ++++++++++++++++++++++ api/tests/unit/team-auth.test.ts | 82 ++++++++++++++ bun.lock | 5 +- ui/package.json | 1 + 8 files changed, 360 insertions(+), 36 deletions(-) create mode 100644 api/src/feature-areas.ts create mode 100644 api/src/lib/team-auth.ts create mode 100644 api/tests/integration/team-gating.test.ts create mode 100644 api/tests/unit/team-auth.test.ts diff --git a/api/package.json b/api/package.json index e6f41224b..0ec3ee504 100644 --- a/api/package.json +++ b/api/package.json @@ -13,6 +13,10 @@ "./contract": { "types": "./src/contract.ts", "default": "./src/contract.ts" + }, + "./feature-areas": { + "types": "./src/feature-areas.ts", + "default": "./src/feature-areas.ts" } }, "peerDependencies": { diff --git a/api/src/feature-areas.ts b/api/src/feature-areas.ts new file mode 100644 index 000000000..f2f0d42a0 --- /dev/null +++ b/api/src/feature-areas.ts @@ -0,0 +1,14 @@ +export const FEATURE_AREAS = ["node-operations", "finance", "things", "stake"] as const; + +export type FeatureArea = (typeof FEATURE_AREAS)[number]; + +export const FEATURE_AREA_LABELS: Record = { + "node-operations": "Node operations", + finance: "Finance", + things: "Things", + stake: "Stake", +}; + +export function isFeatureArea(value: string): value is FeatureArea { + return (FEATURE_AREAS as readonly string[]).includes(value); +} diff --git a/api/src/index.ts b/api/src/index.ts index 12996575d..c5206adcd 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -9,6 +9,7 @@ import { DatabaseLive } from "./db/layer"; import { createAuthMiddleware } from "./lib/auth"; import { ContextSchema } from "./lib/context"; import type { PluginsClient } from "./lib/plugins-types.gen"; +import { createTeamMiddleware } from "./lib/team-auth"; import { verifyDaoMembership } from "./services/dao"; import type { DiscoveryService } from "./services/discovery"; import { DiscoveryLive, DiscoveryTag } from "./services/discovery"; @@ -95,6 +96,8 @@ export default createPlugin.withPlugins()({ createRouter: (builder, plugins) => { const { requireAuth, requireAdmin, requireOrganization, requireOrgRole } = createAuthMiddleware(builder); + const { requireTeamArea } = createTeamMiddleware(builder); + const requireNodeOperations = requireTeamArea("node-operations"); const authorizedTenant = async ( input: { tenantId: string }, @@ -506,6 +509,7 @@ export default createPlugin.withPlugins()({ createNode: builder.createNode .use(requireAuth) .use(requireOrganization) + .use(requireNodeOperations) .handler(async ({ input, context }) => { const services = Context.get(context["effect/context"], ApiServices); const tenant = await services.tenants.resolveTenantById(input.tenantId); @@ -530,43 +534,47 @@ export default createPlugin.withPlugins()({ }); }), - updateNode: builder.updateNode.use(requireAuth).handler(async ({ input, context }) => { - const services = Context.get(context["effect/context"], ApiServices); - const node = await services.nodes.getById(input.nodeId); - if (!node) { - throw new ORPCError("NOT_FOUND", { - message: "Node not found", - data: { resource: "node", resourceId: input.nodeId }, - }); - } - const tenant = await services.tenants.resolveTenantById(node.tenantId); - if (!tenant) { - throw new ORPCError("NOT_FOUND", { - message: "Tenant not found", - data: { resource: "tenant", resourceId: node.tenantId }, - }); - } - if ( - context.user.role !== "admin" && - (!context.organization?.activeOrganizationId || - tenant.orgId !== context.organization.activeOrganizationId) - ) { - throw new ORPCError("FORBIDDEN", { - message: "This node does not belong to your organization", + updateNode: builder.updateNode + .use(requireAuth) + .use(requireNodeOperations) + .handler(async ({ input, context }) => { + const services = Context.get(context["effect/context"], ApiServices); + const node = await services.nodes.getById(input.nodeId); + if (!node) { + throw new ORPCError("NOT_FOUND", { + message: "Node not found", + data: { resource: "node", resourceId: input.nodeId }, + }); + } + const tenant = await services.tenants.resolveTenantById(node.tenantId); + if (!tenant) { + throw new ORPCError("NOT_FOUND", { + message: "Tenant not found", + data: { resource: "tenant", resourceId: node.tenantId }, + }); + } + if ( + context.user.role !== "admin" && + (!context.organization?.activeOrganizationId || + tenant.orgId !== context.organization.activeOrganizationId) + ) { + throw new ORPCError("FORBIDDEN", { + message: "This node does not belong to your organization", + }); + } + return await services.nodes.update(input.nodeId, { + ...(input.kind !== undefined && { kind: input.kind }), + ...(input.slug !== undefined && { slug: input.slug }), + ...(input.name !== undefined && { name: input.name }), + ...(input.parentId !== undefined && { parentId: input.parentId }), + ...(input.metadata !== undefined && { metadata: input.metadata }), }); - } - return await services.nodes.update(input.nodeId, { - ...(input.kind !== undefined && { kind: input.kind }), - ...(input.slug !== undefined && { slug: input.slug }), - ...(input.name !== undefined && { name: input.name }), - ...(input.parentId !== undefined && { parentId: input.parentId }), - ...(input.metadata !== undefined && { metadata: input.metadata }), - }); - }), + }), deleteNode: builder.deleteNode .use(requireAuth) .use(requireOrgRole("admin")) + .use(requireNodeOperations) .handler(async ({ input, context }) => { const services = Context.get(context["effect/context"], ApiServices); const node = await services.nodes.getById(input.nodeId); @@ -708,6 +716,7 @@ export default createPlugin.withPlugins()({ createValidator: builder.createValidator .use(requireAuth) + .use(requireNodeOperations) .handler(async ({ input, context }) => { const services = Context.get(context["effect/context"], ApiServices); await authorizedNodeForValidators(input.nodeId, context); @@ -724,6 +733,7 @@ export default createPlugin.withPlugins()({ updateValidator: builder.updateValidator .use(requireAuth) + .use(requireNodeOperations) .handler(async ({ input, context }) => { const services = Context.get(context["effect/context"], ApiServices); const validator = await services.validators.getById(input.validatorId); @@ -750,6 +760,7 @@ export default createPlugin.withPlugins()({ deleteValidator: builder.deleteValidator .use(requireAuth) + .use(requireNodeOperations) .handler(async ({ input, context }) => { const services = Context.get(context["effect/context"], ApiServices); const validator = await services.validators.getById(input.validatorId); @@ -766,6 +777,7 @@ export default createPlugin.withPlugins()({ setDefaultValidator: builder.setDefaultValidator .use(requireAuth) + .use(requireNodeOperations) .handler(async ({ input, context }) => { const services = Context.get(context["effect/context"], ApiServices); const target = await services.validators.getById(input.validatorId); diff --git a/api/src/lib/team-auth.ts b/api/src/lib/team-auth.ts new file mode 100644 index 000000000..e203f25c2 --- /dev/null +++ b/api/src/lib/team-auth.ts @@ -0,0 +1,87 @@ +import type { DecoratedMiddleware } from "@orpc/server"; +import { ORPCError } from "@orpc/server"; +import type { FeatureArea } from "../feature-areas"; +import type { AuthContext } from "./auth"; + +const BYPASS_ORG_ROLES = ["owner", "admin"]; + +export interface TeamWorkspace { + id: string; + name: string; + areas: string[]; +} + +export function bypassesTeamRestrictions(context: AuthContext): boolean { + if (context.user?.role === "admin") return true; + const orgRole = context.organization?.member?.role; + return !!orgRole && BYPASS_ORG_ROLES.includes(orgRole); +} + +export function resolveActiveTeam(context: AuthContext): TeamWorkspace | null { + const organization = context.organization; + const activeTeamId = organization?.activeTeamId; + if (!activeTeamId) return null; + return organization?.teams?.find((team) => team.id === activeTeamId) ?? null; +} + +function requireAuthenticated(context: AuthContext) { + if (!context.user || !context.userId) { + throw new ORPCError("UNAUTHORIZED", { + message: "Authentication required", + data: { authType: "session", hint: "Sign in to continue" }, + }); + } +} + +function requireOrganizationMember(context: AuthContext) { + requireAuthenticated(context); + if (!context.organization?.activeOrganizationId) { + throw new ORPCError("FORBIDDEN", { + message: "Active organization required", + data: { hint: "Select or create an organization" }, + }); + } +} + +export function createTeamMiddleware(builder: any) { + type TeamMiddleware = DecoratedMiddleware< + AuthContext, + { activeTeam: TeamWorkspace | null }, + any, + any, + any + >; + + const requireTeam = builder.middleware( + async ({ context, next }: { context: AuthContext; next: any }) => { + requireOrganizationMember(context); + const activeTeam = resolveActiveTeam(context); + if (!activeTeam && !bypassesTeamRestrictions(context)) { + throw new ORPCError("FORBIDDEN", { + message: "Active team required. Switch to one of your teams in this organization.", + data: { action: "switch-team" }, + }); + } + return next({ context: { activeTeam } }); + }, + ) as TeamMiddleware; + + const requireTeamArea = (...areas: TAreas) => + builder.middleware(async ({ context, next }: { context: AuthContext; next: any }) => { + requireAuthenticated(context); + const activeTeam = resolveActiveTeam(context); + if ( + activeTeam && + !bypassesTeamRestrictions(context) && + !areas.some((area) => activeTeam.areas.includes(area)) + ) { + throw new ORPCError("FORBIDDEN", { + message: `Your active team "${activeTeam.name}" is not granted ${areas.join(" or ")}. Switch teams or ask an organization owner to grant it.`, + data: { requiredPermissions: [...areas], action: "switch-team" }, + }); + } + return next({ context: { activeTeam } }); + }) as TeamMiddleware; + + return { requireTeam, requireTeamArea }; +} diff --git a/api/tests/integration/team-gating.test.ts b/api/tests/integration/team-gating.test.ts new file mode 100644 index 000000000..889ca97b5 --- /dev/null +++ b/api/tests/integration/team-gating.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { authedContext, daoContext, getPluginClient, orgContext } from "../setup"; + +vi.mock("@/services/dao", () => ({ + verifyDaoMembership: vi.fn(async () => ({ + isSputnikContract: true, + isMember: true, + policy: { roles: [] }, + })), + parsePolicyGroupMembers: vi.fn(() => []), + isExplicitDaoMember: vi.fn(() => true), +})); + +const ORG = "team-gating-org"; + +function teamContext( + userId: string, + options: { + orgRole?: "owner" | "admin" | "member"; + userRole?: string; + areas?: string[]; + active?: boolean; + } = {}, +) { + const base = orgContext(userId, ORG, options.orgRole ?? "member", options.userRole); + const team = { id: `team-${userId}`, name: "Finance", areas: options.areas ?? ["finance"] }; + return { + ...base, + organization: { + ...(base.organization as Record), + teams: [team], + activeTeamId: options.active === false ? null : team.id, + }, + }; +} + +async function seedNode() { + const owner = await getPluginClient(daoContext("gating-owner", ORG, "admin-gating-owner.near")); + const suffix = crypto.randomUUID().slice(0, 8); + const tenant = await owner.createTenant({ + name: "Gating", + accountId: `gating-${suffix}.near`, + }); + return owner.createNode({ + name: "Gating", + kind: "country", + slug: `gating-${suffix}`, + tenantId: tenant.id, + }); +} + +describe("team area gating on node operations", () => { + it("allows a team member whose active team is granted node-operations", async () => { + const node = await seedNode(); + const client = await getPluginClient(teamContext("ops-member", { areas: ["node-operations"] })); + + await expect(client.updateNode({ nodeId: node.id, name: "Renamed" })).resolves.toMatchObject({ + name: "Renamed", + }); + }); + + it("rejects a team member whose active team lacks node-operations", async () => { + const node = await seedNode(); + const client = await getPluginClient(teamContext("finance-member", { areas: ["finance"] })); + + await expect(client.updateNode({ nodeId: node.id, name: "Renamed" })).rejects.toMatchObject({ + code: "FORBIDDEN", + data: { requiredPermissions: ["node-operations"], action: "switch-team" }, + }); + await expect( + client.createValidator({ + nodeId: node.id, + accountId: "gated.pool.near", + network: "mainnet", + protocol: "near", + role: "community", + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("lets organization owners and admins bypass team restrictions", async () => { + const node = await seedNode(); + const owner = await getPluginClient(teamContext("org-owner", { orgRole: "owner" })); + const admin = await getPluginClient(teamContext("org-admin", { orgRole: "admin" })); + + await expect(owner.updateNode({ nodeId: node.id, name: "Owner" })).resolves.toMatchObject({ + name: "Owner", + }); + await expect(admin.updateNode({ nodeId: node.id, name: "Admin" })).resolves.toMatchObject({ + name: "Admin", + }); + }); + + it("lets platform admins bypass team restrictions", async () => { + const node = await seedNode(); + const client = await getPluginClient(teamContext("platform-admin", { userRole: "admin" })); + + await expect(client.updateNode({ nodeId: node.id, name: "Platform" })).resolves.toMatchObject({ + name: "Platform", + }); + }); + + it("keeps pre-teams access for members without an active team", async () => { + const node = await seedNode(); + const client = await getPluginClient(teamContext("teamless-member", { active: false })); + + await expect(client.updateNode({ nodeId: node.id, name: "Teamless" })).resolves.toMatchObject({ + name: "Teamless", + }); + }); + + it("rejects users without an active organization", async () => { + const node = await seedNode(); + const client = await getPluginClient(authedContext("orgless-user")); + + await expect(client.updateNode({ nodeId: node.id, name: "Nope" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("keeps public node reads open", async () => { + const node = await seedNode(); + const client = await getPluginClient(); + + await expect(client.getNode({ nodeId: node.id })).resolves.toMatchObject({ id: node.id }); + }); +}); diff --git a/api/tests/unit/team-auth.test.ts b/api/tests/unit/team-auth.test.ts new file mode 100644 index 000000000..ba6771a45 --- /dev/null +++ b/api/tests/unit/team-auth.test.ts @@ -0,0 +1,82 @@ +import { call, os } from "@orpc/server"; +import { describe, expect, it } from "vitest"; +import type { AuthContext } from "@/lib/auth"; +import { createTeamMiddleware } from "@/lib/team-auth"; + +const builder = os.$context(); +const { requireTeam } = createTeamMiddleware(builder); +const whoAmI = builder.use(requireTeam).handler(({ context }) => context.activeTeam?.id ?? null); + +function member( + orgRole: string, + organization: { + teams?: Array<{ id: string; name: string; areas: string[] }>; + activeTeamId?: string | null; + }, + userRole?: string, +): AuthContext { + return { + userId: "u1", + user: { + id: "u1", + name: "U", + email: "u1@example.com", + emailVerified: true, + image: null, + role: userRole ?? null, + isAnonymous: false, + }, + organization: { + activeOrganizationId: "org-1", + organization: { id: "org-1", name: "Org", slug: "org" }, + member: { id: "m1", role: orgRole }, + isPersonal: false, + hasOrganization: true, + teams: organization.teams ?? [], + activeTeamId: organization.activeTeamId ?? null, + }, + }; +} + +const ops = { id: "team-ops", name: "Node Operator", areas: ["node-operations"] }; + +describe("requireTeam", () => { + it("resolves the active team for a member of it", async () => { + await expect( + call(whoAmI, undefined, { + context: member("member", { teams: [ops], activeTeamId: ops.id }), + }), + ).resolves.toBe("team-ops"); + }); + + it("rejects a member with no active team", async () => { + await expect( + call(whoAmI, undefined, { context: member("member", { teams: [ops] }) }), + ).rejects.toMatchObject({ code: "FORBIDDEN", message: expect.stringContaining("Active team") }); + }); + + it("rejects an active team id the member does not belong to", async () => { + await expect( + call(whoAmI, undefined, { context: member("member", { teams: [], activeTeamId: ops.id }) }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("lets organization owners, admins and platform admins through without a team", async () => { + await expect(call(whoAmI, undefined, { context: member("owner", {}) })).resolves.toBeNull(); + await expect(call(whoAmI, undefined, { context: member("admin", {}) })).resolves.toBeNull(); + await expect( + call(whoAmI, undefined, { context: member("member", {}, "admin") }), + ).resolves.toBeNull(); + }); + + it("rejects unauthenticated and organization-less requests", async () => { + await expect(call(whoAmI, undefined, { context: {} })).rejects.toMatchObject({ + code: "UNAUTHORIZED", + }); + const orgless = { ...member("member", {}), organization: undefined }; + await expect(call(whoAmI, undefined, { context: orgless })).rejects.toMatchObject({ + code: "FORBIDDEN", + message: expect.stringContaining("Active organization"), + }); + }); +}); diff --git a/bun.lock b/bun.lock index df542d39e..392ba3992 100644 --- a/bun.lock +++ b/bun.lock @@ -527,6 +527,7 @@ "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "api": "workspace:*", "every-plugin": "catalog:", "everything-dev": "catalog:", "jsdom": "^30.0.1", @@ -1375,10 +1376,6 @@ "@rspack/core": ["@rspack/core@2.2.6", "", { "dependencies": { "@rspack/binding": "2.2.6" }, "peerDependencies": { "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", "@swc/helpers": "^0.5.23" }, "optionalPeers": ["@module-federation/runtime-tools", "@swc/helpers"] }, "sha512-sqN75Fgf6v3tCmt8GAG/rdcYHOUDv/YZbQf24YCGDoMLmdNlXCosCHkBYWI8qdLs9IqSnGt7XgbcA39Zilg6UQ=="], - "@rspack/dev-middleware": ["@rspack/dev-middleware@2.0.4", "", { "peerDependencies": { "@rspack/core": "^2.0.0" }, "optionalPeers": ["@rspack/core"] }, "sha512-VCdT8hv9YvMPiBohhukD/OkGB+2GJ7mPRbeuDJ/jIT6hv5gvmJCnydMetJVARh6vXoBtfSHEd2gX78Dqaxh1NQ=="], - - "@rspack/dev-server": ["@rspack/dev-server@2.2.1", "", { "dependencies": { "@rspack/dev-middleware": "^2.0.3" }, "peerDependencies": { "@rspack/core": "^2.0.0", "selfsigned": "^5.0.0" }, "optionalPeers": ["selfsigned"] }, "sha512-Il8HbYGK3rH5rM0XmUOsOGZVw69BKRpBZ61P28Fy8ry35CtfniDBWtbo/rvJtIT/sK0qwijmNrhF498ltsV+uA=="], - "@rspack/plugin-react-refresh": ["@rspack/plugin-react-refresh@2.0.2", "", { "peerDependencies": { "@rspack/core": "^2.0.0", "react-refresh": ">=0.10.0 <1.0.0" }, "optionalPeers": ["@rspack/core"] }, "sha512-dGNZiCxQxgAUI9sah7gd8u+O7OJZRCmqtEJNDOd8xW5RqcieC86F7p5qcShyw6onH5pKf57evpr2VjGbaFGkZg=="], "@scure/base": ["@scure/base@2.4.0", "", {}, "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg=="], diff --git a/ui/package.json b/ui/package.json index f0d93a363..3c98f972e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -82,6 +82,7 @@ "react-dom": "catalog:" }, "devDependencies": { + "api": "workspace:*", "@module-federation/enhanced": "catalog:", "@module-federation/runtime": "catalog:", "@module-federation/runtime-tools": "catalog:", From ee3d491325a0343a3ed0b5d5806636402228c970 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmad Date: Mon, 21 Sep 2026 18:53:49 +0500 Subject: [PATCH 03/13] feat(ui): teams tab in the organization dashboard (#84) - create, rename and delete teams; grant feature areas via checkboxes from the shared vocabulary; add/remove organization members - management controls for organization owners/admins, read-only view for members - auth plugin listTeamMembers allows any member of the team's organization (Better Auth restricts it to team members) Co-Authored-By: Claude Opus 5 (1M context) --- plugins/auth/src/handlers/teams.ts | 35 ++- .../tests/integration/team-context.test.ts | 28 +++ .../_authenticated/_dashboard/orgs/$slug.tsx | 27 ++- .../orgs/-organization-query-keys.ts | 2 + .../_dashboard/orgs/-organization-teams.ts | 91 ++++++++ .../_dashboard/orgs/-team-card.tsx | 213 ++++++++++++++++++ .../_dashboard/orgs/-teams-tab.test.tsx | 127 +++++++++++ .../_dashboard/orgs/-teams-tab.tsx | 96 ++++++++ 8 files changed, 608 insertions(+), 11 deletions(-) create mode 100644 ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-teams.ts create mode 100644 ui/src/routes/_layout/_authenticated/_dashboard/orgs/-team-card.tsx create mode 100644 ui/src/routes/_layout/_authenticated/_dashboard/orgs/-teams-tab.test.tsx create mode 100644 ui/src/routes/_layout/_authenticated/_dashboard/orgs/-teams-tab.tsx diff --git a/plugins/auth/src/handlers/teams.ts b/plugins/auth/src/handlers/teams.ts index a0b23de76..8115051bd 100644 --- a/plugins/auth/src/handlers/teams.ts +++ b/plugins/auth/src/handlers/teams.ts @@ -1,4 +1,7 @@ +import { ORPCError } from "@orpc/server"; +import { and, eq } from "drizzle-orm"; import { Context } from "effect"; +import * as schema from "../db/schema"; import { AuthServicesTag } from "../service-types"; import { createHeaders, @@ -128,19 +131,31 @@ export function createTeamHandlers(builder: any, requireAuth: any) { .use(requireAuth) .handler(async ({ input, context }: { input: any; context: any }) => { const services = Context.get(context["effect/context"], AuthServicesTag); - const result = await safeAuthApi(() => - services.auth.api.listTeamMembers({ - headers: createHeaders(context.reqHeaders), - query: { - teamId: input.teamId, - }, - }), - ); - return (result ?? []).map((tm: any) => ({ + const team = await services.db.query.team.findFirst({ + where: eq(schema.team.id, input.teamId), + }); + if (!team) { + throw new ORPCError("NOT_FOUND", { message: "Team not found" }); + } + const membership = await services.db.query.member.findFirst({ + where: and( + eq(schema.member.userId, context.userId), + eq(schema.member.organizationId, team.organizationId), + ), + }); + if (!membership) { + throw new ORPCError("FORBIDDEN", { + message: "You are not a member of this team's organization", + }); + } + const rows = await services.db.query.teamMember.findMany({ + where: eq(schema.teamMember.teamId, team.id), + }); + return rows.map((tm) => ({ id: tm.id, teamId: tm.teamId, userId: tm.userId, - createdAt: tm.createdAt instanceof Date ? tm.createdAt : new Date(tm.createdAt), + createdAt: toDate(tm.createdAt), })); }), diff --git a/plugins/auth/tests/integration/team-context.test.ts b/plugins/auth/tests/integration/team-context.test.ts index f6de0b948..c0c3fd8d9 100644 --- a/plugins/auth/tests/integration/team-context.test.ts +++ b/plugins/auth/tests/integration/team-context.test.ts @@ -194,3 +194,31 @@ describe("team area grants", () => { expect(listed).toEqual([expect.objectContaining({ id: team.id, areas: ["stake", "finance"] })]); }); }); + +describe("listTeamMembers", () => { + it("lets organization members who are not in the team view its members", async () => { + const { owner, member, org, team, handlers } = await orgWithTeamMember(); + const colleague = await createTestUser(services.services); + await addTestMember(services.services, org.id, colleague.userId, "member"); + + for (const viewer of [owner, colleague]) { + const members = await handlers.teams.listTeamMembers({ + input: { teamId: team.id }, + context: { reqHeaders: viewer.reqHeaders }, + }); + expect(members.map((m: { userId: string }) => m.userId)).toEqual([member.userId]); + } + }); + + it("rejects users outside the team's organization", async () => { + const { team, handlers } = await orgWithTeamMember(); + const stranger = await createTestUser(services.services); + + await expect( + handlers.teams.listTeamMembers({ + input: { teamId: team.id }, + context: { reqHeaders: stranger.reqHeaders }, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/$slug.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/$slug.tsx index b912c4749..b74fb2b44 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/$slug.tsx +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/$slug.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; -import { Building2, Key, Layers, Mail, Users } from "lucide-react"; +import { Building2, Key, Layers, Mail, Users, UsersRound } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { @@ -41,6 +41,8 @@ import { orgMembersQueryKey, } from "./-organization-query-keys"; import { useOrganizationSettings } from "./-organization-settings"; +import { useOrganizationTeams } from "./-organization-teams"; +import { TeamsTab } from "./-teams-tab"; type AuthClientType = import("@/app").AuthClient; type MembersResponse = Awaited>; @@ -162,6 +164,7 @@ function OrganizationDetail() { (apiKey) => setCreatedApiKey(apiKey), ); const { removeMemberMutation } = useOrganizationMemberActions(auth, orgId); + const teamsState = useOrganizationTeams(orgId); const { deleteOrgMutation, leaveOrgMutation, updateOrgMutation } = useOrganizationSettings( auth, orgId, @@ -252,6 +255,10 @@ function OrganizationDetail() { Members ({members.length}) + + + Teams ({teamsState.teams.length}) + Invitations ({pendingInvitationsCount}) @@ -276,6 +283,24 @@ function OrganizationDetail() { onRemove={(member) => removeMemberMutation.mutate(member)} sessionUserId={session?.user?.id} /> + teamsState.addTeamMember.mutate({ teamId, userId })} + onAreasChange={(teamId, areas) => teamsState.updateTeam.mutate({ teamId, areas })} + onCreate={(name) => teamsState.createTeam.mutate(name)} + onDelete={(teamId) => { + const team = teamsState.teams.find((candidate) => candidate.id === teamId); + if (confirm(`Delete team "${team?.name ?? ""}"?`)) + teamsState.deleteTeam.mutate(teamId); + }} + onRemoveMember={(teamId, userId) => + teamsState.removeTeamMember.mutate({ teamId, userId }) + } + onRename={(teamId, name) => teamsState.updateTeam.mutate({ teamId, name })} + orgMembers={members} + teams={teamsState.teams} + /> ["org-members", orgId] as const; export const orgInvitationsQueryKey = (orgId: string) => ["org-invitations", orgId] as const; export const orgApiKeysQueryKey = (orgId: string) => ["org-api-keys", orgId] as const; +export const orgTeamsQueryKey = (orgId: string) => ["org-teams", orgId] as const; +export const orgTeamMembersQueryKey = (teamId: string) => ["org-team-members", teamId] as const; diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-teams.ts b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-teams.ts new file mode 100644 index 000000000..d11b1b82d --- /dev/null +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-teams.ts @@ -0,0 +1,91 @@ +import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useApiClient } from "@/app"; +import { orgTeamMembersQueryKey, orgTeamsQueryKey } from "./-organization-query-keys"; +import type { TeamsTabTeam } from "./-teams-tab"; + +export function useOrganizationTeams(orgId: string) { + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + const teamsQuery = useQuery({ + queryKey: orgTeamsQueryKey(orgId), + queryFn: () => apiClient.auth.listTeams({ organizationId: orgId }), + enabled: !!orgId, + }); + const teamList = teamsQuery.data ?? []; + const memberQueries = useQueries({ + queries: teamList.map((team) => ({ + queryKey: orgTeamMembersQueryKey(team.id), + queryFn: () => apiClient.auth.listTeamMembers({ teamId: team.id }), + })), + }); + const teams: TeamsTabTeam[] = teamList.map((team, index) => ({ + id: team.id, + name: team.name, + areas: team.areas, + memberUserIds: (memberQueries[index]?.data ?? []).map((member) => member.userId), + })); + + const invalidateTeams = () => + queryClient.invalidateQueries({ queryKey: orgTeamsQueryKey(orgId) }); + const invalidateMembers = (teamId: string) => + queryClient.invalidateQueries({ queryKey: orgTeamMembersQueryKey(teamId) }); + const onError = (fallback: string) => (error: Error) => toast.error(error.message || fallback); + + const createTeam = useMutation({ + mutationFn: (name: string) => apiClient.auth.createTeam({ name, organizationId: orgId }), + onSuccess: async (team) => { + toast.success(`Team "${team.name}" created`); + await invalidateTeams(); + }, + onError: onError("Failed to create team"), + }); + const updateTeam = useMutation({ + mutationFn: (input: { teamId: string; name?: string; areas?: string[] }) => + apiClient.auth.updateTeam({ + teamId: input.teamId, + organizationId: orgId, + data: { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.areas !== undefined ? { areas: input.areas } : {}), + }, + }), + onSuccess: invalidateTeams, + onError: onError("Failed to update team"), + }); + const deleteTeam = useMutation({ + mutationFn: (teamId: string) => apiClient.auth.deleteTeam({ teamId, organizationId: orgId }), + onSuccess: async () => { + toast.success("Team deleted"); + await invalidateTeams(); + }, + onError: onError("Failed to delete team"), + }); + const addTeamMember = useMutation({ + mutationFn: (input: { teamId: string; userId: string }) => + apiClient.auth.addTeamMember({ ...input, organizationId: orgId }), + onSuccess: (_, input) => invalidateMembers(input.teamId), + onError: onError("Failed to add team member"), + }); + const removeTeamMember = useMutation({ + mutationFn: (input: { teamId: string; userId: string }) => + apiClient.auth.removeTeamMember({ ...input, organizationId: orgId }), + onSuccess: (_, input) => invalidateMembers(input.teamId), + onError: onError("Failed to remove team member"), + }); + + return { + teams, + isMutating: + createTeam.isPending || + updateTeam.isPending || + deleteTeam.isPending || + addTeamMember.isPending || + removeTeamMember.isPending, + createTeam, + updateTeam, + deleteTeam, + addTeamMember, + removeTeamMember, + }; +} diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-team-card.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-team-card.tsx new file mode 100644 index 000000000..50eef0305 --- /dev/null +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-team-card.tsx @@ -0,0 +1,213 @@ +import { FEATURE_AREA_LABELS, FEATURE_AREAS } from "api/feature-areas"; +import { Pencil, Trash2, UserMinus, UserPlus } from "lucide-react"; +import { useState } from "react"; +import { Button, Card, CardContent, Input } from "@/components"; +import { Checkbox } from "@/components/ui/checkbox"; +import type { MemberCardMember } from "./-member-card"; + +export interface TeamCardTeam { + id: string; + name: string; + areas: string[]; + memberUserIds: string[]; +} + +function memberLabel(member: MemberCardMember | undefined, userId: string) { + return member?.user?.name || member?.user?.email || userId; +} + +export function TeamCard({ + canManage, + isMutating, + onAddMember, + onAreasChange, + onDelete, + onRemoveMember, + onRename, + orgMembers, + team, +}: { + canManage: boolean; + isMutating: boolean; + onAddMember: (userId: string) => void; + onAreasChange: (areas: string[]) => void; + onDelete: () => void; + onRemoveMember: (userId: string) => void; + onRename: (name: string) => void; + orgMembers: MemberCardMember[]; + team: TeamCardTeam; +}) { + const [isRenaming, setIsRenaming] = useState(false); + const [draftName, setDraftName] = useState(team.name); + const [selectedUserId, setSelectedUserId] = useState(""); + const membersByUserId = new Map(orgMembers.map((member) => [member.userId, member])); + const candidates = orgMembers.filter((member) => !team.memberUserIds.includes(member.userId)); + + const toggleArea = (area: string, checked: boolean) => { + const next = checked + ? [...team.areas.filter((granted) => granted !== area), area] + : team.areas.filter((granted) => granted !== area); + onAreasChange(next); + }; + + return ( + + +
+ {isRenaming ? ( +
{ + event.preventDefault(); + const name = draftName.trim(); + if (!name) return; + onRename(name); + setIsRenaming(false); + }} + > + setDraftName(event.target.value)} + aria-label="Team name" + data-testid={`teams-tab-rename-input-${team.id}`} + /> + + +
+ ) : ( +
+
{team.name}
+
+ {team.memberUserIds.length} member{team.memberUserIds.length === 1 ? "" : "s"} +
+
+ )} + {canManage && !isRenaming && ( +
+ + +
+ )} +
+ +
+ + Areas + +
+ {FEATURE_AREAS.map((area) => { + const checkboxId = `team-${team.id}-area-${area}`; + return ( +
+ toggleArea(area, checked === true)} + data-testid={`teams-tab-area-${team.id}-${area}`} + /> + +
+ ); + })} +
+
+ +
+
+ Members +
+ {team.memberUserIds.length > 0 ? ( +
    + {team.memberUserIds.map((userId) => ( +
  • + + {memberLabel(membersByUserId.get(userId), userId)} + + {canManage && ( + + )} +
  • + ))} +
+ ) : ( +

No members in this team

+ )} + {canManage && ( +
+ + +
+ )} +
+
+
+ ); +} diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-teams-tab.test.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-teams-tab.test.tsx new file mode 100644 index 000000000..a080ebd83 --- /dev/null +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-teams-tab.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Tabs } from "@/components"; +import { TeamsTab, type TeamsTabTeam } from "./-teams-tab"; + +const orgMembers = [ + { id: "m-owner", userId: "u-owner", role: "owner", user: { name: "Olive Owner" } }, + { id: "m-ops", userId: "u-ops", role: "member", user: { name: "Oscar Ops" } }, + { id: "m-fin", userId: "u-fin", role: "member", user: { email: "fin@example.com" } }, +]; + +const opsTeam: TeamsTabTeam = { + id: "team-ops", + name: "Node Operator", + areas: ["node-operations"], + memberUserIds: ["u-ops"], +}; + +function renderTab(props: Partial[0]> = {}) { + const handlers = { + onCreate: vi.fn(), + onRename: vi.fn(), + onDelete: vi.fn(), + onAreasChange: vi.fn(), + onAddMember: vi.fn(), + onRemoveMember: vi.fn(), + }; + render( + + + , + ); + return handlers; +} + +afterEach(cleanup); + +describe("TeamsTab", () => { + it("shows management controls only to organization owners and admins", () => { + renderTab({ canManage: false }); + + expect(screen.queryByTestId("teams-tab-create-input")).toBeNull(); + expect(screen.queryByTestId("teams-tab-delete-team-ops")).toBeNull(); + expect(screen.queryByTestId("teams-tab-add-member-team-ops")).toBeNull(); + expect(screen.queryByTestId("teams-tab-remove-member-team-ops-u-ops")).toBeNull(); + expect( + (screen.getByTestId("teams-tab-area-team-ops-node-operations") as HTMLButtonElement).disabled, + ).toBe(true); + expect(screen.getByText("Node operations")).toBeTruthy(); + }); + + it("creates a team from the name field", () => { + const { onCreate } = renderTab(); + + fireEvent.change(screen.getByTestId("teams-tab-create-input"), { + target: { value: " Finance " }, + }); + fireEvent.click(screen.getByTestId("teams-tab-create-button")); + + expect(onCreate).toHaveBeenCalledWith("Finance"); + }); + + it("reflects granted areas and reports toggled grants", () => { + const { onAreasChange } = renderTab(); + const ops = screen.getByTestId("teams-tab-area-team-ops-node-operations"); + const finance = screen.getByTestId("teams-tab-area-team-ops-finance"); + + expect(ops.getAttribute("aria-checked")).toBe("true"); + expect(finance.getAttribute("aria-checked")).toBe("false"); + + fireEvent.click(finance); + expect(onAreasChange).toHaveBeenLastCalledWith("team-ops", ["node-operations", "finance"]); + + fireEvent.click(ops); + expect(onAreasChange).toHaveBeenLastCalledWith("team-ops", []); + }); + + it("renames and deletes a team", () => { + const { onRename, onDelete } = renderTab(); + + fireEvent.click(screen.getByTestId("teams-tab-rename-team-ops")); + fireEvent.change(screen.getByTestId("teams-tab-rename-input-team-ops"), { + target: { value: "Operators" }, + }); + fireEvent.click(screen.getByTestId("teams-tab-rename-save-team-ops")); + expect(onRename).toHaveBeenCalledWith("team-ops", "Operators"); + + fireEvent.click(screen.getByTestId("teams-tab-delete-team-ops")); + expect(onDelete).toHaveBeenCalledWith("team-ops"); + }); + + it("lists team members and offers only organization members outside the team", () => { + const { onAddMember, onRemoveMember } = renderTab(); + const card = screen.getByTestId("teams-tab-team-team-ops"); + + expect(within(card).getByText("Oscar Ops")).toBeTruthy(); + const picker = screen.getByTestId("teams-tab-add-member-team-ops") as HTMLSelectElement; + const options = Array.from(picker.options) + .map((option) => option.value) + .filter(Boolean); + expect(options).toEqual(["u-owner", "u-fin"]); + + fireEvent.change(picker, { target: { value: "u-fin" } }); + fireEvent.click(screen.getByTestId("teams-tab-add-member-button-team-ops")); + expect(onAddMember).toHaveBeenCalledWith("team-ops", "u-fin"); + + fireEvent.click(screen.getByTestId("teams-tab-remove-member-team-ops-u-ops")); + expect(onRemoveMember).toHaveBeenCalledWith("team-ops", "u-ops"); + }); + + it("shows empty states for no teams and teams without members", () => { + renderTab({ teams: [] }); + expect(screen.getByText("No teams yet")).toBeTruthy(); + cleanup(); + + renderTab({ teams: [{ ...opsTeam, memberUserIds: [] }] }); + expect(screen.getByText("No members in this team")).toBeTruthy(); + }); +}); diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-teams-tab.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-teams-tab.tsx new file mode 100644 index 000000000..1bf278873 --- /dev/null +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-teams-tab.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { Button, Card, Input, TabsContent } from "@/components"; +import { OrganizationEmptyState } from "./-empty-state"; +import type { MemberCardMember } from "./-member-card"; +import { TeamCard, type TeamCardTeam } from "./-team-card"; + +export type TeamsTabTeam = TeamCardTeam; + +export function TeamsTab({ + canManage, + isMutating, + onAddMember, + onAreasChange, + onCreate, + onDelete, + onRemoveMember, + onRename, + orgMembers, + teams, +}: { + canManage: boolean; + isMutating: boolean; + onAddMember: (teamId: string, userId: string) => void; + onAreasChange: (teamId: string, areas: string[]) => void; + onCreate: (name: string) => void; + onDelete: (teamId: string) => void; + onRemoveMember: (teamId: string, userId: string) => void; + onRename: (teamId: string, name: string) => void; + orgMembers: MemberCardMember[]; + teams: TeamsTabTeam[]; +}) { + const [teamName, setTeamName] = useState(""); + const trimmedName = teamName.trim(); + + return ( + + {canManage && ( + +
+ Create team +
+

+ Teams group organization members by function. Grant each team the areas it works in; + members operating as that team only see those areas. +

+
{ + event.preventDefault(); + if (!trimmedName) return; + onCreate(trimmedName); + setTeamName(""); + }} + > + setTeamName(event.target.value)} + placeholder="Finance, Node Operator…" + aria-label="Team name" + data-testid="teams-tab-create-input" + /> + +
+
+ )} + + {teams.length > 0 ? ( +
+ {teams.map((team) => ( + onAddMember(team.id, userId)} + onAreasChange={(areas) => onAreasChange(team.id, areas)} + onDelete={() => onDelete(team.id)} + onRemoveMember={(userId) => onRemoveMember(team.id, userId)} + onRename={(name) => onRename(team.id, name)} + orgMembers={orgMembers} + team={team} + /> + ))} +
+ ) : ( + + )} +
+ ); +} From 9f0fb7da93be75f33666caacd7dad7b3cdbfbb41 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmad Date: Mon, 21 Sep 2026 19:02:51 +0500 Subject: [PATCH 04/13] feat(ui): team switcher and team workspace (#85) - sidebar team switcher lists the user's teams in the active organization with an all-areas option; switching refreshes the session and workspace queries - navigation items carry an optional feature area; the sidebar shows only the active team's areas (owners/admins, platform admins and users without an active team see everything) - dashboard route guard redirects to the workspace overview with a restricted-area notice; header shows the team being operated as - switching organizations clears the active team - Better Auth organization client enables teams - browser regression spec for switch, nav filtering and guard redirect Co-Authored-By: Claude Opus 5 (1M context) --- packages/everything-dev/src/ui/auth.ts | 2 +- .../browser/specs/team-workspace.spec.ts | 115 ++++++++++++++++++ ui/src/components/layout/app-header.tsx | 17 ++- .../layout/app-shell-chrome.test.tsx | 21 ++++ ui/src/components/layout/app-shell.tsx | 23 +++- ui/src/components/layout/app-sidebar.tsx | 20 ++- ui/src/components/layout/nav-items.test.ts | 21 +++- ui/src/components/layout/nav-items.ts | 29 ++++- .../layout/sidebar-team-switcher.test.tsx | 88 ++++++++++++++ .../layout/sidebar-team-switcher.tsx | 88 ++++++++++++++ .../layout/use-switch-organization.test.ts | 6 +- .../layout/use-switch-organization.ts | 4 + ui/src/components/layout/use-switch-team.ts | 31 +++++ ui/src/lib/team-workspace.test.ts | 97 +++++++++++++++ ui/src/lib/team-workspace.ts | 71 +++++++++++ .../_layout/_authenticated/_dashboard.tsx | 13 +- .../dashboard/-restricted-area-notice.tsx | 19 +++ .../_dashboard/dashboard/index.tsx | 8 ++ 18 files changed, 660 insertions(+), 13 deletions(-) create mode 100644 tests/regression/browser/specs/team-workspace.spec.ts create mode 100644 ui/src/components/layout/sidebar-team-switcher.test.tsx create mode 100644 ui/src/components/layout/sidebar-team-switcher.tsx create mode 100644 ui/src/components/layout/use-switch-team.ts create mode 100644 ui/src/lib/team-workspace.test.ts create mode 100644 ui/src/lib/team-workspace.ts create mode 100644 ui/src/routes/_layout/_authenticated/_dashboard/dashboard/-restricted-area-notice.tsx diff --git a/packages/everything-dev/src/ui/auth.ts b/packages/everything-dev/src/ui/auth.ts index 0c9836758..6c57b8e74 100644 --- a/packages/everything-dev/src/ui/auth.ts +++ b/packages/everything-dev/src/ui/auth.ts @@ -109,7 +109,7 @@ export function createAuthClient(options: CreateAuthClientOptions = {}) { anonymousClient(), phoneNumberClient(), passkeyClient(), - organizationClient(), + organizationClient({ teams: { enabled: true } }), apiKeyClient(), ], }); diff --git a/tests/regression/browser/specs/team-workspace.spec.ts b/tests/regression/browser/specs/team-workspace.spec.ts new file mode 100644 index 000000000..34b12bd5f --- /dev/null +++ b/tests/regression/browser/specs/team-workspace.spec.ts @@ -0,0 +1,115 @@ +import { expect, type Page, test } from "@playwright/test"; +import { computeRegressionEnv } from "../../lib/regression-env.mjs"; +import { collectErrors, expectNoHydrationFailure, waitForApp } from "../helpers/page-ready"; + +const { baseUrl } = computeRegressionEnv(); + +async function authFetch(path: string, cookie: string, body?: unknown) { + const response = await fetch(`${baseUrl}/api/auth${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { "content-type": "application/json", origin: baseUrl, cookie }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(`${path} failed: ${response.status} ${await response.text()}`); + } + return response; +} + +async function signInAnonymously() { + const response = await fetch(`${baseUrl}/api/auth/sign-in/anonymous`, { + method: "POST", + headers: { origin: baseUrl }, + }); + if (!response.ok) throw new Error(`anonymous sign-in failed: ${response.status}`); + return response.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]) + .join("; "); +} + +async function seedTeamMember() { + const suffix = `${process.pid}-${Date.now()}`; + const ownerCookie = await signInAnonymously(); + const org = await ( + await authFetch("/organization/create", ownerCookie, { + name: `team-workspace-${suffix}`, + slug: `team-workspace-${suffix}`, + }) + ).json(); + const team = await ( + await authFetch("/organization/create-team", ownerCookie, { + name: "Stake Desk", + organizationId: org.id, + metadata: JSON.stringify({ areas: ["stake"] }), + }) + ).json(); + + const memberCookie = await signInAnonymously(); + const session = await (await authFetch("/get-session", memberCookie)).json(); + const invitation = await ( + await authFetch("/organization/invite-member", ownerCookie, { + email: session.user.email, + role: "member", + organizationId: org.id, + teamId: team.id, + }) + ).json(); + await authFetch("/organization/accept-invitation", memberCookie, { + invitationId: invitation.id, + }); + return { memberCookie, teamName: team.name as string }; +} + +async function useCookieHeader(page: Page, cookieHeader: string) { + const url = new URL(baseUrl); + await page.context().addCookies( + cookieHeader.split("; ").map((pair) => { + const index = pair.indexOf("="); + return { + name: pair.slice(0, index), + value: pair.slice(index + 1), + domain: url.hostname, + path: "/", + httpOnly: true, + secure: url.protocol === "https:", + sameSite: "Lax" as const, + }; + }), + ); +} + +test.describe("team workspace", () => { + test("switching teams filters navigation and guards restricted routes", async ({ page }) => { + const pageErrors = collectErrors(page); + const { memberCookie, teamName } = await seedTeamMember(); + await useCookieHeader(page, memberCookie); + + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); + await waitForApp(page); + + const switcher = page.getByTestId("team-switcher"); + await expect(switcher).toContainText(teamName, { timeout: 10000 }); + await expect(page.getByTestId("workspace-active-team")).toContainText(teamName); + await expect(page.getByTestId("sidebar-nav-stake")).toBeVisible(); + await expect(page.getByTestId("sidebar-nav-things")).toHaveCount(0); + + await page.goto("/things", { waitUntil: "domcontentloaded" }); + await page.waitForURL(/\/dashboard\?restricted=things/, { + timeout: 10000, + waitUntil: "commit", + }); + await expect(page.getByTestId("workspace-restricted-notice")).toBeVisible({ timeout: 10000 }); + + await switcher.click(); + await page.getByTestId("team-switcher-item-all").click(); + await expect(switcher).toContainText("All areas", { timeout: 10000 }); + await expect(page.getByTestId("sidebar-nav-things")).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId("workspace-active-team")).toHaveCount(0); + + await page.getByTestId("sidebar-nav-things").click(); + await page.waitForURL(/\/things/, { timeout: 10000, waitUntil: "commit" }); + + expectNoHydrationFailure(pageErrors); + }); +}); diff --git a/ui/src/components/layout/app-header.tsx b/ui/src/components/layout/app-header.tsx index 4b79e94f5..b4adc568a 100644 --- a/ui/src/components/layout/app-header.tsx +++ b/ui/src/components/layout/app-header.tsx @@ -1,4 +1,5 @@ import { useRouterState } from "@tanstack/react-router"; +import { UsersRound } from "lucide-react"; import { Fragment } from "react"; import type { ClientRuntimeConfig } from "@/app"; import { getAccount, getActiveRuntime } from "@/app"; @@ -16,9 +17,10 @@ import { UserNav } from "./user-nav"; interface AppHeaderProps { runtimeConfig?: Partial; + activeTeamName?: string; } -export function AppHeader({ runtimeConfig }: AppHeaderProps) { +export function AppHeader({ runtimeConfig, activeTeamName }: AppHeaderProps) { const pathname = useRouterState({ select: (s) => s.location.pathname }); const runtime = getActiveRuntime(runtimeConfig); const account = getAccount(runtimeConfig); @@ -62,7 +64,18 @@ export function AppHeader({ runtimeConfig }: AppHeaderProps) { )} -
+ {activeTeamName && ( +
+ + + operating as {activeTeamName} + +
+ )} +
diff --git a/ui/src/components/layout/app-shell-chrome.test.tsx b/ui/src/components/layout/app-shell-chrome.test.tsx index 1b1660392..28374e6de 100644 --- a/ui/src/components/layout/app-shell-chrome.test.tsx +++ b/ui/src/components/layout/app-shell-chrome.test.tsx @@ -26,6 +26,10 @@ vi.mock("./use-identity", () => ({ useIdentity: () => identity, })); +vi.mock("./use-switch-team", () => ({ + useSwitchTeam: () => ({ mutate: vi.fn(), isPending: false }), +})); + vi.mock("./theme-toggle", () => ({ ThemeToggle: () => - + )} {pendingInvitations.length > 0 ? ( @@ -69,6 +41,7 @@ export function InvitationsTab({ onCancel(invitation.id) : undefined} onResend={canManageMembers ? () => onResend(invitation) : undefined} isCancelling={isCancelling} diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.test.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.test.tsx new file mode 100644 index 000000000..5bacc069d --- /dev/null +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { InvitationCard } from "./-invitation-card"; +import { InviteMemberForm } from "./-invite-member-form"; + +const teams = [ + { id: "team-fin", name: "Finance" }, + { id: "team-ops", name: "Node Operator" }, +]; + +function renderForm() { + const onInvite = vi.fn().mockResolvedValue(undefined); + render(); + return { onInvite }; +} + +afterEach(cleanup); + +describe("InviteMemberForm team targeting", () => { + it("offers the organization's teams with no team selected by default", () => { + renderForm(); + const picker = screen.getByTestId("invite-team-select") as HTMLSelectElement; + + expect(picker.value).toBe(""); + expect(Array.from(picker.options).map((option) => option.textContent)).toEqual([ + "No team", + "Finance", + "Node Operator", + ]); + }); + + it("sends an invitation without a team", async () => { + const { onInvite } = renderForm(); + + fireEvent.change(screen.getByTestId("invite-identifier-input"), { + target: { value: "hire@example.com" }, + }); + fireEvent.click(screen.getByTestId("invite-submit-button")); + + await waitFor(() => + expect(onInvite).toHaveBeenCalledWith({ email: "hire@example.com", role: "member" }), + ); + }); + + it("sends an invitation targeting the chosen team and role, then resets", async () => { + const { onInvite } = renderForm(); + const input = screen.getByTestId("invite-identifier-input") as HTMLInputElement; + + fireEvent.change(input, { target: { value: "ops@example.com" } }); + fireEvent.change(screen.getByTestId("invite-role-select"), { target: { value: "admin" } }); + fireEvent.change(screen.getByTestId("invite-team-select"), { target: { value: "team-ops" } }); + fireEvent.click(screen.getByTestId("invite-submit-button")); + + await waitFor(() => + expect(onInvite).toHaveBeenCalledWith({ + email: "ops@example.com", + role: "admin", + teamId: "team-ops", + }), + ); + await waitFor(() => expect(input.value).toBe("")); + }); + + it("keeps the input when sending fails", async () => { + const onInvite = vi.fn().mockRejectedValue(new Error("nope")); + render(); + const input = screen.getByTestId("invite-identifier-input") as HTMLInputElement; + + fireEvent.change(input, { target: { value: "retry@example.com" } }); + fireEvent.click(screen.getByTestId("invite-submit-button")); + + await waitFor(() => expect(onInvite).toHaveBeenCalled()); + expect(input.value).toBe("retry@example.com"); + }); +}); + +describe("InvitationCard team targeting", () => { + it("shows the targeted team when present", () => { + render( + , + ); + + expect(screen.getByTestId("invitation-team-inv-1").textContent).toContain("Finance"); + }); +}); diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.tsx new file mode 100644 index 000000000..43b3c29df --- /dev/null +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { Button, Card, Input } from "@/components"; + +export type InviteRole = "admin" | "member"; + +export interface InviteMemberValues { + email: string; + role: InviteRole; + teamId?: string; +} + +const selectClassName = + "w-full px-3 py-2 text-sm bg-card text-foreground border-2 border-inset border-border-strong rounded-[8px] outline-none focus:ring-2 focus:ring-ring"; + +export function InviteMemberForm({ + isPending, + onInvite, + teams, +}: { + isPending: boolean; + onInvite: (values: InviteMemberValues) => Promise; + teams: Array<{ id: string; name: string }>; +}) { + const [identifier, setIdentifier] = useState(""); + const [role, setRole] = useState("member"); + const [teamId, setTeamId] = useState(""); + const email = identifier.trim(); + + return ( + +
+ Invite member +
+
{ + event.preventDefault(); + if (!email) return; + try { + await onInvite({ email, role, ...(teamId ? { teamId } : {}) }); + setIdentifier(""); + setTeamId(""); + } catch {} + }} + > +
+ setIdentifier(event.target.value)} + placeholder="email@example.com" + aria-label="Email" + data-testid="invite-identifier-input" + /> + + +
+ +
+
+ ); +} diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-invitations.ts b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-invitations.ts index bbd87d7c3..0248cb536 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-invitations.ts +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-invitations.ts @@ -2,30 +2,25 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import type { AuthClient } from "@/app"; import type { InvitationCardInvitation } from "./-invitation-card"; +import type { InviteMemberValues } from "./-invite-member-form"; import { orgInvitationsQueryKey } from "./-organization-query-keys"; -export function useOrganizationInvitationActions( - auth: AuthClient, - orgId: string, - inviteEmail: string, - inviteRole: "admin" | "member", - onInvited: () => void, -) { +export function useOrganizationInvitationActions(auth: AuthClient, orgId: string) { const queryClient = useQueryClient(); const invalidateInvitations = () => queryClient.invalidateQueries({ queryKey: orgInvitationsQueryKey(orgId) }); const inviteMutation = useMutation({ - mutationFn: async () => { + mutationFn: async (values: InviteMemberValues) => { const { error } = await auth.organization.inviteMember({ organizationId: orgId, - email: inviteEmail, - role: inviteRole, + email: values.email, + role: values.role, + ...(values.teamId ? { teamId: values.teamId } : {}), }); if (error) throw new Error(error.message); }, - onSuccess: async () => { - toast.success(`Invitation sent to ${inviteEmail}`); - onInvited(); + onSuccess: async (_, values) => { + toast.success(`Invitation sent to ${values.email}`); await invalidateInvitations(); }, onError: (error: Error) => toast.error(error.message || "Failed to send invitation"), @@ -47,6 +42,7 @@ export function useOrganizationInvitationActions( organizationId: orgId, email: invitation.email, role: invitation.role as "admin" | "member" | "owner", + ...(invitation.teamId ? { teamId: invitation.teamId } : {}), resend: true, }); if (error) throw new Error(error.message); diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-management.test.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-management.test.tsx index dd9e90e6a..d71d94d77 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-management.test.tsx +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-management.test.tsx @@ -106,18 +106,15 @@ function renderInvitations({ , ); diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/invites.$id.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/invites.$id.tsx index d6562834d..9abf2d11b 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/invites.$id.tsx +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/invites.$id.tsx @@ -4,6 +4,7 @@ import { CheckCircle, XCircle } from "lucide-react"; import { toast } from "sonner"; import { getAppName, useAuthClient } from "@/app"; import { Badge, Button, Card, CardContent, PageContainer, PageHeader } from "@/components"; +import { teamWorkspaceQueryKey } from "@/lib/team-workspace"; export const Route = createFileRoute("/_layout/_authenticated/_dashboard/orgs/invites/$id")({ head: () => ({ @@ -60,6 +61,7 @@ function AcceptInvitation() { queryClient.invalidateQueries({ queryKey: ["organizations"] }), queryClient.invalidateQueries({ queryKey: ["session"] }), queryClient.invalidateQueries({ queryKey: ["user-invitations"] }), + queryClient.invalidateQueries({ queryKey: teamWorkspaceQueryKey }), ]); await queryClient.refetchQueries({ queryKey: ["organizations"] }); await router.navigate({ From f0b651657b88ad82dd7ffccb65c0470812173ac8 Mon Sep 17 00:00:00 2001 From: Zeeshan Ahmad Date: Mon, 21 Sep 2026 22:20:07 +0500 Subject: [PATCH 06/13] feat: wallet invitations for teams and workspaces - Invite members by NEAR account id as well as email, optionally targeting a team - Add near-invitations Better Auth plugin with accept/reject wallet endpoints - List pending wallet invitations independently of email verification - Fix createHeaders dropping cookies when given a native Headers instance - Update invite form, invitation card, and claim-link page in the dashboard - Add integration, unit, and browser regression tests plus walkthrough docs --- .../teams-workspaces-wallet-invitations.md | 8 + CONTEXT.md | 61 ++++ docs/teams-and-workspaces-walkthrough.md | 45 +++ plugins/auth/src/auth-instance.ts | 14 + plugins/auth/src/contract.ts | 27 +- plugins/auth/src/handlers/invitations.ts | 114 ++++++-- plugins/auth/src/near-invitations.ts | 180 ++++++++++++ plugins/auth/src/utils.ts | 6 +- .../integration/near-invitations.test.ts | 275 ++++++++++++++++++ plugins/auth/tests/unit/utils.test.ts | 6 + .../browser/specs/team-workspace.spec.ts | 3 +- .../_authenticated/_dashboard/orgs/$slug.tsx | 14 +- .../_dashboard/orgs/-invitation-card.tsx | 15 +- .../orgs/-invite-member-form.test.tsx | 36 ++- .../_dashboard/orgs/-invite-member-form.tsx | 59 +++- .../orgs/-organization-invitations.test.tsx | 69 +++++ .../orgs/-organization-invitations.ts | 24 +- .../orgs/-organization-management.test.tsx | 4 +- .../_authenticated/_dashboard/orgs/index.tsx | 54 ++-- .../_dashboard/orgs/invites.$id.tsx | 54 ++-- .../_dashboard/settings/-near-method.tsx | 1 + 21 files changed, 971 insertions(+), 98 deletions(-) create mode 100644 .changeset/teams-workspaces-wallet-invitations.md create mode 100644 CONTEXT.md create mode 100644 docs/teams-and-workspaces-walkthrough.md create mode 100644 plugins/auth/src/near-invitations.ts create mode 100644 plugins/auth/tests/integration/near-invitations.test.ts create mode 100644 ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-invitations.test.tsx diff --git a/.changeset/teams-workspaces-wallet-invitations.md b/.changeset/teams-workspaces-wallet-invitations.md new file mode 100644 index 000000000..ec48e2489 --- /dev/null +++ b/.changeset/teams-workspaces-wallet-invitations.md @@ -0,0 +1,8 @@ +--- +"@everything-dev/auth-plugin": minor +"api": patch +"ui": minor +"everything-dev": minor +--- + +Complete organization teams and wallet invitations across the auth plugin, API, and dashboard. Team workspaces now carry feature-area context through node mutation authorization, and organization owners can invite either an email address or a NEAR account, target a team, and manage wallet-aware pending invitations. Invitees can accept email or wallet invitations from the dashboard or claim link and land in the targeted workspace. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..3c762cd0b --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,61 @@ +# City Node + +A geographic node in the City Node network, owned by a node DAO and optionally backed by a staking pool. + +## Language + +**Node DAO Account**: +The node DAO's NEAR account — the account that stakes into the node's pool. +_Avoid_: team account, team wallet, org account, user wallet + +**Staking Pool**: +The validator pool contract the node resolves for staking. +_Avoid_: validator (the metadata record), total staked (the whole pool) + +**Node DAO Stake**: +The Node DAO Account's current stake in the node's Staking Pool, including compounded validator rewards. +_Avoid_: available rewards (product label for this same quantity), total staked, pool stake + +**Validator Rewards**: +NEAR already compounded into Node DAO Stake. Not a separately held balance. +_Avoid_: reward balance, pending rewards + +## Organization access + +**Team**: +A named sub-group within an organization that shares access to the organization's feature areas. +_Avoid_: team account, team wallet, node DAO + +**Active Team**: +The Team currently selected for a user's organization work. +_Avoid_: current team, team account + +**Feature Area**: +A product capability that an organization can grant to a Team. +_Avoid_: permission, role + +**Team Workspace**: +The organization view scoped to an Active Team and its granted feature areas. +_Avoid_: node workspace, organization account + +## Community discovery + +**Discovery Profile**: +A node's public community identity, including its chosen geographic location and official social channels. +_Avoid_: validator profile, node DAO account + +**Community Activity**: +A published event or social update associated with a node and visible to visitors. +_Avoid_: staking activity, validator uptime + +**Active Node**: +A publicly discoverable node with recent Community Activity or an upcoming published event. +_Avoid_: online node, active validator + +**Node Event**: +A community gathering associated with one or more nodes, with a scheduled time and a public destination for event details or registration. +_Avoid_: blockchain event, transaction + +**Social Update**: +A node-associated public post with an attributed source, original publication time, and a link to the original content. +_Avoid_: social account, imported activity diff --git a/docs/teams-and-workspaces-walkthrough.md b/docs/teams-and-workspaces-walkthrough.md new file mode 100644 index 000000000..68eca622c --- /dev/null +++ b/docs/teams-and-workspaces-walkthrough.md @@ -0,0 +1,45 @@ +# Teams and workspaces walkthrough + +Date: 2026-09-21 + +The local databases were started with `docker compose up -d --wait`. The development stack was then started with: + +```text +bun run dev --port 3100 --api-port 3101 --ui-port 3103 --auth-port 3102 --plugin-port-start 3110 +``` + +The stack reached `APP READY` at `http://localhost:3100` with all eight services running: host, UI, API, auth, and the four local plugins. Auth migrations, including the wallet invitation fields, loaded successfully during boot. + +The initial computer-use browser was unavailable. Chromium was subsequently installed with `bunx playwright install chromium`, and a headless Playwright walkthrough exercised the local development stack on `http://localhost:4100` using isolated regression databases. The stack used `CORS_ORIGIN=http://localhost:4100 RATE_LIMIT_WINDOW_MS=1000 RATE_LIMIT_MAX=100 CI=true bun run regression:start:dev`. + +## Observed browser results + +- Created an organization through the UI, then created Finance and Node Operator teams through the Teams tab. +- Saved distinct `finance` and `node-operations` grants through the area checkboxes. +- Invited an email user and a NEAR account through the unified form, targeting different teams. +- Accepted the email invitation from the organizations dashboard; the header and sidebar immediately reflected Finance. +- Observed the wallet invitation on the organizations dashboard with the wallet user's email deliberately unverified, then accepted it through the claim-link page; the workspace immediately reflected Node Operator. +- Both workspaces hid Things and redirected direct navigation to `/things` to `/dashboard?restricted=things`. +- A node mutation probe returned 403 for Finance. Node Operator passed the area gate and reached the handler, which returned 404 for a deliberately nonexistent UUID. +- Clearing the active team restored the full navigation for both users. +- An organization owner and a platform admin, each operating with Finance active, retained full navigation and passed the node API area gate. + +The walkthrough used disposable local users. The wallet identity was linked in the isolated test database as a fixture, and the email identity was marked verified there. This checks wallet invitation ownership matching and browser integration; it does not test an external wallet's SIWN signature flow or delivery to a real mailbox. Local email delivery was preview-only. No GitHub issue comment was posted. + +## Defects found and fixed during verification + +- The browser regression's anonymous sign-in request omitted its JSON content type and received HTTP 415. +- Auth helpers converted native `Headers` with `Object.entries`, discarding cookies and causing team mutations through oRPC to fail with HTTP 401. Native headers now survive conversion, with a regression test. +- Better Auth rejects email-invitation listing for unverified emails. The combined query previously let that rejection hide linked-wallet invitations as well. Wallet discovery now works independently, with an integration regression. + +## Automated validation + +- Full UI suite: 63 files, 354 tests passed. +- Full API suite: 13 files, 116 tests passed. +- Wallet invitation integration suite: 10 tests passed; auth utility suite: 31 tests passed. +- Team workspace Playwright regression: passed after fixing the request headers. +- Final `bun typecheck`: all eight targets passed. Final `bun lint`: passed with warnings. +- The full root test command is not green: after Chromium installation, the host remote-runtime browser suite passed two tests but failed its navigation check because the deployed remote UI had no link named `Skill`; seven cases were skipped and one did not run. That suite targets the deployed remote UI, not the local team workspace. +- A broad auth-suite run also timed out in the session-revocation test and the NEAR sandbox setup hook. Rerunning those two files with `--maxWorkers=1` passed session revocation, but `Sandbox.start` still exceeded the 30-second setup timeout and its four SIWN tests did not run. External-wallet signature verification therefore remains unverified in this environment. + +Automated acceptance coverage lives in `plugins/auth/tests/integration/near-invitations.test.ts`, `ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.test.tsx`, and `tests/regression/browser/specs/team-workspace.spec.ts`. diff --git a/plugins/auth/src/auth-instance.ts b/plugins/auth/src/auth-instance.ts index 88defa98e..c56fca265 100644 --- a/plugins/auth/src/auth-instance.ts +++ b/plugins/auth/src/auth-instance.ts @@ -2,6 +2,7 @@ import { apiKey } from "@better-auth/api-key"; import { passkey } from "@better-auth/passkey"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { APIError } from "better-auth/api"; import { admin, anonymous, organization, phoneNumber } from "better-auth/plugins"; import { createAccessControl } from "better-auth/plugins/access"; import { @@ -37,6 +38,7 @@ const orgRoles = { import type { AuthConfig } from "./auth-config"; import type { Database as AuthDatabase } from "./db"; import * as schema from "./db/schema"; +import { isNearInvitation, nearInvitations } from "./near-invitations"; export function isRecipientsConfig(config: SIWNPluginOptions): config is SIWNPluginOptions & { recipients: { mainnet: string; testnet: string }; @@ -300,7 +302,18 @@ export function createAuthInstance( }, }, }, + organizationHooks: { + beforeAcceptInvitation: async ({ invitation }) => { + if (isNearInvitation(invitation)) { + throw new APIError("BAD_REQUEST", { + message: + "Wallet invitations are accepted by signing in with the invited NEAR account", + }); + } + }, + }, async sendInvitationEmail(data) { + if (isNearInvitation(data.invitation)) return; const inviteLink = `${config.baseUrl}/orgs/invites/${data.id}`; await sendEmail( { @@ -313,6 +326,7 @@ export function createAuthInstance( ); }, }), + nearInvitations(db), apiKey([ { configId: "user-keys", diff --git a/plugins/auth/src/contract.ts b/plugins/auth/src/contract.ts index 990a63323..dea760bfe 100644 --- a/plugins/auth/src/contract.ts +++ b/plugins/auth/src/contract.ts @@ -192,6 +192,7 @@ const invitationSchema = z.object({ expiresAt: z.date(), inviterId: z.string(), teamId: z.string().nullable(), + nearAccountId: z.string().nullable(), }); const teamSchema = z.object({ @@ -518,7 +519,8 @@ export const contract = oc.router({ .route({ method: "POST", path: "/v1/auth/invitations" }) .input( z.object({ - email: z.string(), + email: z.string().optional(), + nearAccountId: z.string().optional(), role: z.enum(["owner", "admin", "member"]), organizationId: z.string().optional(), teamId: z.string().optional(), @@ -541,6 +543,8 @@ export const contract = oc.router({ status: z.string(), expiresAt: z.date(), inviterId: z.string(), + teamId: z.string().nullable(), + nearAccountId: z.string().nullable(), organizationName: z.string(), organizationSlug: z.string(), inviterEmail: z.string(), @@ -561,7 +565,14 @@ export const contract = oc.router({ listUserInvitations: oc .route({ method: "GET", path: "/v1/auth/invitations/user" }) - .output(z.array(invitationSchema)) + .output( + z.array( + invitationSchema.extend({ + organizationName: z.string().optional(), + organizationSlug: z.string().optional(), + }), + ), + ) .errors(Errors), cancelInvitation: oc @@ -576,6 +587,18 @@ export const contract = oc.router({ .output(z.object({ success: z.boolean() })) .errors(Errors), + acceptNearInvitation: oc + .route({ method: "POST", path: "/v1/auth/invitations/accept-near" }) + .input(z.object({ invitationId: z.string() })) + .output(z.object({ success: z.boolean() })) + .errors(Errors), + + rejectNearInvitation: oc + .route({ method: "POST", path: "/v1/auth/invitations/reject-near" }) + .input(z.object({ invitationId: z.string() })) + .output(z.object({ success: z.boolean() })) + .errors(Errors), + rejectInvitation: oc .route({ method: "POST", path: "/v1/auth/invitations/reject" }) .input(z.object({ invitationId: z.string() })) diff --git a/plugins/auth/src/handlers/invitations.ts b/plugins/auth/src/handlers/invitations.ts index 2d2f5d3ee..d948ff0d7 100644 --- a/plugins/auth/src/handlers/invitations.ts +++ b/plugins/auth/src/handlers/invitations.ts @@ -1,7 +1,31 @@ +import { ORPCError } from "@orpc/server"; +import { eq } from "drizzle-orm"; import { Context } from "effect"; +import * as schema from "../db/schema"; +import { + listPendingNearInvitations, + nearInvitationEmail, + normalizeNearAccountId, +} from "../near-invitations"; import { AuthServicesTag } from "../service-types"; import { createHeaders, safeAuthApi } from "../utils"; +function resolveInvitee(input: { email?: string; nearAccountId?: string }) { + if (!!input.email === !!input.nearAccountId) { + throw new ORPCError("BAD_REQUEST", { + message: "Provide either an email address or a NEAR account id", + }); + } + if (input.email) return { email: input.email }; + const nearAccountId = normalizeNearAccountId(input.nearAccountId ?? ""); + if (!nearAccountId) { + throw new ORPCError("BAD_REQUEST", { + message: `"${input.nearAccountId}" is not a valid NEAR account id`, + }); + } + return { email: nearInvitationEmail(nearAccountId), nearAccountId }; +} + function toInvitation(invitation: any) { return { id: invitation.id, @@ -13,6 +37,7 @@ function toInvitation(invitation: any) { invitation.expiresAt instanceof Date ? invitation.expiresAt : new Date(invitation.expiresAt), inviterId: invitation.inviterId, teamId: invitation.teamId ?? null, + nearAccountId: invitation.nearAccountId ?? null, }; } @@ -26,7 +51,7 @@ export function createInvitationHandlers(builder: any, requireAuth: any) { services.auth.api.createInvitation({ headers: createHeaders(context.reqHeaders), body: { - email: input.email, + ...resolveInvitee(input), role: input.role, organizationId: input.organizationId, resend: input.resend, @@ -40,23 +65,34 @@ export function createInvitationHandlers(builder: any, requireAuth: any) { getInvitation: builder.getInvitation.handler( async ({ input, context }: { input: any; context: any }) => { const services = Context.get(context["effect/context"], AuthServicesTag); + const headers = createHeaders(context.reqHeaders ?? {}); try { + const stored = await services.db.query.invitation.findFirst({ + where: eq(schema.invitation.id, input.id), + }); + if (stored?.nearAccountId) { + const session = await services.auth.api.getSession({ headers }); + if (!session?.user) return null; + const pending = await listPendingNearInvitations(services.db, session.user.id); + const match = pending.find((row) => row.invitation.id === input.id); + if (!match) return null; + const inviter = await services.db.query.user.findFirst({ + where: eq(schema.user.id, match.invitation.inviterId), + }); + return { + ...toInvitation(match.invitation), + organizationName: match.organizationName, + organizationSlug: match.organizationSlug, + inviterEmail: inviter?.email ?? "", + }; + } const invitation = await services.auth.api.getInvitation({ - headers: createHeaders(context.reqHeaders ?? {}), + headers, query: { id: input.id }, }); if (!invitation) return null; return { - id: invitation.id, - organizationId: invitation.organizationId, - email: invitation.email, - role: invitation.role, - status: invitation.status, - expiresAt: - invitation.expiresAt instanceof Date - ? invitation.expiresAt - : new Date(invitation.expiresAt), - inviterId: invitation.inviterId, + ...toInvitation(invitation), organizationName: invitation.organizationName, organizationSlug: invitation.organizationSlug, inviterEmail: invitation.inviterEmail, @@ -86,12 +122,28 @@ export function createInvitationHandlers(builder: any, requireAuth: any) { .use(requireAuth) .handler(async ({ context }: { context: any }) => { const services = Context.get(context["effect/context"], AuthServicesTag); - const result = await safeAuthApi(() => - services.auth.api.listUserInvitations({ - headers: createHeaders(context.reqHeaders), - }), - ); - return (result ?? []).map(toInvitation); + const [emailInvitations, walletInvitations] = await Promise.all([ + context.user?.emailVerified === false + ? Promise.resolve([]) + : safeAuthApi(() => + services.auth.api.listUserInvitations({ + headers: createHeaders(context.reqHeaders), + }), + ), + listPendingNearInvitations(services.db, context.userId), + ]); + return [ + ...(emailInvitations ?? []).map((inv: any) => ({ + ...toInvitation(inv), + ...(inv.organizationName ? { organizationName: inv.organizationName } : {}), + ...(inv.organizationSlug ? { organizationSlug: inv.organizationSlug } : {}), + })), + ...walletInvitations.map((row) => ({ + ...toInvitation(row.invitation), + organizationName: row.organizationName, + organizationSlug: row.organizationSlug, + })), + ]; }), cancelInvitation: builder.cancelInvitation @@ -120,6 +172,32 @@ export function createInvitationHandlers(builder: any, requireAuth: any) { return { success: true }; }), + acceptNearInvitation: builder.acceptNearInvitation + .use(requireAuth) + .handler(async ({ input, context }: { input: any; context: any }) => { + const services = Context.get(context["effect/context"], AuthServicesTag); + await safeAuthApi(() => + services.auth.api.acceptNearInvitation({ + headers: createHeaders(context.reqHeaders), + body: { invitationId: input.invitationId }, + }), + ); + return { success: true }; + }), + + rejectNearInvitation: builder.rejectNearInvitation + .use(requireAuth) + .handler(async ({ input, context }: { input: any; context: any }) => { + const services = Context.get(context["effect/context"], AuthServicesTag); + await safeAuthApi(() => + services.auth.api.rejectNearInvitation({ + headers: createHeaders(context.reqHeaders), + body: { invitationId: input.invitationId }, + }), + ); + return { success: true }; + }), + rejectInvitation: builder.rejectInvitation .use(requireAuth) .handler(async ({ input, context }: { input: any; context: any }) => { diff --git a/plugins/auth/src/near-invitations.ts b/plugins/auth/src/near-invitations.ts new file mode 100644 index 000000000..8cf8ef54c --- /dev/null +++ b/plugins/auth/src/near-invitations.ts @@ -0,0 +1,180 @@ +import type { BetterAuthPlugin } from "better-auth"; +import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api"; +import { setSessionCookie } from "better-auth/cookies"; +import { and, eq, gt, inArray } from "drizzle-orm"; +import { z } from "zod"; +import type { Database } from "./db"; +import * as schema from "./db/schema"; + +const NEAR_INVITATION_EMAIL_DOMAIN = "near-wallet.invalid"; +const IMPLICIT_ACCOUNT = /^[0-9a-f]{64}$/; +const ETH_IMPLICIT_ACCOUNT = /^0x[0-9a-f]{40}$/; +const NAMED_ACCOUNT = /^(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$/; + +export function normalizeNearAccountId(value: string): string | null { + const accountId = value.trim().toLowerCase(); + if (IMPLICIT_ACCOUNT.test(accountId) || ETH_IMPLICIT_ACCOUNT.test(accountId)) return accountId; + if (accountId.length < 2 || accountId.length > 64) return null; + return NAMED_ACCOUNT.test(accountId) ? accountId : null; +} + +export function nearInvitationEmail(accountId: string): string { + return `${accountId}@${NEAR_INVITATION_EMAIL_DOMAIN}`; +} + +export function isNearInvitation(invitation: unknown): boolean { + return ( + !!invitation && + typeof invitation === "object" && + typeof (invitation as { nearAccountId?: unknown }).nearAccountId === "string" + ); +} + +export async function listLinkedNearAccountIds(db: Database, userId: string): Promise { + const rows = await db + .select({ accountId: schema.nearAccount.accountId }) + .from(schema.nearAccount) + .where(eq(schema.nearAccount.userId, userId)); + return rows.map((row) => row.accountId); +} + +export async function listPendingNearInvitations(db: Database, userId: string) { + const accountIds = await listLinkedNearAccountIds(db, userId); + if (accountIds.length === 0) return []; + return db + .select({ + invitation: schema.invitation, + organizationName: schema.organization.name, + organizationSlug: schema.organization.slug, + }) + .from(schema.invitation) + .innerJoin(schema.organization, eq(schema.invitation.organizationId, schema.organization.id)) + .where( + and( + inArray(schema.invitation.nearAccountId, accountIds), + eq(schema.invitation.status, "pending"), + gt(schema.invitation.expiresAt, new Date()), + ), + ); +} + +async function findClaimableInvitation(db: Database, invitationId: string, userId: string) { + const invitation = await db.query.invitation.findFirst({ + where: eq(schema.invitation.id, invitationId), + }); + if (!invitation?.nearAccountId) { + throw new APIError("BAD_REQUEST", { message: "Wallet invitation not found" }); + } + if (invitation.status !== "pending") { + throw new APIError("BAD_REQUEST", { message: `Invitation is already ${invitation.status}` }); + } + if (invitation.expiresAt < new Date()) { + throw new APIError("BAD_REQUEST", { message: "Invitation has expired" }); + } + const linked = await listLinkedNearAccountIds(db, userId); + if (!linked.includes(invitation.nearAccountId)) { + throw new APIError("FORBIDDEN", { + message: `This invitation is for ${invitation.nearAccountId}. Sign in with or link that NEAR account to accept it.`, + }); + } + return invitation; +} + +const invitationBody = z.object({ invitationId: z.string() }); + +export function nearInvitations(db: Database) { + return { + id: "near-invitations", + endpoints: { + acceptNearInvitation: createAuthEndpoint( + "/organization/accept-near-invitation", + { method: "POST", body: invitationBody, use: [sessionMiddleware] }, + async (ctx) => { + const { session, user } = ctx.context.session; + const invitation = await findClaimableInvitation(db, ctx.body.invitationId, user.id); + const teamIds = invitation.teamId ? invitation.teamId.split(",") : []; + + const member = await db.transaction(async (tx) => { + const existing = await tx.query.member.findFirst({ + where: and( + eq(schema.member.userId, user.id), + eq(schema.member.organizationId, invitation.organizationId), + ), + }); + if (existing) { + throw new APIError("BAD_REQUEST", { + message: "You are already a member of this organization", + }); + } + const [accepted] = await tx + .update(schema.invitation) + .set({ status: "accepted" }) + .where( + and( + eq(schema.invitation.id, invitation.id), + eq(schema.invitation.status, "pending"), + ), + ) + .returning(); + if (!accepted) { + throw new APIError("BAD_REQUEST", { message: "Invitation is no longer pending" }); + } + for (const teamId of teamIds) { + const team = await tx.query.team.findFirst({ + where: and( + eq(schema.team.id, teamId), + eq(schema.team.organizationId, invitation.organizationId), + ), + }); + if (!team) { + throw new APIError("BAD_REQUEST", { message: "Invited team no longer exists" }); + } + await tx.insert(schema.teamMember).values({ + id: crypto.randomUUID(), + teamId, + userId: user.id, + createdAt: new Date(), + }); + } + const [created] = await tx + .insert(schema.member) + .values({ + id: crypto.randomUUID(), + organizationId: invitation.organizationId, + userId: user.id, + role: invitation.role ?? "member", + createdAt: new Date(), + }) + .returning(); + return created; + }); + + const updated = await ctx.context.internalAdapter.updateSession(session.token, { + activeOrganizationId: invitation.organizationId, + activeTeamId: teamIds.length === 1 ? teamIds[0] : null, + }); + if (updated) { + await setSessionCookie(ctx, { session: updated as typeof session, user }); + } + return ctx.json({ invitation: { ...invitation, status: "accepted" }, member }); + }, + ), + rejectNearInvitation: createAuthEndpoint( + "/organization/reject-near-invitation", + { method: "POST", body: invitationBody, use: [sessionMiddleware] }, + async (ctx) => { + const invitation = await findClaimableInvitation( + db, + ctx.body.invitationId, + ctx.context.session.user.id, + ); + await db + .update(schema.invitation) + .set({ status: "rejected" }) + .where(eq(schema.invitation.id, invitation.id)); + return ctx.json({ invitation: { ...invitation, status: "rejected" } }); + }, + ), + }, + } satisfies BetterAuthPlugin; +} diff --git a/plugins/auth/src/utils.ts b/plugins/auth/src/utils.ts index 7134ce399..9fd4267f6 100644 --- a/plugins/auth/src/utils.ts +++ b/plugins/auth/src/utils.ts @@ -15,8 +15,10 @@ export function tryJsonParse(value: string | null | undefined): T | undefined } } -export function createHeaders(reqHeaders?: Record): Headers { - return new Headers(Object.entries(reqHeaders ?? {}) as [string, string][]); +type HeaderInput = ConstructorParameters[0]; + +export function createHeaders(reqHeaders?: HeaderInput): Headers { + return new Headers(reqHeaders); } export const localDevTrustedOrigins = [ diff --git a/plugins/auth/tests/integration/near-invitations.test.ts b/plugins/auth/tests/integration/near-invitations.test.ts new file mode 100644 index 000000000..47e925c58 --- /dev/null +++ b/plugins/auth/tests/integration/near-invitations.test.ts @@ -0,0 +1,275 @@ +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import * as schema from "../../src/db/schema"; +import { + createTestHandlers, + createTestOrg, + createTestServices, + createTestUser, + type TestUser, +} from "../helpers"; + +let services: Awaited>; + +beforeAll(async () => { + services = await createTestServices(); +}, 30000); + +afterAll(async () => { + await services.driver.close(); +}, 30000); + +function walletId() { + return `wallet-${crypto.randomUUID().slice(0, 8)}.near`; +} + +async function linkWallet(user: TestUser, accountId: string) { + await services.services.db.insert(schema.nearAccount).values({ + id: crypto.randomUUID(), + userId: user.userId, + accountId, + network: "mainnet", + publicKey: "ed25519:test", + isPrimary: true, + createdAt: new Date(), + }); +} + +async function walletInvitation(options: { withTeam?: boolean } = {}) { + const owner = await createTestUser(services.services); + const org = await createTestOrg(services.services, owner.userId); + const handlers = createTestHandlers(services.services); + const team = options.withTeam + ? await handlers.teams.createTeam({ + input: { name: "Node Operator", organizationId: org.id, areas: ["node-operations"] }, + context: { reqHeaders: owner.reqHeaders }, + }) + : null; + const nearAccountId = walletId(); + const invitation = await handlers.invitations.inviteMember({ + input: { + nearAccountId, + role: "member", + organizationId: org.id, + ...(team ? { teamId: team.id } : {}), + }, + context: { reqHeaders: owner.reqHeaders }, + }); + return { owner, org, team, handlers, nearAccountId, invitation }; +} + +describe("NEAR-account invitations", () => { + it("creates a wallet invitation without sending an email", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const { owner, org, handlers, nearAccountId, invitation } = await walletInvitation(); + + expect(invitation).toMatchObject({ nearAccountId, status: "pending", role: "member" }); + expect(log.mock.calls.flat().join("\n")).not.toContain("Invitation to join"); + const listed = await handlers.invitations.listInvitations({ + input: { organizationId: org.id }, + context: { reqHeaders: owner.reqHeaders }, + }); + expect(listed).toEqual([expect.objectContaining({ id: invitation.id, nearAccountId })]); + } finally { + log.mockRestore(); + } + }); + + it("rejects malformed account ids and ambiguous invitees", async () => { + const owner = await createTestUser(services.services); + const org = await createTestOrg(services.services, owner.userId); + const handlers = createTestHandlers(services.services); + + for (const input of [ + { nearAccountId: "Not A Wallet!" }, + { nearAccountId: "alice.near", email: "alice@example.com" }, + {}, + ]) { + await expect( + handlers.invitations.inviteMember({ + input: { ...input, role: "member", organizationId: org.id }, + context: { reqHeaders: owner.reqHeaders }, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + } + }); + + it("accepts with the invited wallet, joining the organization and team workspace", async () => { + const { org, team, handlers, nearAccountId, invitation } = await walletInvitation({ + withTeam: true, + }); + const invitee = await createTestUser(services.services); + await linkWallet(invitee, nearAccountId); + + await handlers.invitations.acceptNearInvitation({ + input: { invitationId: invitation.id }, + context: { reqHeaders: invitee.reqHeaders }, + }); + + const context = await handlers.session.getContext({ + context: { reqHeaders: invitee.reqHeaders }, + }); + expect(context.organization.activeOrganizationId).toBe(org.id); + expect(context.organization.member?.role).toBe("member"); + expect(context.organization.activeTeamId).toBe(team?.id); + expect(context.organization.teams).toEqual([ + { id: team?.id, name: "Node Operator", areas: ["node-operations"] }, + ]); + }); + + it("rejects acceptance from a different wallet", async () => { + const { handlers, invitation } = await walletInvitation(); + const impostor = await createTestUser(services.services); + await linkWallet(impostor, walletId()); + + await expect( + handlers.invitations.acceptNearInvitation({ + input: { invitationId: invitation.id }, + context: { reqHeaders: impostor.reqHeaders }, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("rejects expired and already-accepted invitations", async () => { + const { handlers, nearAccountId, invitation } = await walletInvitation(); + const invitee = await createTestUser(services.services); + await linkWallet(invitee, nearAccountId); + await handlers.invitations.acceptNearInvitation({ + input: { invitationId: invitation.id }, + context: { reqHeaders: invitee.reqHeaders }, + }); + + await expect( + handlers.invitations.acceptNearInvitation({ + input: { invitationId: invitation.id }, + context: { reqHeaders: invitee.reqHeaders }, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + + const expired = await walletInvitation(); + await linkWallet(invitee, expired.nearAccountId); + await services.services.db + .update(schema.invitation) + .set({ expiresAt: new Date(Date.now() - 1000) }) + .where(eq(schema.invitation.id, expired.invitation.id)); + await expect( + handlers.invitations.acceptNearInvitation({ + input: { invitationId: expired.invitation.id }, + context: { reqHeaders: invitee.reqHeaders }, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("cannot be accepted through the email acceptance flow", async () => { + const { handlers, nearAccountId, invitation } = await walletInvitation(); + const invitee = await createTestUser(services.services, { + email: `${nearAccountId}@near-wallet.invalid`, + }); + + await expect( + handlers.invitations.acceptInvitation({ + input: { invitationId: invitation.id }, + context: { reqHeaders: invitee.reqHeaders }, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("surfaces pending wallet invitations once the wallet is linked", async () => { + const { org, nearAccountId, invitation, handlers } = await walletInvitation(); + const invitee = await createTestUser(services.services); + + const before = await handlers.invitations.listUserInvitations({ + context: { reqHeaders: invitee.reqHeaders }, + }); + expect(before).toEqual([]); + + await linkWallet(invitee, nearAccountId); + const after = await handlers.invitations.listUserInvitations({ + context: { reqHeaders: invitee.reqHeaders }, + }); + expect(after).toEqual([ + expect.objectContaining({ + id: invitation.id, + nearAccountId, + organizationId: org.id, + organizationName: org.name, + organizationSlug: org.slug, + }), + ]); + }); + + it("surfaces wallet invitations for users without a verified email", async () => { + const { org, nearAccountId, invitation, handlers } = await walletInvitation(); + const invitee = await createTestUser(services.services); + await services.services.db + .update(schema.user) + .set({ emailVerified: false }) + .where(eq(schema.user.id, invitee.userId)); + await linkWallet(invitee, nearAccountId); + + const listed = await handlers.invitations.listUserInvitations({ + context: { reqHeaders: invitee.reqHeaders }, + }); + expect(listed).toEqual([ + expect.objectContaining({ + id: invitation.id, + nearAccountId, + organizationId: org.id, + organizationName: org.name, + organizationSlug: org.slug, + }), + ]); + }); + + it("resolves the claimable link only for the invited wallet", async () => { + const { org, nearAccountId, invitation, handlers } = await walletInvitation(); + const invitee = await createTestUser(services.services); + const stranger = await createTestUser(services.services); + await linkWallet(invitee, nearAccountId); + + const claimed = await handlers.invitations.getInvitation({ + input: { id: invitation.id }, + context: { reqHeaders: invitee.reqHeaders }, + }); + expect(claimed).toMatchObject({ + id: invitation.id, + nearAccountId, + organizationName: org.name, + organizationSlug: org.slug, + }); + const hidden = await handlers.invitations.getInvitation({ + input: { id: invitation.id }, + context: { reqHeaders: stranger.reqHeaders }, + }); + expect(hidden).toBeNull(); + }); + + it("can be declined by the invited wallet and canceled by the organization", async () => { + const declined = await walletInvitation(); + const invitee = await createTestUser(services.services); + await linkWallet(invitee, declined.nearAccountId); + await declined.handlers.invitations.rejectNearInvitation({ + input: { invitationId: declined.invitation.id }, + context: { reqHeaders: invitee.reqHeaders }, + }); + expect( + await declined.handlers.invitations.listUserInvitations({ + context: { reqHeaders: invitee.reqHeaders }, + }), + ).toEqual([]); + + const canceled = await walletInvitation(); + await canceled.handlers.invitations.cancelInvitation({ + input: { invitationId: canceled.invitation.id }, + context: { reqHeaders: canceled.owner.reqHeaders }, + }); + const listed = await canceled.handlers.invitations.listInvitations({ + input: { organizationId: canceled.org.id }, + context: { reqHeaders: canceled.owner.reqHeaders }, + }); + expect(listed).toEqual([ + expect.objectContaining({ id: canceled.invitation.id, status: "canceled" }), + ]); + }); +}); diff --git a/plugins/auth/tests/unit/utils.test.ts b/plugins/auth/tests/unit/utils.test.ts index 13285e973..c3b8f15fa 100644 --- a/plugins/auth/tests/unit/utils.test.ts +++ b/plugins/auth/tests/unit/utils.test.ts @@ -78,6 +78,12 @@ describe("createHeaders", () => { expect(headers.get("x-api-key")).toBe("abc"); }); + it("preserves headers passed as a native Headers instance", () => { + const headers = createHeaders(new Headers({ cookie: "session=abc", origin: "https://app" })); + expect(headers.get("cookie")).toBe("session=abc"); + expect(headers.get("origin")).toBe("https://app"); + }); + it("returns empty Headers for undefined", () => { const headers = createHeaders(); expect(headers).toBeInstanceOf(Headers); diff --git a/tests/regression/browser/specs/team-workspace.spec.ts b/tests/regression/browser/specs/team-workspace.spec.ts index 34b12bd5f..e2f193d8b 100644 --- a/tests/regression/browser/specs/team-workspace.spec.ts +++ b/tests/regression/browser/specs/team-workspace.spec.ts @@ -19,7 +19,8 @@ async function authFetch(path: string, cookie: string, body?: unknown) { async function signInAnonymously() { const response = await fetch(`${baseUrl}/api/auth/sign-in/anonymous`, { method: "POST", - headers: { origin: baseUrl }, + headers: { "content-type": "application/json", origin: baseUrl }, + body: JSON.stringify({}), }); if (!response.ok) throw new Error(`anonymous sign-in failed: ${response.status}`); return response.headers diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/$slug.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/$slug.tsx index 982e75afa..086556265 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/$slug.tsx +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/$slug.tsx @@ -9,6 +9,7 @@ import { type Organization, type SessionData, sessionQueryOptions, + useApiClient, useAuthClient, } from "@/app"; import { @@ -45,10 +46,10 @@ import { useOrganizationTeams } from "./-organization-teams"; import { TeamsTab } from "./-teams-tab"; type AuthClientType = import("@/app").AuthClient; +type ApiClientType = import("@/app").ApiClient; type MembersResponse = Awaited>; type MemberItem = NonNullable["members"][number]; -type InvitationsResponse = Awaited>; -type InvitationItem = NonNullable[number]; +type InvitationItem = Awaited>[number]; async function handleCopyApiKey(value: string, message = "API key copied") { try { @@ -84,6 +85,7 @@ function OrganizationDetail() { const router = useRouter(); const { slug: orgSlug } = Route.useParams(); const auth = useAuthClient(); + const apiClient = useApiClient(); const { runtimeConfig } = Route.useRouteContext(); const gatewayId = getActiveRuntime(runtimeConfig)?.gatewayId ?? ""; const baseAccount = getAccount(runtimeConfig); @@ -116,11 +118,7 @@ function OrganizationDetail() { useQuery({ queryKey: orgInvitationsQueryKey(orgId), queryFn: async (): Promise => { - const { data, error } = await auth.organization.listInvitations({ - query: { organizationId: orgId }, - }); - if (error) throw new Error(error.message); - return (data ?? []) as InvitationItem[]; + return apiClient.auth.listInvitations({ organizationId: orgId }); }, enabled: !!orgId, }).data ?? []; @@ -153,7 +151,7 @@ function OrganizationDetail() { (org?.metadata as { isPersonal?: boolean } | null | undefined)?.isPersonal === true : false; const { cancelInvitationMutation, inviteMutation, resendInvitationMutation } = - useOrganizationInvitationActions(auth, orgId); + useOrganizationInvitationActions(apiClient, orgId); const { createApiKeyMutation, deleteApiKeyMutation } = useOrganizationApiKeyActions( auth, orgId, diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invitation-card.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invitation-card.tsx index 6424cf881..b7393e0d7 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invitation-card.tsx +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invitation-card.tsx @@ -1,10 +1,11 @@ -import { Mail, RefreshCw, Trash2, UsersRound } from "lucide-react"; +import { Mail, RefreshCw, Trash2, UsersRound, Wallet } from "lucide-react"; import { Button, Card, CardContent } from "@/components"; export interface InvitationCardInvitation { id: string; email: string; - role: string; + nearAccountId?: string | null; + role: string | null; status: string; expiresAt: string | Date; teamId?: string | null; @@ -31,8 +32,14 @@ export function InvitationCard({
- -
{invitation.email}
+ {invitation.nearAccountId ? ( + + ) : ( + + )} +
+ {invitation.nearAccountId ?? invitation.email} +
{invitation.role}
{teamName && ( diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.test.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.test.tsx index 5bacc069d..d3c4ef915 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.test.tsx +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.test.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { InvitationCard } from "./-invitation-card"; -import { InviteMemberForm } from "./-invite-member-form"; +import { detectInviteIdentifier, InviteMemberForm } from "./-invite-member-form"; const teams = [ { id: "team-fin", name: "Finance" }, @@ -18,6 +18,22 @@ function renderForm() { afterEach(cleanup); describe("InviteMemberForm team targeting", () => { + it("detects email, named NEAR, and implicit NEAR identifiers", () => { + expect(detectInviteIdentifier("hire@example.com")).toEqual({ + kind: "email", + value: "hire@example.com", + }); + expect(detectInviteIdentifier("Alice.NEAR")).toEqual({ + kind: "near", + value: "alice.near", + }); + expect(detectInviteIdentifier("f".repeat(64))).toEqual({ + kind: "near", + value: "f".repeat(64), + }); + expect(detectInviteIdentifier("not an identifier")).toBeNull(); + }); + it("offers the organization's teams with no team selected by default", () => { renderForm(); const picker = screen.getByTestId("invite-team-select") as HTMLSelectElement; @@ -62,6 +78,24 @@ describe("InviteMemberForm team targeting", () => { await waitFor(() => expect(input.value).toBe("")); }); + it("sends a wallet invitation and gives wallet-specific feedback", async () => { + const { onInvite } = renderForm(); + const input = screen.getByTestId("invite-identifier-input") as HTMLInputElement; + + fireEvent.change(input, { target: { value: "operator.near" } }); + expect(screen.getByTestId("invite-identifier-feedback").textContent).toContain( + "NEAR invitation", + ); + fireEvent.click(screen.getByTestId("invite-submit-button")); + + await waitFor(() => + expect(onInvite).toHaveBeenCalledWith({ + nearAccountId: "operator.near", + role: "member", + }), + ); + }); + it("keeps the input when sending fails", async () => { const onInvite = vi.fn().mockRejectedValue(new Error("nope")); render(); diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.tsx index 43b3c29df..baae58718 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.tsx +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-invite-member-form.tsx @@ -4,11 +4,35 @@ import { Button, Card, Input } from "@/components"; export type InviteRole = "admin" | "member"; export interface InviteMemberValues { - email: string; + email?: string; + nearAccountId?: string; role: InviteRole; teamId?: string; } +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const IMPLICIT_ACCOUNT_PATTERN = /^[0-9a-f]{64}$/i; +const ETH_IMPLICIT_ACCOUNT_PATTERN = /^0x[0-9a-f]{40}$/i; +const NAMED_ACCOUNT_PATTERN = /^(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$/i; + +export function detectInviteIdentifier( + value: string, +): { kind: "email"; value: string } | { kind: "near"; value: string } | null { + const trimmed = value.trim(); + if (EMAIL_PATTERN.test(trimmed)) return { kind: "email", value: trimmed }; + + const normalized = trimmed.toLowerCase(); + if ( + IMPLICIT_ACCOUNT_PATTERN.test(normalized) || + ETH_IMPLICIT_ACCOUNT_PATTERN.test(normalized) || + (normalized.length >= 2 && normalized.length <= 64 && NAMED_ACCOUNT_PATTERN.test(normalized)) + ) { + return { kind: "near", value: normalized }; + } + + return null; +} + const selectClassName = "w-full px-3 py-2 text-sm bg-card text-foreground border-2 border-inset border-border-strong rounded-[8px] outline-none focus:ring-2 focus:ring-ring"; @@ -24,7 +48,7 @@ export function InviteMemberForm({ const [identifier, setIdentifier] = useState(""); const [role, setRole] = useState("member"); const [teamId, setTeamId] = useState(""); - const email = identifier.trim(); + const detectedIdentifier = detectInviteIdentifier(identifier); return ( @@ -35,9 +59,15 @@ export function InviteMemberForm({ className="space-y-4" onSubmit={async (event) => { event.preventDefault(); - if (!email) return; + if (!detectedIdentifier) return; try { - await onInvite({ email, role, ...(teamId ? { teamId } : {}) }); + await onInvite({ + ...(detectedIdentifier.kind === "email" + ? { email: detectedIdentifier.value } + : { nearAccountId: detectedIdentifier.value }), + role, + ...(teamId ? { teamId } : {}), + }); setIdentifier(""); setTeamId(""); } catch {} @@ -45,11 +75,11 @@ export function InviteMemberForm({ >
setIdentifier(event.target.value)} - placeholder="email@example.com" - aria-label="Email" + placeholder="email@example.com or alice.near" + aria-label="Email or NEAR account" data-testid="invite-identifier-input" />
+ {detectedIdentifier?.kind === "near" && ( + + )}
{ await act(async () => { await result.current.inviteMutation.mutateAsync({ nearAccountId: "alice.near", + nearNetwork: "testnet", role: "member", teamId: "team-ops", }); @@ -45,12 +46,48 @@ describe("organization invitation actions", () => { expect(apiClient.auth.inviteMember).toHaveBeenCalledWith({ organizationId: "org-1", nearAccountId: "alice.near", + nearNetwork: "testnet", role: "member", teamId: "team-ops", }); expect(toast.success).toHaveBeenCalledWith("Invitation created for alice.near"); }); + it("preserves the wallet network on resend and refuses ambiguous legacy invitations", async () => { + const inviteMember = vi.fn().mockResolvedValue({ id: "inv-1" }); + const apiClient = { auth: { inviteMember } } as unknown as ApiClient; + const { result } = renderActions(apiClient); + const invitation = { + id: "inv-1", + email: "wallet@near-wallet.invalid", + nearAccountId: "alice.near", + nearNetwork: "testnet" as const, + role: "member", + status: "pending", + expiresAt: new Date(), + }; + await act(async () => { + await result.current.resendInvitationMutation.mutateAsync(invitation); + }); + expect(inviteMember).toHaveBeenCalledWith({ + organizationId: "org-1", + nearAccountId: "alice.near", + nearNetwork: "testnet", + role: "member", + resend: true, + }); + inviteMember.mockClear(); + await act(async () => { + await expect( + result.current.resendInvitationMutation.mutateAsync({ + ...invitation, + nearNetwork: null, + }), + ).rejects.toThrow(/reissue/i); + }); + expect(inviteMember).not.toHaveBeenCalled(); + }); + it("cancels through the wallet-aware auth contract", async () => { const apiClient = { auth: { diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-invitations.ts b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-invitations.ts index edbb61b6d..dca75491b 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-invitations.ts +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-invitations.ts @@ -15,7 +15,9 @@ export function useOrganizationInvitationActions(apiClient: ApiClient, orgId: st organizationId: orgId, role: values.role, ...(values.email ? { email: values.email } : {}), - ...(values.nearAccountId ? { nearAccountId: values.nearAccountId } : {}), + ...(values.nearAccountId + ? { nearAccountId: values.nearAccountId, nearNetwork: values.nearNetwork } + : {}), ...(values.teamId ? { teamId: values.teamId } : {}), }); }, @@ -37,11 +39,17 @@ export function useOrganizationInvitationActions(apiClient: ApiClient, orgId: st }); const resendInvitationMutation = useMutation({ mutationFn: async (invitation: InvitationCardInvitation) => { + if (invitation.nearAccountId && !invitation.nearNetwork) { + throw new Error("Cancel and reissue this wallet invitation with an explicit network."); + } return apiClient.auth.inviteMember({ organizationId: orgId, role: (invitation.role ?? "member") as "admin" | "member" | "owner", ...(invitation.nearAccountId - ? { nearAccountId: invitation.nearAccountId } + ? { + nearAccountId: invitation.nearAccountId, + nearNetwork: invitation.nearNetwork ?? undefined, + } : { email: invitation.email }), ...(invitation.teamId ? { teamId: invitation.teamId } : {}), resend: true, diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-teams.test.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-teams.test.tsx new file mode 100644 index 000000000..9135c079d --- /dev/null +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-teams.test.tsx @@ -0,0 +1,216 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ApiClient, AuthClient } from "@/app"; +import { useOrganizationTeams } from "./-organization-teams"; + +const mocks = vi.hoisted(() => ({ + apiClient: null as ApiClient | null, + auth: null as AuthClient | null, + invalidateRouter: vi.fn(), + toastSuccess: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock("@/app", () => ({ + useApiClient: () => mocks.apiClient, + useAuthClient: () => mocks.auth, + sessionQueryKey: ["session"], +})); + +vi.mock("@tanstack/react-router", () => ({ + useRouter: () => ({ invalidate: mocks.invalidateRouter }), +})); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => mocks.toastSuccess(...args), + error: (...args: unknown[]) => mocks.toastError(...args), + }, +})); + +function renderTeams(membershipsEnabled = false) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + const result = renderHook( + ({ enabled }: { enabled: boolean }) => useOrganizationTeams("org-1", enabled), + { initialProps: { enabled: membershipsEnabled }, wrapper }, + ); + return { ...result, queryClient }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("useOrganizationTeams", () => { + it.each([ + false, + true, + ])("clears the selected team before deletion and refreshes even if deletion fails: %s", async (fails) => { + let activeTeamId: string | null = "team-1"; + const getSession = vi.fn(async () => ({ data: { session: { activeTeamId } }, error: null })); + const setActiveTeam = vi.fn(async () => { + activeTeamId = null; + return { error: null }; + }); + const deleteTeam = vi.fn(async () => { + expect(activeTeamId).toBeNull(); + if (fails) throw new Error("Team deletion failed"); + return { success: true }; + }); + mocks.auth = { getSession, organization: { setActiveTeam } } as unknown as AuthClient; + mocks.apiClient = { + auth: { + listTeams: vi.fn().mockResolvedValue([]), + deleteTeam, + }, + } as unknown as ApiClient; + mocks.invalidateRouter.mockResolvedValue(undefined); + const hook = renderTeams(); + await act(async () => { + const deletion = hook.result.current.deleteTeam.mutateAsync("team-1"); + if (fails) await expect(deletion).rejects.toThrow("Team deletion failed"); + else await deletion; + }); + expect(setActiveTeam).toHaveBeenCalledWith({ teamId: null }); + expect(deleteTeam).toHaveBeenCalledOnce(); + expect(mocks.invalidateRouter).toHaveBeenCalledOnce(); + expect(getSession).toHaveBeenCalledTimes(2); + hook.unmount(); + hook.queryClient.clear(); + }); + + it("keeps team names available without loading memberships until the Teams tab is active", async () => { + const teams = Array.from({ length: 20 }, (_, index) => ({ + id: `team-${index}`, + name: `Team ${index}`, + areas: ["things"], + })); + const listTeamMembers = vi.fn(async ({ teamId }: { teamId: string }) => [ + { userId: `${teamId}-member` }, + ]); + mocks.apiClient = { + auth: { + listTeams: vi.fn().mockResolvedValue(teams), + listTeamMembers, + }, + } as unknown as ApiClient; + mocks.auth = { getSession: vi.fn() } as unknown as AuthClient; + + const hook = renderTeams(); + await waitFor(() => expect(hook.result.current.teams).toHaveLength(20)); + expect(listTeamMembers).not.toHaveBeenCalled(); + expect(hook.result.current.teams[0]).toMatchObject({ + id: "team-0", + name: "Team 0", + memberStatus: "unloaded", + }); + + hook.rerender({ enabled: true }); + await waitFor(() => expect(listTeamMembers).toHaveBeenCalledTimes(20)); + await waitFor(() => + expect(hook.result.current.teams[0]).toMatchObject({ + memberStatus: "success", + memberUserIds: ["team-0-member"], + }), + ); + + hook.rerender({ enabled: false }); + hook.rerender({ enabled: true }); + await waitFor(() => expect(listTeamMembers).toHaveBeenCalledTimes(20)); + hook.unmount(); + hook.queryClient.clear(); + }); + + it("reports a membership transport error and retries the membership query", async () => { + let shouldFail = true; + const listTeamMembers = vi.fn(async () => { + if (shouldFail) throw new Error("membership transport unavailable"); + return [{ userId: "user-1" }]; + }); + mocks.apiClient = { + auth: { + listTeams: vi + .fn() + .mockResolvedValue([{ id: "team-1", name: "Operations", areas: ["things"] }]), + listTeamMembers, + }, + } as unknown as ApiClient; + mocks.auth = { getSession: vi.fn() } as unknown as AuthClient; + + const hook = renderTeams(true); + await waitFor(() => + expect(hook.result.current.teams[0]).toMatchObject({ + memberStatus: "error", + memberUserIds: [], + }), + ); + expect(hook.result.current.teams[0].memberError).toBe("membership transport unavailable"); + + shouldFail = false; + await act(async () => { + await hook.result.current.retryTeamMembers("team-1"); + }); + await waitFor(() => + expect(hook.result.current.teams[0]).toMatchObject({ + memberStatus: "success", + memberUserIds: ["user-1"], + }), + ); + expect(listTeamMembers).toHaveBeenCalledTimes(2); + hook.unmount(); + hook.queryClient.clear(); + }); + + it("refreshes after a committed team change and can retry refresh without repeating it", async () => { + let shouldFailRefresh = false; + const updateTeam = vi.fn().mockResolvedValue({ + id: "team-1", + name: "Renamed", + areas: ["things"], + }); + const listTeams = vi.fn(async () => { + if (shouldFailRefresh) throw new Error("team list transport unavailable"); + return [{ id: "team-1", name: "Operations", areas: ["things"] }]; + }); + mocks.apiClient = { + auth: { + listTeams, + listTeamMembers: vi.fn().mockResolvedValue([]), + updateTeam, + }, + } as unknown as ApiClient; + mocks.auth = { + getSession: vi.fn().mockResolvedValue({ data: { user: { id: "user-1" } }, error: null }), + } as unknown as AuthClient; + mocks.invalidateRouter.mockResolvedValue(undefined); + + const hook = renderTeams(); + await waitFor(() => expect(hook.result.current.teams).toHaveLength(1)); + shouldFailRefresh = true; + await expect( + act(async () => { + await hook.result.current.updateTeam.mutateAsync({ teamId: "team-1", name: "Renamed" }); + }), + ).rejects.toThrow("Workspace refresh failed: team list transport unavailable"); + expect(updateTeam).toHaveBeenCalledOnce(); + + shouldFailRefresh = false; + await act(async () => { + await hook.result.current.refreshWorkspace(); + }); + expect(updateTeam).toHaveBeenCalledOnce(); + expect(listTeams).toHaveBeenCalledTimes(3); + expect(mocks.auth.getSession).toHaveBeenCalledTimes(2); + expect(mocks.invalidateRouter).toHaveBeenCalledTimes(1); + hook.unmount(); + hook.queryClient.clear(); + }); +}); diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-teams.ts b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-teams.ts index d11b1b82d..5fd7be0f3 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-teams.ts +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-organization-teams.ts @@ -1,12 +1,28 @@ -import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + type QueryKey, + useMutation, + useQueries, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { useRouter } from "@tanstack/react-router"; +import { useRef } from "react"; import { toast } from "sonner"; -import { useApiClient } from "@/app"; +import { useApiClient, useAuthClient } from "@/app"; +import { + createWorkspaceSynchronization, + reportWorkspaceRefreshError, +} from "@/lib/workspace-synchronization"; import { orgTeamMembersQueryKey, orgTeamsQueryKey } from "./-organization-query-keys"; -import type { TeamsTabTeam } from "./-teams-tab"; +import type { TeamMembershipStatus, TeamsTabTeam } from "./-teams-tab"; -export function useOrganizationTeams(orgId: string) { +export function useOrganizationTeams(orgId: string, membershipsEnabled = false) { const apiClient = useApiClient(); + const auth = useAuthClient(); const queryClient = useQueryClient(); + const router = useRouter(); + const synchronization = createWorkspaceSynchronization({ auth, queryClient, router }); + const lastRefreshKeys = useRef([]); const teamsQuery = useQuery({ queryKey: orgTeamsQueryKey(orgId), queryFn: () => apiClient.auth.listTeams({ organizationId: orgId }), @@ -17,6 +33,8 @@ export function useOrganizationTeams(orgId: string) { queries: teamList.map((team) => ({ queryKey: orgTeamMembersQueryKey(team.id), queryFn: () => apiClient.auth.listTeamMembers({ teamId: team.id }), + enabled: membershipsEnabled, + staleTime: 30 * 1000, })), }); const teams: TeamsTabTeam[] = teamList.map((team, index) => ({ @@ -24,19 +42,29 @@ export function useOrganizationTeams(orgId: string) { name: team.name, areas: team.areas, memberUserIds: (memberQueries[index]?.data ?? []).map((member) => member.userId), + memberStatus: resolveMembershipStatus(membershipsEnabled, memberQueries[index]), + memberError: + memberQueries[index]?.error instanceof Error ? memberQueries[index].error.message : undefined, })); - const invalidateTeams = () => - queryClient.invalidateQueries({ queryKey: orgTeamsQueryKey(orgId) }); - const invalidateMembers = (teamId: string) => - queryClient.invalidateQueries({ queryKey: orgTeamMembersQueryKey(teamId) }); - const onError = (fallback: string) => (error: Error) => toast.error(error.message || fallback); + const synchronizeAfterMutation = (queryKeys: readonly QueryKey[]) => { + lastRefreshKeys.current = queryKeys; + return synchronization.synchronize({ queryKeys }); + }; + const refreshWorkspace = () => + synchronization.synchronize({ queryKeys: lastRefreshKeys.current }); + const onError = (fallback: string) => (error: Error) => { + if (reportWorkspaceRefreshError(error, refreshWorkspace, onError(fallback))) { + return; + } + toast.error(error.message || fallback); + }; const createTeam = useMutation({ mutationFn: (name: string) => apiClient.auth.createTeam({ name, organizationId: orgId }), onSuccess: async (team) => { + await synchronizeAfterMutation([orgTeamsQueryKey(orgId)]); toast.success(`Team "${team.name}" created`); - await invalidateTeams(); }, onError: onError("Failed to create team"), }); @@ -50,27 +78,41 @@ export function useOrganizationTeams(orgId: string) { ...(input.areas !== undefined ? { areas: input.areas } : {}), }, }), - onSuccess: invalidateTeams, + onSuccess: () => synchronizeAfterMutation([orgTeamsQueryKey(orgId)]), onError: onError("Failed to update team"), }); const deleteTeam = useMutation({ - mutationFn: (teamId: string) => apiClient.auth.deleteTeam({ teamId, organizationId: orgId }), + mutationFn: async (teamId: string) => { + const { data, error } = await auth.getSession({ query: { disableCookieCache: true } }); + if (error) throw new Error(error.message); + const wasActive = data?.session.activeTeamId === teamId; + if (wasActive) { + const { error: clearError } = await auth.organization.setActiveTeam({ teamId: null }); + if (clearError) throw new Error(clearError.message); + } + try { + return await apiClient.auth.deleteTeam({ teamId, organizationId: orgId }); + } catch (deleteError) { + if (wasActive) await synchronizeAfterMutation([orgTeamsQueryKey(orgId)]); + throw deleteError; + } + }, onSuccess: async () => { + await synchronizeAfterMutation([orgTeamsQueryKey(orgId)]); toast.success("Team deleted"); - await invalidateTeams(); }, onError: onError("Failed to delete team"), }); const addTeamMember = useMutation({ mutationFn: (input: { teamId: string; userId: string }) => apiClient.auth.addTeamMember({ ...input, organizationId: orgId }), - onSuccess: (_, input) => invalidateMembers(input.teamId), + onSuccess: (_, input) => synchronizeAfterMutation([orgTeamMembersQueryKey(input.teamId)]), onError: onError("Failed to add team member"), }); const removeTeamMember = useMutation({ mutationFn: (input: { teamId: string; userId: string }) => apiClient.auth.removeTeamMember({ ...input, organizationId: orgId }), - onSuccess: (_, input) => invalidateMembers(input.teamId), + onSuccess: (_, input) => synchronizeAfterMutation([orgTeamMembersQueryKey(input.teamId)]), onError: onError("Failed to remove team member"), }); @@ -87,5 +129,22 @@ export function useOrganizationTeams(orgId: string) { deleteTeam, addTeamMember, removeTeamMember, + membershipsEnabled, + refreshWorkspace, + retryTeamMembers: (teamId: string) => + queryClient.refetchQueries( + { queryKey: orgTeamMembersQueryKey(teamId), type: "active" }, + { throwOnError: true }, + ), }; } + +function resolveMembershipStatus( + membershipsEnabled: boolean, + query: { data: unknown; isError: boolean } | undefined, +): TeamMembershipStatus { + if (!membershipsEnabled) return "unloaded"; + if (query?.isError) return "error"; + if (query?.data) return "success"; + return "loading"; +} diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-team-card.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-team-card.tsx index 8cec834ca..42e6df693 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-team-card.tsx +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/-team-card.tsx @@ -10,8 +10,12 @@ export interface TeamCardTeam { name: string; areas: string[]; memberUserIds: string[]; + memberStatus?: TeamMembershipStatus; + memberError?: string; } +export type TeamMembershipStatus = "unloaded" | "loading" | "success" | "error"; + function memberLabel(member: MemberCardMember | undefined, userId: string) { return member?.user?.name || member?.user?.email || userId; } @@ -23,6 +27,7 @@ export function TeamCard({ onAreasChange, onDelete, onRemoveMember, + onRetryMembers, onRename, orgMembers, team, @@ -33,6 +38,7 @@ export function TeamCard({ onAreasChange: (areas: string[]) => void; onDelete: () => void; onRemoveMember: (userId: string) => void; + onRetryMembers?: () => void; onRename: (name: string) => void; orgMembers: MemberCardMember[]; team: TeamCardTeam; @@ -41,7 +47,11 @@ export function TeamCard({ const [draftName, setDraftName] = useState(team.name); const [selectedUserId, setSelectedUserId] = useState(""); const membersByUserId = new Map(orgMembers.map((member) => [member.userId, member])); - const candidates = orgMembers.filter((member) => !team.memberUserIds.includes(member.userId)); + const memberStatus = team.memberStatus ?? "success"; + const membersLoaded = memberStatus === "success"; + const candidates = membersLoaded + ? orgMembers.filter((member) => !team.memberUserIds.includes(member.userId)) + : []; const toggleArea = (area: string, checked: boolean) => { const next = checked @@ -86,9 +96,11 @@ export function TeamCard({ ) : (
{team.name}
-
- {team.memberUserIds.length} member{team.memberUserIds.length === 1 ? "" : "s"} -
+ {memberStatus === "success" && ( +
+ {team.memberUserIds.length} member{team.memberUserIds.length === 1 ? "" : "s"} +
+ )}
)} {canManage && !isRenaming && ( @@ -148,14 +160,36 @@ export function TeamCard({
Members
- {team.memberUserIds.length > 0 ? ( + {memberStatus === "loading" ? ( +

+ Loading members... +

+ ) : memberStatus === "error" ? ( +
+

+ {team.memberError || "Unable to load team members."} +

+ {onRetryMembers && ( + + )} +
+ ) : memberStatus === "unloaded" ? ( +

Members are not loaded yet

+ ) : team.memberUserIds.length > 0 ? (
    {team.memberUserIds.map((userId) => (
  • {memberLabel(membersByUserId.get(userId), userId)} - {canManage && ( + {canManage && membersLoaded && ( diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/invites.$id.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/invites.$id.tsx index 969e8ec0a..e4842ff0a 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/orgs/invites.$id.tsx +++ b/ui/src/routes/_layout/_authenticated/_dashboard/orgs/invites.$id.tsx @@ -1,10 +1,10 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; import { CheckCircle, XCircle } from "lucide-react"; import { toast } from "sonner"; -import { getAppName, sessionQueryKey, useApiClient, useAuthClient } from "@/app"; +import { getAppName, useApiClient, useAuthClient } from "@/app"; import { Badge, Button, Card, CardContent, PageContainer, PageHeader } from "@/components"; -import { teamWorkspaceQueryKey } from "@/lib/team-workspace"; +import { useInvitationActions } from "./-use-invitation-actions"; export const Route = createFileRoute("/_layout/_authenticated/_dashboard/orgs/invites/$id")({ head: () => ({ @@ -26,7 +26,6 @@ function AcceptInvitation() { const router = useRouter(); const auth = useAuthClient(); const apiClient = useApiClient(); - const queryClient = useQueryClient(); const { data: invitation, isLoading } = useQuery({ queryKey: ["invitation", id], @@ -35,50 +34,24 @@ function AcceptInvitation() { retry: false, }); - const acceptMutation = useMutation({ - mutationFn: async () => { - if (invitation?.nearAccountId) { - await apiClient.auth.acceptNearInvitation({ invitationId: id }); - } else { - await apiClient.auth.acceptInvitation({ invitationId: id }); - } - const { data: session, error } = await auth.getSession({ - query: { disableCookieCache: true }, - }); - if (error) throw new Error(error.message); - queryClient.setQueryData(sessionQueryKey, session ?? null); - }, - onSuccess: async () => { + const { acceptMutation, rejectMutation } = useInvitationActions({ + apiClient, + auth, + onAccepted: async (acceptedInvitation) => { toast.success("Invitation accepted"); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["organizations"] }), - queryClient.invalidateQueries({ queryKey: ["session"] }), - queryClient.invalidateQueries({ queryKey: ["user-invitations"] }), - queryClient.invalidateQueries({ queryKey: teamWorkspaceQueryKey }), - ]); - await queryClient.refetchQueries({ queryKey: ["organizations"] }); - await router.navigate({ - to: "/orgs/$slug", - params: { slug: invitation?.organizationSlug ?? "" }, - }); - }, - onError: (error: Error) => toast.error(error.message || "Failed to accept invitation"), - }); - - const rejectMutation = useMutation({ - mutationFn: async () => { - if (invitation?.nearAccountId) { - await apiClient.auth.rejectNearInvitation({ invitationId: id }); + if (acceptedInvitation.organizationSlug) { + await router.navigate({ + to: "/orgs/$slug", + params: { slug: acceptedInvitation.organizationSlug }, + }); } else { - await apiClient.auth.rejectInvitation({ invitationId: id }); + await router.navigate({ to: "/orgs" }); } }, - onSuccess: async () => { + onRejected: async () => { toast.success("Invitation declined"); - await queryClient.invalidateQueries({ queryKey: ["user-invitations"] }); await router.navigate({ to: "/orgs" }); }, - onError: (error: Error) => toast.error(error.message || "Failed to decline invitation"), }); if (isLoading) { @@ -150,6 +123,12 @@ function AcceptInvitation() { {invitation.nearAccountId ?? invitation.email}
+ {invitation.nearAccountId && invitation.nearNetwork && ( +
+ network + {invitation.nearNetwork} +
+ )} {invitation.teamId && (
team @@ -167,11 +146,15 @@ function AcceptInvitation() {
-