From 9ef39304dfb074e71b04cf5cafacacc645ac9857 Mon Sep 17 00:00:00 2001 From: Caleb McQuaid Date: Wed, 16 Sep 2026 09:37:55 -0400 Subject: [PATCH] feat: save user theme preference --- client/src/graphql/generated.ts | 27 ++++++ ..._preference_to_user_interface_settings.sql | 12 +++ server/graphql/generated.ts | 51 +++++++++++ server/graphql/modules/org.ts | 3 + server/graphql/modules/user.resolver.test.ts | 39 +++++++++ server/graphql/modules/user.ts | 36 ++++++++ .../services/userManagementService/dbTypes.ts | 4 + .../userManagementService.test.ts | 86 +++++++++++++++++++ .../userManagementService.ts | 8 +- 9 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 db/src/scripts/api-server-pg/2026.09.15T14.43.58.add_theme_preference_to_user_interface_settings.sql diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index 817f3ae7..841ec5a2 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -2561,6 +2561,7 @@ export type GQLMutation = { readonly setMrtChartConfigurationSettings?: Maybe; readonly setOrgDefaultSafetySettings?: Maybe; readonly setPluginIntegrationConfig: GQLSetIntegrationConfigResponse; + readonly setThemePreference?: Maybe; readonly signUp: GQLSignUpResponse; readonly submitManualReviewDecision: GQLSubmitDecisionResponse; readonly updateAccountInfo?: Maybe; @@ -2834,6 +2835,10 @@ export type GQLMutationSetPluginIntegrationConfigArgs = { input: GQLSetPluginIntegrationConfigInput; }; +export type GQLMutationSetThemePreferenceArgs = { + themePreference: GQLThemePreference; +}; + export type GQLMutationSignUpArgs = { input: GQLSignUpInput; }; @@ -4352,6 +4357,11 @@ export type GQLSetPluginIntegrationConfigInput = { readonly integrationId: Scalars['String']['input']; }; +export type GQLSetThemePreferenceSuccessResponse = { + readonly __typename: 'SetThemePreferenceSuccessResponse'; + readonly _?: Maybe; +}; + export type GQLSetUserStrikeThresholdInput = { readonly actionParameters?: InputMaybe; readonly actions: ReadonlyArray; @@ -4655,6 +4665,18 @@ export const GQLTextBankType = { export type GQLTextBankType = (typeof GQLTextBankType)[keyof typeof GQLTextBankType]; +/** + * The user's color scheme preference. SYSTEM means "follow the operating + * system / browser color scheme". + */ +export const GQLThemePreference = { + Dark: 'DARK', + Light: 'LIGHT', + System: 'SYSTEM', +} as const; + +export type GQLThemePreference = + (typeof GQLThemePreference)[keyof typeof GQLThemePreference]; export type GQLThreadAppealManualReviewJobPayload = { readonly __typename: 'ThreadAppealManualReviewJobPayload'; readonly actionsTaken: ReadonlyArray; @@ -5077,6 +5099,11 @@ export type GQLUserInterfacePreferences = { readonly moderatorSafetyMuteVideo: Scalars['Boolean']['output']; readonly moderatorSafetySepia: Scalars['Boolean']['output']; readonly mrtChartConfigurations: ReadonlyArray; + /** + * Null means the user has never chosen a theme; the client falls back to + * its default (currently light). + */ + readonly themePreference?: Maybe; }; export type GQLUserItem = GQLItemBase & { diff --git a/db/src/scripts/api-server-pg/2026.09.15T14.43.58.add_theme_preference_to_user_interface_settings.sql b/db/src/scripts/api-server-pg/2026.09.15T14.43.58.add_theme_preference_to_user_interface_settings.sql new file mode 100644 index 00000000..219b5e1e --- /dev/null +++ b/db/src/scripts/api-server-pg/2026.09.15T14.43.58.add_theme_preference_to_user_interface_settings.sql @@ -0,0 +1,12 @@ +-- Per-user color scheme preference (issue #365). NULL means "follow the +-- system/browser color scheme", which is the default for all existing users. + +ALTER TABLE user_management_service.user_interface_settings + ADD COLUMN IF NOT EXISTS theme_preference character varying(16); + +ALTER TABLE user_management_service.user_interface_settings + ADD CONSTRAINT user_interface_settings_theme_preference_check + CHECK ( + theme_preference IS NULL + OR theme_preference IN ('SYSTEM', 'LIGHT', 'DARK') + ); diff --git a/server/graphql/generated.ts b/server/graphql/generated.ts index e4ded355..69e5d4b0 100644 --- a/server/graphql/generated.ts +++ b/server/graphql/generated.ts @@ -2629,6 +2629,7 @@ export type GQLMutation = { readonly setMrtChartConfigurationSettings?: Maybe; readonly setOrgDefaultSafetySettings?: Maybe; readonly setPluginIntegrationConfig: GQLSetIntegrationConfigResponse; + readonly setThemePreference?: Maybe; readonly signUp: GQLSignUpResponse; readonly submitManualReviewDecision: GQLSubmitDecisionResponse; readonly updateAccountInfo?: Maybe; @@ -2902,6 +2903,10 @@ export type GQLMutationSetPluginIntegrationConfigArgs = { input: GQLSetPluginIntegrationConfigInput; }; +export type GQLMutationSetThemePreferenceArgs = { + themePreference: GQLThemePreference; +}; + export type GQLMutationSignUpArgs = { input: GQLSignUpInput; }; @@ -4420,6 +4425,11 @@ export type GQLSetPluginIntegrationConfigInput = { readonly integrationId: Scalars['String']['input']; }; +export type GQLSetThemePreferenceSuccessResponse = { + readonly __typename?: 'SetThemePreferenceSuccessResponse'; + readonly _?: Maybe; +}; + export type GQLSetUserStrikeThresholdInput = { readonly actionParameters?: InputMaybe; readonly actions: ReadonlyArray; @@ -4723,6 +4733,18 @@ export const GQLTextBankType = { export type GQLTextBankType = (typeof GQLTextBankType)[keyof typeof GQLTextBankType]; +/** + * The user's color scheme preference. SYSTEM means "follow the operating + * system / browser color scheme". + */ +export const GQLThemePreference = { + Dark: 'DARK', + Light: 'LIGHT', + System: 'SYSTEM', +} as const; + +export type GQLThemePreference = + (typeof GQLThemePreference)[keyof typeof GQLThemePreference]; export type GQLThreadAppealManualReviewJobPayload = { readonly __typename?: 'ThreadAppealManualReviewJobPayload'; readonly actionsTaken: ReadonlyArray; @@ -5145,6 +5167,11 @@ export type GQLUserInterfacePreferences = { readonly moderatorSafetyMuteVideo: Scalars['Boolean']['output']; readonly moderatorSafetySepia: Scalars['Boolean']['output']; readonly mrtChartConfigurations: ReadonlyArray; + /** + * Null means the user has never chosen a theme; the client falls back to + * its default (currently light). + */ + readonly themePreference?: Maybe; }; export type GQLUserItem = GQLItemBase & { @@ -6504,6 +6531,7 @@ export type GQLResolversTypes = { SetModeratorSafetySettingsSuccessResponse: ResolverTypeWrapper; SetMrtChartConfigurationSettingsSuccessResponse: ResolverTypeWrapper; SetPluginIntegrationConfigInput: GQLSetPluginIntegrationConfigInput; + SetThemePreferenceSuccessResponse: ResolverTypeWrapper; SetUserStrikeThresholdInput: GQLSetUserStrikeThresholdInput; SignUpInput: GQLSignUpInput; SignUpResponse: ResolverTypeWrapper< @@ -6555,6 +6583,7 @@ export type GQLResolversTypes = { TableDecisionCount: ResolverTypeWrapper; TextBank: ResolverTypeWrapper; TextBankType: GQLTextBankType; + ThemePreference: GQLThemePreference; ThreadAppealManualReviewJobPayload: ResolverTypeWrapper; ThreadItem: ResolverTypeWrapper< Omit & { type: GQLResolversTypes['ThreadItemType'] } @@ -7191,6 +7220,7 @@ export type GQLResolversParentTypes = { SetModeratorSafetySettingsSuccessResponse: GQLSetModeratorSafetySettingsSuccessResponse; SetMrtChartConfigurationSettingsSuccessResponse: GQLSetMrtChartConfigurationSettingsSuccessResponse; SetPluginIntegrationConfigInput: GQLSetPluginIntegrationConfigInput; + SetThemePreferenceSuccessResponse: GQLSetThemePreferenceSuccessResponse; SetUserStrikeThresholdInput: GQLSetUserStrikeThresholdInput; SignUpInput: GQLSignUpInput; SignUpResponse: GQLResolversUnionTypes['SignUpResponse']; @@ -11436,6 +11466,12 @@ export type GQLMutationResolvers< ContextType, RequireFields >; + setThemePreference?: Resolver< + Maybe, + ParentType, + ContextType, + RequireFields + >; signUp?: Resolver< GQLResolversTypes['SignUpResponse'], ParentType, @@ -13877,6 +13913,15 @@ export type GQLSetMrtChartConfigurationSettingsSuccessResponseResolvers< _?: Resolver, ParentType, ContextType>; }; +export type GQLSetThemePreferenceSuccessResponseResolvers< + ContextType = Context, + ParentType extends + GQLResolversParentTypes['SetThemePreferenceSuccessResponse'] = + GQLResolversParentTypes['SetThemePreferenceSuccessResponse'], +> = { + _?: Resolver, ParentType, ContextType>; +}; + export type GQLSignUpResponseResolvers< ContextType = Context, ParentType extends GQLResolversParentTypes['SignUpResponse'] = @@ -14836,6 +14881,11 @@ export type GQLUserInterfacePreferencesResolvers< ParentType, ContextType >; + themePreference?: Resolver< + Maybe, + ParentType, + ContextType + >; }; export type GQLUserItemResolvers< @@ -15471,6 +15521,7 @@ export type GQLResolvers = { SetIntegrationConfigSuccessResponse?: GQLSetIntegrationConfigSuccessResponseResolvers; SetModeratorSafetySettingsSuccessResponse?: GQLSetModeratorSafetySettingsSuccessResponseResolvers; SetMrtChartConfigurationSettingsSuccessResponse?: GQLSetMrtChartConfigurationSettingsSuccessResponseResolvers; + SetThemePreferenceSuccessResponse?: GQLSetThemePreferenceSuccessResponseResolvers; SignUpResponse?: GQLSignUpResponseResolvers; SignUpSuccessResponse?: GQLSignUpSuccessResponseResolvers; SignUpUserExistsError?: GQLSignUpUserExistsErrorResolvers; diff --git a/server/graphql/modules/org.ts b/server/graphql/modules/org.ts index 73af7504..bfc0eaf8 100644 --- a/server/graphql/modules/org.ts +++ b/server/graphql/modules/org.ts @@ -717,6 +717,9 @@ const Org: GQLOrgResolvers = { // set by the org's admin. We can always add that ability later, but we're // leaving this empty for now. mrtChartConfigurations: [], + // Theme is a personal accessibility preference with no org-level + // default. + themePreference: null, }; }, async isDemoOrg(org, _, context) { diff --git a/server/graphql/modules/user.resolver.test.ts b/server/graphql/modules/user.resolver.test.ts index c108d9fa..1018949d 100644 --- a/server/graphql/modules/user.resolver.test.ts +++ b/server/graphql/modules/user.resolver.test.ts @@ -54,6 +54,45 @@ describe('user resolvers', () => { }); }); + describe('Mutation.setThemePreference', () => { + function makeCtx(user: { id: string; orgId: string } | null) { + const upsertUserInterfaceSettings = jest.fn(async () => []); + const ctx = { + getUser: () => user, + services: { UserManagementService: { upsertUserInterfaceSettings } }, + }; + return { ctx, upsertUserInterfaceSettings }; + } + + const Mutation = resolvers.Mutation as { + setThemePreference: ( + parent: unknown, + args: { themePreference: 'SYSTEM' | 'LIGHT' | 'DARK' }, + ctx: unknown, + ) => Promise; + }; + + it('throws unauthenticatedError when there is no signed-in user', async () => { + const { ctx, upsertUserInterfaceSettings } = makeCtx(null); + await expect( + Mutation.setThemePreference({}, { themePreference: 'DARK' }, ctx), + ).rejects.toThrow('User required.'); + expect(upsertUserInterfaceSettings).not.toHaveBeenCalled(); + }); + + it('persists the preference for the signed-in user', async () => { + const { ctx, upsertUserInterfaceSettings } = makeCtx({ + id: 'user-1', + orgId: 'org-1', + }); + await Mutation.setThemePreference({}, { themePreference: 'DARK' }, ctx); + expect(upsertUserInterfaceSettings).toHaveBeenCalledWith({ + userId: 'user-1', + userInterfaceSettings: { themePreference: 'DARK' }, + }); + }); + }); + describe('User.readMeJWT does not leak org secrets to non-admins', () => { const TEST_JWT_SECRET = 'test-readme-jwt-secret'; let originalSecret: string | undefined; diff --git a/server/graphql/modules/user.ts b/server/graphql/modules/user.ts index edd51e9d..12762b82 100644 --- a/server/graphql/modules/user.ts +++ b/server/graphql/modules/user.ts @@ -82,6 +82,21 @@ const typeDefs = /* GraphQL */ ` moderatorSafetySepia: Boolean! moderatorSafetyBlurLevel: Int! mrtChartConfigurations: [ManualReviewChartSettings!]! + """ + Null means the user has never chosen a theme; the client falls back to + its default (currently light). + """ + themePreference: ThemePreference + } + + """ + The user's color scheme preference. SYSTEM means "follow the operating + system / browser color scheme". + """ + enum ThemePreference { + SYSTEM + LIGHT + DARK } input ModeratorSafetySettingsInput { @@ -157,6 +172,9 @@ const typeDefs = /* GraphQL */ ` setMrtChartConfigurationSettings( mrtChartConfigurationSettings: ManualReviewChartConfigurationsInput! ): SetMrtChartConfigurationSettingsSuccessResponse + setThemePreference( + themePreference: ThemePreference! + ): SetThemePreferenceSuccessResponse } union AddFavoriteRuleResponse = AddFavoriteRuleSuccessResponse @@ -177,6 +195,10 @@ const typeDefs = /* GraphQL */ ` _: Boolean } + type SetThemePreferenceSuccessResponse { + _: Boolean + } + type AddFavoriteMRTQueueSuccessResponse { _: Boolean } @@ -297,6 +319,20 @@ const Mutation: GQLMutationResolvers = { return gqlSuccessResult({}, 'SetModeratorSafetySettingsSuccessResponse'); }, + async setThemePreference(_, params, context) { + const user = context.getUser(); + if (user == null) { + throw unauthenticatedError('User required.'); + } + await context.services.UserManagementService.upsertUserInterfaceSettings({ + userId: user.id, + userInterfaceSettings: { + themePreference: params.themePreference, + }, + }); + return gqlSuccessResult({}, 'SetThemePreferenceSuccessResponse'); + }, + async setMrtChartConfigurationSettings(_, params, context) { const user = context.getUser(); if (user == null) { diff --git a/server/services/userManagementService/dbTypes.ts b/server/services/userManagementService/dbTypes.ts index 9dc3ef4a..1755359a 100644 --- a/server/services/userManagementService/dbTypes.ts +++ b/server/services/userManagementService/dbTypes.ts @@ -21,6 +21,8 @@ export type MrtChartConfig = { ) ); +export type ThemePreference = 'SYSTEM' | 'LIGHT' | 'DARK'; + export type UserManagementPg = { 'user_management_service.user_interface_settings': { user_id: string; @@ -29,6 +31,8 @@ export type UserManagementPg = { moderator_safety_sepia: boolean | null; moderator_safety_blur_level: number | null; mrt_chart_configurations: MrtChartConfig[] | null; + // NULL means "follow the system color scheme" (see issue #365) + theme_preference: ThemePreference | null; }; // We use ColumnType in this table because all the moderator_safety columns // are non-null and have default values, so we can provide null values on diff --git a/server/services/userManagementService/userManagementService.test.ts b/server/services/userManagementService/userManagementService.test.ts index b51c1ea0..cbfaf686 100644 --- a/server/services/userManagementService/userManagementService.test.ts +++ b/server/services/userManagementService/userManagementService.test.ts @@ -208,6 +208,92 @@ describe('UserManagementService', () => { ); }); + describe('#upsertUserInterfaceSettings', () => { + testWithFixtures('persists the theme preference', async ({ sut }) => { + const mockInsert = { + values: jest.fn().mockReturnThis(), + onConflict: jest.fn().mockReturnThis(), + returningAll: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue([]), + }; + (mockDb.insertInto as jest.Mock).mockReturnValue(mockInsert); + + await sut.upsertUserInterfaceSettings({ + userId: 'user-123', + userInterfaceSettings: { themePreference: 'DARK' }, + }); + + expect(mockDb.insertInto).toHaveBeenCalledWith( + 'user_management_service.user_interface_settings', + ); + expect(mockInsert.values).toHaveBeenCalledWith({ + user_id: 'user-123', + theme_preference: 'DARK', + }); + }); + }); + + describe('#getUserInterfaceSettings', () => { + testWithFixtures( + 'returns the stored theme preference alongside safety settings', + async ({ sut }) => { + const mockSelect = { + selectAll: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + executeTakeFirst: jest.fn().mockResolvedValue({ + user_id: 'user-123', + moderator_safety_grayscale: true, + moderator_safety_sepia: false, + moderator_safety_blur_level: 2, + moderator_safety_mute_video: true, + mrt_chart_configurations: null, + theme_preference: 'DARK', + }), + }; + (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect); + + const settings = await sut.getUserInterfaceSettings({ + userId: 'user-123', + orgId: 'org-456', + }); + + expect(settings.themePreference).toBe('DARK'); + }, + ); + + testWithFixtures( + 'returns a null theme preference when the user has no settings row', + async ({ sut }) => { + const mockUserSelect = { + selectAll: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + executeTakeFirst: jest.fn().mockResolvedValue(undefined), + }; + const mockOrgDefaultsSelect = { + selectAll: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + executeTakeFirstOrThrow: jest.fn().mockResolvedValue({ + org_id: 'org-456', + moderator_safety_grayscale: true, + moderator_safety_sepia: false, + moderator_safety_blur_level: 2, + moderator_safety_mute_video: true, + }), + }; + (mockDb.selectFrom as jest.Mock) + .mockReturnValueOnce(mockUserSelect) + .mockReturnValueOnce(mockOrgDefaultsSelect); + + const settings = await sut.getUserInterfaceSettings({ + userId: 'user-123', + orgId: 'org-456', + }); + + expect(settings.themePreference).toBeNull(); + }, + ); + }); + describe('#resetPasswordForToken', () => { testWithFixtures( 'invalidates all sessions for the user after resetting the password', diff --git a/server/services/userManagementService/userManagementService.ts b/server/services/userManagementService/userManagementService.ts index bf86bdbd..bcb54319 100644 --- a/server/services/userManagementService/userManagementService.ts +++ b/server/services/userManagementService/userManagementService.ts @@ -14,7 +14,7 @@ import { asyncRandomBytes } from '../../utils/misc.js'; import { HOUR_MS } from '../../utils/time.js'; import { CoopEmailAddress } from '../sendEmailService/sendEmailService.js'; import { MIN_PASSWORD_LENGTH } from './constants.js'; -import type { MrtChartConfig } from './dbTypes.js'; +import type { MrtChartConfig, ThemePreference } from './dbTypes.js'; import type { UserManagementPg } from './index.js'; import { UserPermission, @@ -67,6 +67,7 @@ class UserManagementService { moderatorSafetyMuteVideo: row.moderator_safety_mute_video, moderatorSafetySepia: row.moderator_safety_sepia, mrtChartConfigurations: row.mrt_chart_configurations ?? [], + themePreference: row.theme_preference, }; } @@ -85,6 +86,7 @@ class UserManagementService { row?.moderator_safety_mute_video ?? orgDefaults.moderatorSafetyMuteVideo, mrtChartConfigurations: row?.mrt_chart_configurations ?? [], + themePreference: row?.theme_preference ?? null, }; } @@ -191,10 +193,11 @@ class UserManagementService { moderatorSafetySepia: boolean; }; mrtChartConfigurations?: readonly MrtChartConfig[]; + themePreference?: ThemePreference; }; }) { const { userId, userInterfaceSettings } = input; - const { moderatorSafetySettings, mrtChartConfigurations } = + const { moderatorSafetySettings, mrtChartConfigurations, themePreference } = userInterfaceSettings; const dbFormattedInterfaceSettings = { @@ -215,6 +218,7 @@ class UserManagementService { mrt_chart_configurations: [...mrtChartConfigurations], } : {}), + ...(themePreference ? { theme_preference: themePreference } : {}), }; let query = this.pgQuery