0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-24 22:05:56 -05:00

chore: profile api test page

This commit is contained in:
wangsijie 2024-11-11 10:16:01 +08:00
parent b210d8b59c
commit b2ce6976cd
No known key found for this signature in database
GPG key ID: F95DE0D0DDB952CF
14 changed files with 191 additions and 103 deletions

View file

@ -18,6 +18,7 @@ import CheckoutSuccessCallback from '@/pages/CheckoutSuccessCallback';
import { dropLeadingSlash } from '@/utils/url';
import { __Internal__ImportError } from './internal';
import HandleSocialCallback from '@/pages/Profile/containers/HandleSocialCallback';
const Welcome = safeLazy(async () => import('@/pages/Welcome'));
const Profile = safeLazy(async () => import('@/pages/Profile'));
@ -52,6 +53,7 @@ export function ConsoleRoutes() {
)}
<Route element={<ProtectedRoutes />}>
<Route path={dropLeadingSlash(GlobalRoute.Profile) + '/*'} element={<Profile />} />
<Route path={dropLeadingSlash(GlobalRoute.HandleSocial)} element={<HandleSocialCallback />} />
<Route element={<TenantAccess />}>
{isCloud && (
<Route

View file

@ -0,0 +1,57 @@
import { useLogto } from '@logto/react';
import type { JsonObject, UserProfileResponse } from '@logto/schemas';
import { useCallback } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import useSWR from 'swr';
import { adminTenantEndpoint, meApi } from '@/consts';
import type { RequestError } from './use-api';
import { useStaticApi } from './use-api';
import useSwrFetcher from './use-swr-fetcher';
const useAccount = () => {
const { isAuthenticated } = useLogto();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
const fetcher = useSwrFetcher<UserProfileResponse>(api);
const {
data: user,
error,
isLoading,
mutate,
} = useSWR<UserProfileResponse, RequestError>(isAuthenticated && 'me', fetcher);
const updateCustomData = useCallback(
async (customData: JsonObject) => {
if (!user) {
toast.error(t('errors.unexpected_error'));
return;
}
await mutate({
...user,
customData: await api
.patch(`me/custom-data`, {
json: customData,
})
.json<JsonObject>(),
});
},
[api, mutate, t, user]
);
return {
user,
isLoading,
error,
isLoaded: !isLoading && !error,
reload: mutate,
customData: user?.customData,
/** Patch (shallow merge) the custom data of the current user. */
updateCustomData,
};
};
export default useAccount;

View file

@ -39,7 +39,7 @@ export class RequestError extends Error {
export type StaticApiProps = {
prefixUrl?: URL;
hideErrorToast?: boolean | LogtoErrorCode[];
resourceIndicator: string;
resourceIndicator?: string;
timeout?: number;
signal?: AbortSignal;
};
@ -143,7 +143,7 @@ export const useStaticApi = ({
beforeRequest: [
async (request) => {
if (isAuthenticated) {
const accessToken = await (resourceIndicator.startsWith(organizationUrnPrefix)
const accessToken = await (resourceIndicator?.startsWith(organizationUrnPrefix)
? getOrganizationToken(getOrganizationIdFromUrn(resourceIndicator))
: getAccessToken(resourceIndicator));
request.headers.set('Authorization', `Bearer ${accessToken ?? ''}`);

View file

@ -12,7 +12,7 @@ import type { Row } from '../CardContent';
import CardContent from '../CardContent';
type Props = {
readonly user: UserProfileResponse;
readonly user: Partial<UserProfileResponse>;
readonly onUpdate?: () => void;
};

View file

@ -5,7 +5,7 @@ import type { ConnectorResponse, UserProfileResponse } from '@logto/schemas';
import { generateStandardId } from '@logto/shared/universal';
import type { Optional } from '@silverhand/essentials';
import { appendPath, conditional } from '@silverhand/essentials';
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import MailIcon from '@/assets/icons/mail.svg?react';
@ -19,15 +19,16 @@ import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import useTheme from '@/hooks/use-theme';
import { popupWindow } from '../../utils';
import { parseLocationState, popupWindow } from '../../utils';
import type { Row } from '../CardContent';
import CardContent from '../CardContent';
import NotSet from '../NotSet';
import styles from './index.module.scss';
import { useLocation } from 'react-router-dom';
type Props = {
readonly user: UserProfileResponse;
readonly user: Partial<UserProfileResponse>;
readonly connectors?: ConnectorResponse[];
readonly onUpdate: () => void;
};
@ -37,24 +38,64 @@ function LinkAccountSection({ user, connectors, onUpdate }: Props) {
const { navigate } = useTenantPathname();
const theme = useTheme();
const { show: showConfirm } = useConfirmModal();
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
const api = useStaticApi({ prefixUrl: adminTenantEndpoint });
const { state } = useLocation();
const { verificationRecordId, connectorId } = parseLocationState(state);
const getSocialAuthorizationUri = useCallback(
async (connectorId: string) => {
async (connectorId: string, verificationRecordId: string) => {
const adminTenantEndpointUrl = new URL(adminTenantEndpoint);
const state = generateStandardId(8);
const redirectUri = new URL(`/callback/${connectorId}`, adminTenantEndpointUrl).href;
const { redirectTo } = await api
.post('me/social/authorization-uri', { json: { connectorId, state, redirectUri } })
.json<{ redirectTo: string }>();
const { authorizationUri, verificationRecordId: newIdentifierVerificationRecordId } =
await api
.post('api/verifications/social', { json: { connectorId, state, redirectUri } })
.json<{ authorizationUri: string; verificationRecordId: string }>();
sessionStorage.setItem(storageKeys.linkingSocialConnector, connectorId);
sessionStorage.setItem(
storageKeys.linkingSocialConnector,
`${connectorId}:${verificationRecordId}:${newIdentifierVerificationRecordId}`
);
return redirectTo;
return { authorizationUri };
},
[api]
);
useEffect(() => {
if (!verificationRecordId || !connectorId) {
return;
}
const handleSocial = async () => {
const { authorizationUri } = await getSocialAuthorizationUri(connectorId, verificationRecordId);
// Profile page has been moved to the root path instead of being nested inside a tenant context.
// Therefore, we don't need to use `getUrl` to prepend the tenant segment in the callback URL.
// Also, link social is Cloud only, so no need to conditionally prepend the `ossConsolePath`, either.
const callback = new URL('/console/handle-social', window.location.href).href;
const queries = new URLSearchParams({
redirectTo: authorizationUri,
connectorId,
callback,
});
const newWindow = popupWindow(
appendPath(adminTenantEndpoint, `/springboard?${queries.toString()}`).href,
'Link social account with Logto',
600,
640
);
newWindow?.focus();
// Clear the location state
navigate(window.location.pathname, { replace: true, state: {} });
};
void handleSocial();
}, [verificationRecordId]);
const tableInfo = useMemo((): Array<Row<Optional<SocialUserInfo>>> => {
if (!connectors) {
return [];
@ -62,7 +103,7 @@ function LinkAccountSection({ user, connectors, onUpdate }: Props) {
return connectors.map(({ id, name, logo, logoDark, target }) => {
const logoSrc = theme === Theme.Dark && logoDark ? logoDark : logo;
const relatedUserDetails = user.identities[target]?.details;
const relatedUserDetails = user.identities?.[target]?.details;
const socialUserInfo = socialUserInfoGuard.safeParse(relatedUserDetails);
const hasLinked = socialUserInfo.success;
@ -95,26 +136,10 @@ function LinkAccountSection({ user, connectors, onUpdate }: Props) {
: {
name: 'profile.link',
handler: async () => {
const authUri = await getSocialAuthorizationUri(id);
// Profile page has been moved to the root path instead of being nested inside a tenant context.
// Therefore, we don't need to use `getUrl` to prepend the tenant segment in the callback URL.
// Also, link social is Cloud only, so no need to conditionally prepend the `ossConsolePath`, either.
const callback = new URL('/handle-social', window.location.href).href;
const queries = new URLSearchParams({
redirectTo: authUri,
connectorId: id,
callback,
navigate('verify-password', {
state: { action: 'linkSocial', connectorId: id },
});
const newWindow = popupWindow(
appendPath(adminTenantEndpoint, `/springboard?${queries.toString()}`).href,
'Link social account with Logto',
600,
640
);
newWindow?.focus();
return;
},
},
};
@ -151,7 +176,7 @@ function LinkAccountSection({ user, connectors, onUpdate }: Props) {
action: {
name: user.primaryEmail ? 'profile.change' : 'profile.link',
handler: () => {
navigate('link-email', {
navigate('verify-password', {
state: { email: user.primaryEmail, action: 'changeEmail' },
});
},

View file

@ -33,11 +33,7 @@ type FormFields = {
function BasicUserInfoUpdateModal({ field, value: initialValue, isOpen, onClose }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { show: showModal } = useConfirmModal();
const api = useStaticApi({
prefixUrl: adminTenantEndpoint,
resourceIndicator: meApi.indicator,
hideErrorToast: true,
});
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, hideErrorToast: true });
const {
register,
clearErrors,
@ -85,7 +81,7 @@ function BasicUserInfoUpdateModal({ field, value: initialValue, isOpen, onClose
clearErrors();
void handleSubmit(async (data) => {
try {
await api.patch('me', { json: { [field]: data[field] } });
await api.patch('api/profile', { json: { [field]: data[field] } });
toast.success(t('profile.updated', { target: t(`profile.settings.${field}`) }));
onClose();
} catch (error: unknown) {

View file

@ -15,7 +15,8 @@ import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import ExperienceLikeModal from '../../components/ExperienceLikeModal';
import { handleError } from '../../utils';
import { handleError, parseLocationState } from '../../utils';
import { useLocation } from 'react-router-dom';
type FormFields = {
newPassword: string;
@ -33,6 +34,8 @@ function ChangePasswordModal() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { navigate } = useTenantPathname();
const { show: showModal } = useConfirmModal();
const { state } = useLocation();
const { verificationRecordId } = parseLocationState(state);
const {
watch,
reset,
@ -48,7 +51,6 @@ function ChangePasswordModal() {
const [showPassword, setShowPassword] = useState(false);
const api = useStaticApi({
prefixUrl: adminTenantEndpoint,
resourceIndicator: meApi.indicator,
hideErrorToast: true,
});
@ -61,7 +63,9 @@ function ChangePasswordModal() {
clearErrors();
void handleSubmit(async ({ newPassword }) => {
try {
await api.post(`me/password`, { json: { password: newPassword } });
await api.post(`api/profile/password`, {
json: { password: newPassword, verificationRecordId: verificationRecordId ?? null },
});
toast.success(t('profile.password_changed'));
onClose();
} catch (error: unknown) {

View file

@ -13,16 +13,17 @@ function HandleSocialCallback() {
const { show: showModal } = useConfirmModal();
const api = useStaticApi({
prefixUrl: adminTenantEndpoint,
resourceIndicator: meApi.indicator,
hideErrorToast: true,
});
useEffect(() => {
(async () => {
const connectorId = sessionStorage.getItem(storageKeys.linkingSocialConnector);
const [connectorId, verificationRecordId, newIdentifierVerificationRecordId] = String(
sessionStorage.getItem(storageKeys.linkingSocialConnector)
).split(':');
sessionStorage.removeItem(storageKeys.linkingSocialConnector);
if (connectorId) {
if (connectorId && verificationRecordId && newIdentifierVerificationRecordId) {
const queries = new URLSearchParams(search);
queries.set(
'redirectUri',
@ -31,7 +32,12 @@ function HandleSocialCallback() {
const connectorData = Object.fromEntries(queries);
try {
await api.post('me/social/link-identity', { json: { connectorId, connectorData } });
await api.post('api/verifications/social/verify', {
json: { verificationRecordId: newIdentifierVerificationRecordId, connectorData },
});
await api.post('api/profile/identities', {
json: { verificationRecordId, newIdentifierVerificationRecordId },
});
window.close();
} catch (error: unknown) {

View file

@ -30,7 +30,8 @@ function LinkEmailModal() {
} = useForm<EmailForm>({
reValidateMode: 'onBlur',
});
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
const api = useStaticApi({ prefixUrl: adminTenantEndpoint });
const { email: currentEmail, verificationRecordId } = parseLocationState(state);
const onClose = () => {
navigate('/profile');
@ -40,15 +41,19 @@ function LinkEmailModal() {
clearErrors();
void handleSubmit(
trySubmitSafe(async ({ email }) => {
await api.post(`me/verification-codes`, { json: { email } });
const { verificationRecordId: newVerificationRecordId } = await api
.post(`api/verifications/verification-code`, {
json: { identifier: { type: 'email', value: email } },
})
.json<{ verificationRecordId: string }>();
reset();
navigate('../verification-code', { state: { email, action: 'changeEmail' } });
navigate('../verification-code', {
state: { email, action: 'changeEmail', verificationRecordId, newVerificationRecordId },
});
})
)();
};
const { email: currentEmail } = parseLocationState(state);
return (
<ExperienceLikeModal
title="profile.link_account.link_email"

View file

@ -19,15 +19,6 @@ import { handleError, parseLocationState } from '../../utils';
import styles from './index.module.scss';
const resendTimeout = 59;
const getTimeout = () => {
const now = new Date();
now.setSeconds(now.getSeconds() + resendTimeout);
return now;
};
function VerificationCodeModal() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { navigate } = useTenantPathname();
@ -38,15 +29,10 @@ function VerificationCodeModal() {
const [error, setError] = useState<string>();
const api = useStaticApi({
prefixUrl: adminTenantEndpoint,
resourceIndicator: meApi.indicator,
hideErrorToast: true,
});
const { email, action } = parseLocationState(state);
const { seconds, isRunning, restart } = useTimer({
autoStart: true,
expiryTimestamp: getTimeout(),
});
const { email, action, verificationRecordId, newVerificationRecordId } =
parseLocationState(state);
const onClose = useCallback(() => {
navigate('/profile');
@ -61,10 +47,22 @@ function VerificationCodeModal() {
}
try {
await api.post(`me/verification-codes/verify`, { json: { verificationCode, email } });
await api.post(`api/verifications/verification-code/verify`, {
json: {
verificationId: newVerificationRecordId,
identifier: { type: 'email', value: email },
code: verificationCode,
},
});
if (action === 'changeEmail') {
await api.patch('me', { json: { primaryEmail: email } });
await api.post('api/profile/primary-email', {
json: {
email,
verificationRecordId,
newIdentifierVerificationRecordId: newVerificationRecordId,
},
});
toast.success(t('profile.email_changed'));
onClose();
@ -125,23 +123,6 @@ function VerificationCodeModal() {
setError(undefined);
}}
/>
{isRunning ? (
<div className={styles.message}>
{t('profile.code.resend_countdown', { countdown: seconds })}
</div>
) : (
<TextLink
className={styles.link}
onClick={async () => {
setCode([]);
setError(undefined);
await api.post(`me/verification-codes`, { json: { email } });
restart(getTimeout(), true);
}}
>
{t('profile.code.resend')}
</TextLink>
)}
{action === 'changePassword' && (
<TextLink
className={styles.link}

View file

@ -39,11 +39,10 @@ function VerifyPasswordModal() {
});
const api = useStaticApi({
prefixUrl: adminTenantEndpoint,
resourceIndicator: meApi.indicator,
hideErrorToast: true,
});
const [showPassword, setShowPassword] = useState(false);
const { email } = parseLocationState(state);
const { email, action, connectorId } = parseLocationState(state);
const onClose = () => {
navigate('/profile');
@ -53,9 +52,19 @@ function VerifyPasswordModal() {
clearErrors();
void handleSubmit(async ({ password }) => {
try {
await api.post(`me/password/verify`, { json: { password } });
const { verificationRecordId } = await api
.post(`api/verifications/password`, { json: { password } })
.json<{ verificationRecordId: string }>();
reset();
navigate('../change-password', { state });
if (action === 'changeEmail') {
navigate('../link-email', {
state: { email, action, verificationRecordId },
});
} else if (action === 'linkSocial') {
navigate('/profile', { state: { action, verificationRecordId, connectorId } });
} else {
navigate('../change-password', { state: { email, action, verificationRecordId } });
}
} catch (error: unknown) {
void handleError(error, async (code, message) => {
if (code === 'session.invalid_credentials') {

View file

@ -1,8 +1,9 @@
import type { ConnectorResponse } from '@logto/schemas';
import type { ConnectorResponse, UserProfileResponse } from '@logto/schemas';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useRoutes } from 'react-router-dom';
import useSWRImmutable from 'swr/immutable';
import useSWR from 'swr';
import FormCard from '@/components/FormCard';
import PageMeta from '@/components/PageMeta';
@ -13,10 +14,8 @@ import AppBoundary from '@/containers/AppBoundary';
import Button from '@/ds-components/Button';
import CardTitle from '@/ds-components/CardTitle';
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
import type { RequestError } from '@/hooks/use-api';
import { useStaticApi } from '@/hooks/use-api';
import { useStaticApi, type RequestError } from '@/hooks/use-api';
import { profile } from '@/hooks/use-console-routes/routes/profile';
import useCurrentUser from '@/hooks/use-current-user';
import { usePlausiblePageview } from '@/hooks/use-plausible-pageview';
import useSwrFetcher from '@/hooks/use-swr-fetcher';
import useTenantPathname from '@/hooks/use-tenant-pathname';
@ -44,10 +43,13 @@ function Profile() {
RequestError
>('me/social/connectors', fetcher);
const isLoadingConnectors = !connectors && !fetchConnectorsError;
const { user, reload, isLoading: isLoadingUser } = useCurrentUser();
const { isLoading: isUserAssetServiceLoading } = useUserAssetsService();
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false);
const profileApi = useStaticApi({ prefixUrl: adminTenantEndpoint });
const profileFetcher = useSwrFetcher<Partial<UserProfileResponse>>(profileApi);
const { data: user, mutate, isLoading: isLoadingUser } = useSWR('api/profile', profileFetcher);
// Avoid unnecessary re-renders in child components
const show = useCallback(() => {
setShowDeleteAccountModal(true);
@ -73,10 +75,8 @@ function Profile() {
{showLoadingSkeleton && <Skeleton />}
{user && !showLoadingSkeleton && (
<div className={styles.content}>
<BasicUserInfoSection user={user} onUpdate={reload} />
{isCloud && (
<LinkAccountSection user={user} connectors={connectors} onUpdate={reload} />
)}
<BasicUserInfoSection user={user} onUpdate={mutate} />
<LinkAccountSection user={user} connectors={connectors} onUpdate={mutate} />
<FormCard title="profile.password.title">
<CardContent
title="profile.password.password_setting"

View file

@ -7,7 +7,7 @@ import { type LocationState, locationStateGuard } from '@/types/profile';
export const parseLocationState = (state: unknown): Partial<LocationState> => {
const parsed = locationStateGuard.safeParse(state);
return parsed.success ? parsed.data : { email: undefined, action: undefined };
return parsed.success ? parsed.data : { email: undefined, action: undefined, verificationRecordId: undefined };
};
export const popupWindow = (url: string, windowName: string, width: number, height: number) => {

View file

@ -2,8 +2,11 @@ import { emailRegEx } from '@logto/core-kit';
import { z } from 'zod';
export const locationStateGuard = z.object({
email: z.string().regex(emailRegEx),
action: z.union([z.literal('changeEmail'), z.literal('changePassword')]),
email: z.string().regex(emailRegEx).nullable().optional(),
action: z.union([z.literal('changeEmail'), z.literal('changePassword'), z.literal('linkSocial')]),
verificationRecordId: z.string().optional(),
newVerificationRecordId: z.string().optional(),
connectorId: z.string().optional(),
});
export type LocationState = z.infer<typeof locationStateGuard>;