diff --git a/packages/console/src/components/RadioGroup/Radio.module.scss b/packages/console/src/components/RadioGroup/Radio.module.scss index 0f918d52f..075a74288 100644 --- a/packages/console/src/components/RadioGroup/Radio.module.scss +++ b/packages/console/src/components/RadioGroup/Radio.module.scss @@ -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 { diff --git a/packages/console/src/components/RadioGroup/Radio.tsx b/packages/console/src/components/RadioGroup/Radio.tsx index 187cefb42..f1a33e023 100644 --- a/packages/console/src/components/RadioGroup/Radio.tsx +++ b/packages/console/src/components/RadioGroup/Radio.tsx @@ -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; diff --git a/packages/console/src/components/RadioGroup/index.module.scss b/packages/console/src/components/RadioGroup/index.module.scss index 1ac562707..d7a163170 100644 --- a/packages/console/src/components/RadioGroup/index.module.scss +++ b/packages/console/src/components/RadioGroup/index.module.scss @@ -10,7 +10,8 @@ } } -.compact { +.compact, +.small { display: flex; flex-wrap: nowrap; align-items: stretch; diff --git a/packages/console/src/components/RadioGroup/index.tsx b/packages/console/src/components/RadioGroup/index.tsx index 3c70d6213..8feecfc55 100644 --- a/packages/console/src/components/RadioGroup/index.tsx +++ b/packages/console/src/components/RadioGroup/index.tsx @@ -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; }; diff --git a/packages/console/src/containers/ConsoleContent/index.tsx b/packages/console/src/containers/ConsoleContent/index.tsx index 1a8bff97e..9cb96a1d0 100644 --- a/packages/console/src/containers/ConsoleContent/index.tsx +++ b/packages/console/src/containers/ConsoleContent/index.tsx @@ -144,7 +144,7 @@ function ConsoleContent() { {isCloud && ( }> - } /> + } /> {!isProduction && ( } /> )} diff --git a/packages/console/src/contexts/TenantsProvider.tsx b/packages/console/src/contexts/TenantsProvider.tsx index 19e7f2cdc..ec971bed8 100644 --- a/packages/console/src/contexts/TenantsProvider.tsx +++ b/packages/console/src/contexts/TenantsProvider.tsx @@ -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({ 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] ); diff --git a/packages/console/src/hooks/use-tenants.ts b/packages/console/src/hooks/use-tenants.ts new file mode 100644 index 000000000..df9c234bc --- /dev/null +++ b/packages/console/src/hooks/use-tenants.ts @@ -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; + 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(cloudApi); + const { + data: availableTenants, + error, + mutate, + } = useSWR('/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 = 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; diff --git a/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/index.module.scss b/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/index.module.scss new file mode 100644 index 000000000..79c43143e --- /dev/null +++ b/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/index.module.scss @@ -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); +} diff --git a/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/index.tsx b/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/index.tsx new file mode 100644 index 000000000..8d6ac8a8c --- /dev/null +++ b/packages/console/src/pages/TenantSettings/TenantBasicSettings/ProfileForm/index.tsx @@ -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(); + + return ( + + + + + + + + + ( + + {tagOptions.map(({ value: optionValue, title }) => ( + + ))} + + )} + /> + + {t('tenant_settings.profile.environment_tag_description')} + + + + ); +} + +export default ProfileForm; diff --git a/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.module.scss b/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.module.scss new file mode 100644 index 000000000..d13f31ad2 --- /dev/null +++ b/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.module.scss @@ -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); + } + } +} diff --git a/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.tsx b/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.tsx index e3f4c068a..f2b18a36f 100644 --- a/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.tsx +++ b/packages/console/src/pages/TenantSettings/TenantBasicSettings/index.tsx @@ -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 BasicSettings (WIP); + const { + api: cloudApi, + currentTenant, + currentTenantId, + error: requestError, + mutate, + isLoading, + } = useTenants(); + const [error, setError] = useState(); + + useEffect(() => { + if (requestError) { + setError(requestError); + } + }, [requestError]); + + const methods = useForm(); + 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(); + 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 ; + } + + if (error) { + return ; + } + + return ( + <> + + + + + + + + + + + > + ); } export default TenantBasicSettings; diff --git a/packages/console/src/pages/TenantSettings/TenantBasicSettings/types.ts b/packages/console/src/pages/TenantSettings/TenantBasicSettings/types.ts new file mode 100644 index 000000000..9bf3a69d2 --- /dev/null +++ b/packages/console/src/pages/TenantSettings/TenantBasicSettings/types.ts @@ -0,0 +1,5 @@ +import { type PatchTenant } from '@logto/schemas'; + +export type TenantSettingsForm = { + profile: Required; +}; diff --git a/packages/console/src/pages/TenantSettings/index.module.scss b/packages/console/src/pages/TenantSettings/index.module.scss index 87b0509b0..4b3206caf 100644 --- a/packages/console/src/pages/TenantSettings/index.module.scss +++ b/packages/console/src/pages/TenantSettings/index.module.scss @@ -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; } diff --git a/packages/console/src/pages/TenantSettings/index.tsx b/packages/console/src/pages/TenantSettings/index.tsx index 2219bd595..ad35ec095 100644 --- a/packages/console/src/pages/TenantSettings/index.tsx +++ b/packages/console/src/pages/TenantSettings/index.tsx @@ -11,8 +11,12 @@ import * as styles from './index.module.scss'; function TenantSettings() { return ( - - + + {!isProduction && (