0
Fork 0
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:
Darcy Ye 2023-06-10 22:39:03 +08:00 committed by GitHub
parent a187503213
commit 690cd37e71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 374 additions and 14 deletions

View file

@ -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 {

View file

@ -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;

View file

@ -10,7 +10,8 @@
}
}
.compact {
.compact,
.small {
display: flex;
flex-wrap: nowrap;
align-items: stretch;

View file

@ -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;
};

View file

@ -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 />} />
)}

View file

@ -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]
);

View 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;

View file

@ -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);
}

View file

@ -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;

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -0,0 +1,5 @@
import { type PatchTenant } from '@logto/schemas';
export type TenantSettingsForm = {
profile: Required<PatchTenant>;
};

View file

@ -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;
}

View file

@ -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" />