diff --git a/src/__tests__/_/api-handlers/account.ts b/src/__tests__/_/api-handlers/account.ts index 4d5754549..6c9675d00 100644 --- a/src/__tests__/_/api-handlers/account.ts +++ b/src/__tests__/_/api-handlers/account.ts @@ -8,7 +8,6 @@ let ME: IAuthenticatedUserBag = { role: "creator", systemProfile: "{userId: 1}", username: "root", - preferences: '{"theme":"dark"}', }; let USERS = [ @@ -58,20 +57,6 @@ export const accountApiHandlers = [ return res(ctx.status(204)); }), - rest.patch( - BASE_TEST_URL("/api/account/preferences"), - async (req, res, ctx) => { - ME = { - ...ME, - preferences: JSON.stringify({ - ...JSON.parse(ME.preferences), - ...(await req.json()), - }), - }; - return res(ctx.status(204)); - } - ), - rest.get(BASE_TEST_URL("/api/account/:username"), async (_, res, ctx) => { return res(ctx.json(USER)); }), diff --git a/src/__tests__/api/_test-utils/_all.ts b/src/__tests__/api/_test-utils/_all.ts index 743ba8c42..a602d46e7 100644 --- a/src/__tests__/api/_test-utils/_all.ts +++ b/src/__tests__/api/_test-utils/_all.ts @@ -12,6 +12,7 @@ import { setupActivatedActionTestData } from "./_activated-actions"; import { setupActionInstanceTestData } from "./_action-instances"; import { setupTestDatabaseData } from "./_data"; import { portalTestData } from "./portal"; +import { setupUserPreferencesTestData } from "./_user-preferences"; type DomainTypes = ConfigDomain | KeyValueDomain | "data"; @@ -28,6 +29,7 @@ export const setupAllTestData = async (domains: DomainTypes[]) => { ["constants", setupIntegrationsConstantsTestData], ["environment-variables", setupIntegrationsEnvTestData], ["credentials", setupCredentialsTestData], + ["users-preferences", setupUserPreferencesTestData], ...portalTestData, ]; diff --git a/src/__tests__/api/_test-utils/_user-preferences.ts b/src/__tests__/api/_test-utils/_user-preferences.ts new file mode 100644 index 000000000..0944db628 --- /dev/null +++ b/src/__tests__/api/_test-utils/_user-preferences.ts @@ -0,0 +1,8 @@ +import { createConfigDomainPersistenceService } from "backend/lib/config-persistence"; + +export const setupUserPreferencesTestData = async () => { + const configPersistenceService = + createConfigDomainPersistenceService("users-preferences"); + + await configPersistenceService.resetToEmpty(); +}; diff --git a/src/__tests__/api/account/preferences.spec.ts b/src/__tests__/api/account/preferences.spec.ts deleted file mode 100644 index 4b22f7aa7..000000000 --- a/src/__tests__/api/account/preferences.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import handler from "pages/api/account/preferences"; -import mineHandler from "pages/api/account/mine"; -import { - createAuthenticatedMocks, - setupAllTestData, -} from "__tests__/api/_test-utils"; - -describe("/api/account/preferences", () => { - beforeAll(async () => { - await setupAllTestData(["users"]); - }); - - it("should create authenticated user preference when doesn't exist", async () => { - const postRequest = createAuthenticatedMocks({ - method: "PATCH", - body: { - theme: "light", - }, - }); - - await handler(postRequest.req, postRequest.res); - - expect(postRequest.res._getStatusCode()).toBe(200); - - const getRequest = createAuthenticatedMocks({ - method: "GET", - }); - - await mineHandler(getRequest.req, getRequest.res); - - expect(getRequest.res._getJSONData()).toMatchInlineSnapshot(` - { - "name": "Root User", - "permissions": [], - "preferences": "{"theme":"light"}", - "role": "creator", - "systemProfile": "{"userId": "1"}", - "username": "root", - } - `); - }); - - it("should update authenticated user preference it when exists", async () => { - const postRequest = createAuthenticatedMocks({ - method: "PATCH", - body: { - theme: "dark", - }, - }); - - await handler(postRequest.req, postRequest.res); - - expect(postRequest.res._getStatusCode()).toBe(200); - - const getRequest = createAuthenticatedMocks({ - method: "GET", - }); - - await mineHandler(getRequest.req, getRequest.res); - // Would be nice to test that it doesn't reset other fields data - expect(getRequest.res._getJSONData()).toMatchInlineSnapshot(` - { - "name": "Root User", - "permissions": [], - "preferences": "{"theme":"dark"}", - "role": "creator", - "systemProfile": "{"userId": "1"}", - "username": "root", - } - `); - }); -}); diff --git a/src/__tests__/api/config/[key]/index.spec.ts b/src/__tests__/api/config/[key]/index.spec.ts index a1e42433d..4d416590c 100644 --- a/src/__tests__/api/config/[key]/index.spec.ts +++ b/src/__tests__/api/config/[key]/index.spec.ts @@ -117,4 +117,70 @@ describe("/api/config/[key]/index", () => { expect(getReq.res._getStatusCode()).toBe(200); expect(getReq.res._getJSONData()).toEqual(["order-1", "order-2"]); }); + + describe("App config key validation", () => { + it("should return error for invalid config key", async () => { + const { req, res } = createAuthenticatedMocks({ + method: "GET", + query: { + key: "some-invalid-key", + }, + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + expect(res._getJSONData()).toMatchInlineSnapshot(` + { + "message": "Configuration key 'some-invalid-key' doesn't exist", + "method": "GET", + "name": "BadRequestError", + "path": "", + "statusCode": 400, + } + `); + }); + + it("should return error for no config key", async () => { + const { req, res } = createAuthenticatedMocks({ + method: "GET", + query: {}, + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + expect(res._getJSONData()).toMatchInlineSnapshot(` + { + "message": "Configuration key 'undefined' doesn't exist", + "method": "GET", + "name": "BadRequestError", + "path": "", + "statusCode": 400, + } + `); + }); + + it("should return error for entity config keys when the entity is not passed", async () => { + const { req, res } = createAuthenticatedMocks({ + method: "GET", + query: { + key: "entity_views", + }, + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + expect(res._getJSONData()).toMatchInlineSnapshot(` + { + "message": "Configuration of key 'entity_views' requires entity", + "method": "GET", + "name": "BadRequestError", + "path": "", + "statusCode": 400, + } + `); + }); + }); }); diff --git a/src/__tests__/api/user-preferences/[key].spec.ts b/src/__tests__/api/user-preferences/[key].spec.ts new file mode 100644 index 000000000..0e16ed848 --- /dev/null +++ b/src/__tests__/api/user-preferences/[key].spec.ts @@ -0,0 +1,139 @@ +import handler from "pages/api/user-preferences/[key]"; +import { + createAuthenticatedMocks, + setupAllTestData, +} from "__tests__/api/_test-utils"; + +describe("/api/user-preferences/[key]", () => { + beforeAll(async () => { + await setupAllTestData(["users-preferences"]); + }); + + it("should get the default value when value doesn't exist for user", async () => { + const getRequest = createAuthenticatedMocks({ + method: "GET", + query: { + key: "theme", + }, + }); + + await handler(getRequest.req, getRequest.res); + + expect(getRequest.res._getJSONData()).toMatchInlineSnapshot(` + { + "data": "light", + } + `); + }); + + it("should create authenticated user preference when doesn't exist", async () => { + const postRequest = createAuthenticatedMocks({ + method: "PUT", + query: { + key: "theme", + }, + body: { + data: "dark", + }, + }); + + await handler(postRequest.req, postRequest.res); + + expect(postRequest.res._getStatusCode()).toBe(204); + + const getRequest = createAuthenticatedMocks({ + method: "GET", + query: { + key: "theme", + }, + }); + + await handler(getRequest.req, getRequest.res); + + expect(getRequest.res._getJSONData()).toMatchInlineSnapshot(` + { + "data": "dark", + } + `); + }); + + it("should update authenticated user preference it when exists", async () => { + const postRequest = createAuthenticatedMocks({ + method: "PUT", + query: { + key: "theme", + }, + body: { + data: "light", + }, + }); + + await handler(postRequest.req, postRequest.res); + + const getRequest = createAuthenticatedMocks({ + method: "GET", + query: { + key: "theme", + }, + }); + + await handler(getRequest.req, getRequest.res); + + expect(getRequest.res._getJSONData()).toMatchInlineSnapshot(` + { + "data": "light", + } + `); + }); + + describe("Preference key validation", () => { + it("GET", async () => { + const getRequest = createAuthenticatedMocks({ + method: "GET", + query: { + key: "some-invalid-preference-key", + }, + }); + + await handler(getRequest.req, getRequest.res); + + expect(getRequest.res._getStatusCode()).toBe(400); + + expect(getRequest.res._getJSONData()).toMatchInlineSnapshot(` + { + "message": "User Preference key 'some-invalid-preference-key' doesn't exist", + "method": "GET", + "name": "BadRequestError", + "path": "", + "statusCode": 400, + } + `); + }); + + it("PUT", async () => { + const getRequest = createAuthenticatedMocks({ + query: { + key: "some-invalid-preference-key", + }, + method: "PUT", + body: { + data: "light", + }, + }); + + await handler(getRequest.req, getRequest.res); + + expect(getRequest.res._getStatusCode()).toBe(400); + + expect(getRequest.res._getJSONData()).toMatchInlineSnapshot(` + { + "message": "User Preference key 'some-invalid-preference-key' doesn't exist", + "method": "PUT", + "name": "BadRequestError", + "path": "", + "statusCode": 400, + } + `); + }); + }); +}); diff --git a/src/backend/configuration/configuration.controller.ts b/src/backend/configuration/configuration.controller.ts index 4c6d437e3..26ce46d43 100644 --- a/src/backend/configuration/configuration.controller.ts +++ b/src/backend/configuration/configuration.controller.ts @@ -11,23 +11,46 @@ import { export class ConfigurationApiController { constructor(private _configurationService: ConfigurationApiService) {} - async showConfig(key: AppConfigurationKeys, entity?: string) { - return await this._configurationService.show(key, entity); + async showConfig(key: string, entity?: string) { + const appConfigurationKey = this.validateAppConfigurationKeys({ + key, + entity, + }); + return await this._configurationService.show(appConfigurationKey, entity); } - async showGuestConfig(key: AppConfigurationKeys) { + async showGuestConfig(key: string) { if (!APP_CONFIGURATION_CONFIG[key].guest) { throw new BadRequestError(`Invalid guest config key ${key}`); } return await this.showConfig(key); } - async upsertConfig( - key: AppConfigurationKeys, - value: unknown, - entity?: string - ) { - await this._configurationService.upsert(key, value, entity); + async upsertConfig(key: string, value: unknown, entity?: string) { + const appConfigurationKey = this.validateAppConfigurationKeys({ + key, + entity, + }); + await this._configurationService.upsert(appConfigurationKey, value, entity); + } + + private validateAppConfigurationKeys({ + key, + entity, + }: { + key: string; + entity?: string; + }) { + const configBag = APP_CONFIGURATION_CONFIG[key]; + if (!configBag) { + throw new BadRequestError(`Configuration key '${key}' doesn't exist`); + } + if (configBag.requireEntity && !entity) { + throw new BadRequestError( + `Configuration of key '${key}' requires entity` + ); + } + return key as AppConfigurationKeys; } } diff --git a/src/backend/lib/request/validations/implementations/__tests__/config-body.spec.ts b/src/backend/lib/request/validations/implementations/__tests__/config-body.spec.ts deleted file mode 100644 index 08ac1647f..000000000 --- a/src/backend/lib/request/validations/implementations/__tests__/config-body.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { requestHandler } from "backend/lib/request"; -import { createAuthenticatedMocks } from "__tests__/api/_test-utils"; - -const handler = requestHandler({ - POST: async (getValidatedRequest) => { - const requestBody = await getValidatedRequest(["configBody"]); - return requestBody.configBody; - }, -}); - -describe("Request Validations => configBodyValidationImpl", () => { - it("should return config body", async () => { - const { req, res } = createAuthenticatedMocks({ - method: "POST", - body: { - data: { - hello: "there", - }, - }, - }); - - await handler(req, res); - - expect(res._getJSONData()).toMatchInlineSnapshot(` - { - "hello": "there", - } - `); - }); -}); diff --git a/src/backend/lib/request/validations/implementations/__tests__/config-key.spec.ts b/src/backend/lib/request/validations/implementations/__tests__/config-key.spec.ts deleted file mode 100644 index e70b39b5e..000000000 --- a/src/backend/lib/request/validations/implementations/__tests__/config-key.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { requestHandler } from "backend/lib/request"; -import { createAuthenticatedMocks } from "__tests__/api/_test-utils"; - -const handler = requestHandler({ - GET: async (getValidatedRequest) => { - const validatedRequest = await getValidatedRequest(["configKey"]); - return validatedRequest.configKey; - }, -}); - -describe("Request Validations => configKeyFilterValidationImpl", () => { - it("should return config key", async () => { - const { req, res } = createAuthenticatedMocks({ - method: "GET", - query: { - key: "theme_color", - }, - }); - - await handler(req, res); - - expect(res._getData()).toBe('"theme_color"'); - }); - - it("should return error for invalid config key", async () => { - const { req, res } = createAuthenticatedMocks({ - method: "GET", - query: { - key: "some-invalid-key", - }, - }); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - expect(res._getJSONData()).toMatchInlineSnapshot(` - { - "message": "Configuration key 'some-invalid-key' doesn't exist", - "method": "GET", - "name": "BadRequestError", - "path": "", - "statusCode": 400, - } - `); - }); - - it("should return error for no config key", async () => { - const { req, res } = createAuthenticatedMocks({ - method: "GET", - query: {}, - }); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - expect(res._getJSONData()).toMatchInlineSnapshot(` - { - "message": "Configuration key 'undefined' doesn't exist", - "method": "GET", - "name": "BadRequestError", - "path": "", - "statusCode": 400, - } - `); - }); - - it("should return error for entity config keys when the entity is not passed", async () => { - const { req, res } = createAuthenticatedMocks({ - method: "GET", - query: { - key: "entity_views", - }, - }); - - await handler(req, res); - - expect(res._getStatusCode()).toBe(400); - expect(res._getJSONData()).toMatchInlineSnapshot(` - { - "message": "Configuration of key 'entity_views' requires entity", - "method": "GET", - "name": "BadRequestError", - "path": "", - "statusCode": 400, - } - `); - }); - - it("should return entity config key", async () => { - const { req, res } = createAuthenticatedMocks({ - method: "GET", - query: { - key: "entity_views", - entity: "foo", - }, - }); - - await handler(req, res); - - expect(res._getData()).toBe('"entity_views"'); - }); -}); diff --git a/src/backend/lib/request/validations/implementations/__tests__/entity-request-body.spec.ts b/src/backend/lib/request/validations/implementations/__tests__/entity-request-body.spec.ts deleted file mode 100644 index d9c6a6690..000000000 --- a/src/backend/lib/request/validations/implementations/__tests__/entity-request-body.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { requestHandler } from "backend/lib/request"; -import { createAuthenticatedMocks } from "__tests__/api/_test-utils"; - -const handler = requestHandler({ - POST: async (getValidatedRequest) => { - const requestBody = await getValidatedRequest(["entityRequestBody"]); - return requestBody.entityRequestBody; - }, -}); - -describe("Request Validations => entityRequestBodyValidationImpl", () => { - it("should return entity request body", async () => { - const { req, res } = createAuthenticatedMocks({ - method: "POST", - body: { - data: { - name: "John Doe", - }, - }, - }); - - await handler(req, res); - - expect(res._getJSONData()).toMatchInlineSnapshot(` - { - "name": "John Doe", - } - `); - }); -}); diff --git a/src/backend/lib/request/validations/implementations/config-body.ts b/src/backend/lib/request/validations/implementations/config-body.ts deleted file mode 100644 index 2faa4b442..000000000 --- a/src/backend/lib/request/validations/implementations/config-body.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ValidationImplType } from "./types"; - -export const configBodyValidationImpl: ValidationImplType< - Record -> = async (req) => { - return req.body.data; -}; diff --git a/src/backend/lib/request/validations/implementations/config-key.ts b/src/backend/lib/request/validations/implementations/config-key.ts deleted file mode 100644 index 7a5bc68bf..000000000 --- a/src/backend/lib/request/validations/implementations/config-key.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - APP_CONFIGURATION_CONFIG, - AppConfigurationKeys, -} from "shared/configurations"; -import { BadRequestError } from "backend/lib/errors"; -import { ValidationImplType } from "./types"; - -export const configKeyFilterValidationImpl: ValidationImplType< - AppConfigurationKeys -> = async (req) => { - const key = req.query.key as string; - const configBag = APP_CONFIGURATION_CONFIG[key]; - if (!configBag) { - throw new BadRequestError(`Configuration key '${key}' doesn't exist`); - } - if (configBag.requireEntity && !req.query.entity) { - throw new BadRequestError(`Configuration of key '${key}' requires entity`); - } - return key as AppConfigurationKeys; -}; diff --git a/src/backend/lib/request/validations/implementations/entity-request-body.ts b/src/backend/lib/request/validations/implementations/entity-request-body.ts deleted file mode 100644 index e916ed1c4..000000000 --- a/src/backend/lib/request/validations/implementations/entity-request-body.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ValidationImplType } from "./types"; - -export const entityRequestBodyValidationImpl: ValidationImplType< - Record -> = async (req) => { - const { data } = req.body; - /* - For performance reasons all the required validations will be done in the controller - So that we can parallelize all the async requests - */ - return data; -}; diff --git a/src/backend/lib/request/validations/implementations/index.ts b/src/backend/lib/request/validations/implementations/index.ts index 7c2e3ff77..a1a14f32e 100644 --- a/src/backend/lib/request/validations/implementations/index.ts +++ b/src/backend/lib/request/validations/implementations/index.ts @@ -1,13 +1,10 @@ import { ValidationKeys } from "../types"; import { crudEnabledValidationImpl as crudEnabled } from "./crud-enabled"; -import { configBodyValidationImpl as configBody } from "./config-body"; -import { configKeyFilterValidationImpl as configKey } from "./config-key"; import { entityValidationImpl as entity } from "./entity"; import { entityIdFilterValidationImpl as entityId } from "./entity-id"; import { isAuthenticatedValidationImpl as isAuthenticated } from "./is-authenticated"; import { paginationFilterValidationImpl as paginationFilter } from "./pagination-filter"; import { queryFilterValidationImpl as queryFilters } from "./query-filters"; -import { entityRequestBodyValidationImpl as entityRequestBody } from "./entity-request-body"; import { requestBodyValidationImpl as requestBody } from "./request-body"; import { guestValidationImpl as guest } from "./guest"; import { anyBodyValidationImpl as anyBody } from "./any-body"; @@ -36,12 +33,9 @@ export const ValidationImpl: Record< rawRequest, requestQueries, authenticatedUser, - configBody, - entityRequestBody, entity, paginationFilter, queryFilters, entityId, withPassword, - configKey, }; diff --git a/src/backend/lib/request/validations/types.ts b/src/backend/lib/request/validations/types.ts index b63d10af1..e73b6d51d 100644 --- a/src/backend/lib/request/validations/types.ts +++ b/src/backend/lib/request/validations/types.ts @@ -8,7 +8,6 @@ export type ValidationKeys = { | "guest" | "entity" | "authenticatedUser" - | "configKey" // TODO remove this | "rawRequest" | "paginationFilter" | "canUser" @@ -17,10 +16,8 @@ export type ValidationKeys = { | "requestQuery" | "requestQueries" | "entityId" - | "entityRequestBody" | "queryFilters" - | "withPassword" - | "configBody"; + | "withPassword"; method?: RequestMethod[]; body?: unknown; }; diff --git a/src/backend/user-preferences/user-preferences.controller.ts b/src/backend/user-preferences/user-preferences.controller.ts index 46d924bbc..dedd71704 100644 --- a/src/backend/user-preferences/user-preferences.controller.ts +++ b/src/backend/user-preferences/user-preferences.controller.ts @@ -1,4 +1,8 @@ -import { UserPreferencesKeys } from "shared/user-preferences/constants"; +import { + USER_PREFERENCES_CONFIG, + UserPreferencesKeys, +} from "shared/user-preferences/constants"; +import { BadRequestError } from "backend/lib/errors"; import { UserPreferencesApiService, userPreferencesApiService, @@ -7,12 +11,30 @@ import { export class UserPreferenceApiController { constructor(private _userPreferencesApiService: UserPreferencesApiService) {} - async show(username: string, key: UserPreferencesKeys) { - return await this._userPreferencesApiService.show(username, key); + async show(username: string, key: string) { + return { + data: await this._userPreferencesApiService.show( + username, + this.validateUserPreferencesKeys(key) + ), + }; + } + + async upsert(username: string, key: string, value: unknown) { + await this._userPreferencesApiService.upsert( + username, + this.validateUserPreferencesKeys(key), + value + ); } - async upsert(username: string, key: UserPreferencesKeys, value: unknown) { - await this._userPreferencesApiService.upsert(username, key, value); + private validateUserPreferencesKeys(key: string) { + const configBag = USER_PREFERENCES_CONFIG[key]; + if (!configBag) { + throw new BadRequestError(`User Preference key '${key}' doesn't exist`); + } + + return key as UserPreferencesKeys; } } diff --git a/src/backend/user-preferences/user-preferences.service.ts b/src/backend/user-preferences/user-preferences.service.ts index ee308f3d9..8d067be01 100644 --- a/src/backend/user-preferences/user-preferences.service.ts +++ b/src/backend/user-preferences/user-preferences.service.ts @@ -8,8 +8,6 @@ import { AbstractConfigDataPersistenceService, } from "../lib/config-persistence"; -// TODO hidden_entity_table_columns - export class UserPreferencesApiService implements IApplicationService { constructor( private _userPreferencesPersistenceService: AbstractConfigDataPersistenceService diff --git a/src/backend/users/users.service.ts b/src/backend/users/users.service.ts index 2f1624e2c..8e3e980e4 100644 --- a/src/backend/users/users.service.ts +++ b/src/backend/users/users.service.ts @@ -57,7 +57,6 @@ export class UsersApiService implements IApplicationService { return users.map((user) => { const userCopy = { ...user }; delete userCopy.password; - delete userCopy.preferences; return userCopy; }); diff --git a/src/frontend/_layouts/portal/main.ts b/src/frontend/_layouts/portal/main.ts index 02ffb6841..9a7eb7c4b 100644 --- a/src/frontend/_layouts/portal/main.ts +++ b/src/frontend/_layouts/portal/main.ts @@ -1,9 +1,10 @@ import { IColorMode } from "frontend/design-system/theme/types"; import { noop } from "shared/lib/noop"; +import { ColorSchemes } from "shared/types/ui"; export const processThemeColors = ( primaryColor: string, - theme: "light" | "dark" | IColorMode, + theme: ColorSchemes | IColorMode, _: unknown ) => { noop(_); diff --git a/src/frontend/_layouts/useAppTheme.ts b/src/frontend/_layouts/useAppTheme.ts index acab9e107..26a3c8276 100644 --- a/src/frontend/_layouts/useAppTheme.ts +++ b/src/frontend/_layouts/useAppTheme.ts @@ -1,8 +1,9 @@ import { useAppConfiguration } from "frontend/hooks/configuration/configuration.store"; -import { useAuthenticatedUserPreferences } from "frontend/hooks/auth/user.store"; import { MAKE_CRUD_CONFIG } from "frontend/lib/crud-config"; import { IColorMode } from "frontend/design-system/theme/types"; import { useTheme } from "frontend/design-system/theme/useTheme"; +import { useUserPreference } from "frontend/hooks/auth/preferences.store"; +import { ColorSchemes } from "shared/types/ui"; import { processThemeColors } from "./portal"; import { IThemeSettings } from "./types"; import { getThemePrimaryColor } from "./utils"; @@ -15,10 +16,9 @@ export const THEME_SETTINGS_CRUD_CONFIG = MAKE_CRUD_CONFIG({ export const useUserThemePreference = () => { const themeColor = useAppConfiguration("theme_color"); - const userPreferences = useAuthenticatedUserPreferences(); + const userPreferences = useUserPreference("theme"); - const theme: "light" | "dark" | IColorMode = - (userPreferences.data?.theme as "light" | "dark") || "light"; + const theme: ColorSchemes | IColorMode = userPreferences.data; const primaryColor = getThemePrimaryColor(theme, themeColor); diff --git a/src/frontend/_layouts/utils.ts b/src/frontend/_layouts/utils.ts index 2c8a5532c..c983292ea 100644 --- a/src/frontend/_layouts/utils.ts +++ b/src/frontend/_layouts/utils.ts @@ -1,8 +1,9 @@ import { DataStateKeys } from "frontend/lib/data/types"; +import { ColorSchemes } from "shared/types/ui"; import { IThemeSettings } from "./types"; export const getThemePrimaryColor = ( - theme: "light" | "dark", + theme: ColorSchemes, themeColor: DataStateKeys ) => theme === "dark" ? themeColor.data?.primaryDark : themeColor.data?.primary; diff --git a/src/frontend/design-system/theme/useTheme.ts b/src/frontend/design-system/theme/useTheme.ts index a3d9d258a..dda23d382 100644 --- a/src/frontend/design-system/theme/useTheme.ts +++ b/src/frontend/design-system/theme/useTheme.ts @@ -1,5 +1,6 @@ import { useCallback, useContext, useEffect } from "react"; import { darken } from "polished"; +import { ColorSchemes } from "shared/types/ui"; import { DEFAULT_PRIMARY_COLOR } from "./constants"; import { ThemeContext } from "./Context"; import { colorModeToRootColors } from "./generate"; @@ -8,7 +9,7 @@ import { IColorMode, IRootColors } from "./types"; import { prefixVarNameSpace } from "./root"; const getColorModeImplementation = ( - colorMode?: "light" | "dark" | IColorMode + colorMode?: ColorSchemes | IColorMode ): IColorMode => { if (!colorMode) { return window.matchMedia("(prefers-color-scheme: dark)").matches @@ -23,7 +24,7 @@ const getColorModeImplementation = ( export const useTheme = ( themeColor?: string, - colorMode?: "light" | "dark" | IColorMode + colorMode?: ColorSchemes | IColorMode ) => { const themeContext = useContext(ThemeContext); diff --git a/src/frontend/hooks/auth/preferences.store.ts b/src/frontend/hooks/auth/preferences.store.ts new file mode 100644 index 000000000..ab6f42f7f --- /dev/null +++ b/src/frontend/hooks/auth/preferences.store.ts @@ -0,0 +1,64 @@ +import { useMutation } from "react-query"; +import { makeActionRequest } from "frontend/lib/data/makeRequest"; +import { useStorageApi } from "frontend/lib/data/useApi"; +import { useWaitForResponseMutationOptions } from "frontend/lib/data/useMutate/useWaitForResponseMutationOptions"; +import { AppStorage } from "frontend/lib/storage/app"; +import { + USER_PREFERENCES_CONFIG, + UserPreferencesKeys, +} from "shared/user-preferences/constants"; +import { MAKE_CRUD_CONFIG } from "frontend/lib/crud-config"; +import { useIsAuthenticatedStore } from "./useAuthenticateUser"; + +const userPrefrencesApiPath = (key: UserPreferencesKeys) => { + return `/api/user-preferences/${key}`; +}; + +export const MAKE_USER_PREFERENCE_CRUD_CONFIG = (key: UserPreferencesKeys) => { + return MAKE_CRUD_CONFIG({ + path: "N/A", + plural: USER_PREFERENCES_CONFIG[key].label, + singular: USER_PREFERENCES_CONFIG[key].label, + }); +}; + +export function useUserPreference(key: UserPreferencesKeys) { + const isAuthenticated = useIsAuthenticatedStore( + (store) => store.isAuthenticated + ); + + return useStorageApi(userPrefrencesApiPath(key), { + enabled: isAuthenticated === true, + returnUndefinedOnError: true, + errorMessage: MAKE_USER_PREFERENCE_CRUD_CONFIG(key).TEXT_LANG.NOT_FOUND, + defaultData: USER_PREFERENCES_CONFIG[key].defaultValue as T, + selector: (data) => data.data, + }); +} + +interface IUpsertConfigMutationOptions { + otherEndpoints: string[]; +} + +export function useUpsertUserPreferenceMutation( + key: UserPreferencesKeys, + mutationOptions?: IUpsertConfigMutationOptions +) { + const apiMutateOptions = useWaitForResponseMutationOptions({ + endpoints: [ + userPrefrencesApiPath(key), + ...(mutationOptions?.otherEndpoints || []), + ], + onSuccessActionWithFormData: (data) => { + AppStorage.set(userPrefrencesApiPath(key), data); + }, + successMessage: MAKE_USER_PREFERENCE_CRUD_CONFIG(key).MUTATION_LANG.SAVED, + }); + + return useMutation(async (values: T) => { + await makeActionRequest("PUT", userPrefrencesApiPath(key), { + data: values, + }); + return values; + }, apiMutateOptions); +} diff --git a/src/frontend/hooks/auth/user.store.ts b/src/frontend/hooks/auth/user.store.ts index 6ed0d27c0..809ad1f96 100644 --- a/src/frontend/hooks/auth/user.store.ts +++ b/src/frontend/hooks/auth/user.store.ts @@ -29,22 +29,6 @@ export function useAuthenticatedUserBag() { }); } -export function useAuthenticatedUserPreferences() { - const isAuthenticated = useIsAuthenticatedStore( - (store) => store.isAuthenticated - ); - return useStorageApi(AUTHENTICATED_ACCOUNT_URL, { - returnUndefinedOnError: true, - defaultData: DEFAULT_USER_PREFERENCE, - enabled: isAuthenticated === true, - selector: (data: IAuthenticatedUserBag) => { - return data.preferences - ? JSON.parse(data.preferences) - : DEFAULT_USER_PREFERENCE; - }, - }); -} - const doPermissionCheck = ( requiredPermission: string, isLoadingUser: boolean, diff --git a/src/frontend/hooks/configuration/configuration.store.ts b/src/frontend/hooks/configuration/configuration.store.ts index b24269606..aa1866016 100644 --- a/src/frontend/hooks/configuration/configuration.store.ts +++ b/src/frontend/hooks/configuration/configuration.store.ts @@ -10,6 +10,7 @@ import { AppStorage } from "frontend/lib/storage/app"; import { isRouterParamEnabled } from ".."; import { MAKE_APP_CONFIGURATION_CRUD_CONFIG } from "./configuration.constant"; +// :eyes export const configurationApiPath = ( key: AppConfigurationKeys, entity?: string, @@ -19,7 +20,11 @@ export const configurationApiPath = ( return `/api/config/${key}/${entity}`; } - if (APP_CONFIGURATION_CONFIG[key].guest && method === "GET") { + if (method === "PUT") { + return `/api/config/${key}`; + } + + if (APP_CONFIGURATION_CONFIG[key].guest) { return `/api/config/${key}/__guest`; } diff --git a/src/frontend/lib/storage/app.ts b/src/frontend/lib/storage/app.ts index 87fa2bedc..c9c324189 100644 --- a/src/frontend/lib/storage/app.ts +++ b/src/frontend/lib/storage/app.ts @@ -8,7 +8,7 @@ const makeKey = (key: string): string => { }; export const AppStorage = { - set: (key: string, value: Record | unknown[]) => { + set: (key: string, value: unknown) => { StorageService.setString(makeKey(key), JSON.stringify(value)); }, get: (key: string) => { diff --git a/src/frontend/views/account/Preferences/Form.tsx b/src/frontend/views/account/Preferences/Form.tsx index 8063cb402..7e06e86f2 100644 --- a/src/frontend/views/account/Preferences/Form.tsx +++ b/src/frontend/views/account/Preferences/Form.tsx @@ -1,12 +1,14 @@ import { IFormProps } from "frontend/lib/form/types"; import { SchemaForm } from "frontend/components/SchemaForm"; -import { UPDATE_USER_PREFERENCES_FORM_SCHEMA } from "shared/form-schemas/profile/update"; -import { IUserPreferences } from "shared/types/user"; import { userFriendlyCase } from "shared/lib/strings/friendly-case"; import { useEffect } from "react"; import { usePortalThemes } from "frontend/_layouts/portal"; import { uniqBy } from "shared/lib/array/uniq-by"; -import { ACCOUNT_PREFERENCES_CRUD_CONFIG } from "../constants"; +import { + ACCOUNT_PREFERENCES_CRUD_CONFIG, + UPDATE_USER_PREFERENCES_FORM_SCHEMA, +} from "./constants"; +import { IUserPreferences } from "./types"; export function UserPreferencesForm({ onSubmit, diff --git a/src/frontend/views/account/Preferences/constants.ts b/src/frontend/views/account/Preferences/constants.ts new file mode 100644 index 000000000..727ed1ba7 --- /dev/null +++ b/src/frontend/views/account/Preferences/constants.ts @@ -0,0 +1,31 @@ +import { MAKE_CRUD_CONFIG } from "frontend/lib/crud-config"; +import { IAppliedSchemaFormConfig } from "shared/form-schemas/types"; +import { IUserPreferences } from "./types"; + +export const ACCOUNT_PREFERENCES_CRUD_CONFIG = MAKE_CRUD_CONFIG({ + path: "N/A", + plural: "Account Preferences", + singular: "Account Preferences", +}); + +export const UPDATE_USER_PREFERENCES_FORM_SCHEMA: IAppliedSchemaFormConfig = + { + theme: { + type: "selection", + validations: [ + { + validationType: "required", + }, + ], + selections: [ + { + label: "Light", + value: "light", + }, + { + label: "Dark", + value: "dark", + }, + ], + }, + }; diff --git a/src/frontend/views/account/Preferences/index.tsx b/src/frontend/views/account/Preferences/index.tsx index 683244688..ba55e6f14 100644 --- a/src/frontend/views/account/Preferences/index.tsx +++ b/src/frontend/views/account/Preferences/index.tsx @@ -1,4 +1,3 @@ -import { useAuthenticatedUserPreferences } from "frontend/hooks/auth/user.store"; import { useSetPageDetails } from "frontend/lib/routing/usePageDetails"; import { ViewStateMachine } from "frontend/components/ViewStateMachine"; import { META_USER_PERMISSIONS } from "shared/constants/user"; @@ -7,18 +6,20 @@ import { FormSkeleton, FormSkeletonSchema, } from "frontend/design-system/components/Skeleton/Form"; -import { useUpdateUserPreferencesMutation } from "../account.store"; import { - ACCOUNT_PREFERENCES_CRUD_CONFIG, - ACCOUNT_VIEW_KEY, -} from "../constants"; + useUpsertUserPreferenceMutation, + useUserPreference, +} from "frontend/hooks/auth/preferences.store"; +import { ColorSchemes } from "shared/types/ui"; +import { ACCOUNT_VIEW_KEY } from "../constants"; import { BaseAccountLayout } from "../_Base"; import { UserPreferencesForm } from "./Form"; +import { ACCOUNT_PREFERENCES_CRUD_CONFIG } from "./constants"; export function UserPreferences() { - const userPreferences = useAuthenticatedUserPreferences(); - const updateProfilePreferencesMutation = useUpdateUserPreferencesMutation(); + const userPreferences = useUserPreference("theme"); + const upsertUserPreferenceMutation = useUpsertUserPreferenceMutation("theme"); useSetPageDetails({ pageTitle: ACCOUNT_PREFERENCES_CRUD_CONFIG.TEXT_LANG.EDIT, @@ -35,8 +36,10 @@ export function UserPreferences() { loader={} > { + await upsertUserPreferenceMutation.mutateAsync(data.theme); + }} + initialValues={{ theme: userPreferences.data }} /> diff --git a/src/frontend/views/account/Preferences/types.ts b/src/frontend/views/account/Preferences/types.ts new file mode 100644 index 000000000..725866588 --- /dev/null +++ b/src/frontend/views/account/Preferences/types.ts @@ -0,0 +1,5 @@ +import { ColorSchemes } from "shared/types/ui"; + +export type IUserPreferences = { + theme: ColorSchemes; +}; diff --git a/src/frontend/views/account/account.store.ts b/src/frontend/views/account/account.store.ts index e7c5f8270..798e4dc8c 100644 --- a/src/frontend/views/account/account.store.ts +++ b/src/frontend/views/account/account.store.ts @@ -5,10 +5,7 @@ import { IUpdateUserForm } from "shared/form-schemas/profile/update"; import { ACCOUNT_PROFILE_CRUD_CONFIG } from "frontend/hooks/auth/constants"; import { useWaitForResponseMutationOptions } from "frontend/lib/data/useMutate/useWaitForResponseMutationOptions"; import { makeActionRequest } from "frontend/lib/data/makeRequest"; -import { - ACCOUNT_PREFERENCES_CRUD_CONFIG, - PASSWORD_CRUD_CONFIG, -} from "./constants"; +import { PASSWORD_CRUD_CONFIG } from "./constants"; export function useUpdateProfileMutation() { const apiMutateOptions = useWaitForResponseMutationOptions({ @@ -23,19 +20,6 @@ export function useUpdateProfileMutation() { ); } -export function useUpdateUserPreferencesMutation() { - const apiMutateOptions = useWaitForResponseMutationOptions({ - endpoints: [AUTHENTICATED_ACCOUNT_URL], - successMessage: ACCOUNT_PREFERENCES_CRUD_CONFIG.MUTATION_LANG.SAVED, - }); - - return useMutation( - async (data: IUserPreferences) => - await makeActionRequest("PATCH", "/api/account/preferences", data), - apiMutateOptions - ); -} - export function useChangePasswordMutation() { const apiMutateOptions = useWaitForResponseMutationOptions({ endpoints: [], diff --git a/src/frontend/views/account/constants.ts b/src/frontend/views/account/constants.ts index 06247c355..c9845f838 100644 --- a/src/frontend/views/account/constants.ts +++ b/src/frontend/views/account/constants.ts @@ -2,12 +2,6 @@ import { MAKE_CRUD_CONFIG } from "frontend/lib/crud-config"; export const ACCOUNT_VIEW_KEY = "ACCOUNT_VIEW_KEY"; -export const ACCOUNT_PREFERENCES_CRUD_CONFIG = MAKE_CRUD_CONFIG({ - path: "N/A", - plural: "Account Preferences", - singular: "Account Preferences", -}); - export const PASSWORD_CRUD_CONFIG = MAKE_CRUD_CONFIG({ path: "N/A", plural: "Password", diff --git a/src/frontend/views/settings/Menu/index.tsx b/src/frontend/views/settings/Menu/index.tsx index 805757d24..12c6d4d3c 100644 --- a/src/frontend/views/settings/Menu/index.tsx +++ b/src/frontend/views/settings/Menu/index.tsx @@ -86,7 +86,7 @@ export function MenuSettings() { return ( - + ("theme_color"); - const userPreferences = useAuthenticatedUserPreferences(); + const userPreference = useUserPreference("theme"); const upsertConfigurationMutation = useUpsertConfigurationMutation("theme_color"); - const updateUserPreferencesMutation = useUpdateUserPreferencesMutation(); + const upsertUserPreferenceMutation = + useUpsertUserPreferenceMutation("theme"); useSetPageDetails({ pageTitle: THEME_SETTINGS_CRUD_CONFIG.TEXT_LANG.TITLE, @@ -38,21 +42,21 @@ export function ThemeSettings() { } > { await Promise.all([ - updateUserPreferencesMutation.mutateAsync({ theme }), + upsertUserPreferenceMutation.mutateAsync(theme), upsertConfigurationMutation.mutateAsync({ primary, primaryDark, }), ]); }} - initialValues={{ ...themeColor.data, ...userPreferences.data }} + initialValues={{ ...themeColor.data, theme: userPreference.data }} /> diff --git a/src/pages/api/account/preferences.ts b/src/pages/api/account/preferences.ts deleted file mode 100644 index c4c81f6a9..000000000 --- a/src/pages/api/account/preferences.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { usersApiController } from "backend/users/users.controller"; -import { UPDATE_USER_PREFERENCES_FORM_SCHEMA } from "shared/form-schemas/profile/update"; -import { IAccountProfile } from "shared/types/user"; -import { requestHandler } from "backend/lib/request"; - -export default requestHandler({ - PATCH: async (getValidatedRequest) => { - const validatedRequest = await getValidatedRequest([ - "authenticatedUser", - { - _type: "requestBody", - options: UPDATE_USER_PREFERENCES_FORM_SCHEMA, - }, - ]); - return await usersApiController.updateUserPreferences( - (validatedRequest.authenticatedUser as IAccountProfile).username, - validatedRequest.requestBody - ); - }, -}); diff --git a/src/pages/api/config/[key]/[entity].ts b/src/pages/api/config/[key]/[entity].ts index 49c1009df..1cec0de31 100644 --- a/src/pages/api/config/[key]/[entity].ts +++ b/src/pages/api/config/[key]/[entity].ts @@ -2,33 +2,44 @@ import { USER_PERMISSIONS } from "shared/constants/user"; import { configurationApiController } from "backend/configuration/configuration.controller"; import { requestHandler } from "backend/lib/request"; +const REQUEST_QUERY_FIELD = "key"; + export default requestHandler( { GET: async (getValidatedRequest) => { const validatedRequest = await getValidatedRequest([ - "configKey", + { + _type: "requestQuery", + options: REQUEST_QUERY_FIELD, + }, { _type: "entity", options: true, }, ]); return await configurationApiController.showConfig( - validatedRequest.configKey, + validatedRequest.requestQuery, validatedRequest.entity ); }, PUT: async (getValidatedRequest) => { const validatedRequest = await getValidatedRequest([ - "configKey", + { + _type: "requestQuery", + options: REQUEST_QUERY_FIELD, + }, { _type: "entity", options: true, }, - "configBody", + { + _type: "requestBody", + options: {}, + }, ]); return await configurationApiController.upsertConfig( - validatedRequest.configKey, - validatedRequest.configBody, + validatedRequest.requestQuery, + validatedRequest.requestBody.data, validatedRequest.entity ); }, diff --git a/src/pages/api/config/[key]/__guest.ts b/src/pages/api/config/[key]/__guest.ts index 55cabfc22..47269efa0 100644 --- a/src/pages/api/config/[key]/__guest.ts +++ b/src/pages/api/config/[key]/__guest.ts @@ -1,13 +1,20 @@ import { configurationApiController } from "backend/configuration/configuration.controller"; import { requestHandler } from "backend/lib/request"; +const REQUEST_QUERY_FIELD = "key"; + export default requestHandler( { GET: async (getValidatedRequest) => { - const validatedRequest = await getValidatedRequest(["configKey"]); + const validatedRequest = await getValidatedRequest([ + { + _type: "requestQuery", + options: REQUEST_QUERY_FIELD, + }, + ]); return await configurationApiController.showGuestConfig( - validatedRequest.configKey + validatedRequest.requestQuery ); }, }, diff --git a/src/pages/api/config/[key]/index.ts b/src/pages/api/config/[key]/index.ts index c6d8ca6d4..087e213b3 100644 --- a/src/pages/api/config/[key]/index.ts +++ b/src/pages/api/config/[key]/index.ts @@ -2,24 +2,37 @@ import { USER_PERMISSIONS } from "shared/constants/user"; import { configurationApiController } from "backend/configuration/configuration.controller"; import { requestHandler } from "backend/lib/request"; +const REQUEST_QUERY_FIELD = "key"; + export default requestHandler( { GET: async (getValidatedRequest) => { - const validatedRequest = await getValidatedRequest(["configKey"]); + const validatedRequest = await getValidatedRequest([ + { + _type: "requestQuery", + options: REQUEST_QUERY_FIELD, + }, + ]); return await configurationApiController.showConfig( - validatedRequest.configKey + validatedRequest.requestQuery ); }, PUT: async (getValidatedRequest) => { const validatedRequest = await getValidatedRequest([ - "configKey", - "configBody", + { + _type: "requestQuery", + options: REQUEST_QUERY_FIELD, + }, + { + _type: "requestBody", + options: {}, + }, ]); return await configurationApiController.upsertConfig( - validatedRequest.configKey, - validatedRequest.configBody + validatedRequest.requestQuery, + validatedRequest.requestBody.data ); }, }, diff --git a/src/pages/api/data/[entity]/[id]/index.ts b/src/pages/api/data/[entity]/[id]/index.ts index 6222ea1e2..bcbe38a0f 100644 --- a/src/pages/api/data/[entity]/[id]/index.ts +++ b/src/pages/api/data/[entity]/[id]/index.ts @@ -36,7 +36,10 @@ export default requestHandler({ "entity", "entityId", "authenticatedUser", - "entityRequestBody", + { + _type: "requestBody", + options: {}, + }, { _type: "crudEnabled", options: DataActionType.Update, @@ -45,7 +48,7 @@ export default requestHandler({ return await dataApiController.updateData( validatedRequest.entity, validatedRequest.entityId, - validatedRequest.entityRequestBody, + validatedRequest.requestBody.data, validatedRequest.authenticatedUser as IAccountProfile ); }, diff --git a/src/pages/api/data/[entity]/index.ts b/src/pages/api/data/[entity]/index.ts index 95c6c808d..2477eb1a7 100644 --- a/src/pages/api/data/[entity]/index.ts +++ b/src/pages/api/data/[entity]/index.ts @@ -8,7 +8,10 @@ export default requestHandler({ const validatedRequest = await getValidatedRequest([ "entity", "authenticatedUser", - "entityRequestBody", + { + _type: "requestBody", + options: {}, + }, { _type: "crudEnabled", options: DataActionType.Create, @@ -16,7 +19,7 @@ export default requestHandler({ ]); return await dataApiController.createData( validatedRequest.entity, - validatedRequest.entityRequestBody, + validatedRequest.requestBody.data, validatedRequest.authenticatedUser as IAccountProfile ); }, diff --git a/src/pages/api/user-preferences/[key].ts b/src/pages/api/user-preferences/[key].ts index b0381694a..078b6d1c7 100644 --- a/src/pages/api/user-preferences/[key].ts +++ b/src/pages/api/user-preferences/[key].ts @@ -33,7 +33,7 @@ export default requestHandler({ return await userPreferenceApiController.upsert( (validatedRequest.authenticatedUser as IAccountProfile).username, validatedRequest.requestQuery, - validatedRequest.requestBody + validatedRequest.requestBody.data ); }, }); diff --git a/src/shared/form-schemas/profile/update.ts b/src/shared/form-schemas/profile/update.ts index 553a49918..b8590b3d6 100644 --- a/src/shared/form-schemas/profile/update.ts +++ b/src/shared/form-schemas/profile/update.ts @@ -15,25 +15,3 @@ export const UPDATE_PROFILE_FORM_SCHEMA: IAppliedSchemaFormConfig = - { - theme: { - type: "selection", - validations: [ - { - validationType: "required", - }, - ], - selections: [ - { - label: "Light", - value: "light", - }, - { - label: "Dark", - value: "dark", - }, - ], - }, - }; diff --git a/src/shared/types/ui.ts b/src/shared/types/ui.ts index 38df66473..1e0d2e787 100644 --- a/src/shared/types/ui.ts +++ b/src/shared/types/ui.ts @@ -10,3 +10,5 @@ export type EntityTypesForSelection = | "boolean"; export const FOR_CODE_COV = 1; + +export type ColorSchemes = "light" | "dark"; diff --git a/src/shared/user-preferences/constants.ts b/src/shared/user-preferences/constants.ts index ee6855d3d..544371536 100644 --- a/src/shared/user-preferences/constants.ts +++ b/src/shared/user-preferences/constants.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { BaseUserPreferencesKeys } from "./base-types"; import { PortalUserPreferencesKeys, PORTAL_CONFIGURATION_KEYS } from "./portal"; import { IUserPreferencesBag } from "./types"; @@ -14,5 +15,6 @@ export const USER_PREFERENCES_CONFIG: Record< theme: { label: "Theme", defaultValue: "light", + validation: z.enum(["light", "dark"]), }, }; diff --git a/src/shared/user-preferences/types.ts b/src/shared/user-preferences/types.ts index 0baaf4f52..42f7dc1f1 100644 --- a/src/shared/user-preferences/types.ts +++ b/src/shared/user-preferences/types.ts @@ -1,4 +1,7 @@ +import { z } from "zod"; + export interface IUserPreferencesBag { defaultValue: unknown; label: string; + validation?: z.ZodType; }