0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

fix(console): make profile a tenant independent page (#5687)

* fix(console): make profile a tenant independent page

* refactor(console): profile routes

* chore(core): refactor later
This commit is contained in:
Charles Zhao 2024-05-09 19:16:26 +08:00 committed by GitHub
parent 3227f61fad
commit ff65cfb75b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 300 additions and 100 deletions

View file

@ -1,11 +1,11 @@
import { Route, Routes } from 'react-router-dom';
import { isCloud } from '@/consts/env';
import ProtectedRoutes from '@/containers/ProtectedRoutes';
import { GlobalAnonymousRoute, GlobalRoute } from '@/contexts/TenantsProvider';
import AcceptInvitation from '@/pages/AcceptInvitation';
import Callback from '@/pages/Callback';
import CheckoutSuccessCallback from '@/pages/CheckoutSuccessCallback';
import Profile from '@/pages/Profile';
import * as styles from './AppRoutes.module.scss';
import Main from './pages/Main';
@ -19,12 +19,11 @@ function AppRoutes() {
<Route path={GlobalAnonymousRoute.Callback} element={<Callback />} />
<Route path={GlobalAnonymousRoute.SocialDemoCallback} element={<SocialDemoCallback />} />
<Route element={<ProtectedRoutes />}>
{isCloud && (
<Route
path={`${GlobalRoute.AcceptInvitation}/:invitationId`}
element={<AcceptInvitation />}
/>
)}
<Route
path={`${GlobalRoute.AcceptInvitation}/:invitationId`}
element={<AcceptInvitation />}
/>
<Route path={GlobalAnonymousRoute.Profile + '/*'} element={<Profile />} />
<Route path={GlobalRoute.CheckoutSuccessCallback} element={<CheckoutSuccessCallback />} />
<Route index element={<Main />} />
</Route>

View file

@ -48,6 +48,11 @@
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: var(--color-text-secondary);
}

View file

@ -5,12 +5,14 @@ import classNames from 'classnames';
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ExternalLinkIcon from '@/assets/icons/external-link.svg';
import Globe from '@/assets/icons/globe.svg';
import Palette from '@/assets/icons/palette.svg';
import Profile from '@/assets/icons/profile.svg';
import SignOut from '@/assets/icons/sign-out.svg';
import UserAvatar from '@/components/UserAvatar';
import UserInfoCard from '@/components/UserInfoCard';
import { isCloud } from '@/consts/env';
import Divider from '@/ds-components/Divider';
import Dropdown, { DropdownItem } from '@/ds-components/Dropdown';
import Spacer from '@/ds-components/Spacer';
@ -28,7 +30,7 @@ import * as styles from './index.module.scss';
function UserInfo() {
const { signOut } = useLogto();
const { navigate } = useTenantPathname();
const { getUrl } = useTenantPathname();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { user, isLoading: isLoadingUser } = useCurrentUser();
const anchorRef = useRef<HTMLDivElement>(null);
@ -77,10 +79,19 @@ function UserInfo() {
className={classNames(styles.dropdownItem, isLoading && styles.loading)}
icon={<Profile className={styles.icon} />}
onClick={() => {
navigate('/profile');
// In OSS version, there will be a `/console` context path in the URL.
const profileRouteWithConsoleContext = getUrl('/profile');
// Open the profile page in a new tab. In Logto Cloud, the profile page is not nested in the tenant independent,
// whereas in OSS version, it is under the `/console` context path.
window.open(isCloud ? '/profile' : profileRouteWithConsoleContext, '_blank');
}}
>
{t('menu.profile')}
<Spacer />
<div className={styles.icon}>
<ExternalLinkIcon />
</div>
</DropdownItem>
<Divider />
<SubMenu

View file

@ -14,17 +14,21 @@ import * as styles from './index.module.scss';
type Props = {
readonly className?: string;
/* eslint-disable react/boolean-prop-naming */
readonly hideTenantSelector?: boolean;
readonly hideTitle?: boolean;
/* eslint-enable react/boolean-prop-naming */
};
function Topbar({ className }: Props) {
function Topbar({ className, hideTenantSelector, hideTitle }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const LogtoLogo = isCloud ? CloudLogo : Logo;
return (
<div className={classNames(styles.topbar, className)}>
<LogtoLogo className={styles.logo} />
{isCloud && <TenantSelector />}
{!isCloud && (
{isCloud && !hideTenantSelector && <TenantSelector />}
{!isCloud && !hideTitle && (
<>
<div className={styles.line} />
<div className={styles.text}>{t('title')}</div>

View file

@ -14,9 +14,10 @@ function ConsoleContent() {
const { scrollableContent } = useOutletContext<AppContentOutletContext>();
const routeObjects = useConsoleRoutes();
const routes = useRoutes(routeObjects);
usePlausiblePageview(routeObjects);
// Use this hook here to make sure console listens to user tenant scope changes.
useTenantScopeListener();
usePlausiblePageview(routeObjects);
return (
<div className={styles.content}>

View file

@ -8,11 +8,12 @@ import AppContent, { RedirectToFirstItem } from '@/containers/AppContent';
import ConsoleContent from '@/containers/ConsoleContent';
import ProtectedRoutes from '@/containers/ProtectedRoutes';
import TenantAccess from '@/containers/TenantAccess';
import { GlobalRoute } from '@/contexts/TenantsProvider';
import { GlobalAnonymousRoute, GlobalRoute } from '@/contexts/TenantsProvider';
import Toast from '@/ds-components/Toast';
import useSwrOptions from '@/hooks/use-swr-options';
import Callback from '@/pages/Callback';
import CheckoutSuccessCallback from '@/pages/CheckoutSuccessCallback';
import Profile from '@/pages/Profile';
import HandleSocialCallback from '@/pages/Profile/containers/HandleSocialCallback';
import Welcome from '@/pages/Welcome';
import { dropLeadingSlash } from '@/utils/url';
@ -39,6 +40,9 @@ export function ConsoleRoutes() {
* console path to trigger the console routes.
*/}
{!isCloud && <Route path="/" element={<Navigate to={ossConsolePath} />} />}
{!isCloud && (
<Route path={ossConsolePath + GlobalAnonymousRoute.Profile + '/*'} element={<Profile />} />
)}
<Route path="/:tenantId" element={<Layout />}>
<Route path="callback" element={<Callback />} />
<Route path="welcome" element={<Welcome />} />

View file

@ -21,6 +21,7 @@ import { isCloud } from '@/consts/env';
export enum GlobalAnonymousRoute {
Callback = '/callback',
SocialDemoCallback = '/social-demo-callback',
Profile = '/profile',
}
/**

View file

@ -1,6 +1,7 @@
import type { AllowedUploadMimeType, UserAssets } from '@logto/schemas';
import { maxUploadFileSize } from '@logto/schemas';
import classNames from 'classnames';
import { type KyInstance } from 'ky';
import { useCallback, useEffect, useState } from 'react';
import { type FileRejection, useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
@ -20,6 +21,15 @@ export type Props = {
readonly onCompleted: (fileUrl: string) => void;
readonly onUploadErrorChange: (errorMessage?: string) => void;
readonly className?: string;
/**
* Specify which API instance to use for the upload request. For example, you can use admin tenant API instead.
* Defaults to the return value of `useApi()`.
*/
readonly apiInstance?: KyInstance;
/**
* Specify the URL to upload the file to. Defaults to `api/user-assets`.
*/
readonly uploadUrl?: string;
};
function FileUploader({
@ -29,6 +39,8 @@ function FileUploader({
onCompleted,
onUploadErrorChange,
className,
apiInstance,
uploadUrl = 'api/user-assets',
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const [isUploading, setIsUploading] = useState(false);
@ -91,7 +103,8 @@ function FileUploader({
try {
setIsUploading(true);
const { url } = await api.post('api/user-assets', { body: formData }).json<UserAssets>();
const uploadApi = apiInstance ?? api;
const { url } = await uploadApi.post(uploadUrl, { body: formData }).json<UserAssets>();
onCompleted(url);
} catch {
@ -100,7 +113,7 @@ function FileUploader({
setIsUploading(false);
}
},
[allowedMimeTypes, api, maxSize, onCompleted, t]
[api, apiInstance, allowedMimeTypes, maxSize, onCompleted, t, uploadUrl]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({

View file

@ -9,7 +9,7 @@ import type { Props as ImageUploaderProps } from '../ImageUploader';
import * as styles from './index.module.scss';
type Props = Pick<ImageUploaderProps, 'name' | 'value' | 'actionDescription'> & {
type Props = Omit<ImageUploaderProps, 'onDelete' | 'onCompleted' | 'onUploadErrorChange'> & {
readonly onChange: (value: string) => void;
readonly allowedMimeTypes?: UserAssetsServiceStatus['allowUploadMimeTypes'];
};

View file

@ -23,7 +23,6 @@ import { customizeJwt } from './routes/customize-jwt';
import { enterpriseSso } from './routes/enterprise-sso';
import { organizationTemplate } from './routes/organization-template';
import { organizations } from './routes/organizations';
import { profile } from './routes/profile';
import { roles } from './routes/roles';
import { signInExperience } from './routes/sign-in-experience';
import { useTenantSettings } from './routes/tenant-settings';
@ -62,7 +61,6 @@ export const useConsoleRoutes = () => {
{ path: steps.organizationInfo, element: <OrganizationInfo /> },
],
},
profile,
{ path: 'signing-keys', element: <SigningKeys /> },
isCloud && tenantSettings,
isCloud && customizeJwt

View file

@ -1,18 +1,13 @@
import { type RouteObject } from 'react-router-dom';
import Profile from '@/pages/Profile';
import ChangePasswordModal from '@/pages/Profile/containers/ChangePasswordModal';
import LinkEmailModal from '@/pages/Profile/containers/LinkEmailModal';
import VerificationCodeModal from '@/pages/Profile/containers/VerificationCodeModal';
import VerifyPasswordModal from '@/pages/Profile/containers/VerifyPasswordModal';
export const profile: RouteObject = {
path: 'profile',
children: [
{ index: true, element: <Profile /> },
{ path: 'verify-password', element: <VerifyPasswordModal /> },
{ path: 'change-password', element: <ChangePasswordModal /> },
{ path: 'link-email', element: <LinkEmailModal /> },
{ path: 'verification-code', element: <VerificationCodeModal /> },
],
};
export const profile: RouteObject[] = [
{ path: 'verify-password', element: <VerifyPasswordModal /> },
{ path: 'change-password', element: <ChangePasswordModal /> },
{ path: 'link-email', element: <LinkEmailModal /> },
{ path: 'verification-code', element: <VerificationCodeModal /> },
];

View file

@ -1,11 +1,30 @@
import type { UserAssetsServiceStatus } from '@logto/schemas';
import { type UserAssetsServiceStatus } from '@logto/schemas';
import { useLocation } from 'react-router-dom';
import useSWRImmutable from 'swr/immutable';
import type { RequestError } from './use-api';
import { adminTenantEndpoint, meApi } from '@/consts';
import { isCloud } from '@/consts/env';
import { GlobalAnonymousRoute } from '@/contexts/TenantsProvider';
import useApi, { useStaticApi, type RequestError } from './use-api';
import useSwrFetcher from './use-swr-fetcher';
const useUserAssetsService = () => {
const adminApi = useStaticApi({
prefixUrl: adminTenantEndpoint,
resourceIndicator: meApi.indicator,
});
const api = useApi();
const { pathname } = useLocation();
const isProfilePage =
pathname === GlobalAnonymousRoute.Profile ||
pathname.startsWith(GlobalAnonymousRoute.Profile + '/');
const shouldUseAdminApi = isCloud && isProfilePage;
const fetcher = useSwrFetcher<UserAssetsServiceStatus>(shouldUseAdminApi ? adminApi : api);
const { data, error } = useSWRImmutable<UserAssetsServiceStatus, RequestError>(
'api/user-assets/service-status'
`${shouldUseAdminApi ? 'me' : 'api'}/user-assets/service-status`,
fetcher
);
return {

View file

@ -130,7 +130,13 @@ function BasicUserInfoUpdateModal({ field, value: initialValue, isOpen, onClose
name="avatar"
control={control}
render={({ field: { onChange, value, name } }) => (
<ImageUploaderField name={name} value={value} onChange={onChange} />
<ImageUploaderField
name={name}
value={value}
uploadUrl="me/user-assets"
apiInstance={api}
onChange={onChange}
/>
)}
/>
) : (

View file

@ -1,24 +1,43 @@
@use '@/scss/underscore' as _;
.content {
margin-top: _.unit(4);
padding-bottom: _.unit(6);
> div + div {
margin-top: _.unit(4);
}
}
.deleteAccount {
flex: 1;
.pageContainer {
position: absolute;
inset: 0;
display: flex;
align-items: center;
border: 1px solid var(--color-divider);
border-radius: 8px;
padding: _.unit(4);
flex-direction: column;
height: 100%;
.description {
font: var(--font-body-2);
margin-right: _.unit(2);
.scrollable {
width: 100%;
}
.wrapper {
@include _.main-content-width;
width: 100%;
padding: _.unit(3) _.unit(6) 0;
}
.content {
width: 100%;
margin-top: _.unit(4);
padding-bottom: _.unit(6);
> div + div {
margin-top: _.unit(4);
}
}
.deleteAccount {
flex: 1;
display: flex;
align-items: center;
border: 1px solid var(--color-divider);
border-radius: 8px;
padding: _.unit(4);
.description {
font: var(--font-body-2);
margin-right: _.unit(2);
}
}
}

View file

@ -1,17 +1,22 @@
import type { ConnectorResponse } from '@logto/schemas';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useRoutes } from 'react-router-dom';
import useSWRImmutable from 'swr/immutable';
import FormCard from '@/components/FormCard';
import PageMeta from '@/components/PageMeta';
import Topbar from '@/components/Topbar';
import { adminTenantEndpoint, meApi } from '@/consts';
import { isCloud } from '@/consts/env';
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 { 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';
import useUserAssetsService from '@/hooks/use-user-assets-service';
@ -28,6 +33,9 @@ import * as styles from './index.module.scss';
function Profile() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { navigate } = useTenantPathname();
const childrenRoutes = useRoutes(profile);
usePlausiblePageview(profile);
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
const fetcher = useSwrFetcher<ConnectorResponse[]>(api);
const { data: connectors, error: fetchConnectorsError } = useSWRImmutable<
@ -42,58 +50,68 @@ function Profile() {
const showLoadingSkeleton = isLoadingUser || isLoadingConnectors || isUserAssetServiceLoading;
return (
<div className={pageLayout.container}>
<PageMeta titleKey="profile.page_title" />
<div className={pageLayout.headline}>
<CardTitle title="profile.title" subtitle="profile.description" />
</div>
{showLoadingSkeleton && <Skeleton />}
{user && !showLoadingSkeleton && (
<div className={styles.content}>
<BasicUserInfoSection user={user} onUpdate={reload} />
{isCloud && <LinkAccountSection user={user} connectors={connectors} onUpdate={reload} />}
<FormCard title="profile.password.title">
<CardContent
title="profile.password.password_setting"
data={[
{
key: 'password',
label: 'profile.password.password',
value: user.hasPassword,
renderer: (value) => (value ? <span>********</span> : <NotSet />),
action: {
name: 'profile.change',
handler: () => {
navigate(user.hasPassword ? 'verify-password' : 'change-password', {
state: { email: user.primaryEmail, action: 'changePassword' },
});
<div className={styles.pageContainer}>
<Topbar hideTenantSelector hideTitle />
<OverlayScrollbar className={styles.scrollable}>
<div className={styles.wrapper}>
<PageMeta titleKey="profile.page_title" />
<div className={pageLayout.headline}>
<CardTitle title="profile.title" subtitle="profile.description" />
</div>
{showLoadingSkeleton && <Skeleton />}
{user && !showLoadingSkeleton && (
<div className={styles.content}>
<BasicUserInfoSection user={user} onUpdate={reload} />
{isCloud && (
<LinkAccountSection user={user} connectors={connectors} onUpdate={reload} />
)}
<FormCard title="profile.password.title">
<CardContent
title="profile.password.password_setting"
data={[
{
key: 'password',
label: 'profile.password.password',
value: user.hasPassword,
renderer: (value) => (value ? <span>********</span> : <NotSet />),
action: {
name: 'profile.change',
handler: () => {
navigate(user.hasPassword ? 'verify-password' : 'change-password', {
state: { email: user.primaryEmail, action: 'changePassword' },
});
},
},
},
},
},
]}
/>
</FormCard>
{isCloud && (
<FormCard title="profile.delete_account.title">
<div className={styles.deleteAccount}>
<div className={styles.description}>{t('profile.delete_account.description')}</div>
<Button
title="profile.delete_account.button"
onClick={() => {
setShowDeleteAccountModal(true);
}}
]}
/>
</div>
<DeleteAccountModal
isOpen={showDeleteAccountModal}
onClose={() => {
setShowDeleteAccountModal(false);
}}
/>
</FormCard>
</FormCard>
{isCloud && (
<FormCard title="profile.delete_account.title">
<div className={styles.deleteAccount}>
<div className={styles.description}>
{t('profile.delete_account.description')}
</div>
<Button
title="profile.delete_account.button"
onClick={() => {
setShowDeleteAccountModal(true);
}}
/>
</div>
<DeleteAccountModal
isOpen={showDeleteAccountModal}
onClose={() => {
setShowDeleteAccountModal(false);
}}
/>
</FormCard>
)}
</div>
)}
</div>
)}
</OverlayScrollbar>
{childrenRoutes}
</div>
);
}

View file

@ -11,6 +11,7 @@ import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import socialRoutes from './social.js';
import userAssetsRoutes from './user-assets.js';
import userRoutes from './user.js';
import verificationCodeRoutes from './verification-code.js';
@ -36,6 +37,7 @@ export default function initMeApis(tenant: TenantContext): Koa {
userRoutes(meRouter, tenant);
socialRoutes(meRouter, tenant);
verificationCodeRoutes(meRouter, tenant);
userAssetsRoutes(meRouter, tenant);
const meApp = new Koa();
meApp.use(koaCors(EnvSet.values.cloudUrlSet));

View file

@ -0,0 +1,105 @@
import { readFile } from 'node:fs/promises';
import { consoleLog } from '@logto/cli/lib/utils.js';
import {
userAssetsServiceStatusGuard,
allowUploadMimeTypes,
maxUploadFileSize,
type UserAssets,
userAssetsGuard,
adminTenantId,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { format } from 'date-fns';
import { object } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type { RouterInitArgs } from '#src/routes/types.js';
import SystemContext from '#src/tenants/SystemContext.js';
import assertThat from '#src/utils/assert-that.js';
import { uploadFileGuard } from '#src/utils/storage/consts.js';
import { buildUploadFile } from '#src/utils/storage/index.js';
import type { AuthedMeRouter } from './types.js';
/**
* Duplicated from `/user-assets` management API and used specifically for admin tenant.
* E.g. Profile avatar upload.
*
* @todo: Refactor to reuse as much code as possible. @Charles
*/
export default function userAssetsRoutes<T extends AuthedMeRouter>(...[router]: RouterInitArgs<T>) {
router.get(
'/user-assets/service-status',
koaGuard({
response: userAssetsServiceStatusGuard,
}),
async (ctx, next) => {
const { storageProviderConfig } = SystemContext.shared;
const status = storageProviderConfig
? {
status: 'ready',
allowUploadMimeTypes,
maxUploadFileSize,
}
: {
status: 'not_configured',
};
ctx.body = status;
return next();
}
);
router.post(
'/user-assets',
koaGuard({
files: object({
file: uploadFileGuard,
}),
response: userAssetsGuard,
}),
async (ctx, next) => {
const { file } = ctx.guard.files;
assertThat(file.size <= maxUploadFileSize, 'guard.file_size_exceeded');
assertThat(
allowUploadMimeTypes.map(String).includes(file.mimetype),
'guard.mime_type_not_allowed'
);
const { storageProviderConfig } = SystemContext.shared;
assertThat(storageProviderConfig, 'storage.not_configured');
const userId = ctx.auth.id;
const uploadFile = buildUploadFile(storageProviderConfig);
const objectKey = `${adminTenantId}/${userId}/${format(
new Date(),
'yyyy/MM/dd'
)}/${generateStandardId(8)}/${file.originalFilename}`;
try {
const { url } = await uploadFile(await readFile(file.filepath), objectKey, {
contentType: file.mimetype,
publicUrl: storageProviderConfig.publicUrl,
});
const result: UserAssets = {
url,
};
ctx.body = result;
} catch (error: unknown) {
consoleLog.error(error);
throw new RequestError({
code: 'storage.upload_error',
status: 500,
});
}
return next();
}
);
}