Skip to content

Commit 24f48bf

Browse files
authored
feat(console): use account API for profile page (#8130)
* feat(console): use account API for profile page Replace /me API with Account API (/api/my-account) for user profile management in the console. Changes: - Add useAccountApi hook for calling Account API with opaque access tokens - Update BasicUserInfoUpdateModal to use Account API for name and username - Update useCurrentUser to fetch user info and update custom data via Account API - Add UserScope.Profile to console scopes - Enable account center for admin tenant with all fields editable - Add database alteration for existing installations * feat(console): guard Account API usage with isDevFeaturesEnabled flag Conditionally use Account API or Me API based on the dev features flag: - BasicUserInfoUpdateModal uses Account API for name/username only when enabled - use-current-user uses Account API for fetching and updating only when enabled
1 parent 3f71959 commit 24f48bf

File tree

7 files changed

+218
-15
lines changed

7 files changed

+218
-15
lines changed

packages/cli/src/commands/database/seed/tables.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ import {
2828
AccountCenters,
2929
} from '@logto/schemas';
3030
import { getTenantRole } from '@logto/schemas';
31-
import { createDefaultAccountCenter } from '@logto/schemas/lib/seeds/account-center.js';
31+
import {
32+
createDefaultAccountCenter,
33+
createAdminTenantAccountCenter,
34+
} from '@logto/schemas/lib/seeds/account-center.js';
3235
import { Tenants } from '@logto/schemas/models';
3336
import { generateStandardId } from '@logto/shared';
3437
import type { DatabaseTransactionConnection } from '@silverhand/slonik';
@@ -204,7 +207,9 @@ export const seedTables = async (
204207
connection.query(insertInto(createAdminTenantSignInExperience(), SignInExperiences.table)),
205208
connection.query(insertInto(createDefaultAdminConsoleApplication(), Applications.table)),
206209
connection.query(insertInto(createDefaultAccountCenter(defaultTenantId), AccountCenters.table)),
207-
connection.query(insertInto(createDefaultAccountCenter(adminTenantId), AccountCenters.table)),
210+
connection.query(
211+
insertInto(createAdminTenantAccountCenter(adminTenantId), AccountCenters.table)
212+
),
208213
]);
209214

210215
// The below seed data is for the Logto Cloud only. We put it here for the sake of simplicity.

packages/console/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ function Providers() {
8484

8585
const scopes = useMemo(
8686
() => [
87+
UserScope.Profile,
8788
UserScope.Email,
8889
UserScope.Identities,
8990
UserScope.CustomData,
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { httpCodeToMessage } from '@logto/core-kit';
2+
import type { LogtoErrorCode } from '@logto/phrases';
3+
import { useLogto } from '@logto/react';
4+
import type { RequestErrorBody } from '@logto/schemas';
5+
import { conditionalArray } from '@silverhand/essentials';
6+
import ky from 'ky';
7+
import { type KyInstance } from 'node_modules/ky/distribution/types/ky';
8+
import { useCallback, useMemo } from 'react';
9+
import { toast } from 'react-hot-toast';
10+
import { useTranslation } from 'react-i18next';
11+
12+
import { requestTimeout, adminTenantEndpoint } from '@/consts';
13+
14+
import useRedirectUri from './use-redirect-uri';
15+
import useSignOut from './use-sign-out';
16+
17+
type AccountApiProps = {
18+
hideErrorToast?: boolean | LogtoErrorCode[];
19+
timeout?: number;
20+
signal?: AbortSignal;
21+
};
22+
23+
const useGlobalRequestErrorHandler = (toastDisabledErrorCodes?: LogtoErrorCode[]) => {
24+
const { signOut } = useSignOut();
25+
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
26+
const postSignOutRedirectUri = useRedirectUri('signOut');
27+
28+
const handleError = useCallback(
29+
async (response: Response) => {
30+
const fallbackErrorMessage = t('errors.unknown_server_error');
31+
32+
try {
33+
const data = await response.clone().json<RequestErrorBody>();
34+
35+
if (response.status === 403 && data.message === 'Insufficient permissions.') {
36+
await signOut(postSignOutRedirectUri.href);
37+
return;
38+
}
39+
40+
if (toastDisabledErrorCodes?.includes(data.code)) {
41+
return;
42+
}
43+
44+
toast.error([data.message, data.details].join('\n') || fallbackErrorMessage);
45+
} catch {
46+
toast.error(httpCodeToMessage[response.status] ?? fallbackErrorMessage);
47+
}
48+
},
49+
[t, toastDisabledErrorCodes, signOut, postSignOutRedirectUri.href]
50+
);
51+
52+
return { handleError };
53+
};
54+
55+
/**
56+
* A hook to get a Ky instance for calling the Account API (/api/my-account).
57+
* The Account API uses OIDC opaque access tokens (no resource indicator).
58+
*/
59+
const useAccountApi = ({
60+
hideErrorToast,
61+
timeout = requestTimeout,
62+
signal,
63+
}: AccountApiProps = {}): KyInstance => {
64+
const { isAuthenticated, getAccessToken } = useLogto();
65+
const { i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' });
66+
67+
const disableGlobalErrorHandling = hideErrorToast === true;
68+
const toastDisabledErrorCodes = Array.isArray(hideErrorToast) ? hideErrorToast : undefined;
69+
const { handleError } = useGlobalRequestErrorHandler(toastDisabledErrorCodes);
70+
71+
const api = useMemo(
72+
() =>
73+
ky.create({
74+
prefixUrl: adminTenantEndpoint,
75+
timeout,
76+
signal,
77+
hooks: {
78+
beforeError: conditionalArray(
79+
!disableGlobalErrorHandling &&
80+
(async (error) => {
81+
await handleError(error.response);
82+
return error;
83+
})
84+
),
85+
beforeRequest: [
86+
async (request) => {
87+
if (isAuthenticated) {
88+
// Get opaque access token without resource indicator for Account API
89+
const accessToken = await getAccessToken();
90+
request.headers.set('Authorization', `Bearer ${accessToken ?? ''}`);
91+
request.headers.set('Accept-Language', i18n.language);
92+
}
93+
},
94+
],
95+
},
96+
}),
97+
[
98+
timeout,
99+
signal,
100+
disableGlobalErrorHandling,
101+
handleError,
102+
isAuthenticated,
103+
getAccessToken,
104+
i18n.language,
105+
]
106+
);
107+
108+
return api;
109+
};
110+
111+
export default useAccountApi;

packages/console/src/hooks/use-current-user.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,32 @@ import { useTranslation } from 'react-i18next';
66
import useSWR from 'swr';
77

88
import { adminTenantEndpoint, meApi } from '@/consts';
9+
import { isDevFeaturesEnabled } from '@/consts/env';
910

11+
import useAccountApi from './use-account-api';
1012
import type { RequestError } from './use-api';
1113
import { useStaticApi } from './use-api';
1214
import useSwrFetcher from './use-swr-fetcher';
1315

1416
const useCurrentUser = () => {
1517
const { isAuthenticated } = useLogto();
1618
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
17-
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
19+
const meApi_ = useStaticApi({
20+
prefixUrl: adminTenantEndpoint,
21+
resourceIndicator: meApi.indicator,
22+
});
23+
const accountApi = useAccountApi();
24+
const api = isDevFeaturesEnabled ? accountApi : meApi_;
1825
const fetcher = useSwrFetcher<UserProfileResponse>(api);
1926
const {
2027
data: user,
2128
error,
2229
isLoading,
2330
mutate,
24-
} = useSWR<UserProfileResponse, RequestError>(isAuthenticated && 'me', fetcher);
31+
} = useSWR<UserProfileResponse, RequestError>(
32+
isAuthenticated && (isDevFeaturesEnabled ? 'api/my-account' : 'me'),
33+
fetcher
34+
);
2535

2636
const updateCustomData = useCallback(
2737
async (customData: JsonObject) => {
@@ -30,16 +40,26 @@ const useCurrentUser = () => {
3040
return;
3141
}
3242

33-
await mutate({
34-
...user,
35-
customData: await api
36-
.patch(`me/custom-data`, {
37-
json: customData,
43+
await (isDevFeaturesEnabled
44+
? mutate({
45+
...user,
46+
customData: await accountApi
47+
.patch('api/my-account', {
48+
json: { customData },
49+
})
50+
.json<UserProfileResponse>()
51+
.then((response) => response.customData),
3852
})
39-
.json<JsonObject>(),
40-
});
53+
: mutate({
54+
...user,
55+
customData: await meApi_
56+
.patch('me/custom-data', {
57+
json: customData,
58+
})
59+
.json<JsonObject>(),
60+
}));
4161
},
42-
[api, mutate, t, user]
62+
[accountApi, meApi_, mutate, t, user]
4363
);
4464

4565
return {

packages/console/src/pages/Profile/containers/BasicUserInfoUpdateModal/index.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import { useTranslation } from 'react-i18next';
66
import ReactModal from 'react-modal';
77

88
import { adminTenantEndpoint, meApi } from '@/consts';
9+
import { isDevFeaturesEnabled } from '@/consts/env';
910
import Button from '@/ds-components/Button';
1011
import ModalLayout from '@/ds-components/ModalLayout';
1112
import TextInput from '@/ds-components/TextInput';
1213
import ImageUploaderField from '@/ds-components/Uploader/ImageUploaderField';
14+
import useAccountApi from '@/hooks/use-account-api';
1315
import { useStaticApi } from '@/hooks/use-api';
1416
import { useConfirmModal } from '@/hooks/use-confirm-modal';
1517
import useUserAssetsService from '@/hooks/use-user-assets-service';
@@ -33,11 +35,12 @@ type FormFields = {
3335
function BasicUserInfoUpdateModal({ field, value: initialValue, isOpen, onClose }: Props) {
3436
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
3537
const { show: showModal } = useConfirmModal();
36-
const api = useStaticApi({
38+
const meApi_ = useStaticApi({
3739
prefixUrl: adminTenantEndpoint,
3840
resourceIndicator: meApi.indicator,
3941
hideErrorToast: true,
4042
});
43+
const accountApi = useAccountApi({ hideErrorToast: true });
4144
const {
4245
register,
4346
clearErrors,
@@ -85,7 +88,11 @@ function BasicUserInfoUpdateModal({ field, value: initialValue, isOpen, onClose
8588
clearErrors();
8689
void handleSubmit(async (data) => {
8790
try {
88-
await api.patch('me', { json: { [field]: data[field] } });
91+
// Use Account API for name and username fields when dev features enabled,
92+
// Me API for avatar (image upload not supported in Account API yet) and fallback
93+
await (isDevFeaturesEnabled && field !== 'avatar'
94+
? accountApi.patch('api/my-account', { json: { [field]: data[field] } })
95+
: meApi_.patch('me', { json: { [field]: data[field] } }));
8996
toast.success(t('profile.updated', { target: t(`profile.settings.${field}`) }));
9097
onClose();
9198
} catch (error: unknown) {
@@ -134,7 +141,7 @@ function BasicUserInfoUpdateModal({ field, value: initialValue, isOpen, onClose
134141
name={name}
135142
value={value}
136143
uploadUrl="me/user-assets"
137-
apiInstance={api}
144+
apiInstance={meApi_}
138145
onChange={onChange}
139146
/>
140147
)}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { sql } from '@silverhand/slonik';
2+
3+
import type { AlterationScript } from '../lib/types/alteration.js';
4+
5+
const adminTenantId = 'admin';
6+
7+
/**
8+
* Enable the account center for the admin tenant and set all fields to Edit.
9+
* This allows the console profile page to use the Account API.
10+
*/
11+
const alteration: AlterationScript = {
12+
up: async (pool) => {
13+
await pool.query(sql`
14+
update account_centers
15+
set enabled = true,
16+
fields = '{"name": "Edit", "avatar": "Edit", "profile": "Edit", "email": "Edit", "phone": "Edit", "password": "Edit", "username": "Edit", "social": "Edit", "customData": "Edit", "mfa": "Edit"}'::jsonb
17+
where tenant_id = ${adminTenantId}
18+
and id = 'default'
19+
`);
20+
},
21+
down: async (pool) => {
22+
await pool.query(sql`
23+
update account_centers
24+
set enabled = false,
25+
fields = '{}'::jsonb
26+
where tenant_id = ${adminTenantId}
27+
and id = 'default'
28+
`);
29+
},
30+
};
31+
32+
export default alteration;

packages/schemas/src/seeds/account-center.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { CreateAccountCenter } from '../db-entries/index.js';
2+
import { AccountCenterControlValue } from '../foundations/index.js';
23

34
export const createDefaultAccountCenter = (forTenantId: string): Readonly<CreateAccountCenter> =>
45
Object.freeze({
@@ -7,3 +8,29 @@ export const createDefaultAccountCenter = (forTenantId: string): Readonly<Create
78
enabled: false,
89
fields: {},
910
});
11+
12+
/**
13+
* Create the account center for the admin tenant.
14+
* The account center is enabled by default and allows editing all fields,
15+
* so that the console profile page can use the Account API.
16+
*/
17+
export const createAdminTenantAccountCenter = (
18+
forTenantId: string
19+
): Readonly<CreateAccountCenter> =>
20+
Object.freeze({
21+
tenantId: forTenantId,
22+
id: 'default',
23+
enabled: true,
24+
fields: {
25+
name: AccountCenterControlValue.Edit,
26+
avatar: AccountCenterControlValue.Edit,
27+
profile: AccountCenterControlValue.Edit,
28+
email: AccountCenterControlValue.Edit,
29+
phone: AccountCenterControlValue.Edit,
30+
password: AccountCenterControlValue.Edit,
31+
username: AccountCenterControlValue.Edit,
32+
social: AccountCenterControlValue.Edit,
33+
customData: AccountCenterControlValue.Edit,
34+
mfa: AccountCenterControlValue.Edit,
35+
},
36+
});

0 commit comments

Comments
 (0)