Autosave: 20260404-042157

This commit is contained in:
Flatlogic Bot 2026-04-04 04:22:00 +00:00
parent b4ba3c2646
commit 33f59460fd
19 changed files with 1283 additions and 596 deletions

View File

@ -903,19 +903,23 @@ module.exports = class UsersDBApi {
static async createFromAuth(data, options) { static async createFromAuth(data, options) {
const transaction = (options && options.transaction) || undefined; const transaction = (options && options.transaction) || undefined;
const organizationId = data.organizationId || data.organizationsId || null;
const users = await db.users.create( const users = await db.users.create(
{ {
email: data.email, email: data.email,
firstName: data.firstName, firstName: data.firstName,
authenticationUid: data.authenticationUid, authenticationUid: data.authenticationUid,
password: data.password, password: data.password,
organizationId: data.organizationId,
}, },
{ transaction }, { transaction },
); );
if (organizationId) {
await users.setOrganizations(organizationId, {
transaction,
});
}
const app_role = await db.roles.findOne({ const app_role = await db.roles.findOne({
where: { name: config.roles?.user || "User" }, where: { name: config.roles?.user || "User" },
}); });

View File

@ -1,13 +1,31 @@
const express = require('express'); const express = require('express');
const db = require('../db/models');
const OrganizationsDBApi = require('../db/api/organizations'); const OrganizationsDBApi = require('../db/api/organizations');
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
const router = express.Router(); const router = express.Router();
async function loadLinkedTenantsForOrganization(organizationId) {
const linkedTenants = await db.tenants.findAll({
attributes: ['id', 'name', 'slug', 'primary_domain', 'timezone', 'default_currency', 'is_active'],
include: [
{
model: db.organizations,
as: 'organizations_filter',
required: true,
attributes: [],
through: { attributes: [] },
where: { id: organizationId },
},
],
limit: 3,
order: [['name', 'asc']],
});
return linkedTenants.map((tenant) => tenant.get({ plain: true }));
}
/** /**
* @swagger * @swagger
* /api/organizations: * /api/organizations:
@ -33,23 +51,29 @@ const router = express.Router();
* 500: * 500:
* description: Some server error * description: Some server error
*/ */
router.get( router.get(
'/', '/',
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
const payload = await OrganizationsDBApi.findAll(req.query); const payload = await OrganizationsDBApi.findAll(req.query || {}, true, {});
const simplifiedPayload = payload.rows.map(org => ({
id: org.id,
name: org.name
}));
res.status(200).send(simplifiedPayload);
const simplifiedPayload = await Promise.all(
(payload.rows || []).map(async (org) => {
const linkedTenants = await loadLinkedTenantsForOrganization(org.id);
return {
id: org.id,
name: org.name,
linkedTenants,
linkedTenantNames: linkedTenants
.map((tenant) => tenant.name)
.filter(Boolean),
primaryTenant: linkedTenants[0] || null,
};
}), }),
); );
res.status(200).send(simplifiedPayload);
}),
);
module.exports = router; module.exports = router;

View File

@ -4,6 +4,8 @@ import BaseIcon from './BaseIcon';
import AsideMenuList from './AsideMenuList'; import AsideMenuList from './AsideMenuList';
import { MenuAsideItem } from '../interfaces'; import { MenuAsideItem } from '../interfaces';
import { useAppSelector } from '../stores/hooks'; import { useAppSelector } from '../stores/hooks';
import { loadLinkedTenantSummary } from '../helpers/organizationTenants';
import type { LinkedTenantRecord } from '../helpers/organizationTenants';
const ASIDE_WIDTH_STORAGE_KEY = 'aside-width'; const ASIDE_WIDTH_STORAGE_KEY = 'aside-width';
const DEFAULT_ASIDE_WIDTH = 320; const DEFAULT_ASIDE_WIDTH = 320;
@ -25,6 +27,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
const darkMode = useAppSelector((state) => state.style.darkMode); const darkMode = useAppSelector((state) => state.style.darkMode);
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const [asideWidth, setAsideWidth] = useState(DEFAULT_ASIDE_WIDTH); const [asideWidth, setAsideWidth] = useState(DEFAULT_ASIDE_WIDTH);
const [linkedTenants, setLinkedTenants] = useState<LinkedTenantRecord[]>([]);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@ -45,6 +48,40 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
} }
}, [asideWidth]); }, [asideWidth]);
useEffect(() => {
const organizationId =
currentUser?.organizations?.id || currentUser?.organization?.id || currentUser?.organizationsId || currentUser?.organizationId;
if (!organizationId) {
setLinkedTenants([]);
return;
}
let isMounted = true;
loadLinkedTenantSummary(organizationId)
.then((summary) => {
if (isMounted) {
setLinkedTenants(summary.rows.slice(0, 2));
}
})
.catch((error) => {
console.error('Failed to load sidebar tenant context:', error);
if (isMounted) {
setLinkedTenants([]);
}
});
return () => {
isMounted = false;
};
}, [
currentUser?.organizations?.id,
currentUser?.organization?.id,
currentUser?.organizationsId,
currentUser?.organizationId,
]);
const handleAsideLgCloseClick = (e: React.MouseEvent) => { const handleAsideLgCloseClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
props.onAsideLgCloseClick(); props.onAsideLgCloseClick();
@ -61,6 +98,11 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
const organizationName = const organizationName =
currentUser?.organizations?.name || currentUser?.organization?.name || 'Corporate workspace'; currentUser?.organizations?.name || currentUser?.organization?.name || 'Corporate workspace';
const tenantContextLabel = linkedTenants.length
? `Tenant${linkedTenants.length > 1 ? 's' : ''}: ${linkedTenants
.map((tenant) => tenant.name || 'Unnamed tenant')
.join(', ')}`
: 'No tenant link surfaced yet';
return ( return (
<aside <aside
@ -79,6 +121,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
Corporate Stay Portal Corporate Stay Portal
</div> </div>
<p className="mt-1 break-words text-sm leading-5 text-slate-300">{organizationName}</p> <p className="mt-1 break-words text-sm leading-5 text-slate-300">{organizationName}</p>
<p className="mt-1 break-words text-xs leading-5 text-cyan-100/80">{tenantContextLabel}</p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@ -20,6 +20,7 @@ type ConnectedEntityCardProps = {
entityLabel: string entityLabel: string
title?: ReactNode title?: ReactNode
titleFallback?: string titleFallback?: string
badges?: ReactNode[]
details?: ConnectedEntityCardDetail[] details?: ConnectedEntityCardDetail[]
actions?: ConnectedEntityCardAction[] actions?: ConnectedEntityCardAction[]
helperText?: ReactNode helperText?: ReactNode
@ -30,6 +31,7 @@ const ConnectedEntityCard = ({
entityLabel, entityLabel,
title, title,
titleFallback = 'Unnamed record', titleFallback = 'Unnamed record',
badges = [],
details = [], details = [],
actions = [], actions = [],
helperText, helperText,
@ -41,6 +43,8 @@ const ConnectedEntityCard = ({
return value !== undefined && value !== null && value !== '' return value !== undefined && value !== null && value !== ''
}) })
const visibleBadges = badges.filter(Boolean)
return ( return (
<div <div
className={`rounded-xl border border-blue-100 bg-white p-4 shadow-sm dark:border-blue-950/60 dark:bg-dark-900 ${className}`.trim()} className={`rounded-xl border border-blue-100 bg-white p-4 shadow-sm dark:border-blue-950/60 dark:bg-dark-900 ${className}`.trim()}
@ -60,6 +64,8 @@ const ConnectedEntityCard = ({
{title || titleFallback} {title || titleFallback}
</p> </p>
{visibleBadges.length ? <div className="mt-3 flex flex-wrap gap-2">{visibleBadges}</div> : null}
{visibleDetails.length ? ( {visibleDetails.length ? (
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{visibleDetails.map((detail) => ( {visibleDetails.map((detail) => (

View File

@ -0,0 +1,303 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import {
mdiChevronDown,
mdiChevronUp,
mdiDomain,
mdiEmailOutline,
mdiOfficeBuilding,
mdiOpenInNew,
mdiShieldAccount,
} from '@mdi/js'
import BaseButton from './BaseButton'
import BaseIcon from './BaseIcon'
import ClickOutside from './ClickOutside'
import TenantStatusChip from './TenantStatusChip'
import { useAppSelector } from '../stores/hooks'
import {
emptyOrganizationTenantSummary,
getOrganizationViewHref,
getTenantViewHref,
loadLinkedTenantSummary,
} from '../helpers/organizationTenants'
import { hasPermission } from '../helpers/userPermissions'
const CurrentWorkspaceChip = () => {
const router = useRouter()
const triggerRef = useRef(null)
const { currentUser } = useAppSelector((state) => state.auth)
const [linkedTenantSummary, setLinkedTenantSummary] = useState(emptyOrganizationTenantSummary)
const [isLoadingTenants, setIsLoadingTenants] = useState(false)
const [isPopoverActive, setIsPopoverActive] = useState(false)
const organizationId = useMemo(
() =>
currentUser?.organizations?.id ||
currentUser?.organization?.id ||
currentUser?.organizationsId ||
currentUser?.organizationId ||
'',
[
currentUser?.organization?.id,
currentUser?.organizationId,
currentUser?.organizations?.id,
currentUser?.organizationsId,
],
)
useEffect(() => {
let isMounted = true
if (!organizationId) {
setLinkedTenantSummary(emptyOrganizationTenantSummary)
setIsLoadingTenants(false)
return () => {
isMounted = false
}
}
setIsLoadingTenants(true)
loadLinkedTenantSummary(organizationId)
.then((summary) => {
if (!isMounted) {
return
}
setLinkedTenantSummary(summary)
})
.catch((error) => {
console.error('Failed to load current workspace chip context:', error)
if (!isMounted) {
return
}
setLinkedTenantSummary(emptyOrganizationTenantSummary)
})
.finally(() => {
if (isMounted) {
setIsLoadingTenants(false)
}
})
return () => {
isMounted = false
}
}, [organizationId])
useEffect(() => {
setIsPopoverActive(false)
}, [router.asPath])
useEffect(() => {
if (!isPopoverActive) {
return () => undefined
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsPopoverActive(false)
}
}
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('keydown', handleEscape)
}
}, [isPopoverActive])
if (!currentUser) {
return null
}
const organizationName =
currentUser?.organizations?.name ||
currentUser?.organization?.name ||
currentUser?.organizationName ||
'No organization assigned yet'
const appRoleName = currentUser?.app_role?.name || 'User'
const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : []
const linkedTenantCount = linkedTenantSummary.count || 0
const tenantLabel = isLoadingTenants
? 'Loading tenants…'
: `${linkedTenantCount} tenant${linkedTenantCount === 1 ? '' : 's'}`
const canViewOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS')
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS')
const organizationHref =
canViewOrganizations && organizationId ? getOrganizationViewHref(organizationId) : '/profile'
return (
<div className="relative hidden xl:block" ref={triggerRef}>
<button
type="button"
className="group flex items-center gap-3 rounded-xl border border-blue-200 bg-blue-50/80 px-3 py-2 text-left transition-colors hover:border-blue-300 hover:bg-blue-100/80 dark:border-blue-900/70 dark:bg-blue-950/30 dark:hover:border-blue-800 dark:hover:bg-blue-950/40"
title={`Current workspace: ${organizationName}`}
aria-haspopup="dialog"
aria-expanded={isPopoverActive}
onClick={() => setIsPopoverActive((currentValue) => !currentValue)}
>
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-blue-100 text-blue-900 dark:bg-blue-900/60 dark:text-blue-100">
<BaseIcon path={mdiOfficeBuilding} size="18" />
</span>
<span className="min-w-0">
<span className="block text-[10px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100">
Current workspace
</span>
<span className="block truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{organizationName}
</span>
<span className="mt-0.5 flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
<BaseIcon path={mdiDomain} size="14" />
<span className="truncate">
{tenantLabel} {appRoleName}
</span>
</span>
</span>
<span className="flex-none text-blue-800 dark:text-blue-200">
<BaseIcon path={isPopoverActive ? mdiChevronUp : mdiChevronDown} size="16" />
</span>
</button>
{isPopoverActive ? (
<div className="absolute right-0 top-full z-40 mt-2 w-[26rem] max-w-[calc(100vw-2rem)]">
<ClickOutside onClickOutside={() => setIsPopoverActive(false)} excludedElements={[triggerRef]}>
<div className="overflow-hidden rounded-2xl border border-blue-100 bg-white shadow-2xl dark:border-blue-950/70 dark:bg-dark-900">
<div className="border-b border-blue-100 bg-blue-50/80 px-4 py-3 dark:border-blue-950/70 dark:bg-blue-950/30">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100">
Current workspace
</p>
<p className="mt-1 truncate text-base font-semibold text-gray-900 dark:text-gray-100">
{organizationName}
</p>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-700 dark:text-gray-200">
<span className="inline-flex items-center gap-1 rounded-full bg-white/80 px-2.5 py-1 dark:bg-dark-800">
<BaseIcon path={mdiShieldAccount} size="14" />
{appRoleName}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-white/80 px-2.5 py-1 dark:bg-dark-800">
<BaseIcon path={mdiDomain} size="14" />
{tenantLabel}
</span>
</div>
</div>
<BaseButton
href={organizationHref}
label={canViewOrganizations && organizationId ? 'Open' : 'Profile'}
color="info"
outline
small
icon={mdiOpenInNew}
/>
</div>
</div>
<div className="space-y-4 px-4 py-4">
<div className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-3 dark:border-dark-700 dark:bg-dark-800">
<div className="flex items-start gap-3">
<span className="mt-0.5 flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-blue-100 text-blue-900 dark:bg-blue-900/60 dark:text-blue-100">
<BaseIcon path={mdiOfficeBuilding} size="16" />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-500 dark:text-gray-300">
Organization access
</p>
<p className="mt-1 truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{organizationName}
</p>
<p className="mt-1 flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
<BaseIcon path={mdiEmailOutline} size="14" />
<span className="truncate">{currentUser?.email || 'No email surfaced yet'}</span>
</p>
</div>
</div>
</div>
<div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-500 dark:text-gray-300">
Linked tenants
</p>
<Link
href="/profile"
className="text-xs font-semibold text-blue-700 hover:text-blue-800 dark:text-blue-200 dark:hover:text-blue-100"
>
View profile context
</Link>
</div>
<div className="mt-3 space-y-2">
{isLoadingTenants ? (
<div className="rounded-xl border border-dashed border-blue-200 bg-blue-50/60 px-3 py-3 text-sm text-blue-900 dark:border-blue-900/70 dark:bg-blue-950/20 dark:text-blue-100">
Loading tenant context
</div>
) : linkedTenants.length ? (
linkedTenants.slice(0, 3).map((tenant) => (
<div
key={tenant.id}
className="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-dark-700 dark:bg-dark-800"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{tenant.name || 'Unnamed tenant'}
</p>
<div className="mt-2 flex flex-wrap gap-2">
<TenantStatusChip isActive={tenant.is_active} />
{tenant.slug ? (
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100">
Slug: {tenant.slug}
</span>
) : null}
{tenant.primary_domain ? (
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100">
Domain: {tenant.primary_domain}
</span>
) : null}
</div>
</div>
{canViewTenants && tenant.id ? (
<BaseButton
href={getTenantViewHref(tenant.id, organizationId, organizationName)}
label="Open"
color="info"
outline
small
/>
) : null}
</div>
</div>
))
) : (
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 px-3 py-3 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300">
No tenant link surfaced yet for this workspace.
</div>
)}
</div>
{!isLoadingTenants && linkedTenantCount > 3 ? (
<p className="mt-2 text-xs text-gray-500 dark:text-gray-300">
Showing 3 of {linkedTenantCount} linked tenants in this quick view.
</p>
) : null}
</div>
</div>
</div>
</ClickOutside>
</div>
) : null}
</div>
)
}
export default CurrentWorkspaceChip

View File

@ -0,0 +1,191 @@
import React, { useEffect, useMemo, useState } from 'react'
import CardBox from './CardBox'
import ConnectedEntityCard from './ConnectedEntityCard'
import TenantStatusChip from './TenantStatusChip'
import { useAppSelector } from '../stores/hooks'
import {
emptyOrganizationTenantSummary,
getOrganizationViewHref,
getTenantViewHref,
loadLinkedTenantSummary,
} from '../helpers/organizationTenants'
import { hasPermission } from '../helpers/userPermissions'
type MyOrgTenantSummaryProps = {
className?: string
}
const MyOrgTenantSummary = ({ className = '' }: MyOrgTenantSummaryProps) => {
const { currentUser } = useAppSelector((state) => state.auth)
const [linkedTenantSummary, setLinkedTenantSummary] = useState(emptyOrganizationTenantSummary)
const [isLoadingTenants, setIsLoadingTenants] = useState(false)
const organizationId = useMemo(
() =>
currentUser?.organizations?.id ||
currentUser?.organization?.id ||
currentUser?.organizationsId ||
currentUser?.organizationId ||
'',
[
currentUser?.organization?.id,
currentUser?.organizationId,
currentUser?.organizations?.id,
currentUser?.organizationsId,
],
)
const organizationName =
currentUser?.organizations?.name ||
currentUser?.organization?.name ||
currentUser?.organizationName ||
'No organization assigned yet'
useEffect(() => {
let isMounted = true
if (!organizationId) {
setLinkedTenantSummary(emptyOrganizationTenantSummary)
setIsLoadingTenants(false)
return () => {
isMounted = false
}
}
setIsLoadingTenants(true)
loadLinkedTenantSummary(organizationId)
.then((summary) => {
if (!isMounted) {
return
}
setLinkedTenantSummary(summary)
})
.catch((error) => {
console.error('Failed to load current user org/tenant context:', error)
if (!isMounted) {
return
}
setLinkedTenantSummary(emptyOrganizationTenantSummary)
})
.finally(() => {
if (isMounted) {
setIsLoadingTenants(false)
}
})
return () => {
isMounted = false
}
}, [organizationId])
if (!currentUser) {
return null
}
const appRoleName = currentUser?.app_role?.name || 'No role surfaced yet'
const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : []
const canViewOrganizations = hasPermission(currentUser, 'READ_ORGANIZATIONS')
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS')
return (
<CardBox className={className}>
<div className="space-y-4">
<div>
<div className="inline-flex rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100">
My account context
</div>
<h3 className="mt-3 text-lg font-semibold text-gray-900 dark:text-gray-100">
Your organization and tenant access
</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
This makes it clear which organization your signed-in account belongs to and which tenant links sit behind it.
</p>
</div>
<ConnectedEntityCard
entityLabel="My organization"
title={organizationName}
titleFallback="No organization assigned yet"
details={[
{ label: 'Account role', value: appRoleName },
{ label: 'Email', value: currentUser?.email },
{
label: 'Linked tenants',
value: isLoadingTenants ? 'Loading…' : String(linkedTenantSummary.count || 0),
},
]}
actions={
canViewOrganizations && organizationId
? [
{
href: getOrganizationViewHref(organizationId),
label: 'View organization',
color: 'info',
outline: true,
},
]
: []
}
helperText={
organizationId
? 'This is the workspace context attached to your account after signup and login.'
: 'Your account does not have an organization link yet.'
}
/>
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-300">
My tenant links
</p>
{isLoadingTenants ? (
<div className="rounded-xl border border-dashed border-blue-200 bg-blue-50/60 px-4 py-3 text-sm text-blue-900 dark:border-blue-900/70 dark:bg-blue-950/20 dark:text-blue-100">
Loading tenant context for your organization
</div>
) : linkedTenants.length ? (
<div className="space-y-3">
{linkedTenants.map((tenant) => (
<ConnectedEntityCard
key={tenant.id}
entityLabel="My tenant"
title={tenant.name || 'Unnamed tenant'}
badges={[<TenantStatusChip key={`${tenant.id}-status`} isActive={tenant.is_active} />]}
details={[
{ label: 'Slug', value: tenant.slug },
{ label: 'Domain', value: tenant.primary_domain },
{ label: 'Timezone', value: tenant.timezone },
{ label: 'Currency', value: tenant.default_currency },
]}
actions={
canViewTenants && tenant.id
? [
{
href: getTenantViewHref(tenant.id, organizationId, organizationName),
label: 'View tenant',
color: 'info',
outline: true,
},
]
: []
}
helperText="This tenant link is part of the organization context attached to your account."
/>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300">
No tenant link surfaced yet for your organization.
</div>
)}
</div>
</div>
</CardBox>
)
}
export default MyOrgTenantSummary

View File

@ -67,7 +67,11 @@ const CardOrganizations = ({
{loading && <LoadingSpinner />} {loading && <LoadingSpinner />}
<ul role='list' className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'> <ul role='list' className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'>
{!loading && {!loading &&
organizations.map((item) => ( organizations.map((item) => {
const linkedSummary = linkedTenantSummaries[item.id]
const linkedCount = linkedSummary?.count || 0
return (
<li <li
key={item.id} key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${ className={`overflow-hidden ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
@ -75,11 +79,23 @@ const CardOrganizations = ({
}`} }`}
> >
<div <div
className={`relative flex items-center gap-x-4 border-b border-gray-900/5 bg-gray-50 p-6 ${bgColor} dark:bg-dark-800`} className={`relative border-b border-gray-900/5 bg-gray-50 p-6 ${bgColor} dark:bg-dark-800`}
> >
<Link href={`/organizations/organizations-view/?id=${item.id}`} className='line-clamp-1 text-lg font-bold leading-6'> <div className='flex items-start gap-4'>
{item.name} <div className='min-w-0 flex-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100'>
Organization
</span>
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
{linkedCount ? `${linkedCount} linked tenant${linkedCount === 1 ? '' : 's'}` : 'No tenant link'}
</span>
</div>
<Link href={`/organizations/organizations-view/?id=${item.id}`} className='mt-3 block line-clamp-2 text-lg font-bold leading-6'>
{item.name || 'Unnamed organization'}
</Link> </Link>
</div>
<div className='ml-auto'> <div className='ml-auto'>
<ListActionsPopover <ListActionsPopover
@ -91,26 +107,24 @@ const CardOrganizations = ({
/> />
</div> </div>
</div> </div>
<dl className='h-64 overflow-y-auto divide-y divide-gray-600 px-6 py-4 text-sm leading-6 dark:divide-dark-700'>
<div className='flex justify-between gap-x-4 py-3'>
<dt className='text-gray-500 dark:text-dark-600'>Name</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>{item.name}</div>
</dd>
</div> </div>
<div className='flex flex-col gap-y-3 py-3'> <div className='space-y-4 px-6 py-4 text-sm leading-6'>
<dt className='text-gray-500 dark:text-dark-600'>Linked tenants</dt> <div className='rounded-xl border border-blue-100 bg-blue-50/60 p-4 dark:border-blue-950/60 dark:bg-blue-950/20'>
<dd> <p className='text-xs font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100'>
Connected entities
</p>
<div className='mt-3'>
<LinkedTenantsPreview <LinkedTenantsPreview
summary={linkedTenantSummaries[item.id]} summary={linkedSummary}
emptyMessage='This organization is not linked to a tenant yet.' emptyMessage='This organization is not linked to a tenant yet.'
/> />
</dd>
</div> </div>
</dl> </div>
</div>
</li> </li>
))} )
})}
{!loading && organizations.length === 0 && ( {!loading && organizations.length === 0 && (
<div className='col-span-full flex h-40 items-center justify-center'> <div className='col-span-full flex h-40 items-center justify-center'>
<p>No data to display</p> <p>No data to display</p>

View File

@ -1,88 +1,128 @@
import React from 'react'; import React, { useEffect, useState } from 'react'
import CardBox from '../CardBox'; import Link from 'next/link'
import ImageField from '../ImageField';
import dataFormatter from '../../helpers/dataFormatter';
import {saveFile} from "../../helpers/fileSaver";
import ListActionsPopover from "../ListActionsPopover";
import {useAppSelector} from "../../stores/hooks";
import {Pagination} from "../Pagination";
import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
import CardBox from '../CardBox'
import ListActionsPopover from '../ListActionsPopover'
import { useAppSelector } from '../../stores/hooks'
import { Pagination } from '../Pagination'
import LoadingSpinner from '../LoadingSpinner'
import LinkedTenantsPreview from './LinkedTenantsPreview'
import { hasPermission } from '../../helpers/userPermissions'
import { loadLinkedTenantSummaries, OrganizationTenantSummaryMap } from '../../helpers/organizationTenants'
type Props = { type Props = {
organizations: any[]; organizations: any[]
loading: boolean; loading: boolean
onDelete: (id: string) => void; onDelete: (id: string) => void
currentPage: number; currentPage: number
numPages: number; numPages: number
onPageChange: (page: number) => void; onPageChange: (page: number) => void
}; }
const ListOrganizations = ({ organizations, loading, onDelete, currentPage, numPages, onPageChange }: Props) => { const ListOrganizations = ({ organizations, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
const currentUser = useAppSelector((state) => state.auth.currentUser)
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS') const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_ORGANIZATIONS')
const corners = useAppSelector((state) => state.style.corners); const corners = useAppSelector((state) => state.style.corners)
const bgColor = useAppSelector((state) => state.style.cardsColor); const bgColor = useAppSelector((state) => state.style.cardsColor)
const [linkedTenantSummaries, setLinkedTenantSummaries] = useState<OrganizationTenantSummaryMap>({})
useEffect(() => {
if (!Array.isArray(organizations) || !organizations.length) {
setLinkedTenantSummaries({})
return
}
let isActive = true
loadLinkedTenantSummaries(organizations.map((item: any) => item?.id))
.then((summaries) => {
if (isActive) {
setLinkedTenantSummaries(summaries)
}
})
.catch((error) => {
console.error('Failed to load linked tenants for organization list rows:', error)
if (isActive) {
setLinkedTenantSummaries({})
}
})
return () => {
isActive = false
}
}, [organizations])
return ( return (
<> <>
<div className='relative overflow-x-auto p-4 space-y-4'> <div className='relative space-y-4 overflow-x-auto p-4'>
{loading && <LoadingSpinner />} {loading && <LoadingSpinner />}
{!loading && organizations.map((item) => ( {!loading &&
organizations.map((item) => {
const linkedSummary = linkedTenantSummaries[item.id]
const linkedCount = linkedSummary?.count || 0
return (
<div key={item.id}> <div key={item.id}>
<CardBox hasTable isList className={'rounded shadow-none'}> <CardBox hasTable isList className='rounded shadow-none'>
<div className={`flex ${bgColor} ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 border border-gray-600 items-center overflow-hidden`}> <div
className={`flex items-start overflow-hidden border border-gray-600 ${bgColor} ${
<Link corners !== 'rounded-full' ? corners : 'rounded-3xl'
href={`/organizations/organizations-view/?id=${item.id}`} } dark:bg-dark-900`}
className={
'flex-1 px-4 py-6 h-24 flex divide-x-2 divide-gray-600 items-center overflow-hidden`}> dark:divide-dark-700 overflow-x-auto'
}
> >
<Link href={`/organizations/organizations-view/?id=${item.id}`} className='min-w-0 flex-1 p-4'>
<div className='flex flex-wrap items-center gap-2'>
<div className={'flex-1 px-3'}> <span className='rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100'>
<p className={'text-xs text-gray-500 '}>Name</p> Organization
<p className={'line-clamp-2'}>{ item.name }</p> </span>
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
{linkedCount ? `${linkedCount} linked tenant${linkedCount === 1 ? '' : 's'}` : 'No tenant link'}
</span>
</div> </div>
<p className='mt-3 line-clamp-2 text-base font-semibold text-gray-900 dark:text-gray-100'>
{item.name || 'Unnamed organization'}
</p>
<div className='mt-3 rounded-xl border border-blue-100 bg-blue-50/60 p-3 dark:border-blue-950/60 dark:bg-blue-950/20'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100'>
Connected entities
</p>
<div className='mt-2'>
<LinkedTenantsPreview
summary={linkedSummary}
emptyMessage='This organization is not linked to a tenant yet.'
compact
/>
</div>
</div>
</Link> </Link>
<div className='flex shrink-0 items-start p-4'>
<ListActionsPopover <ListActionsPopover
onDelete={onDelete} onDelete={onDelete}
itemId={item.id} itemId={item.id}
pathEdit={`/organizations/organizations-edit/?id=${item.id}`} pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
pathView={`/organizations/organizations-view/?id=${item.id}`} pathView={`/organizations/organizations-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission} hasUpdatePermission={hasUpdatePermission}
/> />
</div> </div>
</div>
</CardBox> </CardBox>
</div> </div>
))} )
})}
{!loading && organizations.length === 0 && ( {!loading && organizations.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'> <div className='col-span-full flex h-40 items-center justify-center'>
<p className=''>No data to display</p> <p>No data to display</p>
</div> </div>
)} )}
</div> </div>
<div className={'flex items-center justify-center my-6'}> <div className='my-6 flex items-center justify-center'>
<Pagination <Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div> </div>
</> </>
) )
}; }
export default ListOrganizations export default ListOrganizations

View File

@ -0,0 +1,23 @@
import React from 'react'
type TenantStatusChipProps = {
isActive?: boolean | null
className?: string
}
const TenantStatusChip = ({ isActive = false, className = '' }: TenantStatusChipProps) => {
const label = isActive ? 'Active' : 'Inactive'
const toneClasses = isActive
? 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-200'
: 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/70 dark:bg-rose-950/40 dark:text-rose-200'
return (
<span
className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold ${toneClasses} ${className}`.trim()}
>
{label}
</span>
)
}
export default TenantStatusChip

View File

@ -1,24 +1,22 @@
import React from 'react'; import React from 'react'
import ImageField from '../ImageField'; import Link from 'next/link'
import ListActionsPopover from '../ListActionsPopover';
import { useAppSelector } from '../../stores/hooks';
import dataFormatter from '../../helpers/dataFormatter';
import { Pagination } from '../Pagination';
import {saveFile} from "../../helpers/fileSaver";
import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
import ListActionsPopover from '../ListActionsPopover'
import { useAppSelector } from '../../stores/hooks'
import dataFormatter from '../../helpers/dataFormatter'
import { Pagination } from '../Pagination'
import LoadingSpinner from '../LoadingSpinner'
import TenantStatusChip from '../TenantStatusChip'
import { hasPermission } from '../../helpers/userPermissions'
type Props = { type Props = {
tenants: any[]; tenants: any[]
loading: boolean; loading: boolean
onDelete: (id: string) => void; onDelete: (id: string) => void
currentPage: number; currentPage: number
numPages: number; numPages: number
onPageChange: (page: number) => void; onPageChange: (page: number) => void
}; }
const CardTenants = ({ const CardTenants = ({
tenants, tenants,
@ -28,43 +26,48 @@ const CardTenants = ({
numPages, numPages,
onPageChange, onPageChange,
}: Props) => { }: Props) => {
const asideScrollbarsStyle = useAppSelector( const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
(state) => state.style.asideScrollbarsStyle, const bgColor = useAppSelector((state) => state.style.cardsColor)
); const darkMode = useAppSelector((state) => state.style.darkMode)
const bgColor = useAppSelector((state) => state.style.cardsColor); const corners = useAppSelector((state) => state.style.corners)
const darkMode = useAppSelector((state) => state.style.darkMode); const focusRing = useAppSelector((state) => state.style.focusRingColor)
const corners = useAppSelector((state) => state.style.corners);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const currentUser = useAppSelector((state) => state.auth.currentUser); const currentUser = useAppSelector((state) => state.auth.currentUser)
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TENANTS') const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TENANTS')
return (
<div className='p-4'>
{loading && <LoadingSpinner />}
<ul role='list' className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'>
{!loading &&
tenants.map((item) => {
const linkedOrganizations = Array.isArray(item.organizations) ? item.organizations : []
return ( return (
<div className={'p-4'}>
{loading && <LoadingSpinner />}
<ul
role='list'
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
>
{!loading && tenants.map((item, index) => (
<li <li
key={item.id} key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${ className={`overflow-hidden ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`} }`}
> >
<div className={`relative border-b border-gray-900/5 bg-gray-50 p-6 dark:bg-dark-800 ${bgColor}`}>
<div className={`flex items-start ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}> <div className='flex items-start gap-4'>
<div className='min-w-0'> <div className='min-w-0 flex-1'>
<Link href={`/tenants/tenants-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'> <div className='flex flex-wrap items-center gap-2'>
{item.name} <span className='rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100'>
</Link> Tenant
<p className='mt-1 text-xs text-gray-500 dark:text-dark-600'> </span>
{Array.isArray(item.organizations) && item.organizations.length <TenantStatusChip isActive={item.is_active} />
? `${item.organizations.length} linked organization${item.organizations.length === 1 ? '' : 's'}` <span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
{linkedOrganizations.length
? `${linkedOrganizations.length} linked organization${linkedOrganizations.length === 1 ? '' : 's'}`
: 'No linked organizations'} : 'No linked organizations'}
</p> </span>
</div>
<Link href={`/tenants/tenants-view/?id=${item.id}`} className='mt-3 block line-clamp-2 text-lg font-bold leading-6'>
{item.name || 'Unnamed tenant'}
</Link>
</div> </div>
<div className='ml-auto'> <div className='ml-auto'>
@ -73,114 +76,58 @@ const CardTenants = ({
itemId={item.id} itemId={item.id}
pathEdit={`/tenants/tenants-edit/?id=${item.id}`} pathEdit={`/tenants/tenants-edit/?id=${item.id}`}
pathView={`/tenants/tenants-view/?id=${item.id}`} pathView={`/tenants/tenants-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission} hasUpdatePermission={hasUpdatePermission}
/> />
</div> </div>
</div> </div>
<dl className='divide-y divide-gray-600 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Tenantname</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.name }
</div>
</dd>
</div> </div>
<dl className='h-64 overflow-y-auto space-y-4 px-6 py-4 text-sm leading-6'>
<div className='flex flex-wrap gap-2'>
{item.slug ? (
<div className='flex justify-between gap-x-4 py-3'> <span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<dt className=' text-gray-500 dark:text-dark-600'>Tenantslug</dt> <span className='font-semibold text-gray-900 dark:text-gray-100'>Slug:</span> {item.slug}
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.slug }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Legalname</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.legal_name }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Primarydomain</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.primary_domain }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Timezone</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.timezone }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Defaultcurrency</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.default_currency }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Isactive</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.booleanFormatter(item.is_active) }
</div>
</dd>
</div>
<div className='flex flex-col gap-y-3 py-3'>
<dt className='text-gray-500 dark:text-dark-600'>Linked organizations</dt>
<dd className='flex flex-wrap items-center gap-2'>
{Array.isArray(item.organizations) && item.organizations.length ? (
<>
<span className='rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700 dark:bg-blue-950/40 dark:text-blue-100'>
{item.organizations.length} linked
</span> </span>
{item.organizations.map((organization: any) => ( ) : null}
{item.primary_domain ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Domain:</span> {item.primary_domain}
</span>
) : null}
{item.timezone ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Timezone:</span> {item.timezone}
</span>
) : null}
{item.default_currency ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Currency:</span> {item.default_currency}
</span>
) : null}
{item.legal_name ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Legal:</span> {item.legal_name}
</span>
) : null}
</div>
<div className='rounded-xl border border-blue-100 bg-blue-50/60 p-4 dark:border-blue-950/60 dark:bg-blue-950/20'>
<dt className='text-xs font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100'>
Connected entities
</dt>
<dd className='mt-3 flex flex-wrap items-center gap-2'>
{linkedOrganizations.length ? (
<>
<span className='rounded-full bg-blue-100 px-2.5 py-1 text-xs font-semibold text-blue-900 dark:bg-blue-950/50 dark:text-blue-100'>
{linkedOrganizations.length} linked
</span>
{linkedOrganizations.map((organization: any) => (
<span <span
key={organization.id || organization.name} key={organization.id || organization.name}
className='rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100' className='rounded-full bg-white px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'
title={organization.name} title={organization.name}
> >
{organization.name} {organization.name || 'Unnamed organization'}
</span> </span>
))} ))}
</> </>
@ -190,50 +137,38 @@ const CardTenants = ({
</dd> </dd>
</div> </div>
{dataFormatter.propertiesManyListFormatter(item.properties).length ? (
<div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className='text-gray-500 dark:text-dark-600'>Properties</dt> <dt className='text-gray-500 dark:text-dark-600'>Properties</dt>
<dd className='flex items-start gap-x-2'> <dd className='mt-1 font-medium line-clamp-3'>
<div className='font-medium line-clamp-4'>
{dataFormatter.propertiesManyListFormatter(item.properties).join(', ')} {dataFormatter.propertiesManyListFormatter(item.properties).join(', ')}
</div>
</dd> </dd>
</div> </div>
) : null}
{dataFormatter.audit_logsManyListFormatter(item.audit_logs).length ? (
<div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className='text-gray-500 dark:text-dark-600'>Audit logs</dt> <dt className='text-gray-500 dark:text-dark-600'>Audit logs</dt>
<dd className='flex items-start gap-x-2'> <dd className='mt-1 font-medium line-clamp-3'>
<div className='font-medium line-clamp-4'>
{dataFormatter.audit_logsManyListFormatter(item.audit_logs).join(', ')} {dataFormatter.audit_logsManyListFormatter(item.audit_logs).join(', ')}
</div>
</dd> </dd>
</div> </div>
) : null}
</dl> </dl>
</li> </li>
))} )
})}
{!loading && tenants.length === 0 && ( {!loading && tenants.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'> <div className='col-span-full flex h-40 items-center justify-center'>
<p className=''>No data to display</p> <p>No data to display</p>
</div> </div>
)} )}
</ul> </ul>
<div className={'flex items-center justify-center my-6'}> <div className='my-6 flex items-center justify-center'>
<Pagination <Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div> </div>
</div> </div>
); )
}; }
export default CardTenants; export default CardTenants

View File

@ -1,160 +1,138 @@
import React from 'react'; import React from 'react'
import CardBox from '../CardBox'; import Link from 'next/link'
import ImageField from '../ImageField';
import dataFormatter from '../../helpers/dataFormatter';
import {saveFile} from "../../helpers/fileSaver";
import ListActionsPopover from "../ListActionsPopover";
import {useAppSelector} from "../../stores/hooks";
import {Pagination} from "../Pagination";
import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
import CardBox from '../CardBox'
import ListActionsPopover from '../ListActionsPopover'
import { useAppSelector } from '../../stores/hooks'
import { Pagination } from '../Pagination'
import LoadingSpinner from '../LoadingSpinner'
import TenantStatusChip from '../TenantStatusChip'
import { hasPermission } from '../../helpers/userPermissions'
type Props = { type Props = {
tenants: any[]; tenants: any[]
loading: boolean; loading: boolean
onDelete: (id: string) => void; onDelete: (id: string) => void
currentPage: number; currentPage: number
numPages: number; numPages: number
onPageChange: (page: number) => void; onPageChange: (page: number) => void
}; }
const ListTenants = ({ tenants, loading, onDelete, currentPage, numPages, onPageChange }: Props) => { const ListTenants = ({ tenants, loading, onDelete, currentPage, numPages, onPageChange }: Props) => {
const currentUser = useAppSelector((state) => state.auth.currentUser)
const currentUser = useAppSelector((state) => state.auth.currentUser);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TENANTS') const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_TENANTS')
const corners = useAppSelector((state) => state.style.corners); const corners = useAppSelector((state) => state.style.corners)
const bgColor = useAppSelector((state) => state.style.cardsColor); const bgColor = useAppSelector((state) => state.style.cardsColor)
return ( return (
<> <>
<div className='relative overflow-x-auto p-4 space-y-4'> <div className='relative space-y-4 overflow-x-auto p-4'>
{loading && <LoadingSpinner />} {loading && <LoadingSpinner />}
{!loading && tenants.map((item) => ( {!loading &&
tenants.map((item) => {
const linkedOrganizations = Array.isArray(item.organizations) ? item.organizations : []
return (
<div key={item.id}> <div key={item.id}>
<CardBox hasTable isList className={'rounded shadow-none'}> <CardBox hasTable isList className='rounded shadow-none'>
<div className={`flex ${bgColor} ${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 border border-gray-600 items-center overflow-hidden`}> <div
className={`flex items-start overflow-hidden border border-gray-600 ${bgColor} ${
<Link corners !== 'rounded-full' ? corners : 'rounded-3xl'
href={`/tenants/tenants-view/?id=${item.id}`} } dark:bg-dark-900`}
className={
'flex-1 px-4 py-6 h-24 flex divide-x-2 divide-gray-600 items-center overflow-hidden`}> dark:divide-dark-700 overflow-x-auto'
}
> >
<Link href={`/tenants/tenants-view/?id=${item.id}`} className='min-w-0 flex-1 p-4'>
<div className='flex flex-wrap items-center gap-2'>
<div className={'flex-1 px-3'}> <span className='rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100'>
<p className={'text-xs text-gray-500 '}>Tenantname</p> Tenant
<p className={'line-clamp-2'}>{ item.name }</p> </span>
<TenantStatusChip isActive={item.is_active} />
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
{linkedOrganizations.length
? `${linkedOrganizations.length} linked organization${linkedOrganizations.length === 1 ? '' : 's'}`
: 'No linked organizations'}
</span>
</div> </div>
<p className='mt-3 line-clamp-2 text-base font-semibold text-gray-900 dark:text-gray-100'>
{item.name || 'Unnamed tenant'}
</p>
<div className='mt-3 flex flex-wrap gap-2'>
{item.slug ? (
<div className={'flex-1 px-3'}> <span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<p className={'text-xs text-gray-500 '}>Tenantslug</p> <span className='font-semibold text-gray-900 dark:text-gray-100'>Slug:</span> {item.slug}
<p className={'line-clamp-2'}>{ item.slug }</p> </span>
) : null}
{item.primary_domain ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Domain:</span> {item.primary_domain}
</span>
) : null}
{item.timezone ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Timezone:</span> {item.timezone}
</span>
) : null}
{item.default_currency ? (
<span className='rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'>
<span className='font-semibold text-gray-900 dark:text-gray-100'>Currency:</span> {item.default_currency}
</span>
) : null}
</div> </div>
<div className='mt-3 rounded-xl border border-blue-100 bg-blue-50/60 p-3 dark:border-blue-950/60 dark:bg-blue-950/20'>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100'>
Connected entities
<div className={'flex-1 px-3'}> </p>
<p className={'text-xs text-gray-500 '}>Legalname</p> <div className='mt-2 flex flex-wrap items-center gap-2'>
<p className={'line-clamp-2'}>{ item.legal_name }</p> {linkedOrganizations.length ? (
<>
<span className='rounded-full bg-blue-100 px-2.5 py-1 text-xs font-semibold text-blue-900 dark:bg-blue-950/50 dark:text-blue-100'>
{linkedOrganizations.length} linked
</span>
{linkedOrganizations.map((organization: any) => (
<span
key={organization.id || organization.name}
className='rounded-full bg-white px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100'
title={organization.name}
>
{organization.name || 'Unnamed organization'}
</span>
))}
</>
) : (
<span className='text-sm text-gray-500 dark:text-dark-600'>No linked organizations</span>
)}
</div> </div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Primarydomain</p>
<p className={'line-clamp-2'}>{ item.primary_domain }</p>
</div> </div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Timezone</p>
<p className={'line-clamp-2'}>{ item.timezone }</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Defaultcurrency</p>
<p className={'line-clamp-2'}>{ item.default_currency }</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Isactive</p>
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.is_active) }</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Organizations</p>
<p className={'line-clamp-2'}>{ dataFormatter.organizationsManyListFormatter(item.organizations).join(', ')}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Properties</p>
<p className={'line-clamp-2'}>{ dataFormatter.propertiesManyListFormatter(item.properties).join(', ')}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Auditlogs</p>
<p className={'line-clamp-2'}>{ dataFormatter.audit_logsManyListFormatter(item.audit_logs).join(', ')}</p>
</div>
</Link> </Link>
<div className='flex shrink-0 items-start p-4'>
<ListActionsPopover <ListActionsPopover
onDelete={onDelete} onDelete={onDelete}
itemId={item.id} itemId={item.id}
pathEdit={`/tenants/tenants-edit/?id=${item.id}`} pathEdit={`/tenants/tenants-edit/?id=${item.id}`}
pathView={`/tenants/tenants-view/?id=${item.id}`} pathView={`/tenants/tenants-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission} hasUpdatePermission={hasUpdatePermission}
/> />
</div> </div>
</div>
</CardBox> </CardBox>
</div> </div>
))} )
})}
{!loading && tenants.length === 0 && ( {!loading && tenants.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'> <div className='col-span-full flex h-40 items-center justify-center'>
<p className=''>No data to display</p> <p>No data to display</p>
</div> </div>
)} )}
</div> </div>
<div className={'flex items-center justify-center my-6'}> <div className='my-6 flex items-center justify-center'>
<Pagination <Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div> </div>
</> </>
) )
}; }
export default ListTenants export default ListTenants

View File

@ -10,6 +10,7 @@ import AsideMenu from '../components/AsideMenu'
import FooterBar from '../components/FooterBar' import FooterBar from '../components/FooterBar'
import { useAppDispatch, useAppSelector } from '../stores/hooks' import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Search from '../components/Search'; import Search from '../components/Search';
import CurrentWorkspaceChip from '../components/CurrentWorkspaceChip'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import {findMe, logoutUser} from "../stores/authSlice"; import {findMe, logoutUser} from "../stores/authSlice";
@ -113,6 +114,7 @@ export default function LayoutAuthenticated({
<NavBarItemPlain useMargin> <NavBarItemPlain useMargin>
<Search /> <Search />
</NavBarItemPlain> </NavBarItemPlain>
<CurrentWorkspaceChip />
</NavBar> </NavBar>
<AsideMenu <AsideMenu
isAsideMobileExpanded={isAsideMobileExpanded} isAsideMobileExpanded={isAsideMobileExpanded}

View File

@ -19,6 +19,7 @@ import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import BaseButton from '../components/BaseButton'; import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon'; import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox'; import CardBox from '../components/CardBox';
import MyOrgTenantSummary from '../components/MyOrgTenantSummary';
import SectionMain from '../components/SectionMain'; import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
@ -806,6 +807,8 @@ const CommandCenterPage = () => {
<BaseButton color="whiteDark" icon={mdiRefresh} label="Refresh" onClick={loadOverview} /> <BaseButton color="whiteDark" icon={mdiRefresh} label="Refresh" onClick={loadOverview} />
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<MyOrgTenantSummary className="mb-6" />
{errorMessage ? ( {errorMessage ? (
<CardBox className="mb-6 border border-rose-200 bg-rose-50"> <CardBox className="mb-6 border border-rose-200 bg-rose-50">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">

View File

@ -16,6 +16,7 @@ import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget'; import { SmartWidget } from '../components/SmartWidget/SmartWidget';
import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import MyOrgTenantSummary from '../components/MyOrgTenantSummary';
const Dashboard = () => { const Dashboard = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const iconsColor = useAppSelector((state) => state.style.iconsColor); const iconsColor = useAppSelector((state) => state.style.iconsColor);
@ -122,6 +123,8 @@ const Dashboard = () => {
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<MyOrgTenantSummary className='mb-6' />
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator {hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
currentUser={currentUser} currentUser={currentUser}
isFetchingQuery={isFetchingQuery} isFetchingQuery={isFetchingQuery}

View File

@ -40,6 +40,12 @@ export default function Login() {
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector( const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
(state) => state.auth, (state) => state.auth,
); );
const registeredOrg = typeof router.query.registeredOrg === 'string' ? router.query.registeredOrg : '';
const registeredRole = typeof router.query.registeredRole === 'string' ? router.query.registeredRole : '';
const registeredTenants =
typeof router.query.registeredTenants === 'string'
? router.query.registeredTenants.split(' | ').filter(Boolean)
: [];
const [initialValues, setInitialValues] = React.useState({ email:'super_admin@flatlogic.com', const [initialValues, setInitialValues] = React.useState({ email:'super_admin@flatlogic.com',
password: '946cafba', password: '946cafba',
remember: true }) remember: true })
@ -208,6 +214,41 @@ export default function Login() {
</div> </div>
</CardBox> </CardBox>
{registeredOrg ? (
<CardBox className='w-full border border-blue-200 bg-blue-50 text-blue-950 shadow-none md:w-3/5 lg:w-2/3 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-100'>
<div className='flex items-start gap-3'>
<BaseIcon className='mt-1 text-blue-700 dark:text-blue-200' size={20} path={mdiInformation} />
<div className='min-w-0 space-y-2'>
<p className='font-semibold'>
Your {registeredRole || 'customer'} account was created for {registeredOrg}.
</p>
{registeredTenants.length ? (
<div>
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-blue-800 dark:text-blue-200'>Linked tenants</p>
<div className='mt-2 flex flex-wrap gap-2'>
{registeredTenants.map((tenantName) => (
<span
key={tenantName}
className='inline-flex items-center rounded-full border border-blue-200 bg-white/80 px-3 py-1 text-xs font-medium text-blue-900 dark:border-blue-800 dark:bg-blue-900/40 dark:text-blue-100'
>
{tenantName}
</span>
))}
</div>
</div>
) : (
<p className='text-sm text-blue-800 dark:text-blue-100'>
No linked tenant is shown for that organization yet.
</p>
)}
<p className='text-xs text-blue-800 dark:text-blue-100'>
Admin and Super Admin accounts are provisioned internally, not through self-signup.
</p>
</div>
</div>
</CardBox>
) : null}
<CardBox className='w-full md:w-3/5 lg:w-2/3'> <CardBox className='w-full md:w-3/5 lg:w-2/3'>
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}

View File

@ -9,6 +9,7 @@ import BaseButtons from '../../components/BaseButtons'
import BaseDivider from '../../components/BaseDivider' import BaseDivider from '../../components/BaseDivider'
import CardBox from '../../components/CardBox' import CardBox from '../../components/CardBox'
import ConnectedEntityCard from '../../components/ConnectedEntityCard' import ConnectedEntityCard from '../../components/ConnectedEntityCard'
import TenantStatusChip from '../../components/TenantStatusChip'
import ConnectedEntityNotice from '../../components/ConnectedEntityNotice' import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
import FormField from '../../components/FormField' import FormField from '../../components/FormField'
import NotificationBar from '../../components/NotificationBar' import NotificationBar from '../../components/NotificationBar'
@ -217,12 +218,12 @@ const EditOrganizationsPage = () => {
key={tenant.id} key={tenant.id}
entityLabel="Tenant" entityLabel="Tenant"
title={tenant.name || 'Unnamed tenant'} title={tenant.name || 'Unnamed tenant'}
badges={[<TenantStatusChip key={`tenant-status-${tenant.id}`} isActive={tenant.is_active} />]}
details={[ details={[
{ label: 'Slug', value: tenant.slug }, { label: 'Slug', value: tenant.slug },
{ label: 'Domain', value: tenant.primary_domain }, { label: 'Domain', value: tenant.primary_domain },
{ label: 'Timezone', value: tenant.timezone }, { label: 'Timezone', value: tenant.timezone },
{ label: 'Currency', value: tenant.default_currency }, { label: 'Currency', value: tenant.default_currency },
{ label: 'Status', value: tenant.is_active ? 'Active' : 'Inactive' },
]} ]}
actions={[ actions={[
...(canReadTenants ...(canReadTenants

View File

@ -15,6 +15,7 @@ import SectionTitleLineWithButton from "../../components/SectionTitleLineWithBut
import SectionMain from "../../components/SectionMain"; import SectionMain from "../../components/SectionMain";
import CardBox from "../../components/CardBox"; import CardBox from "../../components/CardBox";
import ConnectedEntityCard from '../../components/ConnectedEntityCard' import ConnectedEntityCard from '../../components/ConnectedEntityCard'
import TenantStatusChip from '../../components/TenantStatusChip'
import ConnectedEntityNotice from '../../components/ConnectedEntityNotice' import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
import BaseButton from "../../components/BaseButton"; import BaseButton from "../../components/BaseButton";
import BaseDivider from "../../components/BaseDivider"; import BaseDivider from "../../components/BaseDivider";
@ -132,10 +133,12 @@ const OrganizationsView = () => {
key={tenant.id} key={tenant.id}
entityLabel='Tenant' entityLabel='Tenant'
title={tenant.name || 'Unnamed tenant'} title={tenant.name || 'Unnamed tenant'}
badges={[<TenantStatusChip key={`tenant-status-${tenant.id}`} isActive={tenant.is_active} />]}
details={[ details={[
{ label: 'Slug', value: tenant.slug }, { label: 'Slug', value: tenant.slug },
{ label: 'Domain', value: tenant.primary_domain }, { label: 'Domain', value: tenant.primary_domain },
{ label: 'Timezone', value: tenant.timezone }, { label: 'Timezone', value: tenant.timezone },
{ label: 'Currency', value: tenant.default_currency },
]} ]}
actions={[ actions={[
...(canReadTenants ...(canReadTenants

View File

@ -29,6 +29,7 @@ import { update, fetch } from '../stores/users/usersSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import {findMe} from "../stores/authSlice"; import {findMe} from "../stores/authSlice";
import MyOrgTenantSummary from '../components/MyOrgTenantSummary';
const EditUsers = () => { const EditUsers = () => {
const { currentUser, isFetching, token } = useAppSelector( const { currentUser, isFetching, token } = useAppSelector(
@ -81,6 +82,7 @@ const EditUsers = () => {
> >
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<MyOrgTenantSummary className='mb-6' />
<CardBox> <CardBox>
{currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}> {currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}>
<div className="col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8"> <div className="col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8">

View File

@ -12,113 +12,184 @@ import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons'; import BaseButtons from '../components/BaseButtons';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { getPageTitle } from '../config'; import { getPageTitle } from '../config';
import Select from 'react-select'; import Select from 'react-select';
import { useAppDispatch } from '../stores/hooks'; import axios from 'axios';
import { createAsyncThunk } from '@reduxjs/toolkit'; import ConnectedEntityNotice from '../components/ConnectedEntityNotice';
import axios from "axios"; type PublicTenantOption = {
id: string;
name?: string;
slug?: string;
primary_domain?: string;
timezone?: string;
default_currency?: string;
is_active?: boolean;
};
type PublicOrganizationOption = {
id: string;
name: string;
linkedTenants?: PublicTenantOption[];
linkedTenantNames?: string[];
primaryTenant?: PublicTenantOption | null;
};
type OrganizationSelectOption = {
value: string;
label: string;
organization: PublicOrganizationOption;
};
export default function Register() { export default function Register() {
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [organizations, setOrganizations] = React.useState<PublicOrganizationOption[]>([]);
const [selectedOrganization, setSelectedOrganization] = React.useState<OrganizationSelectOption | null>(null);
const router = useRouter(); const router = useRouter();
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"}); const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' });
const [organizations, setOrganizations] = React.useState(null); React.useEffect(() => {
const [selectedOrganization, setSelectedOrganization] = React.useState(null); const fetchOrganizations = async () => {
const dispatch = useAppDispatch();
const fetchOrganizations = createAsyncThunk(
'/org-for-auth',
async () => {
try { try {
const response = await axios.get('/org-for-auth'); const response = await axios.get('/org-for-auth');
setOrganizations(response.data); setOrganizations(Array.isArray(response.data) ? response.data : []);
return response.data;
} catch (error) { } catch (error) {
console.error(error.response); console.error('Failed to load organizations for signup:', error);
throw error;
} }
} };
);
React.useEffect(() => { fetchOrganizations();
dispatch(fetchOrganizations()); }, []);
}, [dispatch]);
const options = organizations?.map(org => ({ const options: OrganizationSelectOption[] = organizations.map((org) => ({
value: org.id, value: org.id,
label: org.name label: org.name,
organization: org,
})); }));
const selectedOrganizationRecord = selectedOrganization?.organization || null;
const selectedTenants = selectedOrganizationRecord?.linkedTenants || [];
const handleSubmit = async (value) => { const handleSubmit = async (value) => {
setLoading(true) if (!selectedOrganization) {
try { notify('error', 'Please select your organization before creating the account.');
return;
}
if (value.password !== value.confirm) {
notify('error', 'Password and confirmation must match.');
return;
}
setLoading(true);
try {
const formData = { ...value, organizationId: selectedOrganization.value }; const formData = { ...value, organizationId: selectedOrganization.value };
const { data: response } = await axios.post('/auth/signup',formData); await axios.post('/auth/signup', formData);
await router.push('/login')
setLoading(false) const tenantNames = selectedTenants
notify('success', 'Please check your email for verification link') .map((tenant) => tenant?.name)
.filter(Boolean)
.join(' | ');
await router.push({
pathname: '/login',
query: {
registeredOrg: selectedOrganizationRecord?.name || selectedOrganization.label,
registeredRole: 'customer',
...(tenantNames ? { registeredTenants: tenantNames } : {}),
},
});
} catch (error) { } catch (error) {
setLoading(false) console.error('Signup failed:', error);
console.log('error: ', error) notify('error', 'Something was wrong. Try again.');
notify('error', 'Something was wrong. Try again') } finally {
setLoading(false);
} }
}; };
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('Login')}</title> <title>{getPageTitle('Register')}</title>
</Head> </Head>
<SectionFullScreen bg='violet'> <SectionFullScreen bg="violet">
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'> <CardBox className="w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12">
<Formik <Formik
initialValues={{ initialValues={{
email: '', email: '',
password: '', password: '',
confirm: '' confirm: '',
}} }}
onSubmit={(values) => handleSubmit(values)} onSubmit={(values) => handleSubmit(values)}
> >
<Form> <Form>
<label className="mb-2 block font-bold">Organization</label>
<label className="block font-bold mb-2" >Organization</label> <Select<OrganizationSelectOption, false>
<Select
classNames={{ classNames={{
control: () => 'px-1 mb-4 py-2', control: () => 'px-1 mb-4 py-2',
}} }}
value={selectedOrganization} value={selectedOrganization}
onChange={setSelectedOrganization} onChange={(option) => setSelectedOrganization(option)}
options={options} options={options}
placeholder="Select organization..." placeholder="Select organization..."
/> />
<FormField label='Email' help='Please enter your email'> {selectedOrganizationRecord ? (
<Field type='email' name='email' /> <ConnectedEntityNotice
title="Account context"
description={
<div className="space-y-3">
<p>
Self-signup creates a <span className="font-semibold">Customer</span> account inside{' '}
<span className="font-semibold">{selectedOrganizationRecord.name}</span>.
</p>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-blue-800 dark:text-blue-200">
Linked tenant preview
</p>
{selectedTenants.length ? (
<div className="flex flex-wrap gap-2">
{selectedTenants.map((tenant) => (
<span
key={tenant.id}
className="inline-flex items-center rounded-full border border-blue-200 bg-white/80 px-3 py-1 text-xs font-medium text-blue-900 dark:border-blue-800 dark:bg-blue-900/40 dark:text-blue-100"
>
{tenant.name || 'Unnamed tenant'}
</span>
))}
</div>
) : (
<p className="text-sm text-blue-800 dark:text-blue-100">
No linked tenant is visible for this organization yet.
</p>
)}
</div>
<p className="text-xs text-blue-800 dark:text-blue-100">
Admin and Super Admin accounts are created internally by an existing privileged user.
</p>
</div>
}
/>
) : null}
<FormField label="Email" help="Please enter your email">
<Field type="email" name="email" />
</FormField> </FormField>
<FormField label='Password' help='Please enter your password'> <FormField label="Password" help="Please enter your password">
<Field type='password' name='password' /> <Field type="password" name="password" />
</FormField> </FormField>
<FormField label='Confirm Password' help='Please confirm your password'> <FormField label="Confirm Password" help="Please confirm your password">
<Field type='password' name='confirm' /> <Field type="password" name="confirm" />
</FormField> </FormField>
<BaseDivider /> <BaseDivider />
<BaseButtons> <BaseButtons>
<BaseButton <BaseButton type="submit" label={loading ? 'Loading...' : 'Register'} color="info" />
type='submit' <BaseButton href={'/login'} label={'Login'} color="info" />
label={loading ? 'Loading...' : 'Register' }
color='info'
/>
<BaseButton
href={'/login'}
label={'Login'}
color='info'
/>
</BaseButtons> </BaseButtons>
</Form> </Form>
</Formik> </Formik>