From f2b44b49f9763b365b0062000146fee2b8df72a9 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 7 Jun 2022 16:05:24 +0800 Subject: [PATCH] feat: use user level custom data to save preferences (#1045) * feat: use user level custom data to save preferences * fix(console): bugs * refactor(console): per review * refactor(core): update return order * fix(core): error constructor --- packages/console/package.json | 4 +- .../src/components/AppBoundary/index.tsx | 23 +++--- .../AppContent/components/Sidebar/hook.tsx | 28 +++++-- .../AppContent/components/Sidebar/index.tsx | 2 +- .../src/components/AppContent/index.tsx | 18 +++-- packages/console/src/consts/index.ts | 2 +- .../hooks/{use-configs.ts => use-settings.ts} | 14 ++-- .../console/src/hooks/use-user-preferences.ts | 81 +++++++++++++++++++ .../components/CreateForm/index.tsx | 6 +- .../components/GuideModal/index.tsx | 6 +- .../components/GetStartedProgress/index.tsx | 8 +- packages/console/src/pages/GetStarted/hook.ts | 18 ++--- .../console/src/pages/GetStarted/index.tsx | 6 +- packages/console/src/pages/Settings/index.tsx | 42 +++------- .../components/GuideModal.tsx | 18 ++--- .../src/pages/SignInExperience/index.tsx | 18 +++-- packages/core/src/__mocks__/index.ts | 4 - packages/core/src/middleware/koa-auth.test.ts | 5 +- packages/core/src/middleware/koa-auth.ts | 37 +++++---- packages/core/src/oidc/init.ts | 7 +- packages/core/src/routes/admin-user.ts | 2 +- packages/core/src/routes/init.ts | 32 +++++--- packages/core/src/routes/me.ts | 37 +++++++++ .../schemas/src/foundations/jsonb-types.ts | 4 - pnpm-lock.yaml | 3 +- 25 files changed, 279 insertions(+), 146 deletions(-) rename packages/console/src/hooks/{use-configs.ts => use-settings.ts} (62%) create mode 100644 packages/console/src/hooks/use-user-preferences.ts create mode 100644 packages/core/src/routes/me.ts diff --git a/packages/console/package.json b/packages/console/package.json index a6c1ae332..841fe66ae 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -22,6 +22,7 @@ "@logto/react": "^0.1.14", "@logto/shared": "^0.1.0", "@logto/schemas": "^0.1.0", + "@logto/shared": "^0.1.0", "@mdx-js/react": "^1.6.22", "@parcel/core": "2.5.0", "@parcel/transformer-mdx": "2.5.0", @@ -71,7 +72,8 @@ "remark-gfm": "^3.0.1", "stylelint": "^14.8.2", "swr": "^1.2.2", - "typescript": "^4.6.2" + "typescript": "^4.6.2", + "zod": "^3.14.3" }, "alias": { "@/*": "./src/$1", diff --git a/packages/console/src/components/AppBoundary/index.tsx b/packages/console/src/components/AppBoundary/index.tsx index 0653193c2..9a8ea8fe6 100644 --- a/packages/console/src/components/AppBoundary/index.tsx +++ b/packages/console/src/components/AppBoundary/index.tsx @@ -1,8 +1,7 @@ import { AppearanceMode } from '@logto/schemas'; import React, { ReactNode, useEffect } from 'react'; -import { themeStorageKey } from '@/consts'; -import useAdminConsoleConfigs from '@/hooks/use-configs'; +import useUserPreferences from '@/hooks/use-user-preferences'; import initI18n from '@/i18n/init'; import * as styles from './index.module.scss'; @@ -12,30 +11,30 @@ type Props = { }; const AppBoundary = ({ children }: Props) => { - const defaultTheme = localStorage.getItem(themeStorageKey) ?? AppearanceMode.SyncWithSystem; - const { configs } = useAdminConsoleConfigs(); - const theme = configs?.appearanceMode ?? defaultTheme; + const { + data: { appearanceMode, language }, + } = useUserPreferences(); useEffect(() => { - const isFollowSystem = theme === AppearanceMode.SyncWithSystem; - const className = styles[theme] ?? ''; + const isSyncWithSystem = appearanceMode === AppearanceMode.SyncWithSystem; + const className = styles[appearanceMode] ?? ''; - if (!isFollowSystem) { + if (!isSyncWithSystem) { document.body.classList.add(className); } return () => { - if (!isFollowSystem) { + if (!isSyncWithSystem) { document.body.classList.remove(className); } }; - }, [theme]); + }, [appearanceMode]); useEffect(() => { (async () => { - void initI18n(configs?.language); + void initI18n(language); })(); - }, [configs?.language]); + }, [language]); // eslint-disable-next-line react/jsx-no-useless-fragment return <>{children}; diff --git a/packages/console/src/components/AppContent/components/Sidebar/hook.tsx b/packages/console/src/components/AppContent/components/Sidebar/hook.tsx index 85d1f0d7f..6ec81f583 100644 --- a/packages/console/src/components/AppContent/components/Sidebar/hook.tsx +++ b/packages/console/src/components/AppContent/components/Sidebar/hook.tsx @@ -1,7 +1,8 @@ +import { Optional } from '@silverhand/essentials'; import React, { FC, ReactNode } from 'react'; import { TFuncKey } from 'react-i18next'; -import useAdminConsoleConfigs from '@/hooks/use-configs'; +import useUserPreferences from '@/hooks/use-user-preferences'; import Contact from './components/Contact'; import BarGraph from './icons/BarGraph'; @@ -27,17 +28,32 @@ type SidebarSection = { items: SidebarItem[]; }; -export const useSidebarMenuItems = (): SidebarSection[] => { - const { configs } = useAdminConsoleConfigs(); +const findFirstItem = (sections: SidebarSection[]): Optional => { + for (const section of sections) { + const found = section.items.find((item) => !item.isHidden); - return [ + if (found) { + return found; + } + } +}; + +export const useSidebarMenuItems = (): { + sections: SidebarSection[]; + firstItem: Optional; +} => { + const { + data: { hideGetStarted }, + } = useUserPreferences(); + + const sections: SidebarSection[] = [ { title: 'overview', items: [ { Icon: Bolt, title: 'get_started', - isHidden: configs?.hideGetStarted, + isHidden: hideGetStarted, }, { Icon: BarGraph, @@ -94,4 +110,6 @@ export const useSidebarMenuItems = (): SidebarSection[] => { ], }, ]; + + return { sections, firstItem: findFirstItem(sections) }; }; diff --git a/packages/console/src/components/AppContent/components/Sidebar/index.tsx b/packages/console/src/components/AppContent/components/Sidebar/index.tsx index 5446a93fc..0f8de2d76 100644 --- a/packages/console/src/components/AppContent/components/Sidebar/index.tsx +++ b/packages/console/src/components/AppContent/components/Sidebar/index.tsx @@ -14,7 +14,7 @@ const Sidebar = () => { keyPrefix: 'admin_console.tab_sections', }); const location = useLocation(); - const sections = useSidebarMenuItems(); + const { sections } = useSidebarMenuItems(); return (
diff --git a/packages/console/src/components/AppContent/index.tsx b/packages/console/src/components/AppContent/index.tsx index 2835c310a..645bded5f 100644 --- a/packages/console/src/components/AppContent/index.tsx +++ b/packages/console/src/components/AppContent/index.tsx @@ -5,7 +5,8 @@ import { Outlet, useHref, useLocation, useNavigate } from 'react-router-dom'; import AppError from '@/components/AppError'; import LogtoLoading from '@/components/LogtoLoading'; import SessionExpired from '@/components/SessionExpired'; -import useAdminConsoleConfigs from '@/hooks/use-configs'; +import useSettings from '@/hooks/use-settings'; +import useUserPreferences from '@/hooks/use-user-preferences'; import Sidebar, { getPath } from './components/Sidebar'; import { useSidebarMenuItems } from './components/Sidebar/hook'; @@ -15,12 +16,13 @@ import * as styles from './index.module.scss'; const AppContent = () => { const { isAuthenticated, error, signIn } = useLogto(); const href = useHref('/callback'); - const { configs, error: configsError } = useAdminConsoleConfigs(); - const isLoadingConfigs = !configs && !configsError; + const { isLoading: isPreferencesLoading } = useUserPreferences(); + const { isLoading: isSettingsLoading } = useSettings(); + const isLoading = isPreferencesLoading || isSettingsLoading; const location = useLocation(); const navigate = useNavigate(); - const sections = useSidebarMenuItems(); + const { firstItem } = useSidebarMenuItems(); useEffect(() => { if (!isAuthenticated) { @@ -30,10 +32,10 @@ const AppContent = () => { useEffect(() => { // Navigate to the first menu item after configs are loaded. - if (configs && location.pathname === '/') { - navigate(getPath(sections[0]?.items[0]?.title ?? '')); + if (!isLoading && location.pathname === '/') { + navigate(getPath(firstItem?.title ?? '')); } - }, [location.pathname, configs, sections, navigate]); + }, [firstItem?.title, isLoading, location.pathname, navigate]); if (error) { if (error instanceof LogtoClientError) { @@ -43,7 +45,7 @@ const AppContent = () => { return ; } - if (!isAuthenticated || isLoadingConfigs) { + if (!isAuthenticated || isLoading) { return ; } diff --git a/packages/console/src/consts/index.ts b/packages/console/src/consts/index.ts index f3f43dc9f..5bad5fe45 100644 --- a/packages/console/src/consts/index.ts +++ b/packages/console/src/consts/index.ts @@ -1,4 +1,4 @@ export * from './applications'; export * from './icons'; -export const themeStorageKey = 'adminConsoleTheme'; +export const themeStorageKey = 'logto:admin_console:theme'; diff --git a/packages/console/src/hooks/use-configs.ts b/packages/console/src/hooks/use-settings.ts similarity index 62% rename from packages/console/src/hooks/use-configs.ts rename to packages/console/src/hooks/use-settings.ts index b186b3701..ae11e5ac1 100644 --- a/packages/console/src/hooks/use-configs.ts +++ b/packages/console/src/hooks/use-settings.ts @@ -4,16 +4,17 @@ import useSWR from 'swr'; import useApi, { RequestError } from './use-api'; -const useAdminConsoleConfigs = () => { +const useSettings = () => { const { isAuthenticated, error: authError } = useLogto(); + const shouldFetch = isAuthenticated && !authError; const { data: settings, error, mutate, - } = useSWR(isAuthenticated && !authError && '/api/settings'); + } = useSWR(shouldFetch && '/api/settings'); const api = useApi(); - const updateConfigs = async (delta: Partial) => { + const updateSettings = async (delta: Partial) => { const updatedSettings = await api .patch('/api/settings', { json: { @@ -27,10 +28,11 @@ const useAdminConsoleConfigs = () => { }; return { - configs: settings?.adminConsole, + isLoading: !settings && !error, + settings: settings?.adminConsole, error, - updateConfigs, + updateSettings, }; }; -export default useAdminConsoleConfigs; +export default useSettings; diff --git a/packages/console/src/hooks/use-user-preferences.ts b/packages/console/src/hooks/use-user-preferences.ts new file mode 100644 index 000000000..7adbf85a0 --- /dev/null +++ b/packages/console/src/hooks/use-user-preferences.ts @@ -0,0 +1,81 @@ +import { Language } from '@logto/phrases'; +import { useLogto } from '@logto/react'; +import { AppearanceMode } from '@logto/schemas'; +import { Nullable, Optional } from '@silverhand/essentials'; +import { useCallback, useEffect, useMemo } from 'react'; +import useSWR from 'swr'; +import { z } from 'zod'; + +import { themeStorageKey } from '@/consts'; + +import useApi, { RequestError } from './use-api'; + +const userPreferencesGuard = z.object({ + language: z.nativeEnum(Language), + appearanceMode: z.nativeEnum(AppearanceMode), + experienceNoticeConfirmed: z.boolean().optional(), + hideGetStarted: z.boolean().optional(), +}); + +export type UserPreferences = z.infer; + +const key = 'adminConsolePreferences'; + +const getEnumFromArray = ( + array: T[], + value: Nullable> +): Optional => array.find((element) => element === value); + +const useUserPreferences = () => { + const { isAuthenticated, error: authError } = useLogto(); + const shouldFetch = isAuthenticated && !authError; + const { data, mutate, error } = useSWR( + shouldFetch && '/api/me/custom-data' + ); + const api = useApi(); + + const parseData = useCallback((): UserPreferences => { + try { + return z.object({ [key]: userPreferencesGuard }).parse(data).adminConsolePreferences; + } catch { + return { + language: Language.English, + appearanceMode: + getEnumFromArray(Object.values(AppearanceMode), localStorage.getItem(themeStorageKey)) ?? + AppearanceMode.SyncWithSystem, + }; + } + }, [data]); + + const userPreferences = useMemo(() => parseData(), [parseData]); + + const update = async (data: Partial) => { + const updated = await api + .patch('/api/me/custom-data', { + json: { + customData: { + [key]: { + ...userPreferences, + ...data, + }, + }, + }, + }) + .json(); + void mutate(updated); + }; + + useEffect(() => { + localStorage.setItem(themeStorageKey, userPreferences.appearanceMode); + }, [userPreferences.appearanceMode]); + + return { + isLoading: !data && !error, + isLoaded: data && !error, + data: userPreferences, + update, + error, + }; +}; + +export default useUserPreferences; diff --git a/packages/console/src/pages/Applications/components/CreateForm/index.tsx b/packages/console/src/pages/Applications/components/CreateForm/index.tsx index ef232ca8f..cea3bbf87 100644 --- a/packages/console/src/pages/Applications/components/CreateForm/index.tsx +++ b/packages/console/src/pages/Applications/components/CreateForm/index.tsx @@ -9,7 +9,7 @@ import ModalLayout from '@/components/ModalLayout'; import RadioGroup, { Radio } from '@/components/RadioGroup'; import TextInput from '@/components/TextInput'; import useApi from '@/hooks/use-api'; -import useAdminConsoleConfigs from '@/hooks/use-configs'; +import useSettings from '@/hooks/use-settings'; import { applicationTypeI18nKey } from '@/types/applications'; import { GuideForm } from '@/types/guide'; @@ -28,7 +28,7 @@ type Props = { }; const CreateForm = ({ onClose }: Props) => { - const { updateConfigs } = useAdminConsoleConfigs(); + const { updateSettings } = useSettings(); const [createdApp, setCreatedApp] = useState(); const [isGetStartedModalOpen, setIsGetStartedModalOpen] = useState(false); const { @@ -74,7 +74,7 @@ const CreateForm = ({ onClose }: Props) => { }, }) .json(); - await updateConfigs({ createApplication: true }); + await updateSettings({ createApplication: true }); setCreatedApp(application); closeModal(); }; diff --git a/packages/console/src/pages/Connectors/components/GuideModal/index.tsx b/packages/console/src/pages/Connectors/components/GuideModal/index.tsx index abecd1577..9668a304d 100644 --- a/packages/console/src/pages/Connectors/components/GuideModal/index.tsx +++ b/packages/console/src/pages/Connectors/components/GuideModal/index.tsx @@ -13,7 +13,7 @@ import CodeEditor from '@/components/CodeEditor'; import DangerousRaw from '@/components/DangerousRaw'; import IconButton from '@/components/IconButton'; import useApi from '@/hooks/use-api'; -import useAdminConsoleConfigs from '@/hooks/use-configs'; +import useSettings from '@/hooks/use-settings'; import Close from '@/icons/Close'; import Step from '@/mdx-components/Step'; import SenderTester from '@/pages/ConnectorDetails/components/SenderTester'; @@ -31,7 +31,7 @@ type Props = { const GuideModal = ({ connector, isOpen, onClose }: Props) => { const api = useApi(); - const { updateConfigs } = useAdminConsoleConfigs(); + const { updateSettings } = useSettings(); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { id: connectorId, type: connectorType, name, configTemplate, readme } = connector; @@ -68,7 +68,7 @@ const GuideModal = ({ connector, isOpen, onClose }: Props) => { }) .json(); - await updateConfigs({ + await updateSettings({ ...conditional(!isSocialConnector && { configurePasswordless: true }), ...conditional(isSocialConnector && { configureSocialSignIn: true }), }); diff --git a/packages/console/src/pages/GetStarted/components/GetStartedProgress/index.tsx b/packages/console/src/pages/GetStarted/components/GetStartedProgress/index.tsx index dcac9f356..1b364816f 100644 --- a/packages/console/src/pages/GetStarted/components/GetStartedProgress/index.tsx +++ b/packages/console/src/pages/GetStarted/components/GetStartedProgress/index.tsx @@ -5,19 +5,21 @@ import { useTranslation } from 'react-i18next'; import icon from '@/assets/images/tada.svg'; import Dropdown, { DropdownItem } from '@/components/Dropdown'; import Index from '@/components/Index'; -import useConfigs from '@/hooks/use-configs'; +import useUserPreferences from '@/hooks/use-user-preferences'; import useGetStartedMetadata from '../../hook'; import * as styles from './index.module.scss'; const GetStartedProgress = () => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const { configs } = useConfigs(); + const { + data: { hideGetStarted }, + } = useUserPreferences(); const anchorRef = useRef(null); const [showDropDown, setShowDropdown] = useState(false); const { data, completedCount, totalCount } = useGetStartedMetadata(); - if (!configs || configs.hideGetStarted) { + if (hideGetStarted) { return null; } diff --git a/packages/console/src/pages/GetStarted/hook.ts b/packages/console/src/pages/GetStarted/hook.ts index 39051b94f..9542a0c99 100644 --- a/packages/console/src/pages/GetStarted/hook.ts +++ b/packages/console/src/pages/GetStarted/hook.ts @@ -7,7 +7,7 @@ import customizeIcon from '@/assets/images/customize.svg'; import furtherReadingsIcon from '@/assets/images/further-readings.svg'; import oneClickIcon from '@/assets/images/one-click.svg'; import passwordlessIcon from '@/assets/images/passwordless.svg'; -import useAdminConsoleConfigs from '@/hooks/use-configs'; +import useSettings from '@/hooks/use-settings'; type GetStartedMetadata = { id: string; @@ -20,7 +20,7 @@ type GetStartedMetadata = { }; const useGetStartedMetadata = () => { - const { configs, updateConfigs } = useAdminConsoleConfigs(); + const { settings, updateSettings } = useSettings(); const navigate = useNavigate(); const data: GetStartedMetadata[] = [ @@ -30,9 +30,9 @@ const useGetStartedMetadata = () => { subtitle: 'get_started.card1_subtitle', icon: checkDemoIcon, buttonText: 'general.check_out', - isComplete: configs?.checkDemo, + isComplete: settings?.checkDemo, onClick: async () => { - void updateConfigs({ checkDemo: true }); + void updateSettings({ checkDemo: true }); window.open('/demo-app', '_blank'); }, }, @@ -42,7 +42,7 @@ const useGetStartedMetadata = () => { subtitle: 'get_started.card2_subtitle', icon: createAppIcon, buttonText: 'general.create', - isComplete: configs?.createApplication, + isComplete: settings?.createApplication, onClick: () => { navigate('/applications'); }, @@ -53,7 +53,7 @@ const useGetStartedMetadata = () => { subtitle: 'get_started.card3_subtitle', icon: passwordlessIcon, buttonText: 'general.create', - isComplete: configs?.configurePasswordless, + isComplete: settings?.configurePasswordless, onClick: () => { navigate('/connectors'); }, @@ -74,7 +74,7 @@ const useGetStartedMetadata = () => { subtitle: 'get_started.card5_subtitle', icon: customizeIcon, buttonText: 'general.customize', - isComplete: configs?.customizeSignInExperience, + isComplete: settings?.customizeSignInExperience, onClick: () => { navigate('/sign-in-experience'); }, @@ -85,9 +85,9 @@ const useGetStartedMetadata = () => { subtitle: 'get_started.card6_subtitle', icon: furtherReadingsIcon, buttonText: 'general.check_out', - isComplete: configs?.checkFurtherReadings, + isComplete: settings?.checkFurtherReadings, onClick: () => { - void updateConfigs({ checkFurtherReadings: true }); + void updateSettings({ checkFurtherReadings: true }); window.open('https://docs.logto.io/', '_blank'); }, }, diff --git a/packages/console/src/pages/GetStarted/index.tsx b/packages/console/src/pages/GetStarted/index.tsx index 96a149df6..c1880f946 100644 --- a/packages/console/src/pages/GetStarted/index.tsx +++ b/packages/console/src/pages/GetStarted/index.tsx @@ -7,7 +7,7 @@ import Button from '@/components/Button'; import Card from '@/components/Card'; import ConfirmModal from '@/components/ConfirmModal'; import Spacer from '@/components/Spacer'; -import useAdminConsoleConfigs from '@/hooks/use-configs'; +import useUserPreferences from '@/hooks/use-user-preferences'; import useGetStartedMetadata from './hook'; import * as styles from './index.module.scss'; @@ -16,11 +16,11 @@ const GetStarted = () => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const navigate = useNavigate(); const { data } = useGetStartedMetadata(); - const { updateConfigs } = useAdminConsoleConfigs(); + const { update } = useUserPreferences(); const [showConfirmModal, setShowConfirmModal] = useState(false); const hideGetStarted = () => { - void updateConfigs({ hideGetStarted: true }); + void update({ hideGetStarted: true }); // Navigate to next menu item navigate('/dashboard'); }; diff --git a/packages/console/src/pages/Settings/index.tsx b/packages/console/src/pages/Settings/index.tsx index 5a35c6b4d..81893ae05 100644 --- a/packages/console/src/pages/Settings/index.tsx +++ b/packages/console/src/pages/Settings/index.tsx @@ -1,11 +1,10 @@ import { Language } from '@logto/phrases'; -import { AppearanceMode, Setting } from '@logto/schemas'; +import { AppearanceMode } from '@logto/schemas'; import classNames from 'classnames'; -import React, { useEffect } from 'react'; +import React from 'react'; import { Controller, useForm } from 'react-hook-form'; import { toast } from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; -import useSWR from 'swr'; import Button from '@/components/Button'; import Card from '@/components/Card'; @@ -13,43 +12,26 @@ import CardTitle from '@/components/CardTitle'; import FormField from '@/components/FormField'; import Select from '@/components/Select'; import TabNav, { TabNavItem } from '@/components/TabNav'; -import { themeStorageKey } from '@/consts'; -import useApi, { RequestError } from '@/hooks/use-api'; +import useUserPreferences, { UserPreferences } from '@/hooks/use-user-preferences'; import * as detailsStyles from '@/scss/details.module.scss'; import * as styles from './index.module.scss'; const Settings = () => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const { data, error, mutate } = useSWR('/api/settings'); + const { data, error, update, isLoading, isLoaded } = useUserPreferences(); const { - reset, handleSubmit, control, formState: { isSubmitting }, - } = useForm(); - const api = useApi(); - - useEffect(() => { - if (data) { - reset(data); - } - }, [data, reset]); + } = useForm({ defaultValues: data }); const onSubmit = handleSubmit(async (formData) => { - if (!data || isSubmitting) { + if (isSubmitting) { return; } - const updatedData = await api - .patch('/api/settings', { - json: { - ...formData, - }, - }) - .json(); - void mutate(updatedData); - localStorage.setItem(themeStorageKey, updatedData.adminConsole.appearanceMode); + await update(formData); toast.success(t('settings.saved')); }); @@ -59,14 +41,14 @@ const Settings = () => { {t('settings.tabs.general')} - {!data && !error &&
loading
} - {!data && error &&
{`error occurred: ${error.body?.message ?? error.message}`}
} - {data && ( + {isLoading &&
loading
} + {error &&
{`error occurred: ${error.body?.message ?? error.message}`}
} + {isLoaded && (
( { const { data } = useSWR('/api/sign-in-exp'); - const { configs, updateConfigs } = useAdminConsoleConfigs(); + const { data: preferences, update: updatePreferences } = useUserPreferences(); + const { updateSettings } = useSettings(); const methods = useForm(); const { reset, @@ -53,15 +55,11 @@ const GuideModal = ({ isOpen, onClose }: Props) => { }, [data, reset, isDirty]); const onGotIt = async () => { - if (!configs) { - return; - } - - await updateConfigs({ experienceNoticeConfirmed: true }); + await updatePreferences({ experienceNoticeConfirmed: true }); }; const onSubmit = handleSubmit(async (formData) => { - if (!data || isSubmitting || !configs) { + if (!data || isSubmitting) { return; } @@ -69,7 +67,7 @@ const GuideModal = ({ isOpen, onClose }: Props) => { api.patch('/api/sign-in-exp', { json: signInExperienceParser.toRemoteModel(formData), }), - updateConfigs({ customizeSignInExperience: true }), + updateSettings({ customizeSignInExperience: true }), ]); location.reload(); @@ -88,7 +86,7 @@ const GuideModal = ({ isOpen, onClose }: Props) => {
- {configs && !configs.experienceNoticeConfirmed && ( + {!preferences.experienceNoticeConfirmed && (
{ const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { tab } = useParams(); const { data, error, mutate } = useSWR('/api/sign-in-exp'); - const { configs, error: configError, updateConfigs } = useAdminConsoleConfigs(); + const { settings, error: settingsError, updateSettings } = useSettings(); + const { + data: { experienceNoticeConfirmed }, + } = useUserPreferences(); const [dataToCompare, setDataToCompare] = useState(); const methods = useForm(); @@ -62,7 +66,7 @@ const SignInExperience = () => { }) .json(); void mutate(updatedData); - await updateConfigs({ customizeSignInExperience: true }); + await updateSettings({ customizeSignInExperience: true }); toast.success(t('application_details.save_success')); }; @@ -83,15 +87,15 @@ const SignInExperience = () => { await saveData(); }); - if (!configs && !configError) { + if (!settings && !settingsError) { return
loading
; } - if (!configs && configError) { - return
{configError.body?.message ?? configError.message}
; + if (!settings && settingsError) { + return
{settingsError.body?.message ?? settingsError.message}
; } - if (!configs?.customizeSignInExperience) { + if (!experienceNoticeConfirmed) { return ; } diff --git a/packages/core/src/__mocks__/index.ts b/packages/core/src/__mocks__/index.ts index e55e428ad..a1caf825b 100644 --- a/packages/core/src/__mocks__/index.ts +++ b/packages/core/src/__mocks__/index.ts @@ -1,6 +1,4 @@ -import { Language } from '@logto/phrases'; import { - AppearanceMode, Application, ApplicationType, Passcode, @@ -46,8 +44,6 @@ export const mockRole: Role = { export const mockSetting: Setting = { id: 'foo setting', adminConsole: { - language: Language.English, - appearanceMode: AppearanceMode.SyncWithSystem, checkDemo: false, createApplication: false, configurePasswordless: false, diff --git a/packages/core/src/middleware/koa-auth.test.ts b/packages/core/src/middleware/koa-auth.test.ts index 3238a4284..483513c4d 100644 --- a/packages/core/src/middleware/koa-auth.test.ts +++ b/packages/core/src/middleware/koa-auth.test.ts @@ -1,3 +1,4 @@ +import { UserRole } from '@logto/schemas'; import { jwtVerify } from 'jose'; import { Context } from 'koa'; import { IRouterParamContext } from 'koa-router'; @@ -105,7 +106,7 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth()(ctx, next)).rejects.toMatchError(unauthorizedError); + await expect(koaAuth(UserRole.Admin)(ctx, next)).rejects.toMatchError(unauthorizedError); }); it('expect to throw if jwt role_names does not include admin', async () => { @@ -121,6 +122,6 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth()(ctx, next)).rejects.toMatchError(unauthorizedError); + await expect(koaAuth(UserRole.Admin)(ctx, next)).rejects.toMatchError(unauthorizedError); }); }); diff --git a/packages/core/src/middleware/koa-auth.ts b/packages/core/src/middleware/koa-auth.ts index 2076bed31..c3ab74d29 100644 --- a/packages/core/src/middleware/koa-auth.ts +++ b/packages/core/src/middleware/koa-auth.ts @@ -2,6 +2,7 @@ import { IncomingHttpHeaders } from 'http'; import { UserRole } from '@logto/schemas'; import { managementResource } from '@logto/schemas/lib/seeds'; +import { conditional } from '@silverhand/essentials'; import { jwtVerify } from 'jose'; import { MiddlewareType, Request } from 'koa'; import { IRouterParamContext } from 'koa-router'; @@ -33,12 +34,17 @@ const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) = return authorization.slice(bearerTokenIdentifier.length + 1); }; -const getUserInfoFromRequest = async (request: Request) => { +type UserInfo = { + sub: string; + roleNames?: string[]; +}; + +const getUserInfoFromRequest = async (request: Request): Promise => { const { isProduction, developmentUserId, oidc } = envSet.values; const userId = developmentUserId || request.headers['development-user-id']?.toString(); if (!isProduction && userId) { - return userId; + return { sub: userId, roleNames: [UserRole.Admin] }; } const { publicKey, issuer } = oidc; @@ -51,23 +57,24 @@ const getUserInfoFromRequest = async (request: Request) => { assertThat(sub, new RequestError({ code: 'auth.jwt_sub_missing', status: 401 })); - assertThat( - Array.isArray(roleNames) && roleNames.includes(UserRole.Admin), - new RequestError({ code: 'auth.unauthorized', status: 401 }) - ); - - return sub; + return { sub, roleNames: conditional(Array.isArray(roleNames) && roleNames) }; }; -export default function koaAuth< - StateT, - ContextT extends IRouterParamContext, - ResponseBodyT ->(): MiddlewareType, ResponseBodyT> { +export default function koaAuth( + forRole?: UserRole +): MiddlewareType, ResponseBodyT> { return async (ctx, next) => { try { - const userId = await getUserInfoFromRequest(ctx.request); - ctx.auth = userId; + const { sub, roleNames } = await getUserInfoFromRequest(ctx.request); + + if (forRole) { + assertThat( + roleNames?.includes(forRole), + new RequestError({ code: 'auth.unauthorized', status: 401 }) + ); + } + + ctx.auth = sub; } catch { throw new RequestError({ code: 'auth.unauthorized', status: 401 }); } diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 25473af05..c2964c5ce 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -88,22 +88,21 @@ export default async function initOidc(app: Koa): Promise { ctx.request.origin === origin || isOriginAllowed(origin, client.metadata()), // https://github.com/panva/node-oidc-provider/blob/main/recipes/claim_configuration.md claims: { - profile: ['username', 'name', 'avatar', 'role_names', 'custom_data'], + profile: ['username', 'name', 'avatar', 'role_names'], }, // https://github.com/panva/node-oidc-provider/tree/main/docs#findaccount findAccount: async (_ctx, sub) => { - const { username, name, avatar, roleNames, customData } = await findUserById(sub); + const { username, name, avatar, roleNames } = await findUserById(sub); return { accountId: sub, - claims: async (use) => { + claims: async () => { return snakecaseKeys({ sub, username, name, avatar, roleNames, - ...(use === 'userinfo' && { customData }), }); }, }; diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts index 11f7366cd..31a792794 100644 --- a/packages/core/src/routes/admin-user.ts +++ b/packages/core/src/routes/admin-user.ts @@ -124,7 +124,7 @@ export default function adminUserRoutes(router: T) { await findUserById(userId); // Clear customData to achieve full replacement, - // to partial update, call patch /users/:userId/customData + // to partial update, call patch /users/:userId/custom-data if (body.customData) { await clearUserCustomDataById(userId); } diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index ca9fe385f..a20c31237 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -1,3 +1,4 @@ +import { UserRole } from '@logto/schemas'; import Koa from 'koa'; import mount from 'koa-mount'; import Router from 'koa-router'; @@ -18,6 +19,7 @@ import swaggerRoutes from '@/routes/swagger'; import adminUserRoutes from './admin-user'; import logRoutes from './log'; +import meRoutes from './me'; import roleRoutes from './role'; import { AnonymousRouter, AuthedRouter } from './types'; @@ -26,25 +28,29 @@ const createRouters = (provider: Provider) => { sessionRouter.use(koaLogSession(provider)); sessionRoutes(sessionRouter, provider); - const authedRouter: AuthedRouter = new Router(); - authedRouter.use(koaAuth()); - applicationRoutes(authedRouter); - settingRoutes(authedRouter); - connectorRoutes(authedRouter); - resourceRoutes(authedRouter); - signInExperiencesRoutes(authedRouter); - adminUserRoutes(authedRouter); - logRoutes(authedRouter); - roleRoutes(authedRouter); - dashboardRoutes(authedRouter); + const managementRouter: AuthedRouter = new Router(); + managementRouter.use(koaAuth(UserRole.Admin)); + applicationRoutes(managementRouter); + settingRoutes(managementRouter); + connectorRoutes(managementRouter); + resourceRoutes(managementRouter); + signInExperiencesRoutes(managementRouter); + adminUserRoutes(managementRouter); + logRoutes(managementRouter); + roleRoutes(managementRouter); + dashboardRoutes(managementRouter); + + const meRouter: AuthedRouter = new Router(); + meRouter.use(koaAuth()); + meRoutes(meRouter); const anonymousRouter: AnonymousRouter = new Router(); signInSettingsRoutes(anonymousRouter); statusRoutes(anonymousRouter); // The swagger.json should contain all API routers. - swaggerRoutes(anonymousRouter, [sessionRouter, authedRouter, anonymousRouter]); + swaggerRoutes(anonymousRouter, [sessionRouter, managementRouter, meRouter, anonymousRouter]); - return [sessionRouter, anonymousRouter, authedRouter]; + return [sessionRouter, managementRouter, meRouter, anonymousRouter]; }; export default function initRouter(app: Koa, provider: Provider) { diff --git a/packages/core/src/routes/me.ts b/packages/core/src/routes/me.ts new file mode 100644 index 000000000..f98ec53e9 --- /dev/null +++ b/packages/core/src/routes/me.ts @@ -0,0 +1,37 @@ +import { arbitraryObjectGuard } from '@logto/schemas'; +import { object } from 'zod'; + +import koaGuard from '@/middleware/koa-guard'; +import { findUserById, updateUserById } from '@/queries/user'; + +import { AuthedRouter } from './types'; + +export default function meRoutes(router: T) { + router.get('/me/custom-data', async (ctx, next) => { + const { customData } = await findUserById(ctx.auth); + + ctx.body = customData; + + return next(); + }); + + router.patch( + '/me/custom-data', + koaGuard({ body: object({ customData: arbitraryObjectGuard }) }), + async (ctx, next) => { + const { + body: { customData }, + } = ctx.guard; + + await findUserById(ctx.auth); + + const user = await updateUserById(ctx.auth, { + customData, + }); + + ctx.body = user.customData; + + return next(); + } + ); +} diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts index 9271e47aa..2dffe5263 100644 --- a/packages/schemas/src/foundations/jsonb-types.ts +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -141,10 +141,6 @@ export enum AppearanceMode { } export const adminConsoleConfigGuard = z.object({ - language: z.nativeEnum(Language), - appearanceMode: z.nativeEnum(AppearanceMode), - experienceNoticeConfirmed: z.boolean().optional(), - hideGetStarted: z.boolean().optional(), // Get started challenges checkDemo: z.boolean(), createApplication: z.boolean(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bee1713f..40de0bc75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -674,6 +674,7 @@ importers: stylelint: ^14.8.2 swr: ^1.2.2 typescript: ^4.6.2 + zod: ^3.14.3 devDependencies: '@fontsource/roboto-mono': 4.5.7 '@logto/phrases': link:../phrases @@ -730,6 +731,7 @@ importers: stylelint: 14.8.2 swr: 1.2.2_react@17.0.2 typescript: 4.6.2 + zod: 3.14.3 packages/core: specifiers: @@ -22750,7 +22752,6 @@ packages: /zod/3.14.3: resolution: {integrity: sha512-OzwRCSXB1+/8F6w6HkYHdbuWysYWnAF4fkRgKDcSFc54CE+Sv0rHXKfeNUReGCrHukm1LNpi6AYeXotznhYJbQ==} - dev: false /zwitch/1.0.5: resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}