mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
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
This commit is contained in:
parent
00e32f08da
commit
f2b44b49f9
25 changed files with 279 additions and 146 deletions
|
@ -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",
|
||||
|
|
|
@ -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}</>;
|
||||
|
|
|
@ -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<SidebarItem> => {
|
||||
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<SidebarItem>;
|
||||
} => {
|
||||
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) };
|
||||
};
|
||||
|
|
|
@ -14,7 +14,7 @@ const Sidebar = () => {
|
|||
keyPrefix: 'admin_console.tab_sections',
|
||||
});
|
||||
const location = useLocation();
|
||||
const sections = useSidebarMenuItems();
|
||||
const { sections } = useSidebarMenuItems();
|
||||
|
||||
return (
|
||||
<div className={styles.sidebar}>
|
||||
|
|
|
@ -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 <AppError errorMessage={error.message} callStack={error.stack} />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated || isLoadingConfigs) {
|
||||
if (!isAuthenticated || isLoading) {
|
||||
return <LogtoLoading message="general.loading" />;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export * from './applications';
|
||||
export * from './icons';
|
||||
|
||||
export const themeStorageKey = 'adminConsoleTheme';
|
||||
export const themeStorageKey = 'logto:admin_console:theme';
|
||||
|
|
|
@ -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<Setting, RequestError>(isAuthenticated && !authError && '/api/settings');
|
||||
} = useSWR<Setting, RequestError>(shouldFetch && '/api/settings');
|
||||
const api = useApi();
|
||||
|
||||
const updateConfigs = async (delta: Partial<AdminConsoleConfig>) => {
|
||||
const updateSettings = async (delta: Partial<AdminConsoleConfig>) => {
|
||||
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;
|
81
packages/console/src/hooks/use-user-preferences.ts
Normal file
81
packages/console/src/hooks/use-user-preferences.ts
Normal file
|
@ -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<typeof userPreferencesGuard>;
|
||||
|
||||
const key = 'adminConsolePreferences';
|
||||
|
||||
const getEnumFromArray = <T extends string>(
|
||||
array: T[],
|
||||
value: Nullable<Optional<string>>
|
||||
): Optional<T> => array.find((element) => element === value);
|
||||
|
||||
const useUserPreferences = () => {
|
||||
const { isAuthenticated, error: authError } = useLogto();
|
||||
const shouldFetch = isAuthenticated && !authError;
|
||||
const { data, mutate, error } = useSWR<unknown, RequestError>(
|
||||
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<UserPreferences>) => {
|
||||
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;
|
|
@ -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<Application>();
|
||||
const [isGetStartedModalOpen, setIsGetStartedModalOpen] = useState(false);
|
||||
const {
|
||||
|
@ -74,7 +74,7 @@ const CreateForm = ({ onClose }: Props) => {
|
|||
},
|
||||
})
|
||||
.json<Application>();
|
||||
await updateConfigs({ createApplication: true });
|
||||
await updateSettings({ createApplication: true });
|
||||
setCreatedApp(application);
|
||||
closeModal();
|
||||
};
|
||||
|
|
|
@ -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<ConnectorDTO>();
|
||||
|
||||
await updateConfigs({
|
||||
await updateSettings({
|
||||
...conditional(!isSocialConnector && { configurePasswordless: true }),
|
||||
...conditional(isSocialConnector && { configureSocialSignIn: true }),
|
||||
});
|
||||
|
|
|
@ -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<HTMLDivElement>(null);
|
||||
const [showDropDown, setShowDropdown] = useState(false);
|
||||
const { data, completedCount, totalCount } = useGetStartedMetadata();
|
||||
|
||||
if (!configs || configs.hideGetStarted) {
|
||||
if (hideGetStarted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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');
|
||||
};
|
||||
|
|
|
@ -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<Setting, RequestError>('/api/settings');
|
||||
const { data, error, update, isLoading, isLoaded } = useUserPreferences();
|
||||
const {
|
||||
reset,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<Setting>();
|
||||
const api = useApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
}
|
||||
}, [data, reset]);
|
||||
} = useForm<UserPreferences>({ defaultValues: data });
|
||||
|
||||
const onSubmit = handleSubmit(async (formData) => {
|
||||
if (!data || isSubmitting) {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedData = await api
|
||||
.patch('/api/settings', {
|
||||
json: {
|
||||
...formData,
|
||||
},
|
||||
})
|
||||
.json<Setting>();
|
||||
void mutate(updatedData);
|
||||
localStorage.setItem(themeStorageKey, updatedData.adminConsole.appearanceMode);
|
||||
await update(formData);
|
||||
toast.success(t('settings.saved'));
|
||||
});
|
||||
|
||||
|
@ -59,14 +41,14 @@ const Settings = () => {
|
|||
<TabNav>
|
||||
<TabNavItem href="/settings">{t('settings.tabs.general')}</TabNavItem>
|
||||
</TabNav>
|
||||
{!data && !error && <div>loading</div>}
|
||||
{!data && error && <div>{`error occurred: ${error.body?.message ?? error.message}`}</div>}
|
||||
{data && (
|
||||
{isLoading && <div>loading</div>}
|
||||
{error && <div>{`error occurred: ${error.body?.message ?? error.message}`}</div>}
|
||||
{isLoaded && (
|
||||
<form className={detailsStyles.body} onSubmit={onSubmit}>
|
||||
<div className={styles.fields}>
|
||||
<FormField title="admin_console.settings.language" className={styles.textField}>
|
||||
<Controller
|
||||
name="adminConsole.language"
|
||||
name="language"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
|
@ -88,7 +70,7 @@ const Settings = () => {
|
|||
</FormField>
|
||||
<FormField title="admin_console.settings.appearance" className={styles.textField}>
|
||||
<Controller
|
||||
name="adminConsole.appearanceMode"
|
||||
name="appearanceMode"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Select
|
||||
|
|
|
@ -11,7 +11,8 @@ import CardTitle from '@/components/CardTitle';
|
|||
import IconButton from '@/components/IconButton';
|
||||
import Spacer from '@/components/Spacer';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useAdminConsoleConfigs from '@/hooks/use-configs';
|
||||
import useSettings from '@/hooks/use-settings';
|
||||
import useUserPreferences from '@/hooks/use-user-preferences';
|
||||
import Close from '@/icons/Close';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
|
@ -32,7 +33,8 @@ type Props = {
|
|||
|
||||
const GuideModal = ({ isOpen, onClose }: Props) => {
|
||||
const { data } = useSWR<SignInExperience>('/api/sign-in-exp');
|
||||
const { configs, updateConfigs } = useAdminConsoleConfigs();
|
||||
const { data: preferences, update: updatePreferences } = useUserPreferences();
|
||||
const { updateSettings } = useSettings();
|
||||
const methods = useForm<SignInExperienceForm>();
|
||||
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) => {
|
|||
<Button type="plain" size="small" title="general.skip" onClick={onClose} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{configs && !configs.experienceNoticeConfirmed && (
|
||||
{!preferences.experienceNoticeConfirmed && (
|
||||
<div className={styles.reminder}>
|
||||
<Alert
|
||||
action="admin_console.sign_in_exp.welcome.got_it"
|
||||
|
|
|
@ -13,7 +13,8 @@ import Card from '@/components/Card';
|
|||
import CardTitle from '@/components/CardTitle';
|
||||
import TabNav, { TabNavItem } from '@/components/TabNav';
|
||||
import useApi, { RequestError } from '@/hooks/use-api';
|
||||
import useAdminConsoleConfigs from '@/hooks/use-configs';
|
||||
import useSettings from '@/hooks/use-settings';
|
||||
import useUserPreferences from '@/hooks/use-user-preferences';
|
||||
import * as detailsStyles from '@/scss/details.module.scss';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
|
@ -33,7 +34,10 @@ const SignInExperience = () => {
|
|||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { tab } = useParams();
|
||||
const { data, error, mutate } = useSWR<SignInExperienceType, RequestError>('/api/sign-in-exp');
|
||||
const { configs, error: configError, updateConfigs } = useAdminConsoleConfigs();
|
||||
const { settings, error: settingsError, updateSettings } = useSettings();
|
||||
const {
|
||||
data: { experienceNoticeConfirmed },
|
||||
} = useUserPreferences();
|
||||
const [dataToCompare, setDataToCompare] = useState<SignInExperienceType>();
|
||||
|
||||
const methods = useForm<SignInExperienceForm>();
|
||||
|
@ -62,7 +66,7 @@ const SignInExperience = () => {
|
|||
})
|
||||
.json<SignInExperienceType>();
|
||||
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 <div>loading</div>;
|
||||
}
|
||||
|
||||
if (!configs && configError) {
|
||||
return <div>{configError.body?.message ?? configError.message}</div>;
|
||||
if (!settings && settingsError) {
|
||||
return <div>{settingsError.body?.message ?? settingsError.message}</div>;
|
||||
}
|
||||
|
||||
if (!configs?.customizeSignInExperience) {
|
||||
if (!experienceNoticeConfirmed) {
|
||||
return <Welcome />;
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<UserInfo> => {
|
||||
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<StateT, WithAuthContext<ContextT>, ResponseBodyT> {
|
||||
export default function koaAuth<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
|
||||
forRole?: UserRole
|
||||
): MiddlewareType<StateT, WithAuthContext<ContextT>, 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 });
|
||||
}
|
||||
|
|
|
@ -88,22 +88,21 @@ export default async function initOidc(app: Koa): Promise<Provider> {
|
|||
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 }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
@ -124,7 +124,7 @@ export default function adminUserRoutes<T extends AuthedRouter>(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);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
37
packages/core/src/routes/me.ts
Normal file
37
packages/core/src/routes/me.ts
Normal file
|
@ -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<T extends AuthedRouter>(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();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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(),
|
||||
|
|
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
@ -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==}
|
||||
|
|
Loading…
Add table
Reference in a new issue