455 lines
18 KiB
TypeScript
455 lines
18 KiB
TypeScript
import { mdiChartTimelineVariant, mdiOfficeBuildingCogOutline } from '@mdi/js'
|
|
import axios from 'axios'
|
|
import Head from 'next/head'
|
|
import React, { ReactElement, useEffect, useMemo, useState } from 'react'
|
|
import { Field, Form, Formik } from 'formik'
|
|
import { useRouter } from 'next/router'
|
|
|
|
import BaseButton from '../../components/BaseButton'
|
|
import BaseButtons from '../../components/BaseButtons'
|
|
import BaseDivider from '../../components/BaseDivider'
|
|
import CardBox from '../../components/CardBox'
|
|
import ConnectedEntityCard from '../../components/ConnectedEntityCard'
|
|
import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
|
|
import FormField from '../../components/FormField'
|
|
import NotificationBar from '../../components/NotificationBar'
|
|
import SectionMain from '../../components/SectionMain'
|
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
|
import { SelectFieldMany } from '../../components/SelectFieldMany'
|
|
import { SwitchField } from '../../components/SwitchField'
|
|
import { getPageTitle } from '../../config'
|
|
import {
|
|
getOrganizationManageHref,
|
|
getOrganizationViewHref,
|
|
getTenantSetupHref,
|
|
mergeEntityOptions,
|
|
} from '../../helpers/organizationTenants'
|
|
import { hasPermission } from '../../helpers/userPermissions'
|
|
import LayoutAuthenticated from '../../layouts/Authenticated'
|
|
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
|
|
import { fetch, update } from '../../stores/tenants/tenantsSlice'
|
|
|
|
const defaultInitialValues = {
|
|
name: '',
|
|
slug: '',
|
|
legal_name: '',
|
|
primary_domain: '',
|
|
timezone: '',
|
|
default_currency: '',
|
|
is_active: false,
|
|
organizations: [],
|
|
properties: [],
|
|
audit_logs: [],
|
|
}
|
|
|
|
const EditTenantsPage = () => {
|
|
const router = useRouter()
|
|
const dispatch = useAppDispatch()
|
|
const { tenants } = useAppSelector((state) => state.tenants)
|
|
const { currentUser } = useAppSelector((state) => state.auth)
|
|
|
|
const [baseInitialValues, setBaseInitialValues] = useState(defaultInitialValues)
|
|
const [organizationPrefillOptions, setOrganizationPrefillOptions] = useState<any[]>([])
|
|
const [linkedOrganization, setLinkedOrganization] = useState<{ id: string; name: string } | null>(null)
|
|
const [prefillError, setPrefillError] = useState('')
|
|
|
|
const { id } = router.query
|
|
const tenantId = useMemo(() => (typeof id === 'string' ? id : ''), [id])
|
|
const organizationId = useMemo(
|
|
() => (typeof router.query.organizationId === 'string' ? router.query.organizationId : ''),
|
|
[router.query.organizationId],
|
|
)
|
|
const organizationNameFromQuery = useMemo(
|
|
() => (typeof router.query.organizationName === 'string' ? router.query.organizationName : ''),
|
|
[router.query.organizationName],
|
|
)
|
|
|
|
const canReadOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS')
|
|
const canUpdateOrganizations = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS')
|
|
|
|
const initialValues = useMemo(
|
|
() => ({
|
|
...baseInitialValues,
|
|
organizations: mergeEntityOptions(baseInitialValues.organizations, organizationPrefillOptions),
|
|
}),
|
|
[baseInitialValues, organizationPrefillOptions],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!id) {
|
|
return
|
|
}
|
|
|
|
dispatch(fetch({ id }))
|
|
}, [dispatch, id])
|
|
|
|
useEffect(() => {
|
|
if (!tenants || typeof tenants !== 'object' || Array.isArray(tenants)) {
|
|
return
|
|
}
|
|
|
|
setBaseInitialValues({
|
|
...defaultInitialValues,
|
|
name: tenants.name || '',
|
|
slug: tenants.slug || '',
|
|
legal_name: tenants.legal_name || '',
|
|
primary_domain: tenants.primary_domain || '',
|
|
timezone: tenants.timezone || '',
|
|
default_currency: tenants.default_currency || '',
|
|
is_active: Boolean(tenants.is_active),
|
|
organizations: Array.isArray(tenants.organizations) ? tenants.organizations : [],
|
|
properties: Array.isArray(tenants.properties) ? tenants.properties : [],
|
|
audit_logs: Array.isArray(tenants.audit_logs) ? tenants.audit_logs : [],
|
|
})
|
|
}, [tenants])
|
|
|
|
useEffect(() => {
|
|
if (!router.isReady) {
|
|
return
|
|
}
|
|
|
|
if (!organizationId) {
|
|
setLinkedOrganization(null)
|
|
setOrganizationPrefillOptions([])
|
|
setPrefillError('')
|
|
return
|
|
}
|
|
|
|
let isActive = true
|
|
|
|
const loadOrganization = async () => {
|
|
try {
|
|
setPrefillError('')
|
|
|
|
const { data } = await axios.get(`/organizations/${organizationId}`)
|
|
|
|
if (!isActive) {
|
|
return
|
|
}
|
|
|
|
const organizationOption = data?.id ? [{ id: data.id, name: data.name }] : []
|
|
|
|
setLinkedOrganization(data?.id ? { id: data.id, name: data.name } : null)
|
|
setOrganizationPrefillOptions(organizationOption)
|
|
} catch (error) {
|
|
console.error('Failed to prefill tenant edit from organization:', error)
|
|
|
|
if (!isActive) {
|
|
return
|
|
}
|
|
|
|
setLinkedOrganization(
|
|
organizationId
|
|
? {
|
|
id: organizationId,
|
|
name: organizationNameFromQuery || 'Selected organization',
|
|
}
|
|
: null,
|
|
)
|
|
setOrganizationPrefillOptions(
|
|
organizationId
|
|
? [
|
|
{
|
|
id: organizationId,
|
|
name: organizationNameFromQuery || 'Selected organization',
|
|
},
|
|
]
|
|
: [],
|
|
)
|
|
setPrefillError('We could not load the full organization record, but you can still keep or remove the preselected organization below.')
|
|
}
|
|
}
|
|
|
|
loadOrganization()
|
|
|
|
return () => {
|
|
isActive = false
|
|
}
|
|
}, [organizationId, organizationNameFromQuery, router.isReady])
|
|
|
|
const handleSubmit = async (data) => {
|
|
const resultAction = await dispatch(update({ id, data }))
|
|
|
|
if (!update.fulfilled.match(resultAction)) {
|
|
return
|
|
}
|
|
|
|
await router.push('/tenants/tenants-list')
|
|
}
|
|
|
|
const linkedOrganizations = Array.isArray(initialValues.organizations) ? initialValues.organizations : []
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle('Edit tenant')}</title>
|
|
</Head>
|
|
<SectionMain>
|
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Edit tenant" main>
|
|
{''}
|
|
</SectionTitleLineWithButton>
|
|
|
|
<NotificationBar
|
|
color="info"
|
|
icon={mdiOfficeBuildingCogOutline}
|
|
button={<BaseButton href="/organizations/organizations-list" color="info" label="Browse organizations" small />}
|
|
>
|
|
Keep tenant-wide settings here, and use the organizations field below to control which workspaces belong to
|
|
this tenant.
|
|
</NotificationBar>
|
|
|
|
<CardBox>
|
|
{linkedOrganization ? (
|
|
<div className="mb-6 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100">
|
|
<p className="font-semibold">Organization ready to link: {linkedOrganization.name}</p>
|
|
<p className="mt-1 leading-6">
|
|
This organization is preselected below so you can attach it to this tenant in the same save.
|
|
</p>
|
|
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
|
|
<BaseButton
|
|
href={getOrganizationViewHref(linkedOrganization.id, tenantId, tenantNameForLinks)}
|
|
color="info"
|
|
outline
|
|
small
|
|
label="View organization"
|
|
/>
|
|
<BaseButton
|
|
href={getTenantSetupHref(linkedOrganization.id, linkedOrganization.name)}
|
|
color="white"
|
|
outline
|
|
small
|
|
label="Create separate tenant instead"
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{prefillError ? (
|
|
<div className="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
|
|
{prefillError}
|
|
</div>
|
|
) : null}
|
|
|
|
<Formik enableReinitialize initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
|
|
{({ values }) => {
|
|
const tenantNameForLinks = values.name || initialValues.name || tenants?.name || ''
|
|
|
|
return (
|
|
<Form>
|
|
<ConnectedEntityNotice
|
|
contextLabel={linkedOrganization ? linkedOrganization.name : undefined}
|
|
contextHref={linkedOrganization ? getOrganizationViewHref(linkedOrganization.id, tenantId, tenantNameForLinks) : undefined}
|
|
contextActionLabel="View organization"
|
|
description={
|
|
<>
|
|
Tenants hold shared settings, while organizations are the workspaces connected to them.
|
|
{linkedOrganizations.length
|
|
? ` ${linkedOrganizations.length} organization${linkedOrganizations.length === 1 ? '' : 's'} currently linked.`
|
|
: ' No organizations are linked yet.'}
|
|
{linkedOrganization ? ` ${linkedOrganization.name} is already preselected from the organization flow.` : ''}
|
|
</>
|
|
}
|
|
actions={[
|
|
{
|
|
href: '/organizations/organizations-list',
|
|
label: 'Browse organizations',
|
|
color: 'info',
|
|
outline: true,
|
|
},
|
|
...(linkedOrganization
|
|
? canUpdateOrganizations
|
|
? [
|
|
{
|
|
href: getOrganizationManageHref(linkedOrganization.id, tenantId, tenantNameForLinks),
|
|
label: 'Back to organization',
|
|
color: 'white',
|
|
outline: true,
|
|
},
|
|
]
|
|
: canReadOrganizations
|
|
? [
|
|
{
|
|
href: getOrganizationViewHref(linkedOrganization.id, tenantId, tenantNameForLinks),
|
|
label: 'View organization',
|
|
color: 'white',
|
|
outline: true,
|
|
},
|
|
]
|
|
: []
|
|
: []),
|
|
]}
|
|
/>
|
|
|
|
<FormField
|
|
label="Tenant name"
|
|
labelFor="name"
|
|
help="Use the customer-facing or workspace name for this tenant."
|
|
>
|
|
<Field name="name" id="name" placeholder="Acme Hospitality" />
|
|
</FormField>
|
|
|
|
<FormField
|
|
label="Tenant slug"
|
|
labelFor="slug"
|
|
help="Short internal identifier, often URL-friendly. Example: acme-hospitality"
|
|
>
|
|
<Field name="slug" id="slug" placeholder="acme-hospitality" />
|
|
</FormField>
|
|
|
|
<FormField label="Legal name" labelFor="legal_name" help="Optional registered business name.">
|
|
<Field name="legal_name" id="legal_name" placeholder="Acme Hospitality LLC" />
|
|
</FormField>
|
|
|
|
<FormField
|
|
label="Primary domain"
|
|
labelFor="primary_domain"
|
|
help="Optional domain used by this tenant, such as app.acme.com"
|
|
>
|
|
<Field name="primary_domain" id="primary_domain" placeholder="app.acme.com" />
|
|
</FormField>
|
|
|
|
<FormField label="Timezone" labelFor="timezone" help="Example: America/New_York">
|
|
<Field name="timezone" id="timezone" placeholder="America/New_York" />
|
|
</FormField>
|
|
|
|
<FormField label="Default currency" labelFor="default_currency" help="Example: USD">
|
|
<Field name="default_currency" id="default_currency" placeholder="USD" />
|
|
</FormField>
|
|
|
|
<FormField label="Active tenant" labelFor="is_active" help="Turn this on when the tenant is ready to use.">
|
|
<Field name="is_active" id="is_active" component={SwitchField}></Field>
|
|
</FormField>
|
|
|
|
<div className="mb-6 rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-800">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<p className="font-semibold text-gray-700 dark:text-gray-100">Linked organizations</p>
|
|
<p className="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
|
{linkedOrganizations.length
|
|
? `${linkedOrganizations.length} organization${linkedOrganizations.length === 1 ? '' : 's'} will stay attached to this tenant after you save.`
|
|
: 'No organizations are currently linked. Add one below when this tenant should own a workspace.'}
|
|
</p>
|
|
</div>
|
|
<BaseButton
|
|
href="/organizations/organizations-new"
|
|
color="white"
|
|
outline
|
|
small
|
|
label="Create organization"
|
|
className="w-full sm:w-auto"
|
|
/>
|
|
</div>
|
|
|
|
{linkedOrganizations.length ? (
|
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
{linkedOrganizations.map((organization: any) => (
|
|
<ConnectedEntityCard
|
|
key={organization.id}
|
|
entityLabel="Organization"
|
|
title={organization.name || 'Unnamed organization'}
|
|
actions={[
|
|
...(canReadOrganizations
|
|
? [
|
|
{
|
|
href: getOrganizationViewHref(organization.id, tenantId, tenantNameForLinks),
|
|
label: 'View organization',
|
|
color: 'info',
|
|
outline: true,
|
|
},
|
|
]
|
|
: []),
|
|
...(canUpdateOrganizations
|
|
? [
|
|
{
|
|
href: getOrganizationManageHref(organization.id, tenantId, tenantNameForLinks),
|
|
label: 'Edit organization',
|
|
color: 'info',
|
|
},
|
|
]
|
|
: []),
|
|
]}
|
|
helperText={canUpdateOrganizations ? 'Context preserved when editing this organization.' : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<FormField
|
|
label="Organizations"
|
|
labelFor="organizations"
|
|
help={
|
|
linkedOrganization
|
|
? `${linkedOrganization.name} has been preselected. Keep it here to link that organization to this tenant.`
|
|
: 'Link the organizations that belong to this tenant.'
|
|
}
|
|
>
|
|
<Field
|
|
name="organizations"
|
|
id="organizations"
|
|
itemRef={'organizations'}
|
|
options={initialValues.organizations}
|
|
component={SelectFieldMany}
|
|
showField={'name'}
|
|
></Field>
|
|
</FormField>
|
|
|
|
<FormField label="Properties" labelFor="properties" help="Optional: attach properties managed by this tenant.">
|
|
<Field
|
|
name="properties"
|
|
id="properties"
|
|
itemRef={'properties'}
|
|
options={initialValues.properties}
|
|
component={SelectFieldMany}
|
|
showField={'name'}
|
|
></Field>
|
|
</FormField>
|
|
|
|
<FormField
|
|
label="Audit logs"
|
|
labelFor="audit_logs"
|
|
help="Optional: keep existing audit log links if you use them for tenant-level reporting."
|
|
>
|
|
<Field
|
|
name="audit_logs"
|
|
id="audit_logs"
|
|
itemRef={'audit_logs'}
|
|
options={initialValues.audit_logs}
|
|
component={SelectFieldMany}
|
|
showField={'action'}
|
|
></Field>
|
|
</FormField>
|
|
|
|
<BaseDivider />
|
|
|
|
<BaseButtons
|
|
type="justify-start sm:justify-end"
|
|
className="items-stretch sm:items-center"
|
|
classAddon="w-full sm:w-auto mr-0 sm:mr-3 mb-3"
|
|
>
|
|
<BaseButton type="submit" color="info" label="Save changes" />
|
|
<BaseButton type="reset" color="info" outline label="Reset" />
|
|
<BaseButton
|
|
type="reset"
|
|
color="danger"
|
|
outline
|
|
label="Cancel"
|
|
onClick={() => router.push('/tenants/tenants-list')}
|
|
/>
|
|
</BaseButtons>
|
|
</Form>
|
|
)
|
|
}}
|
|
</Formik>
|
|
</CardBox>
|
|
</SectionMain>
|
|
</>
|
|
)
|
|
}
|
|
|
|
EditTenantsPage.getLayout = function getLayout(page: ReactElement) {
|
|
return <LayoutAuthenticated permission={'UPDATE_TENANTS'}>{page}</LayoutAuthenticated>
|
|
}
|
|
|
|
export default EditTenantsPage
|