39443-vm/frontend/src/pages/tenants/tenants-edit.tsx
2026-04-04 15:53:16 +00:00

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