Autosave: 20260404-042157
This commit is contained in:
parent
b4ba3c2646
commit
33f59460fd
@ -903,19 +903,23 @@ module.exports = class UsersDBApi {
|
||||
|
||||
static async createFromAuth(data, options) {
|
||||
const transaction = (options && options.transaction) || undefined;
|
||||
const organizationId = data.organizationId || data.organizationsId || null;
|
||||
const users = await db.users.create(
|
||||
{
|
||||
email: data.email,
|
||||
firstName: data.firstName,
|
||||
authenticationUid: data.authenticationUid,
|
||||
password: data.password,
|
||||
|
||||
organizationId: data.organizationId,
|
||||
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
if (organizationId) {
|
||||
await users.setOrganizations(organizationId, {
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
const app_role = await db.roles.findOne({
|
||||
where: { name: config.roles?.user || "User" },
|
||||
});
|
||||
|
||||
@ -1,13 +1,31 @@
|
||||
|
||||
|
||||
|
||||
const express = require('express');
|
||||
|
||||
const db = require('../db/models');
|
||||
const OrganizationsDBApi = require('../db/api/organizations');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
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
|
||||
* /api/organizations:
|
||||
@ -33,23 +51,29 @@ const router = express.Router();
|
||||
* 500:
|
||||
* description: Some server error
|
||||
*/
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await OrganizationsDBApi.findAll(req.query);
|
||||
const simplifiedPayload = payload.rows.map(org => ({
|
||||
id: org.id,
|
||||
name: org.name
|
||||
}));
|
||||
res.status(200).send(simplifiedPayload);
|
||||
const payload = await OrganizationsDBApi.findAll(req.query || {}, true, {});
|
||||
|
||||
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;
|
||||
|
||||
|
||||
|
||||
@ -4,6 +4,8 @@ import BaseIcon from './BaseIcon';
|
||||
import AsideMenuList from './AsideMenuList';
|
||||
import { MenuAsideItem } from '../interfaces';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import { loadLinkedTenantSummary } from '../helpers/organizationTenants';
|
||||
import type { LinkedTenantRecord } from '../helpers/organizationTenants';
|
||||
|
||||
const ASIDE_WIDTH_STORAGE_KEY = 'aside-width';
|
||||
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 { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [asideWidth, setAsideWidth] = useState(DEFAULT_ASIDE_WIDTH);
|
||||
const [linkedTenants, setLinkedTenants] = useState<LinkedTenantRecord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@ -45,6 +48,40 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
||||
}
|
||||
}, [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) => {
|
||||
e.preventDefault();
|
||||
props.onAsideLgCloseClick();
|
||||
@ -61,6 +98,11 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
||||
|
||||
const organizationName =
|
||||
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 (
|
||||
<aside
|
||||
@ -79,6 +121,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
||||
Corporate Stay Portal
|
||||
</div>
|
||||
<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 className="flex items-center gap-1">
|
||||
|
||||
@ -20,6 +20,7 @@ type ConnectedEntityCardProps = {
|
||||
entityLabel: string
|
||||
title?: ReactNode
|
||||
titleFallback?: string
|
||||
badges?: ReactNode[]
|
||||
details?: ConnectedEntityCardDetail[]
|
||||
actions?: ConnectedEntityCardAction[]
|
||||
helperText?: ReactNode
|
||||
@ -30,6 +31,7 @@ const ConnectedEntityCard = ({
|
||||
entityLabel,
|
||||
title,
|
||||
titleFallback = 'Unnamed record',
|
||||
badges = [],
|
||||
details = [],
|
||||
actions = [],
|
||||
helperText,
|
||||
@ -41,6 +43,8 @@ const ConnectedEntityCard = ({
|
||||
return value !== undefined && value !== null && value !== ''
|
||||
})
|
||||
|
||||
const visibleBadges = badges.filter(Boolean)
|
||||
|
||||
return (
|
||||
<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()}
|
||||
@ -60,6 +64,8 @@ const ConnectedEntityCard = ({
|
||||
{title || titleFallback}
|
||||
</p>
|
||||
|
||||
{visibleBadges.length ? <div className="mt-3 flex flex-wrap gap-2">{visibleBadges}</div> : null}
|
||||
|
||||
{visibleDetails.length ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{visibleDetails.map((detail) => (
|
||||
|
||||
303
frontend/src/components/CurrentWorkspaceChip.tsx
Normal file
303
frontend/src/components/CurrentWorkspaceChip.tsx
Normal 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
|
||||
191
frontend/src/components/MyOrgTenantSummary.tsx
Normal file
191
frontend/src/components/MyOrgTenantSummary.tsx
Normal 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
|
||||
@ -67,7 +67,11 @@ const CardOrganizations = ({
|
||||
{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 &&
|
||||
organizations.map((item) => (
|
||||
organizations.map((item) => {
|
||||
const linkedSummary = linkedTenantSummaries[item.id]
|
||||
const linkedCount = linkedSummary?.count || 0
|
||||
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
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
|
||||
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'>
|
||||
{item.name}
|
||||
<div className='flex items-start gap-4'>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className='ml-auto'>
|
||||
<ListActionsPopover
|
||||
@ -91,26 +107,24 @@ const CardOrganizations = ({
|
||||
/>
|
||||
</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 className='flex flex-col gap-y-3 py-3'>
|
||||
<dt className='text-gray-500 dark:text-dark-600'>Linked tenants</dt>
|
||||
<dd>
|
||||
<div className='space-y-4 px-6 py-4 text-sm leading-6'>
|
||||
<div className='rounded-xl border border-blue-100 bg-blue-50/60 p-4 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-3'>
|
||||
<LinkedTenantsPreview
|
||||
summary={linkedTenantSummaries[item.id]}
|
||||
summary={linkedSummary}
|
||||
emptyMessage='This organization is not linked to a tenant yet.'
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{!loading && organizations.length === 0 && (
|
||||
<div className='col-span-full flex h-40 items-center justify-center'>
|
||||
<p>No data to display</p>
|
||||
|
||||
@ -1,88 +1,128 @@
|
||||
import React from 'react';
|
||||
import CardBox from '../CardBox';
|
||||
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 React, { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
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 = {
|
||||
organizations: any[];
|
||||
loading: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
currentPage: number;
|
||||
numPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
organizations: any[]
|
||||
loading: boolean
|
||||
onDelete: (id: string) => void
|
||||
currentPage: number
|
||||
numPages: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
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 corners = useAppSelector((state) => state.style.corners);
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
||||
const corners = useAppSelector((state) => state.style.corners)
|
||||
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 (
|
||||
<>
|
||||
<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 && organizations.map((item) => (
|
||||
{!loading &&
|
||||
organizations.map((item) => {
|
||||
const linkedSummary = linkedTenantSummaries[item.id]
|
||||
const linkedCount = linkedSummary?.count || 0
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<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`}>
|
||||
|
||||
<Link
|
||||
href={`/organizations/organizations-view/?id=${item.id}`}
|
||||
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'
|
||||
}
|
||||
<CardBox hasTable isList className='rounded shadow-none'>
|
||||
<div
|
||||
className={`flex items-start overflow-hidden border border-gray-600 ${bgColor} ${
|
||||
corners !== 'rounded-full' ? corners : 'rounded-3xl'
|
||||
} dark:bg-dark-900`}
|
||||
>
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>Name</p>
|
||||
<p className={'line-clamp-2'}>{ item.name }</p>
|
||||
<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'>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<div className='flex shrink-0 items-start p-4'>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/organizations/organizations-edit/?id=${item.id}`}
|
||||
pathView={`/organizations/organizations-view/?id=${item.id}`}
|
||||
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{!loading && organizations.length === 0 && (
|
||||
<div className='col-span-full flex items-center justify-center h-40'>
|
||||
<p className=''>No data to display</p>
|
||||
<div className='col-span-full flex h-40 items-center justify-center'>
|
||||
<p>No data to display</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={'flex items-center justify-center my-6'}>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
setCurrentPage={onPageChange}
|
||||
/>
|
||||
<div className='my-6 flex items-center justify-center'>
|
||||
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export default ListOrganizations
|
||||
23
frontend/src/components/TenantStatusChip.tsx
Normal file
23
frontend/src/components/TenantStatusChip.tsx
Normal 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
|
||||
@ -1,24 +1,22 @@
|
||||
import React from 'react';
|
||||
import ImageField from '../ImageField';
|
||||
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 React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
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 = {
|
||||
tenants: any[];
|
||||
loading: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
currentPage: number;
|
||||
numPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
tenants: any[]
|
||||
loading: boolean
|
||||
onDelete: (id: string) => void
|
||||
currentPage: number
|
||||
numPages: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
const CardTenants = ({
|
||||
tenants,
|
||||
@ -28,159 +26,108 @@ const CardTenants = ({
|
||||
numPages,
|
||||
onPageChange,
|
||||
}: Props) => {
|
||||
const asideScrollbarsStyle = useAppSelector(
|
||||
(state) => state.style.asideScrollbarsStyle,
|
||||
);
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||
const corners = useAppSelector((state) => state.style.corners);
|
||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
||||
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor)
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||
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')
|
||||
|
||||
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 (
|
||||
<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
|
||||
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
|
||||
}`}
|
||||
>
|
||||
|
||||
<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='min-w-0'>
|
||||
<Link href={`/tenants/tenants-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
|
||||
{item.name}
|
||||
</Link>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-dark-600'>
|
||||
{Array.isArray(item.organizations) && item.organizations.length
|
||||
? `${item.organizations.length} linked organization${item.organizations.length === 1 ? '' : 's'}`
|
||||
<div className={`relative border-b border-gray-900/5 bg-gray-50 p-6 dark:bg-dark-800 ${bgColor}`}>
|
||||
<div className='flex items-start gap-4'>
|
||||
<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'>
|
||||
Tenant
|
||||
</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'}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='ml-auto '>
|
||||
<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 className='ml-auto'>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/tenants/tenants-edit/?id=${item.id}`}
|
||||
pathView={`/tenants/tenants-view/?id=${item.id}`}
|
||||
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
|
||||
/>
|
||||
</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 className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Tenantslug</dt>
|
||||
<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
|
||||
<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 ? (
|
||||
<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'>Slug:</span> {item.slug}
|
||||
</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
|
||||
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}
|
||||
>
|
||||
{organization.name}
|
||||
{organization.name || 'Unnamed organization'}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
@ -190,50 +137,38 @@ const CardTenants = ({
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Properties</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.propertiesManyListFormatter(item.properties).join(', ')}
|
||||
</div>
|
||||
{dataFormatter.propertiesManyListFormatter(item.properties).length ? (
|
||||
<div>
|
||||
<dt className='text-gray-500 dark:text-dark-600'>Properties</dt>
|
||||
<dd className='mt-1 font-medium line-clamp-3'>
|
||||
{dataFormatter.propertiesManyListFormatter(item.properties).join(', ')}
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Auditlogs</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ dataFormatter.audit_logsManyListFormatter(item.audit_logs).join(', ')}
|
||||
</div>
|
||||
{dataFormatter.audit_logsManyListFormatter(item.audit_logs).length ? (
|
||||
<div>
|
||||
<dt className='text-gray-500 dark:text-dark-600'>Audit logs</dt>
|
||||
<dd className='mt-1 font-medium line-clamp-3'>
|
||||
{dataFormatter.audit_logsManyListFormatter(item.audit_logs).join(', ')}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
) : null}
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{!loading && tenants.length === 0 && (
|
||||
<div className='col-span-full flex items-center justify-center h-40'>
|
||||
<p className=''>No data to display</p>
|
||||
<div className='col-span-full flex h-40 items-center justify-center'>
|
||||
<p>No data to display</p>
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
<div className={'flex items-center justify-center my-6'}>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
setCurrentPage={onPageChange}
|
||||
/>
|
||||
<div className='my-6 flex items-center justify-center'>
|
||||
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default CardTenants;
|
||||
export default CardTenants
|
||||
|
||||
@ -1,160 +1,138 @@
|
||||
import React from 'react';
|
||||
import CardBox from '../CardBox';
|
||||
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 React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
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 = {
|
||||
tenants: any[];
|
||||
loading: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
currentPage: number;
|
||||
numPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
tenants: any[]
|
||||
loading: boolean
|
||||
onDelete: (id: string) => void
|
||||
currentPage: number
|
||||
numPages: number
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
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 corners = useAppSelector((state) => state.style.corners);
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
||||
|
||||
const corners = useAppSelector((state) => state.style.corners)
|
||||
const bgColor = useAppSelector((state) => state.style.cardsColor)
|
||||
|
||||
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 && tenants.map((item) => (
|
||||
{!loading &&
|
||||
tenants.map((item) => {
|
||||
const linkedOrganizations = Array.isArray(item.organizations) ? item.organizations : []
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<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`}>
|
||||
|
||||
<Link
|
||||
href={`/tenants/tenants-view/?id=${item.id}`}
|
||||
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'
|
||||
}
|
||||
<CardBox hasTable isList className='rounded shadow-none'>
|
||||
<div
|
||||
className={`flex items-start overflow-hidden border border-gray-600 ${bgColor} ${
|
||||
corners !== 'rounded-full' ? corners : 'rounded-3xl'
|
||||
} dark:bg-dark-900`}
|
||||
>
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>Tenantname</p>
|
||||
<p className={'line-clamp-2'}>{ item.name }</p>
|
||||
<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'>
|
||||
<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'>
|
||||
Tenant
|
||||
</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>
|
||||
|
||||
<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={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>Tenantslug</p>
|
||||
<p className={'line-clamp-2'}>{ item.slug }</p>
|
||||
<div className='mt-3 flex flex-wrap gap-2'>
|
||||
{item.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'>
|
||||
<span className='font-semibold text-gray-900 dark:text-gray-100'>Slug:</span> {item.slug}
|
||||
</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 className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>Legalname</p>
|
||||
<p className={'line-clamp-2'}>{ item.legal_name }</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 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
|
||||
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 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 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>
|
||||
|
||||
<div className='flex shrink-0 items-start p-4'>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
itemId={item.id}
|
||||
pathEdit={`/tenants/tenants-edit/?id=${item.id}`}
|
||||
pathView={`/tenants/tenants-view/?id=${item.id}`}
|
||||
|
||||
hasUpdatePermission={hasUpdatePermission}
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{!loading && tenants.length === 0 && (
|
||||
<div className='col-span-full flex items-center justify-center h-40'>
|
||||
<p className=''>No data to display</p>
|
||||
<div className='col-span-full flex h-40 items-center justify-center'>
|
||||
<p>No data to display</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={'flex items-center justify-center my-6'}>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
numPages={numPages}
|
||||
setCurrentPage={onPageChange}
|
||||
/>
|
||||
<div className='my-6 flex items-center justify-center'>
|
||||
<Pagination currentPage={currentPage} numPages={numPages} setCurrentPage={onPageChange} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export default ListTenants
|
||||
@ -10,6 +10,7 @@ import AsideMenu from '../components/AsideMenu'
|
||||
import FooterBar from '../components/FooterBar'
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||
import Search from '../components/Search';
|
||||
import CurrentWorkspaceChip from '../components/CurrentWorkspaceChip'
|
||||
import { useRouter } from 'next/router'
|
||||
import {findMe, logoutUser} from "../stores/authSlice";
|
||||
|
||||
@ -113,6 +114,7 @@ export default function LayoutAuthenticated({
|
||||
<NavBarItemPlain useMargin>
|
||||
<Search />
|
||||
</NavBarItemPlain>
|
||||
<CurrentWorkspaceChip />
|
||||
</NavBar>
|
||||
<AsideMenu
|
||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||
|
||||
@ -19,6 +19,7 @@ import React, { ReactElement, useCallback, useEffect, useState } from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import MyOrgTenantSummary from '../components/MyOrgTenantSummary';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
@ -806,6 +807,8 @@ const CommandCenterPage = () => {
|
||||
<BaseButton color="whiteDark" icon={mdiRefresh} label="Refresh" onClick={loadOverview} />
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<MyOrgTenantSummary className="mb-6" />
|
||||
|
||||
{errorMessage ? (
|
||||
<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">
|
||||
|
||||
@ -16,6 +16,7 @@ import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
|
||||
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import MyOrgTenantSummary from '../components/MyOrgTenantSummary';
|
||||
const Dashboard = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
||||
@ -122,6 +123,8 @@ const Dashboard = () => {
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<MyOrgTenantSummary className='mb-6' />
|
||||
|
||||
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
|
||||
currentUser={currentUser}
|
||||
isFetchingQuery={isFetchingQuery}
|
||||
|
||||
@ -40,6 +40,12 @@ export default function Login() {
|
||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
||||
(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',
|
||||
password: '946cafba',
|
||||
remember: true })
|
||||
@ -208,6 +214,41 @@ export default function Login() {
|
||||
</div>
|
||||
</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'>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
|
||||
@ -9,6 +9,7 @@ import BaseButtons from '../../components/BaseButtons'
|
||||
import BaseDivider from '../../components/BaseDivider'
|
||||
import CardBox from '../../components/CardBox'
|
||||
import ConnectedEntityCard from '../../components/ConnectedEntityCard'
|
||||
import TenantStatusChip from '../../components/TenantStatusChip'
|
||||
import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
|
||||
import FormField from '../../components/FormField'
|
||||
import NotificationBar from '../../components/NotificationBar'
|
||||
@ -217,12 +218,12 @@ const EditOrganizationsPage = () => {
|
||||
key={tenant.id}
|
||||
entityLabel="Tenant"
|
||||
title={tenant.name || 'Unnamed tenant'}
|
||||
badges={[<TenantStatusChip key={`tenant-status-${tenant.id}`} 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 },
|
||||
{ label: 'Status', value: tenant.is_active ? 'Active' : 'Inactive' },
|
||||
]}
|
||||
actions={[
|
||||
...(canReadTenants
|
||||
|
||||
@ -15,6 +15,7 @@ import SectionTitleLineWithButton from "../../components/SectionTitleLineWithBut
|
||||
import SectionMain from "../../components/SectionMain";
|
||||
import CardBox from "../../components/CardBox";
|
||||
import ConnectedEntityCard from '../../components/ConnectedEntityCard'
|
||||
import TenantStatusChip from '../../components/TenantStatusChip'
|
||||
import ConnectedEntityNotice from '../../components/ConnectedEntityNotice'
|
||||
import BaseButton from "../../components/BaseButton";
|
||||
import BaseDivider from "../../components/BaseDivider";
|
||||
@ -132,10 +133,12 @@ const OrganizationsView = () => {
|
||||
key={tenant.id}
|
||||
entityLabel='Tenant'
|
||||
title={tenant.name || 'Unnamed tenant'}
|
||||
badges={[<TenantStatusChip key={`tenant-status-${tenant.id}`} 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={[
|
||||
...(canReadTenants
|
||||
|
||||
@ -29,6 +29,7 @@ import { update, fetch } from '../stores/users/usersSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { useRouter } from 'next/router';
|
||||
import {findMe} from "../stores/authSlice";
|
||||
import MyOrgTenantSummary from '../components/MyOrgTenantSummary';
|
||||
|
||||
const EditUsers = () => {
|
||||
const { currentUser, isFetching, token } = useAppSelector(
|
||||
@ -81,6 +82,7 @@ const EditUsers = () => {
|
||||
>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<MyOrgTenantSummary className='mb-6' />
|
||||
<CardBox>
|
||||
{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">
|
||||
|
||||
@ -12,113 +12,184 @@ import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { useRouter } from 'next/router';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
import Select from 'react-select';
|
||||
import { useAppDispatch } from '../stores/hooks';
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import axios from 'axios';
|
||||
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() {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [organizations, setOrganizations] = React.useState<PublicOrganizationOption[]>([]);
|
||||
const [selectedOrganization, setSelectedOrganization] = React.useState<OrganizationSelectOption | null>(null);
|
||||
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);
|
||||
const [selectedOrganization, setSelectedOrganization] = React.useState(null);
|
||||
const dispatch = useAppDispatch();
|
||||
const fetchOrganizations = createAsyncThunk(
|
||||
'/org-for-auth',
|
||||
async () => {
|
||||
React.useEffect(() => {
|
||||
const fetchOrganizations = async () => {
|
||||
try {
|
||||
const response = await axios.get('/org-for-auth');
|
||||
setOrganizations(response.data);
|
||||
return response.data;
|
||||
setOrganizations(Array.isArray(response.data) ? response.data : []);
|
||||
} catch (error) {
|
||||
console.error(error.response);
|
||||
throw error;
|
||||
console.error('Failed to load organizations for signup:', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
React.useEffect(() => {
|
||||
dispatch(fetchOrganizations());
|
||||
}, [dispatch]);
|
||||
const options = organizations?.map(org => ({
|
||||
};
|
||||
|
||||
fetchOrganizations();
|
||||
}, []);
|
||||
|
||||
const options: OrganizationSelectOption[] = organizations.map((org) => ({
|
||||
value: org.id,
|
||||
label: org.name
|
||||
label: org.name,
|
||||
organization: org,
|
||||
}));
|
||||
|
||||
const selectedOrganizationRecord = selectedOrganization?.organization || null;
|
||||
const selectedTenants = selectedOrganizationRecord?.linkedTenants || [];
|
||||
|
||||
const handleSubmit = async (value) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
if (!selectedOrganization) {
|
||||
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 { data: response } = await axios.post('/auth/signup',formData);
|
||||
await router.push('/login')
|
||||
setLoading(false)
|
||||
notify('success', 'Please check your email for verification link')
|
||||
await axios.post('/auth/signup', formData);
|
||||
|
||||
const tenantNames = selectedTenants
|
||||
.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) {
|
||||
setLoading(false)
|
||||
console.log('error: ', error)
|
||||
notify('error', 'Something was wrong. Try again')
|
||||
console.error('Signup failed:', error);
|
||||
notify('error', 'Something was wrong. Try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Login')}</title>
|
||||
<title>{getPageTitle('Register')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<CardBox className='w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12'>
|
||||
<SectionFullScreen bg="violet">
|
||||
<CardBox className="w-11/12 md:w-7/12 lg:w-6/12 xl:w-4/12">
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: '',
|
||||
password: '',
|
||||
confirm: ''
|
||||
confirm: '',
|
||||
}}
|
||||
onSubmit={(values) => handleSubmit(values)}
|
||||
>
|
||||
<Form>
|
||||
<label className="mb-2 block font-bold">Organization</label>
|
||||
|
||||
<label className="block font-bold mb-2" >Organization</label>
|
||||
|
||||
<Select
|
||||
<Select<OrganizationSelectOption, false>
|
||||
classNames={{
|
||||
control: () => 'px-1 mb-4 py-2',
|
||||
}}
|
||||
value={selectedOrganization}
|
||||
onChange={setSelectedOrganization}
|
||||
onChange={(option) => setSelectedOrganization(option)}
|
||||
options={options}
|
||||
placeholder="Select organization..."
|
||||
/>
|
||||
|
||||
<FormField label='Email' help='Please enter your email'>
|
||||
<Field type='email' name='email' />
|
||||
{selectedOrganizationRecord ? (
|
||||
<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 label='Password' help='Please enter your password'>
|
||||
<Field type='password' name='password' />
|
||||
<FormField label="Password" help="Please enter your password">
|
||||
<Field type="password" name="password" />
|
||||
</FormField>
|
||||
<FormField label='Confirm Password' help='Please confirm your password'>
|
||||
<Field type='password' name='confirm' />
|
||||
<FormField label="Confirm Password" help="Please confirm your password">
|
||||
<Field type="password" name="confirm" />
|
||||
</FormField>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
type='submit'
|
||||
label={loading ? 'Loading...' : 'Register' }
|
||||
color='info'
|
||||
/>
|
||||
<BaseButton
|
||||
href={'/login'}
|
||||
label={'Login'}
|
||||
color='info'
|
||||
/>
|
||||
<BaseButton type="submit" label={loading ? 'Loading...' : 'Register'} color="info" />
|
||||
<BaseButton href={'/login'} label={'Login'} color="info" />
|
||||
</BaseButtons>
|
||||
</Form>
|
||||
</Formik>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user