0
Fork 0
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:
Gao Sun 2022-06-07 16:05:24 +08:00 committed by GitHub
parent 00e32f08da
commit f2b44b49f9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 279 additions and 146 deletions

View file

@ -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",

View file

@ -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}</>;

View file

@ -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) };
};

View file

@ -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}>

View file

@ -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" />;
}

View file

@ -1,4 +1,4 @@
export * from './applications';
export * from './icons';
export const themeStorageKey = 'adminConsoleTheme';
export const themeStorageKey = 'logto:admin_console:theme';

View file

@ -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;

View 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;

View file

@ -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();
};

View file

@ -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 }),
});

View file

@ -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;
}

View file

@ -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');
},
},

View file

@ -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');
};

View file

@ -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

View file

@ -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"

View file

@ -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 />;
}

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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 });
}

View file

@ -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 }),
});
},
};

View file

@ -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);
}

View file

@ -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) {

View 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();
}
);
}

View file

@ -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
View file

@ -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==}