diff --git a/AGENTS.md b/AGENTS.md index c957ca41ead..3c1c4188486 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,10 @@ pnpm dev 1. Keep changes company-scoped. Every domain entity should be scoped to a company and company boundaries must be enforced in routes/services. -Explicit exception: announcement dismissals are instance-wide user preferences, +Explicit exceptions: personal keyboard shortcut enablement is stored on the +authenticated user and applies across companies, like the user profile. Only +that user can read or change it; writes validate company membership for their +audit context. Announcement dismissals are instance-wide user preferences, keyed by user and announcement so they persist across companies. Their audit context must still validate company membership. The announcement publication-ID registry is instance-level feed metadata; it contains no company or user data. diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index e6b0505a7c8..0fb6b2aee32 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -128,14 +128,18 @@ async function seedValidWorktreeSource( const issueId = randomUUID(); const userId = options.userId ?? "user-existing"; const now = new Date(); - await db.insert(authUsers).values({ - id: userId, - email: userId === "local-board" ? "local@paperclip.local" : "existing@paperclip.ing", - name: userId === "local-board" ? "Board" : "Existing User", - emailVerified: true, - createdAt: now, - updatedAt: now, - }); + // This fixture also seeds a database before the latest migration. Use the + // historical columns explicitly so current ORM defaults do not require new + // columns that the worktree migration is supposed to add. + await db.$client` + INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at) + VALUES ( + ${userId}, + ${userId === "local-board" ? "Board" : "Existing User"}, + ${userId === "local-board" ? "local@paperclip.local" : "existing@paperclip.ing"}, + true, ${now.toISOString()}, ${now.toISOString()} + ) + `; if (options.includeCredentialAccount !== false) { await db.insert(authAccounts).values({ id: "credential-existing", diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 15c311c909b..cb18da50f92 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1702,3 +1702,19 @@ instance-level registry retains validated publication IDs, allowing offline dismissal retries after withdrawal while rejecting caller-invented IDs. It stores no announcement content, account data or interaction events. See [Announcements](ANNOUNCEMENTS.md) for API and publishing details. + +### Personal keyboard shortcut preference + +Keyboard shortcuts are off by default and are enabled in Settings → Profile. +The preference is stored on the signed-in user, applies across companies and +devices, and does not require instance administrator access. The local trusted +board user has the same preference. `GET /api/auth/preferences` returns only the +current board user's preference. `PATCH /api/auth/preferences` updates only that +user and requires an accessible `companyId` for the activity log, including viewer +memberships. The preference and audit record commit in one transaction. Both +requests require `expectedUserId` (GET query parameter or PATCH body) matching +the authenticated actor, so a cookie change cannot mix accounts in the cache. +Agents cannot +read or change these preferences. The legacy instance general setting is retained +for API compatibility but no longer controls shortcut behavior in the app; +users opt in individually after the upgrade. diff --git a/doc/SPEC.md b/doc/SPEC.md index aceab181b4e..28ec1662cda 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -603,3 +603,19 @@ company search share lexical matching and ranking. Known identifiers and direct title matches lead; current conversation and document content supplies supporting evidence. See [Task search relevance](SEARCH.md) for the evaluation rubric, matching contract and reproducible quality tests. + +### Personal keyboard shortcut preference + +Keyboard shortcuts are off by default and are enabled in Settings → Profile. +The preference is stored on the signed-in user, applies across companies and +devices, and does not require instance administrator access. The local trusted +board user has the same preference. `GET /api/auth/preferences` returns only the +current board user's preference. `PATCH /api/auth/preferences` updates only that +user and requires an accessible `companyId` for the activity log, including viewer +memberships. The preference and audit record commit in one transaction. Both +requests require `expectedUserId` (GET query parameter or PATCH body) matching +the authenticated actor, so a cookie change cannot mix accounts in the cache. +Agents cannot +read or change these preferences. The legacy instance general setting is retained +for API compatibility but no longer controls shortcut behavior in the app; +users opt in individually after the upgrade. diff --git a/packages/db/src/migrations/0284_petite_genesis.sql b/packages/db/src/migrations/0284_petite_genesis.sql new file mode 100644 index 00000000000..e90a7b5b04d --- /dev/null +++ b/packages/db/src/migrations/0284_petite_genesis.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "keyboard_shortcuts" boolean DEFAULT false NOT NULL; diff --git a/packages/db/src/migrations/meta/0279_snapshot.json b/packages/db/src/migrations/meta/0284_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0279_snapshot.json rename to packages/db/src/migrations/meta/0284_snapshot.json index 091efaf1ef8..e2d4cb653a2 100644 --- a/packages/db/src/migrations/meta/0279_snapshot.json +++ b/packages/db/src/migrations/meta/0284_snapshot.json @@ -1,6 +1,6 @@ { - "id": "4770ad6d-2cdc-45c4-af89-c300fd134d91", - "prevId": "94dca593-bcb1-4e7e-8311-42e788a539c2", + "id": "7db2ceec-ed62-4968-90c9-4c79206cfe34", + "prevId": "786cd4a9-366b-4934-be90-014e1ee15b51", "version": "7", "dialect": "postgresql", "tables": { @@ -2040,6 +2040,12 @@ "primaryKey": false, "notNull": false }, + "appearance": { + "name": "appearance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "status": { "name": "status", "type": "text", @@ -3296,6 +3302,13 @@ "primaryKey": false, "notNull": false }, + "keyboard_shortcuts": { + "name": "keyboard_shortcuts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -5695,6 +5708,12 @@ "notNull": true, "default": false }, + "communication_guidance": { + "name": "communication_guidance", + "type": "text", + "primaryKey": false, + "notNull": false + }, "state": { "name": "state", "type": "text", @@ -6605,6 +6624,13 @@ "primaryKey": false, "notNull": false }, + "communication_instructions": { + "name": "communication_instructions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, "allow_direct_messages": { "name": "allow_direct_messages", "type": "boolean", @@ -8090,6 +8116,519 @@ }, "isRLSEnabled": false }, + "public.chat_github_configurations": { + "name": "chat_github_configurations", + "schema": "", + "columns": { + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { + "name": "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", + "tableFrom": "chat_github_configurations", + "tableTo": "chat_endpoints", + "columnsFrom": [ + "company_id", + "endpoint_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chat_github_config_revision_check": { + "name": "chat_github_config_revision_check", + "value": "\"chat_github_configurations\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.chat_github_registrations": { + "name": "chat_github_registrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trusted_origin": { + "name": "trusted_origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_github_registration_state_uq": { + "name": "chat_github_registration_state_uq", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_github_registration_endpoint_idx": { + "name": "chat_github_registration_endpoint_idx", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { + "name": "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", + "tableFrom": "chat_github_registrations", + "tableTo": "chat_endpoints", + "columnsFrom": [ + "company_id", + "endpoint_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chat_github_registration_status_check": { + "name": "chat_github_registration_status_check", + "value": "\"chat_github_registrations\".\"status\" in ('pending', 'exchanging', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.chat_github_reviews": { + "name": "chat_github_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pull_number": { + "name": "pull_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "configuration_revision": { + "name": "configuration_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "policy_snapshot": { + "name": "policy_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "assessment": { + "name": "assessment", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "conclusion": { + "name": "conclusion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_url": { + "name": "check_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_id": { + "name": "summary_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_url": { + "name": "summary_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publication_receipts": { + "name": "publication_receipts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "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": { + "chat_github_reviews_delivery_uq": { + "name": "chat_github_reviews_delivery_uq", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_github_reviews_pull_idx": { + "name": "chat_github_reviews_pull_idx", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pull_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_github_reviews_task_idx": { + "name": "chat_github_reviews_task_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_github_reviews_run_id_heartbeat_runs_id_fk": { + "name": "chat_github_reviews_run_id_heartbeat_runs_id_fk", + "tableFrom": "chat_github_reviews", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { + "name": "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk", + "tableFrom": "chat_github_reviews", + "tableTo": "chat_endpoints", + "columnsFrom": [ + "company_id", + "endpoint_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk": { + "name": "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk", + "tableFrom": "chat_github_reviews", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_github_reviews_company_id_uq": { + "name": "chat_github_reviews_company_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "chat_github_review_pull_number_check": { + "name": "chat_github_review_pull_number_check", + "value": "\"chat_github_reviews\".\"pull_number\" > 0" + }, + "chat_github_review_state_check": { + "name": "chat_github_review_state_check", + "value": "\"chat_github_reviews\".\"state\" in ('queued', 'running', 'completed', 'incomplete', 'error', 'superseded', 'manual_required')" + } + }, + "isRLSEnabled": false + }, "public.chat_teams_file_transfers": { "name": "chat_teams_file_transfers", "schema": "", @@ -37410,6 +37949,26 @@ "notNull": true, "default": true }, + "setup_pending": { + "name": "setup_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_webhook_delivery": { + "name": "last_webhook_delivery", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "cron_expression": { "name": "cron_expression", "type": "text", @@ -37671,6 +38230,100 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.routine_webhook_test_receipts": { + "name": "routine_webhook_test_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "delivery_key_hash": { + "name": "delivery_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routine_webhook_test_receipts_delivery_uq": { + "name": "routine_webhook_test_receipts_delivery_uq", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_webhook_test_receipts_company_id_companies_id_fk": { + "name": "routine_webhook_test_receipts_company_id_companies_id_fk", + "tableFrom": "routine_webhook_test_receipts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk": { + "name": "routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk", + "tableFrom": "routine_webhook_test_receipts", + "tableTo": "routine_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.routines": { "name": "routines", "schema": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 7a6ecc85f28..a46ca4cfb44 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1975,6 +1975,13 @@ "when": 1790018362070, "tag": "0283_jittery_psynapse", "breakpoints": true + }, + { + "idx": 284, + "version": "7", + "when": 1790439010225, + "tag": "0284_petite_genesis", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index 1e82b8a1256..faae5df7076 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -6,6 +6,7 @@ export const authUsers = pgTable("user", { email: text("email").notNull(), emailVerified: boolean("email_verified").notNull().default(false), image: text("image"), + keyboardShortcuts: boolean("keyboard_shortcuts").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }).notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f3161afcb05..9ae73b45045 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2329,6 +2329,10 @@ export { resolveCliAuthChallengeSchema, createBoardApiKeySchema, currentUserProfileSchema, + currentUserPreferencesSchema, + updateCurrentUserPreferencesSchema, + type CurrentUserPreferences, + type UpdateCurrentUserPreferences, authSessionSchema, updateCurrentUserProfileSchema, updateCompanyMemberSchema, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 8bb10f117e7..50f653d325f 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -30,6 +30,7 @@ export type InstanceExecutionMode = "kubernetes" | "any"; export interface InstanceGeneralSettings { censorUsernameInLogs: boolean; + /** @deprecated Legacy instance value. Use /auth/preferences for personal shortcuts. */ keyboardShortcuts: boolean; feedbackDataSharingPreference: FeedbackDataSharingPreference; backupRetention: BackupRetentionPolicy; diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index c046b84d56d..9623aa74eca 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -220,3 +220,15 @@ export const updateCurrentUserProfileSchema = z.object({ }); export type UpdateCurrentUserProfile = z.infer; + +// Personal preferences are shared across companies, but only the signed-in +// user can read or change them. companyId supplies the mutation audit context. +export const currentUserPreferencesSchema = z.object({ + keyboardShortcuts: z.boolean(), +}); +export const updateCurrentUserPreferencesSchema = currentUserPreferencesSchema.extend({ + expectedUserId: z.string().min(1), + companyId: z.string().uuid(), +}).strict(); +export type CurrentUserPreferences = z.infer; +export type UpdateCurrentUserPreferences = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index c6f87a08070..09c4fef7c4c 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -756,6 +756,10 @@ export { resolveCliAuthChallengeSchema, createBoardApiKeySchema, currentUserProfileSchema, + currentUserPreferencesSchema, + updateCurrentUserPreferencesSchema, + type CurrentUserPreferences, + type UpdateCurrentUserPreferences, authSessionSchema, updateCurrentUserProfileSchema, updateCompanyMemberSchema, diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 22f40613943..479b1418a5d 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -956,3 +956,10 @@ describe("heartbeat run ID OpenAPI contract", () => { expect(checked).toBe(12); }); }); + +it("documents the account binding required for preference reads", () => { + const operation = buildOpenApiSpec().paths["/api/auth/preferences"]?.get; + expect(operation?.parameters).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "expectedUserId", in: "query", required: true }), + ])); +}); diff --git a/server/src/__tests__/user-preferences-routes.test.ts b/server/src/__tests__/user-preferences-routes.test.ts new file mode 100644 index 00000000000..42a4ef249a9 --- /dev/null +++ b/server/src/__tests__/user-preferences-routes.test.ts @@ -0,0 +1,143 @@ +import express from "express"; +import request from "supertest"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { authRoutes } from "../routes/auth.js"; +import { errorHandler } from "../middleware/index.js"; + +const { logActivity, publishActivity } = vi.hoisted(() => ({ logActivity: vi.fn(), publishActivity: vi.fn() })); +vi.mock("../services/activity-log.js", () => ({ logActivity, publishActivity })); + +const companyId = "11111111-1111-4111-8111-111111111111"; +const actorExpectedId = "user-1"; +const otherCompanyId = "22222222-2222-4222-8222-222222222222"; +const dialect = new PgDialect(); + +function setup(actor: Express.Request["actor"]) { + const users = new Map([ + ["user-1", { keyboardShortcuts: false }], + ["user-2", { keyboardShortcuts: false }], + ["local-board", { keyboardShortcuts: false }], + ]); + const read = (where: Parameters[0]) => { + const query = dialect.sqlToQuery(where); + expect(query.sql).toContain('"user"."id" ='); + const user = users.get(query.params[0] as string); + return user ? [user] : []; + }; + const update = vi.fn(() => ({ + set: (patch: { keyboardShortcuts: boolean }) => ({ + where: (where: Parameters[0]) => ({ + returning: async () => { + const rows = read(where); + for (const row of rows) row.keyboardShortcuts = patch.keyboardShortcuts; + return rows; + }, + }), + }), + })); + const db = { + select: () => ({ from: () => ({ where: async (where: Parameters[0]) => read(where) }) }), + update, + transaction: async (fn: (tx: unknown) => Promise): Promise => { + const snapshot = structuredClone(users); + try { return await fn(db); } + catch (error) { users.clear(); for (const [key, value] of snapshot) users.set(key, value); throw error; } + }, + }; + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { req.actor = actor; next(); }); + app.use("/api/auth", authRoutes(db as never)); + app.use(errorHandler); + return { app, users, update }; +} + +const board: Express.Request["actor"] = { + type: "board", userId: "user-1", source: "session", isInstanceAdmin: false, companyIds: [companyId], +}; + +describe("personal keyboard shortcut preferences", () => { + beforeEach(() => vi.clearAllMocks()); + + it("lets a non-admin persist their preference without changing another user", async () => { + const { app, users } = setup(board); + expect((await request(app).get("/api/auth/preferences?expectedUserId=user-1")).body).toEqual({ keyboardShortcuts: false }); + const saved = await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: actorExpectedId, keyboardShortcuts: true }); + expect(saved.status).toBe(200); + expect(saved.body).toEqual({ keyboardShortcuts: true }); + expect((await request(app).get("/api/auth/preferences?expectedUserId=user-1")).body).toEqual({ keyboardShortcuts: true }); + expect(users.get("user-2")).toEqual({ keyboardShortcuts: false }); + expect(logActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + companyId, actorId: "user-1", entityId: "user-1", action: "user.preferences_updated", + }), expect.any(Array)); + expect((await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: actorExpectedId, keyboardShortcuts: false })).body) + .toEqual({ keyboardShortcuts: false }); + }); + + it("allows viewer members to save their own preferences", async () => { + const { app } = setup({ ...board, memberships: [{ companyId, membershipRole: "viewer", status: "active" }] } as Express.Request["actor"]); + expect((await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: "user-1", keyboardShortcuts: true })).status).toBe(200); + }); + + it("rejects reads and writes after the cookie changes accounts", async () => { + const { app, update } = setup({ ...board, userId: "user-2" }); + expect((await request(app).get("/api/auth/preferences?expectedUserId=user-1")).status).toBe(401); + expect((await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: "user-1", keyboardShortcuts: true })).status).toBe(401); + expect(update).not.toHaveBeenCalled(); + }); + + it("rolls back the preference when its audit record fails", async () => { + const { app, users } = setup(board); + logActivity.mockRejectedValueOnce(new Error("audit unavailable")); + expect((await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: "user-1", keyboardShortcuts: true })).status).toBe(500); + expect(users.get("user-1")?.keyboardShortcuts).toBe(false); + }); + + it("reports a committed save as successful when activity publication fails", async () => { + const { app, users } = setup(board); + logActivity.mockImplementationOnce(async (_db, _input, publications) => { + publications.push({ companyId, payload: {} }); + }); + publishActivity.mockImplementationOnce(() => { throw new Error("subscriber unavailable"); }); + const saved = await request(app).patch("/api/auth/preferences") + .send({ companyId, expectedUserId: "user-1", keyboardShortcuts: true }); + expect(saved.status).toBe(200); + expect(saved.body).toEqual({ keyboardShortcuts: true }); + expect(users.get("user-1")?.keyboardShortcuts).toBe(true); + expect(publishActivity).toHaveBeenCalledOnce(); + }); + + it("supports the local trusted board identity", async () => { + const { app } = setup({ type: "board", userId: "local-board", source: "local_implicit" }); + expect((await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: "local-board", keyboardShortcuts: true })).status).toBe(200); + }); + + it.each([ + { type: "none" }, + { type: "agent", agentId: "agent-1", companyId }, + { type: "board", source: "session" }, + ] as Express.Request["actor"][])("rejects requests without a board user: %j", async (actor) => { + const { app, update } = setup(actor); + expect((await request(app).get("/api/auth/preferences?expectedUserId=user-1")).status).toBe(401); + expect((await request(app).patch("/api/auth/preferences").send({ companyId, expectedUserId: actorExpectedId, keyboardShortcuts: true })).status).toBe(401); + expect(update).not.toHaveBeenCalled(); + }); + + it("rejects an inaccessible company audit context", async () => { + const { app, update } = setup(board); + expect((await request(app).patch("/api/auth/preferences").send({ companyId: otherCompanyId, expectedUserId: actorExpectedId, keyboardShortcuts: true })).status).toBe(403); + expect(update).not.toHaveBeenCalled(); + expect(logActivity).not.toHaveBeenCalled(); + }); + + it.each([ + { keyboardShortcuts: true }, + { companyId, keyboardShortcuts: "true" }, + { companyId, keyboardShortcuts: true, userId: "user-2" }, + ])("rejects invalid or caller-selected identities: %j", async (body) => { + const { app, update } = setup(board); + expect((await request(app).patch("/api/auth/preferences").send(body)).status).toBe(400); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index cfa65e033cb..faf0a522c7a 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -4,10 +4,15 @@ import type { Db } from "@paperclipai/db"; import { authUsers } from "@paperclipai/db"; import { authSessionSchema, + currentUserPreferencesSchema, + updateCurrentUserPreferencesSchema, currentUserProfileSchema, updateCurrentUserProfileSchema, } from "@paperclipai/shared"; -import { unauthorized } from "../errors.js"; +import { hasCompanyAccess } from "./authz.js"; +import { logActivity, publishActivity, type ActivityPublication } from "../services/activity-log.js"; +import { forbidden, unauthorized } from "../errors.js"; +import { logger } from "../middleware/logger.js"; import { validate } from "../middleware/validate.js"; import { resolveSentryDsns } from "../sentry-dsn.js"; @@ -104,5 +109,55 @@ export function authRoutes(db: Db) { })); }); + router.get("/preferences", async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + if (req.query.expectedUserId !== req.actor.userId) throw unauthorized("Account changed. Refresh and try again."); + const [user] = await db.select({ keyboardShortcuts: authUsers.keyboardShortcuts }) + .from(authUsers).where(eq(authUsers.id, req.actor.userId)); + if (!user) throw unauthorized("Signed-in user not found"); + res.json(currentUserPreferencesSchema.parse(user)); + }); + + router.patch("/preferences", validate(updateCurrentUserPreferencesSchema), async (req, res) => { + if (req.actor.type !== "board" || !req.actor.userId) { + throw unauthorized("Board authentication required"); + } + const { companyId, keyboardShortcuts, expectedUserId } = updateCurrentUserPreferencesSchema.parse(req.body); + const userId = req.actor.userId; + if (expectedUserId !== userId) throw unauthorized("Account changed. Refresh and try again."); + // This is a personal write; company membership supplies audit context only. + if (!hasCompanyAccess(req, companyId)) throw forbidden("User does not have access to this company"); + const publications: ActivityPublication[] = []; + const user = await db.transaction(async (tx) => { + const [updated] = await tx.update(authUsers) + .set({ keyboardShortcuts, updatedAt: new Date() }) + .where(eq(authUsers.id, userId)) + .returning({ keyboardShortcuts: authUsers.keyboardShortcuts }); + if (!updated) throw unauthorized("Signed-in user not found"); + await logActivity(tx as unknown as Db, { + companyId, + actorType: "user", + actorId: userId, + action: "user.preferences_updated", + entityType: "user", + entityId: userId, + details: { keyboardShortcuts }, + }, publications); + return updated; + }); + for (const publication of publications) { + try { + publishActivity(publication); + } catch (err) { + // The preference and audit row are committed; notification failure must + // not tell the caller that its durable save failed. + logger.warn({ err, companyId, userId }, "Could not publish user preference activity"); + } + } + res.json(currentUserPreferencesSchema.parse(user)); + }); + return router; } diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 9612555278c..6763a3ab2c9 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -186,7 +186,7 @@ export function instanceSettingsRoutes(db: Db) { ); router.get("/instance/settings/general", async (req, res) => { - // General settings (e.g. keyboardShortcuts) are readable by any + // General settings (e.g. feedbackDataSharingPreference) are readable by any // authenticated org member or instance admin. Only PATCH requires instance-admin. assertBoardOrgAccess(req); res.json(await svc.getGeneral()); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 62866294e96..439262a6fce 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -174,6 +174,7 @@ import { withdrawIssueThreadInteractionSchema, // Auth / profile updateCurrentUserProfileSchema, + updateCurrentUserPreferencesSchema, // Company portability (legacy routes) companyPortabilityExportSchema, companyPortabilityPreviewSchema, @@ -6553,6 +6554,23 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, }); +registry.registerPath({ + method: "get", + path: "/api/auth/preferences", + tags: ["auth"], + summary: "Get the signed-in user's personal preferences", + request: { query: z.object({ expectedUserId: z.string().min(1) }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); +registry.registerPath({ + method: "patch", + path: "/api/auth/preferences", + tags: ["auth"], + summary: "Update personal preferences with a company audit context", + request: { body: jsonBody(updateCurrentUserPreferencesSchema) }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, +}); + // ─── Auth / profile ────────────────────────────────────────────────────────── registry.registerPath({ diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts index ebdd4129bda..0923f37ddff 100644 --- a/ui/src/api/auth.ts +++ b/ui/src/api/auth.ts @@ -1,5 +1,8 @@ import { authSessionSchema, + currentUserPreferencesSchema, + type CurrentUserPreferences, + type UpdateCurrentUserPreferences, currentUserProfileSchema, type AuthSession, type CurrentUserProfile, @@ -177,6 +180,23 @@ export const authApi = { await authPost("/sign-up/email", input); }, + getPreferences: async (expectedUserId: string): Promise => { + const res = await fetch(`/api/auth/preferences?expectedUserId=${encodeURIComponent(expectedUserId)}`, { + credentials: "include", + headers: { Accept: "application/json" }, + }); + const payload = await res.json().catch(() => null); + if (!res.ok) { + const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload); + if (recovery) return recovery; + throw extractAuthError(payload as AuthErrorBody, res.status); + } + return currentUserPreferencesSchema.parse(payload); + }, + + updatePreferences: (input: UpdateCurrentUserPreferences): Promise => + authPatch("/preferences", input, (payload) => currentUserPreferencesSchema.parse(payload)), + getProfile: async (): Promise => { const res = await fetch("/api/auth/profile", { credentials: "include", diff --git a/ui/src/components/Layout.production.tsx b/ui/src/components/Layout.production.tsx index 170b7bd5498..6f88bbeeb1a 100644 --- a/ui/src/components/Layout.production.tsx +++ b/ui/src/components/Layout.production.tsx @@ -1,3 +1,4 @@ +import { useUserPreferences } from "../hooks/useUserPreferences"; import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext"; import { PluginAppShellOverlays } from "./PluginAppShellOverlays"; import { @@ -47,7 +48,6 @@ import { useSidebar } from "../context/SidebarContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; -import { instanceSettingsApi } from "../api/instanceSettings"; import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute, @@ -252,11 +252,7 @@ export function Layout() { }, refetchIntervalInBackground: false, }); - const keyboardShortcutsEnabled = - useQuery({ - queryKey: queryKeys.instance.generalSettings, - queryFn: () => instanceSettingsApi.getGeneral(), - }).data?.keyboardShortcuts === true; + const keyboardShortcutsEnabled = useUserPreferences().data?.keyboardShortcuts === true; // A secondary sidebar always collapses the app sidebar to its rail (still // peek-able) — a hard invariant that overrides the user pin while the route diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 6ad9e58f71d..79437ea31f7 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,3 +1,4 @@ +import { useUserPreferences } from "../hooks/useUserPreferences"; import { SetupWizardSidebarOutlet } from "./SetupWizard"; import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; @@ -40,7 +41,6 @@ import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; -import { instanceSettingsApi } from "../api/instanceSettings"; import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute } from "../lib/company-selection"; import { useOptionalToastActions } from "../context/ToastContext"; import { @@ -241,10 +241,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) { }, refetchIntervalInBackground: false, }); - const keyboardShortcutsEnabled = useQuery({ - queryKey: queryKeys.instance.generalSettings, - queryFn: () => instanceSettingsApi.getGeneral(), - }).data?.keyboardShortcuts === true; + const keyboardShortcutsEnabled = useUserPreferences().data?.keyboardShortcuts === true; useLayoutEffect(() => { setForceCollapsed(!streamlinedUiEnabled && hasSecondarySidebar); diff --git a/ui/src/hooks/useUserPreferences.test.tsx b/ui/src/hooks/useUserPreferences.test.tsx new file mode 100644 index 00000000000..dd1cd0def44 --- /dev/null +++ b/ui/src/hooks/useUserPreferences.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { expect, it, vi } from "vitest"; +import { useUserPreferences } from "./useUserPreferences"; +import { queryKeys } from "../lib/queryKeys"; + +const getPreferences = vi.hoisted(() => vi.fn()); +vi.mock("../api/auth", () => ({ authApi: { getSession: vi.fn(), getPreferences } })); + +it("does not reuse another account's enabled shortcuts while preferences load", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); + client.setQueryData(queryKeys.auth.preferences("user-1"), { keyboardShortcuts: true }); + getPreferences.mockReturnValue(new Promise(() => {})); + const container = document.createElement("div"); + const root = createRoot(container); + function Probe() { + return {useUserPreferences().data?.keyboardShortcuts === true ? "enabled" : "disabled"}; + } + await act(async () => root.render()); + expect(container.textContent).toBe("enabled"); + await act(async () => { + client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(container.textContent).toBe("disabled"); + expect(getPreferences).toHaveBeenCalledWith("user-2"); + await act(async () => root.unmount()); + client.clear(); +}); + + +it("disables cached shortcuts when an account-bound refetch is rejected", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); + client.setQueryData(queryKeys.auth.preferences("user-1"), { keyboardShortcuts: true }); + getPreferences.mockRejectedValue(new Error("Account changed. Refresh and try again.")); + const container = document.createElement("div"); + const root = createRoot(container); + function Probe() { + return {useUserPreferences().data?.keyboardShortcuts === true ? "enabled" : "disabled"}; + } + await act(async () => root.render()); + expect(container.textContent).toBe("enabled"); + await act(async () => { + await client.invalidateQueries({ queryKey: queryKeys.auth.preferences("user-1") }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(container.textContent).toBe("disabled"); + expect(getPreferences).toHaveBeenCalledWith("user-1"); + expect(client.getQueryData(queryKeys.auth.preferences("user-1"))).toEqual({ keyboardShortcuts: true }); + await act(async () => root.unmount()); + client.clear(); +}); diff --git a/ui/src/hooks/useUserPreferences.ts b/ui/src/hooks/useUserPreferences.ts new file mode 100644 index 00000000000..2c41234264a --- /dev/null +++ b/ui/src/hooks/useUserPreferences.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import { authApi } from "../api/auth"; +import { queryKeys } from "../lib/queryKeys"; + +export function useUserPreferences() { + const session = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + retry: false, + }); + const userId = session.data?.user?.id ?? null; + const preferences = useQuery({ + queryKey: queryKeys.auth.preferences(userId), + queryFn: () => authApi.getPreferences(userId!), + enabled: !!userId, + retry: false, + }); + return { ...preferences, data: preferences.isError ? undefined : preferences.data }; +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index be18e824f62..45d03d80db5 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -592,6 +592,7 @@ export const queryKeys = { currentBoardAccess: ["access", "current-board-access"] as const, }, auth: { + preferences: (userId: string | null) => ["auth", "preferences", userId] as const, session: ["auth", "session"] as const, }, inboxAgentPolicy: { diff --git a/ui/src/pages/InstanceGeneralSettings.test.tsx b/ui/src/pages/InstanceGeneralSettings.test.tsx index 87cc64f2e44..0cf38db7625 100644 --- a/ui/src/pages/InstanceGeneralSettings.test.tsx +++ b/ui/src/pages/InstanceGeneralSettings.test.tsx @@ -131,10 +131,10 @@ describe("InstanceGeneralSettings sign-out", () => { mockAuthApi.signOut.mockRejectedValue(new Error("Sign-out request failed")); await renderPage(SELF_HOSTED_HEALTH); - const keyboardToggle = container.querySelector( - '[aria-label="Toggle keyboard shortcuts"]', + const censorToggle = container.querySelector( + '[aria-label="Toggle username log censoring"]', ); - flushSync(() => keyboardToggle?.click()); + flushSync(() => censorToggle?.click()); await vi.waitFor(() => expect(container.textContent).toContain("Settings update failed")); flushSync(() => signOutButton()?.click()); @@ -150,10 +150,10 @@ describe("InstanceGeneralSettings sign-out", () => { flushSync(() => signOutButton()?.click()); await vi.waitFor(() => expect(container.textContent).toContain("Sign-out request failed")); - const keyboardToggle = container.querySelector( - '[aria-label="Toggle keyboard shortcuts"]', + const censorToggle = container.querySelector( + '[aria-label="Toggle username log censoring"]', ); - flushSync(() => keyboardToggle?.click()); + flushSync(() => censorToggle?.click()); await vi.waitFor(() => expect(mockInstanceSettingsApi.updateGeneral).toHaveBeenCalledOnce()); await vi.waitFor(() => expect(container.textContent).not.toContain("Sign-out request failed")); @@ -168,18 +168,18 @@ describe("InstanceGeneralSettings sign-out", () => { ); await renderPage(SELF_HOSTED_HEALTH); - const keyboardToggle = container.querySelector( - '[aria-label="Toggle keyboard shortcuts"]', + const censorToggle = container.querySelector( + '[aria-label="Toggle username log censoring"]', ); flushSync(() => signOutButton()?.click()); await vi.waitFor(() => expect(mockAuthApi.signOut).toHaveBeenCalledOnce()); - expect(keyboardToggle?.disabled).toBe(true); - flushSync(() => keyboardToggle?.click()); + expect(censorToggle?.disabled).toBe(true); + flushSync(() => censorToggle?.click()); expect(mockInstanceSettingsApi.updateGeneral).not.toHaveBeenCalled(); resolveSignOut?.({ success: true }); - await vi.waitFor(() => expect(keyboardToggle?.disabled).toBe(false)); + await vi.waitFor(() => expect(censorToggle?.disabled).toBe(false)); }); it("disables sign-out while a settings update is pending", async () => { @@ -191,10 +191,10 @@ describe("InstanceGeneralSettings sign-out", () => { ); await renderPage(SELF_HOSTED_HEALTH); - const keyboardToggle = container.querySelector( - '[aria-label="Toggle keyboard shortcuts"]', + const censorToggle = container.querySelector( + '[aria-label="Toggle username log censoring"]', ); - flushSync(() => keyboardToggle?.click()); + flushSync(() => censorToggle?.click()); await vi.waitFor(() => expect(mockInstanceSettingsApi.updateGeneral).toHaveBeenCalledOnce()); expect(signOutButton()?.disabled).toBe(true); @@ -242,7 +242,7 @@ describe("InstanceGeneralSettings operator-hidden sections", () => { , ); }); - await vi.waitFor(() => expect(container.textContent).toContain("Keyboard shortcuts")); + await vi.waitFor(() => expect(container.textContent).toContain("Backup retention")); } it("hides an operator-hidden field-backed section and a UI-only section", async () => { diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index a3f5d0cccd2..d76b2dbb89d 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -76,19 +76,16 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole } const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true; - const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true; const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt"; const backupRetention: BackupRetentionPolicy = generalQuery.data?.backupRetention ?? DEFAULT_BACKUP_RETENTION; const hiddenSettings = new Set(healthQuery.data?.hiddenSettings ?? []); const showDeploymentStatus = !hiddenSettings.has("instance.general.deploymentStatus"); const showCensorUsernameInLogs = !hiddenSettings.has("instance.general.censorUsernameInLogs"); - const showKeyboardShortcuts = !hiddenSettings.has("instance.general.keyboardShortcuts"); const showBackupRetention = !hiddenSettings.has("instance.general.backupRetention"); const showFeedbackDataSharing = !hiddenSettings.has("instance.general.feedbackDataSharingPreference"); const showSignOut = !hiddenSettings.has("instance.general.signOut"); const visibleTopics = [ ...(showCensorUsernameInLogs ? ["log display"] : []), - ...(showKeyboardShortcuts ? ["keyboard shortcuts"] : []), ...(showBackupRetention ? ["backup retention"] : []), ...(showFeedbackDataSharing ? ["data sharing"] : []), ]; @@ -178,26 +175,6 @@ export function InstanceGeneralSettings({ embedded = false }: { embedded?: boole )} - {showKeyboardShortcuts && ( -
-
-
-

Keyboard shortcuts

-

- Enable app keyboard shortcuts, including inbox navigation and global shortcuts like creating tasks or - toggling panels. This is off by default. -

-
- updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })} - disabled={updateGeneralMutation.isPending || signOutMutation.isPending} - aria-label="Toggle keyboard shortcuts" - /> -
-
- )} - {showBackupRetention && (
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index b22390bc07d..089ba6a08d5 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -103,6 +103,7 @@ const mockAccessApi = vi.hoisted(() => ({ const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), + getPreferences: vi.fn(), })); const mockProjectsApi = vi.hoisted(() => ({ @@ -1372,6 +1373,7 @@ describe("IssueDetail", () => { }); mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] }); mockAuthApi.getSession.mockResolvedValue({ session: null, user: null }); + mockAuthApi.getPreferences.mockResolvedValue({ keyboardShortcuts: false }); mockProjectsApi.list.mockResolvedValue([]); mockDecisionsApi.list.mockResolvedValue([]); mockInstanceSettingsApi.getGeneral.mockResolvedValue({ @@ -2809,8 +2811,10 @@ describe("IssueDetail", () => { "issues", ); mockIssuesApi.get.mockResolvedValue(createIssue()); + mockAuthApi.getSession.mockResolvedValue({ session: { userId: "user-1" }, user: { id: "user-1" } }); + mockAuthApi.getPreferences.mockResolvedValue({ keyboardShortcuts: true }); mockInstanceSettingsApi.getGeneral.mockResolvedValue({ - keyboardShortcuts: true, + keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", }); @@ -2849,8 +2853,10 @@ describe("IssueDetail", () => { createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"), ); mockIssuesApi.get.mockResolvedValue(createIssue()); + mockAuthApi.getSession.mockResolvedValue({ session: { userId: "user-1" }, user: { id: "user-1" } }); + mockAuthApi.getPreferences.mockResolvedValue({ keyboardShortcuts: true }); mockInstanceSettingsApi.getGeneral.mockResolvedValue({ - keyboardShortcuts: true, + keyboardShortcuts: false, feedbackDataSharingPreference: "prompt", }); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 25e4ed99f3a..da2a2b4df52 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,3 +1,4 @@ +import { useUserPreferences } from "../hooks/useUserPreferences"; import { DispositionRecoveryProvider } from "../components/DispositionRecoveryNotice"; import { AgentAvatar } from "@/components/AgentAvatar"; import { AgentIdentity } from "@/components/AgentIdentity"; @@ -3382,8 +3383,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS enabled: !!issueId, retry: false, }); - const keyboardShortcutsEnabled = - instanceGeneralSettings?.keyboardShortcuts === true; + const keyboardShortcutsEnabled = useUserPreferences().data?.keyboardShortcuts === true; // Experimental Cases: linkify `PAP-C7` chips in this issue's comment bodies. const casesChipsEnabled = instanceExperimentalSettings?.enableCases === true; const feedbackDataSharingPreference = diff --git a/ui/src/pages/ProfileSettings.test.tsx b/ui/src/pages/ProfileSettings.test.tsx index 17270809f72..c53ca77d71f 100644 --- a/ui/src/pages/ProfileSettings.test.tsx +++ b/ui/src/pages/ProfileSettings.test.tsx @@ -9,6 +9,8 @@ import { ProfileSettings } from "./ProfileSettings"; const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), + getPreferences: vi.fn(), + updatePreferences: vi.fn(), signInEmail: vi.fn(), signUpEmail: vi.fn(), getProfile: vi.fn(), @@ -70,6 +72,8 @@ describe("ProfileSettings", () => { image: "https://example.com/jane.png", }, }); + mockAuthApi.getPreferences.mockResolvedValue({ keyboardShortcuts: false }); + mockAuthApi.updatePreferences.mockResolvedValue({ keyboardShortcuts: true }); mockAssetsApi.uploadImage.mockResolvedValue({ assetId: "asset-1", contentPath: "/api/assets/asset-1/content", @@ -88,6 +92,41 @@ describe("ProfileSettings", () => { vi.clearAllMocks(); }); + it("saves personal shortcuts and immediately updates the user-scoped cache", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root.render(); + }); + await flushReact(); + await flushReact(); + const toggle = container.querySelector('[aria-label="Toggle keyboard shortcuts"]'); + expect(toggle).not.toBeNull(); + expect(toggle?.disabled).toBe(false); + await act(async () => toggle?.click()); + await flushReact(); + expect(mockAuthApi.updatePreferences).toHaveBeenCalledWith({ companyId: "company-1", keyboardShortcuts: true, expectedUserId: "user-1" }, expect.anything()); + expect(queryClient.getQueryData(["auth", "preferences", "user-1"])).toEqual({ keyboardShortcuts: true }); + expect(mockAuthApi.updateProfile).not.toHaveBeenCalled(); + await act(async () => root.unmount()); + }); + + it("shows a preference save failure and leaves shortcuts disabled", async () => { + mockAuthApi.updatePreferences.mockRejectedValueOnce(new Error("Could not save shortcuts")); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await act(async () => { + root.render(); + }); + await flushReact(); + await flushReact(); + await act(async () => container.querySelector('[aria-label="Toggle keyboard shortcuts"]')?.click()); + await flushReact(); + expect(container.textContent).toContain("Could not save shortcuts"); + expect(queryClient.getQueryData(["auth", "preferences", "user-1"])).toEqual({ keyboardShortcuts: false }); + await act(async () => root.unmount()); + }); + it("uploads a clicked avatar into Paperclip storage and persists the returned asset path", async () => { const root = createRoot(container); const queryClient = new QueryClient({ diff --git a/ui/src/pages/ProfileSettings.tsx b/ui/src/pages/ProfileSettings.tsx index 1b7717c5ac3..d79af6468cd 100644 --- a/ui/src/pages/ProfileSettings.tsx +++ b/ui/src/pages/ProfileSettings.tsx @@ -13,6 +13,8 @@ import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; +import { useUserPreferences } from "../hooks/useUserPreferences"; function deriveInitials(name: string) { const parts = name.trim().split(/\s+/).filter(Boolean); @@ -24,6 +26,7 @@ export function ProfileSettings() { const { setBreadcrumbs } = useBreadcrumbs(); const { selectedCompanyId, selectedCompany } = useCompany(); const queryClient = useQueryClient(); + const preferencesQuery = useUserPreferences(); const avatarInputId = useId(); const avatarInputRef = useRef(null); const [name, setName] = useState(""); @@ -34,6 +37,13 @@ export function ProfileSettings() { queryFn: () => authApi.getSession(), retry: false, }); + const updatePreferencesMutation = useMutation({ + mutationFn: authApi.updatePreferences, + onMutate: () => sessionQuery.data?.user.id ?? null, + onSuccess: (preferences, _input, userId) => { + queryClient.setQueryData(queryKeys.auth.preferences(userId), preferences); + }, + }); useEffect(() => { setBreadcrumbs([ @@ -270,6 +280,31 @@ export function ProfileSettings() {
+
+
+
+

Keyboard shortcuts

+

+ Enable app keyboard shortcuts, including inbox navigation and global shortcuts like creating tasks or + toggling panels. This applies only to your account, across all organizations and devices. Off by default. +

+
+ { + if (selectedCompanyId && sessionQuery.data?.user.id) updatePreferencesMutation.mutate({ companyId: selectedCompanyId, keyboardShortcuts, expectedUserId: sessionQuery.data.user.id }); + }} + disabled={!selectedCompanyId || !preferencesQuery.data || preferencesQuery.isError || updatePreferencesMutation.isPending} + aria-label="Toggle keyboard shortcuts" + /> +
+ {preferencesQuery.error || updatePreferencesMutation.error ? ( +

+ {(updatePreferencesMutation.error ?? preferencesQuery.error)?.message} +

+ ) : null} +
+
diff --git a/ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx b/ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx index 587a6960962..f43165b7993 100644 --- a/ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx +++ b/ui/storybook/prototypes/originating-tasks/OriginatingTasks.tsx @@ -160,7 +160,7 @@ function TaskPageData({ children, scenario, withAncestors }: { children: React.R } } client.setQueryData(queryKeys.health, { status: "ok", deploymentMode: "local_trusted", bootstrapStatus: "ready" }); - client.setQueryData(queryKeys.instance.generalSettings, { keyboardShortcuts: true }); + client.setQueryData(queryKeys.auth.preferences(storybookAuthSession.user.id), { keyboardShortcuts: true }); client.setQueryData(queryKeys.access.currentBoardAccess, { companyIds: [] }); client.setQueryData(queryKeys.issues.listCreatedFromIssue(sourceTask.companyId, sourceTask.id), taskCandidates.filter((row) => row.originRunId && runSources.get(row.originRunId) === sourceTask.id)); client.setQueryData(queryKeys.issues.listByDescendantRoot(sourceTask.companyId, sourceTask.id), taskCandidates.filter((row) => row.parentId === sourceTask.id)); @@ -178,7 +178,7 @@ function TaskPageData({ children, scenario, withAncestors }: { children: React.R const url = new URL(raw, window.location.origin); const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); if (url.pathname === "/api/health") return Response.json({ status: "ok", deploymentMode: "local_trusted", bootstrapStatus: "ready" }); - if (url.pathname === "/api/instance/settings/general") return Response.json({ keyboardShortcuts: true }); + if (url.pathname === "/api/auth/preferences") return Response.json({ keyboardShortcuts: true }); const projectMatch = url.pathname.match(/^\/api\/projects\/([^/]+)$/); if (projectMatch && method === "GET") { const project = storybookProjects.find((item) => item.id === projectMatch[1] || projectRouteRef(item) === projectMatch[1]);