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:
parent
3227f61fad
commit
ff65cfb75b
17 changed files with 300 additions and 100 deletions
|
@ -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>
|
||||
|
|
|
@ -48,6 +48,11 @@
|
|||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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 />} />
|
||||
|
|
|
@ -21,6 +21,7 @@ import { isCloud } from '@/consts/env';
|
|||
export enum GlobalAnonymousRoute {
|
||||
Callback = '/callback',
|
||||
SocialDemoCallback = '/social-demo-callback',
|
||||
Profile = '/profile',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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'];
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 /> },
|
||||
];
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
|
|
105
packages/core/src/routes-me/user-assets.ts
Normal file
105
packages/core/src/routes-me/user-assets.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
Loading…
Add table
Reference in a new issue