mirror of
https://github.com/logto-io/logto.git
synced 2025-01-13 21:30:30 -05:00
refactor(console): polish code by improving data types and API routes (#3324)
This commit is contained in:
parent
01735d1647
commit
1f0bc8f3f4
11 changed files with 43 additions and 39 deletions
|
@ -6,7 +6,12 @@ import FallbackImageDark from '@/assets/images/broken-image-dark.svg';
|
||||||
import FallbackImageLight from '@/assets/images/broken-image-light.svg';
|
import FallbackImageLight from '@/assets/images/broken-image-light.svg';
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
import { useTheme } from '@/hooks/use-theme';
|
||||||
|
|
||||||
const ImageWithErrorFallback = ({ src, alt, className }: ImgHTMLAttributes<HTMLImageElement>) => {
|
const ImageWithErrorFallback = ({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ImgHTMLAttributes<HTMLImageElement>) => {
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const Fallback = theme === AppearanceMode.LightMode ? FallbackImageLight : FallbackImageDark;
|
const Fallback = theme === AppearanceMode.LightMode ? FallbackImageLight : FallbackImageDark;
|
||||||
|
@ -19,7 +24,7 @@ const ImageWithErrorFallback = ({ src, alt, className }: ImgHTMLAttributes<HTMLI
|
||||||
return <Fallback className={className} />;
|
return <Fallback className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <img className={className} src={src} alt={alt} onError={errorHandler} />;
|
return <img className={className} src={src} alt={alt} onError={errorHandler} {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ImageWithErrorFallback;
|
export default ImageWithErrorFallback;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { User } from '@logto/schemas';
|
import type { UserProfileResponse } from '@logto/schemas';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
import { adminTenantEndpoint, meApi } from '@/consts';
|
import { adminTenantEndpoint, meApi } from '@/consts';
|
||||||
|
@ -11,12 +11,12 @@ import useSwrFetcher from './use-swr-fetcher';
|
||||||
const useCurrentUser = () => {
|
const useCurrentUser = () => {
|
||||||
const userId = useLogtoUserId();
|
const userId = useLogtoUserId();
|
||||||
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
|
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
|
||||||
const fetcher = useSwrFetcher<User>(api);
|
const fetcher = useSwrFetcher<UserProfileResponse>(api);
|
||||||
const {
|
const {
|
||||||
data: user,
|
data: user,
|
||||||
error,
|
error,
|
||||||
mutate,
|
mutate,
|
||||||
} = useSWR<User, RequestError>(userId && `me/users/${userId}`, fetcher);
|
} = useSWR<UserProfileResponse, RequestError>(userId && 'me', fetcher);
|
||||||
|
|
||||||
const isLoading = !user && !error;
|
const isLoading = !user && !error;
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { User } from '@logto/schemas';
|
import type { UserInfo } from '@logto/schemas';
|
||||||
import type { Nullable } from '@silverhand/essentials';
|
import type { Nullable } from '@silverhand/essentials';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ import type { Row } from '../CardContent';
|
||||||
import CardContent from '../CardContent';
|
import CardContent from '../CardContent';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User;
|
user: UserInfo;
|
||||||
onUpdate?: () => void;
|
onUpdate?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -28,7 +28,7 @@ type Props<T> = {
|
||||||
data: Array<Row<T>>;
|
data: Array<Row<T>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CardContent = <T extends Nullable<string | Record<string, unknown>> | undefined>({
|
const CardContent = <T extends Nullable<boolean | string | Record<string, unknown>> | undefined>({
|
||||||
title,
|
title,
|
||||||
data,
|
data,
|
||||||
}: Props<T>) => {
|
}: Props<T>) => {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { buildIdGenerator } from '@logto/core-kit';
|
import { buildIdGenerator } from '@logto/core-kit';
|
||||||
import type { ConnectorResponse, User } from '@logto/schemas';
|
import type { ConnectorResponse, UserInfo } from '@logto/schemas';
|
||||||
import { AppearanceMode } from '@logto/schemas';
|
import { AppearanceMode } from '@logto/schemas';
|
||||||
import type { Optional } from '@silverhand/essentials';
|
import type { Optional } from '@silverhand/essentials';
|
||||||
import { appendPath, conditional } from '@silverhand/essentials';
|
import { appendPath, conditional } from '@silverhand/essentials';
|
||||||
|
@ -27,7 +27,7 @@ import NotSet from '../NotSet';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: User;
|
user: UserInfo;
|
||||||
connectors?: ConnectorResponse[];
|
connectors?: ConnectorResponse[];
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
};
|
};
|
||||||
|
@ -62,7 +62,7 @@ const LinkAccountSection = ({ user, connectors, onUpdate }: Props) => {
|
||||||
|
|
||||||
return connectors.map(({ id, name, logo, logoDark, target }) => {
|
return connectors.map(({ id, name, logo, logoDark, target }) => {
|
||||||
const logoSrc = theme === AppearanceMode.DarkMode && logoDark ? logoDark : logo;
|
const logoSrc = theme === AppearanceMode.DarkMode && logoDark ? logoDark : logo;
|
||||||
const relatedUserDetails = user.identities[target]?.details;
|
const relatedUserDetails = user.identities?.[target]?.details;
|
||||||
const hasLinked = is(relatedUserDetails, socialUserInfoGuard);
|
const hasLinked = is(relatedUserDetails, socialUserInfoGuard);
|
||||||
const conditionalUnlinkAction: Action[] = hasLinked
|
const conditionalUnlinkAction: Action[] = hasLinked
|
||||||
? [
|
? [
|
||||||
|
|
|
@ -72,7 +72,7 @@ const BasicUserInfoUpdateModal = ({ field, value: initialValue, isOpen, onClose
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
void handleSubmit(async (data) => {
|
void handleSubmit(async (data) => {
|
||||||
await api.patch(`me/user`, { json: { [field]: data[field] } });
|
await api.patch('me', { json: { [field]: data[field] } });
|
||||||
toast.success(t('profile.updated', { target: t(`profile.settings.${field}`) }));
|
toast.success(t('profile.updated', { target: t(`profile.settings.${field}`) }));
|
||||||
onClose();
|
onClose();
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -64,7 +64,7 @@ const VerificationCodeModal = () => {
|
||||||
await api.post(`me/verification-codes/verify`, { json: { verificationCode, email } });
|
await api.post(`me/verification-codes/verify`, { json: { verificationCode, email } });
|
||||||
|
|
||||||
if (action === 'changeEmail') {
|
if (action === 'changeEmail') {
|
||||||
await api.patch(`me/user`, { json: { primaryEmail: email } });
|
await api.patch('me', { json: { primaryEmail: email } });
|
||||||
toast.success(t('profile.email_changed'));
|
toast.success(t('profile.email_changed'));
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
|
|
|
@ -61,12 +61,12 @@ const Profile = () => {
|
||||||
{
|
{
|
||||||
key: 'password',
|
key: 'password',
|
||||||
label: 'profile.password.password',
|
label: 'profile.password.password',
|
||||||
value: user.passwordEncrypted,
|
value: user.hasPassword,
|
||||||
renderer: (value) => (value ? <span>********</span> : <NotSet />),
|
renderer: (value) => (value ? <span>********</span> : <NotSet />),
|
||||||
action: {
|
action: {
|
||||||
name: 'profile.change',
|
name: 'profile.change',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
navigate(user.passwordEncrypted ? 'verify-password' : 'change-password', {
|
navigate(user.hasPassword ? 'verify-password' : 'change-password', {
|
||||||
state: { email: user.primaryEmail, action: 'changePassword' },
|
state: { email: user.primaryEmail, action: 'changePassword' },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { emailRegEx, passwordRegEx, usernameRegEx } from '@logto/core-kit';
|
import { emailRegEx, passwordRegEx, usernameRegEx } from '@logto/core-kit';
|
||||||
|
import type { UserProfileResponse } from '@logto/schemas';
|
||||||
import { userInfoSelectFields, arbitraryObjectGuard } from '@logto/schemas';
|
import { userInfoSelectFields, arbitraryObjectGuard } from '@logto/schemas';
|
||||||
import { pick } from '@silverhand/essentials';
|
import { conditional, pick } from '@silverhand/essentials';
|
||||||
import { literal, object, string } from 'zod';
|
import { literal, object, string } from 'zod';
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
@ -25,26 +26,23 @@ export default function userRoutes<T extends AuthedMeRouter>(
|
||||||
},
|
},
|
||||||
} = tenant;
|
} = tenant;
|
||||||
|
|
||||||
router.get(
|
router.get('/', async (ctx, next) => {
|
||||||
'/users/:userId',
|
const { id: userId } = ctx.auth;
|
||||||
koaGuard({
|
|
||||||
params: object({ userId: string() }),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const {
|
|
||||||
params: { userId },
|
|
||||||
} = ctx.guard;
|
|
||||||
|
|
||||||
const user = await findUserById(userId);
|
const user = await findUserById(userId);
|
||||||
|
|
||||||
ctx.body = pick(user, ...userInfoSelectFields, 'passwordEncrypted');
|
const responseData: UserProfileResponse = {
|
||||||
|
...pick(user, ...userInfoSelectFields),
|
||||||
|
...conditional(user.passwordEncrypted && { hasPassword: Boolean(user.passwordEncrypted) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.body = responseData;
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
router.patch(
|
router.patch(
|
||||||
'/user',
|
'/',
|
||||||
koaGuard({
|
koaGuard({
|
||||||
body: object({
|
body: object({
|
||||||
username: string().regex(usernameRegEx),
|
username: string().regex(usernameRegEx),
|
||||||
|
@ -61,8 +59,9 @@ export default function userRoutes<T extends AuthedMeRouter>(
|
||||||
assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
||||||
|
|
||||||
await checkIdentifierCollision(body, userId);
|
await checkIdentifierCollision(body, userId);
|
||||||
await updateUserById(userId, body);
|
|
||||||
ctx.status = 204;
|
const updatedUser = await updateUserById(userId, body);
|
||||||
|
ctx.body = pick(updatedUser, ...userInfoSelectFields);
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
@ -71,6 +70,7 @@ export default function userRoutes<T extends AuthedMeRouter>(
|
||||||
router.get('/custom-data', async (ctx, next) => {
|
router.get('/custom-data', async (ctx, next) => {
|
||||||
const { id: userId } = ctx.auth;
|
const { id: userId } = ctx.auth;
|
||||||
const user = await findUserById(userId);
|
const user = await findUserById(userId);
|
||||||
|
assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
||||||
|
|
||||||
ctx.body = user.customData;
|
ctx.body = user.customData;
|
||||||
|
|
||||||
|
@ -87,13 +87,14 @@ export default function userRoutes<T extends AuthedMeRouter>(
|
||||||
const { id: userId } = ctx.auth;
|
const { id: userId } = ctx.auth;
|
||||||
const { body: customData } = ctx.guard;
|
const { body: customData } = ctx.guard;
|
||||||
|
|
||||||
await findUserById(userId);
|
const user = await findUserById(userId);
|
||||||
|
assertThat(!user.isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
||||||
|
|
||||||
const user = await updateUserById(userId, {
|
const updatedUser = await updateUserById(userId, {
|
||||||
customData,
|
customData,
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = user.customData;
|
ctx.body = updatedUser.customData;
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,6 @@ export default function verificationCodeRoutes<T extends AuthedMeRouter>(
|
||||||
'/verification-codes',
|
'/verification-codes',
|
||||||
koaGuard({
|
koaGuard({
|
||||||
body: object({ email: string().regex(emailRegEx) }),
|
body: object({ email: string().regex(emailRegEx) }),
|
||||||
status: 204,
|
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const code = await createPasscode(undefined, codeType, ctx.guard.body);
|
const code = await createPasscode(undefined, codeType, ctx.guard.body);
|
||||||
|
@ -48,7 +47,6 @@ export default function verificationCodeRoutes<T extends AuthedMeRouter>(
|
||||||
verificationCode: string().min(1),
|
verificationCode: string().min(1),
|
||||||
action: union([literal('changeEmail'), literal('changePassword')]),
|
action: union([literal('changeEmail'), literal('changePassword')]),
|
||||||
}),
|
}),
|
||||||
status: 204,
|
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { id: userId } = ctx.auth;
|
const { id: userId } = ctx.auth;
|
||||||
|
|
|
@ -20,7 +20,7 @@ export type UserInfo<Keys extends keyof CreateUser = (typeof userInfoSelectField
|
||||||
Keys
|
Keys
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type UserProfileResponse = UserInfo & { hasPasswordSet: boolean };
|
export type UserProfileResponse = UserInfo & { hasPassword?: boolean };
|
||||||
|
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
Admin = 'admin',
|
Admin = 'admin',
|
||||||
|
|
Loading…
Add table
Reference in a new issue