mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(console): add tenant settings page (#3938)
This commit is contained in:
parent
a187503213
commit
690cd37e71
14 changed files with 374 additions and 14 deletions
|
@ -126,6 +126,34 @@
|
|||
}
|
||||
}
|
||||
|
||||
.small {
|
||||
position: relative;
|
||||
border: 1px solid var(--color-border);
|
||||
flex: 1;
|
||||
font: var(--font-body-2);
|
||||
|
||||
&:first-child {
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: unset;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: _.unit(2) 0;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Checked Styles
|
||||
.radio.checked {
|
||||
.content {
|
||||
|
@ -174,6 +202,22 @@
|
|||
}
|
||||
}
|
||||
|
||||
.small.checked {
|
||||
color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-hover-variant);
|
||||
|
||||
&:not(:first-child)::before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
width: 1px;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
bottom: -1px;
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Disabled Styles
|
||||
.radio.disabled {
|
||||
|
|
|
@ -28,7 +28,7 @@ export type Props = {
|
|||
isChecked?: boolean;
|
||||
onClick?: () => void;
|
||||
tabIndex?: number;
|
||||
type?: 'card' | 'plain' | 'compact';
|
||||
type?: 'card' | 'plain' | 'compact' | 'small';
|
||||
isDisabled?: boolean;
|
||||
disabledLabel?: AdminConsoleKey;
|
||||
icon?: ReactNode;
|
||||
|
|
|
@ -10,7 +10,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.compact {
|
||||
.compact,
|
||||
.small {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
|
|
|
@ -19,7 +19,7 @@ type Props = {
|
|||
name: string;
|
||||
children: RadioElement | RadioElement[];
|
||||
value?: string;
|
||||
type?: 'card' | 'plain' | 'compact';
|
||||
type?: 'card' | 'plain' | 'compact' | 'small';
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
|
|
@ -144,7 +144,7 @@ function ConsoleContent() {
|
|||
</Route>
|
||||
{isCloud && (
|
||||
<Route path="tenant-settings" element={<TenantSettings />}>
|
||||
<Route index element={<Navigate replace to={TenantSettingsTabs.Domains} />} />
|
||||
<Route index element={<Navigate replace to={TenantSettingsTabs.Settings} />} />
|
||||
{!isProduction && (
|
||||
<Route path={TenantSettingsTabs.Settings} element={<TenantBasicSettings />} />
|
||||
)}
|
||||
|
|
|
@ -17,6 +17,7 @@ type Tenants = {
|
|||
setTenants: (tenants: TenantInfo[]) => void;
|
||||
setIsSettle: (isSettle: boolean) => void;
|
||||
currentTenantId: string;
|
||||
setCurrentTenantId: (tenantId: string) => void;
|
||||
navigate: (tenantId: string, options?: NavigateOptions) => void;
|
||||
};
|
||||
|
||||
|
@ -33,6 +34,7 @@ export const TenantsContext = createContext<Tenants>({
|
|||
isSettle: false,
|
||||
setIsSettle: noop,
|
||||
currentTenantId: '',
|
||||
setCurrentTenantId: noop,
|
||||
navigate: noop,
|
||||
});
|
||||
|
||||
|
@ -52,7 +54,15 @@ function TenantsProvider({ children }: Props) {
|
|||
}, []);
|
||||
|
||||
const memorizedContext = useMemo(
|
||||
() => ({ tenants, setTenants, isSettle, setIsSettle, currentTenantId, navigate }),
|
||||
() => ({
|
||||
tenants,
|
||||
setTenants,
|
||||
isSettle,
|
||||
setIsSettle,
|
||||
currentTenantId,
|
||||
setCurrentTenantId,
|
||||
navigate,
|
||||
}),
|
||||
[currentTenantId, isSettle, navigate, tenants]
|
||||
);
|
||||
|
||||
|
|
78
packages/console/src/hooks/use-tenants.ts
Normal file
78
packages/console/src/hooks/use-tenants.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import { useLogto } from '@logto/react';
|
||||
import { type TenantInfo } from '@logto/schemas';
|
||||
import { type Optional, trySafe } from '@silverhand/essentials';
|
||||
import type ky from 'ky';
|
||||
import { useCallback, useContext, useEffect, useMemo } from 'react';
|
||||
import useSWR, { type KeyedMutator } from 'swr';
|
||||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
|
||||
import useSwrFetcher from './use-swr-fetcher';
|
||||
|
||||
type KyInstance = typeof ky;
|
||||
|
||||
type TenantsHook = {
|
||||
api: KyInstance;
|
||||
currentTenant?: TenantInfo;
|
||||
currentTenantId: string;
|
||||
error?: Error;
|
||||
isLoaded: boolean;
|
||||
isLoading: boolean;
|
||||
isSettle: boolean;
|
||||
mutate: KeyedMutator<TenantInfo[]>;
|
||||
setCurrentTenantId: (id: string) => void;
|
||||
tenants?: TenantInfo[];
|
||||
};
|
||||
|
||||
const useTenants = (): TenantsHook => {
|
||||
const cloudApi = useCloudApi();
|
||||
const { signIn, getAccessToken } = useLogto();
|
||||
const { currentTenantId, setCurrentTenantId, isSettle, setIsSettle } = useContext(TenantsContext);
|
||||
|
||||
const fetcher = useSwrFetcher<TenantInfo[]>(cloudApi);
|
||||
const {
|
||||
data: availableTenants,
|
||||
error,
|
||||
mutate,
|
||||
} = useSWR<TenantInfo[], Error>('/api/tenants', fetcher);
|
||||
const isLoading = !availableTenants && !error;
|
||||
const isLoaded = Boolean(availableTenants && !error);
|
||||
|
||||
const validate = useCallback(
|
||||
async (tenant: TenantInfo) => {
|
||||
const { id, indicator } = tenant;
|
||||
if (await trySafe(getAccessToken(indicator))) {
|
||||
setIsSettle(true);
|
||||
} else {
|
||||
void signIn(new URL(`/${id}/callback`, window.location.origin).toString());
|
||||
}
|
||||
},
|
||||
[getAccessToken, setIsSettle, signIn]
|
||||
);
|
||||
|
||||
const currentTenant: Optional<TenantInfo> = useMemo(() => {
|
||||
return availableTenants?.find(({ id }) => id === currentTenantId);
|
||||
}, [currentTenantId, availableTenants]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTenant) {
|
||||
void validate(currentTenant);
|
||||
}
|
||||
}, [currentTenant, validate]);
|
||||
|
||||
return {
|
||||
api: cloudApi,
|
||||
currentTenant,
|
||||
currentTenantId,
|
||||
error,
|
||||
isLoaded,
|
||||
isLoading,
|
||||
isSettle,
|
||||
mutate,
|
||||
setCurrentTenantId, // Will be used to switch to another tenant.
|
||||
tenants: availableTenants,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTenants;
|
|
@ -0,0 +1,11 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.textField {
|
||||
@include _.form-text-field;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-text-secondary);
|
||||
font: var(--font-body-2);
|
||||
margin-top: _.unit(0.5);
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import type { AdminConsoleKey } from '@logto/phrases';
|
||||
import { TenantTag } from '@logto/schemas';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import FormCard from '@/components/FormCard';
|
||||
import FormField from '@/components/FormField';
|
||||
import RadioGroup, { Radio } from '@/components/RadioGroup';
|
||||
import TextInput from '@/components/TextInput';
|
||||
|
||||
import { type TenantSettingsForm } from '../types.js';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
currentTenantId: string;
|
||||
};
|
||||
|
||||
const tagOptions: Array<{
|
||||
title: AdminConsoleKey;
|
||||
value: TenantTag;
|
||||
}> = [
|
||||
{
|
||||
title: 'tenant_settings.profile.environment_tag_development',
|
||||
value: TenantTag.Development,
|
||||
},
|
||||
{
|
||||
title: 'tenant_settings.profile.environment_tag_staging',
|
||||
value: TenantTag.Staging,
|
||||
},
|
||||
{
|
||||
title: 'tenant_settings.profile.environment_tag_production',
|
||||
value: TenantTag.Production,
|
||||
},
|
||||
];
|
||||
|
||||
function ProfileForm({ currentTenantId }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<TenantSettingsForm>();
|
||||
|
||||
return (
|
||||
<FormCard title="tenant_settings.profile.title">
|
||||
<FormField title="tenant_settings.profile.tenant_id">
|
||||
<CopyToClipboard value={currentTenantId} variant="border" className={styles.textField} />
|
||||
</FormField>
|
||||
<FormField isRequired title="tenant_settings.profile.tenant_name">
|
||||
<TextInput
|
||||
{...register('profile.name', { required: true })}
|
||||
error={Boolean(errors.profile?.name)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="tenant_settings.profile.environment_tag">
|
||||
<Controller
|
||||
control={control}
|
||||
name="profile.tag"
|
||||
render={({ field: { onChange, value, name } }) => (
|
||||
<RadioGroup type="small" value={value} name={name} onChange={onChange}>
|
||||
{tagOptions.map(({ value: optionValue, title }) => (
|
||||
<Radio key={optionValue} title={title} value={optionValue} />
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
<div className={styles.description}>
|
||||
{t('tenant_settings.profile.environment_tag_description')}
|
||||
</div>
|
||||
</FormField>
|
||||
</FormCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileForm;
|
|
@ -0,0 +1,25 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: _.unit(2);
|
||||
min-width: 540px;
|
||||
|
||||
&.withSubmitActionBar {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
>:not(:first-child) {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
|
||||
.fields {
|
||||
flex-grow: 1;
|
||||
|
||||
> :not(:first-child) {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,104 @@
|
|||
import { type PatchTenant, type TenantInfo, TenantTag } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import AppError from '@/components/AppError';
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
import SubmitFormChangesActionBar from '@/components/SubmitFormChangesActionBar';
|
||||
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
|
||||
import useTenants from '@/hooks/use-tenants';
|
||||
|
||||
import ProfileForm from './ProfileForm';
|
||||
import * as styles from './index.module.scss';
|
||||
import { type TenantSettingsForm } from './types.js';
|
||||
|
||||
function TenantBasicSettings() {
|
||||
// TODO @darcyYe implement this page
|
||||
return <div>BasicSettings (WIP)</div>;
|
||||
const {
|
||||
api: cloudApi,
|
||||
currentTenant,
|
||||
currentTenantId,
|
||||
error: requestError,
|
||||
mutate,
|
||||
isLoading,
|
||||
} = useTenants();
|
||||
const [error, setError] = useState<Error>();
|
||||
|
||||
useEffect(() => {
|
||||
if (requestError) {
|
||||
setError(requestError);
|
||||
}
|
||||
}, [requestError]);
|
||||
|
||||
const methods = useForm<TenantSettingsForm>();
|
||||
const {
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { isDirty, isSubmitting },
|
||||
} = methods;
|
||||
|
||||
useEffect(() => {
|
||||
const { name, tag } = currentTenant ?? { name: 'My project', tag: TenantTag.Development };
|
||||
reset({ profile: { name, tag } });
|
||||
}, [currentTenant, reset]);
|
||||
|
||||
const saveData = async (data: PatchTenant) => {
|
||||
try {
|
||||
const { name, tag } = await cloudApi
|
||||
.patch(`/api/tenants/${currentTenantId}`, {
|
||||
json: data,
|
||||
})
|
||||
.json<TenantInfo>();
|
||||
reset({ profile: { name, tag } });
|
||||
void mutate();
|
||||
} catch (error: unknown) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(JSON.stringify(error, Object.getOwnPropertyNames(error)))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = handleSubmit(async (formData: TenantSettingsForm) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
profile: { name, tag },
|
||||
} = formData;
|
||||
await saveData({ name, tag });
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <AppError errorMessage={error.message} callStack={error.stack} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta titleKey={['tenant_settings.tabs.settings', 'tenant_settings.title']} />
|
||||
<form className={classNames(styles.container, isDirty && styles.withSubmitActionBar)}>
|
||||
<FormProvider {...methods}>
|
||||
<div className={styles.fields}>
|
||||
<ProfileForm currentTenantId={currentTenantId} />
|
||||
</div>
|
||||
</FormProvider>
|
||||
<SubmitFormChangesActionBar
|
||||
isOpen={isDirty}
|
||||
isSubmitting={isSubmitting}
|
||||
onDiscard={reset}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantBasicSettings;
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import { type PatchTenant } from '@logto/schemas';
|
||||
|
||||
export type TenantSettingsForm = {
|
||||
profile: Required<PatchTenant>;
|
||||
};
|
|
@ -1,9 +1,16 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
overflow-y: auto;
|
||||
|
||||
> *:not(:first-child) {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin: _.unit(4) 0;
|
||||
}
|
||||
|
|
|
@ -11,8 +11,12 @@ import * as styles from './index.module.scss';
|
|||
function TenantSettings() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<CardTitle title="tenant_settings.title" subtitle="tenant_settings.description" />
|
||||
<TabNav>
|
||||
<CardTitle
|
||||
title="tenant_settings.title"
|
||||
subtitle="tenant_settings.description"
|
||||
className={styles.cardTitle}
|
||||
/>
|
||||
<TabNav className={styles.tabs}>
|
||||
{!isProduction && (
|
||||
<TabNavItem href={`/tenant-settings/${TenantSettingsTabs.Settings}`}>
|
||||
<DynamicT forKey="tenant_settings.tabs.settings" />
|
||||
|
|
Loading…
Reference in a new issue