Autosave: 20260404-051420

This commit is contained in:
Flatlogic Bot 2026-04-04 05:14:20 +00:00
parent 33f59460fd
commit 95c088fa21
9 changed files with 951 additions and 213 deletions

View File

@ -1,7 +1,5 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
@ -9,6 +7,149 @@ const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
const normalizeRelationIds = (items) => {
if (!Array.isArray(items)) {
return [];
}
return [...new Set(items.map((item) => {
if (!item) {
return null;
}
if (typeof item === 'string') {
return item;
}
if (typeof item === 'object' && item.id) {
return item.id;
}
if (typeof item === 'object' && item.value) {
return item.value;
}
return null;
}).filter(Boolean))];
};
const syncTenantOwnedChildren = async ({
tenantId,
model,
selectedIds,
organizationIds,
transaction,
currentUserId,
organizationField = 'organizationsId',
}) => {
const normalizedSelectedIds = normalizeRelationIds(selectedIds);
const detachWhere = {
tenantId,
};
if (normalizedSelectedIds.length) {
detachWhere.id = {
[Op.notIn]: normalizedSelectedIds,
};
}
await model.update(
{
tenantId: null,
updatedById: currentUserId,
},
{
where: detachWhere,
transaction,
},
);
if (!normalizedSelectedIds.length) {
return normalizedSelectedIds;
}
await model.update(
{
tenantId,
updatedById: currentUserId,
},
{
where: {
id: {
[Op.in]: normalizedSelectedIds,
},
},
transaction,
},
);
if (!organizationField) {
return normalizedSelectedIds;
}
if (!organizationIds.length) {
await model.update(
{
[organizationField]: null,
updatedById: currentUserId,
},
{
where: {
id: {
[Op.in]: normalizedSelectedIds,
},
tenantId,
},
transaction,
},
);
return normalizedSelectedIds;
}
if (organizationIds.length === 1) {
await model.update(
{
[organizationField]: organizationIds[0],
updatedById: currentUserId,
},
{
where: {
id: {
[Op.in]: normalizedSelectedIds,
},
tenantId,
},
transaction,
},
);
return normalizedSelectedIds;
}
await model.update(
{
[organizationField]: null,
updatedById: currentUserId,
},
{
where: {
id: {
[Op.in]: normalizedSelectedIds,
},
tenantId,
[organizationField]: {
[Op.notIn]: organizationIds,
},
},
transaction,
},
);
return normalizedSelectedIds;
};
module.exports = class TenantsDBApi {
@ -66,22 +207,32 @@ module.exports = class TenantsDBApi {
const organizationIds = normalizeRelationIds(data.organizations);
await tenants.setOrganizations(data.organizations || [], {
await tenants.setOrganizations(organizationIds, {
transaction,
});
await tenants.setProperties(data.properties || [], {
await syncTenantOwnedChildren({
tenantId: tenants.id,
model: db.properties,
selectedIds: data.properties || [],
organizationIds,
transaction,
currentUserId: currentUser.id,
});
await tenants.setAudit_logs(data.audit_logs || [], {
await syncTenantOwnedChildren({
tenantId: tenants.id,
model: db.audit_logs,
selectedIds: data.audit_logs || [],
organizationIds,
transaction,
currentUserId: currentUser.id,
});
return tenants;
}
@ -148,8 +299,6 @@ module.exports = class TenantsDBApi {
static async update(id, data, options) {
const currentUser = (options && options.currentUser) || {id: null};
const transaction = (options && options.transaction) || undefined;
const globalAccess = currentUser.app_role?.globalAccess;
const tenants = await db.tenants.findByPk(id, {}, {transaction});
@ -185,18 +334,37 @@ module.exports = class TenantsDBApi {
let organizationIds = null;
if (data.organizations !== undefined) {
await tenants.setOrganizations(data.organizations, { transaction });
organizationIds = normalizeRelationIds(data.organizations);
await tenants.setOrganizations(organizationIds, { transaction });
} else {
organizationIds = normalizeRelationIds(
(await tenants.getOrganizations({ transaction })) || [],
);
}
if (data.properties !== undefined) {
await tenants.setProperties(data.properties, { transaction });
await syncTenantOwnedChildren({
tenantId: tenants.id,
model: db.properties,
selectedIds: data.properties,
organizationIds,
transaction,
currentUserId: currentUser.id,
});
}
if (data.audit_logs !== undefined) {
await tenants.setAudit_logs(data.audit_logs, { transaction });
await syncTenantOwnedChildren({
tenantId: tenants.id,
model: db.audit_logs,
selectedIds: data.audit_logs,
organizationIds,
transaction,
currentUserId: currentUser.id,
});
}
@ -350,15 +518,9 @@ module.exports = class TenantsDBApi {
transaction
});
output.properties = output.properties_tenant;
output.properties = await tenants.getProperties({
transaction
});
output.audit_logs = await tenants.getAudit_logs({
transaction
});
output.audit_logs = output.audit_logs_tenant;
@ -380,41 +542,19 @@ module.exports = class TenantsDBApi {
if (userOrganizations) {
if (options?.currentUser?.organizationsId) {
where.organizationsId = options.currentUser.organizationsId;
}
}
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
let include = [
{
model: db.organizations,
as: 'organizations',
required: false,
required: !globalAccess && Boolean(userOrganizations),
where: !globalAccess && userOrganizations
? {
id: Utils.uuid(userOrganizations),
}
: undefined,
},
{
model: db.properties,
as: 'properties',
required: false,
},
{
model: db.audit_logs,
as: 'audit_logs',
required: false,
},
];
if (filter) {
@ -545,7 +685,7 @@ module.exports = class TenantsDBApi {
include = [
{
model: db.properties,
as: 'properties_filter',
as: 'properties_tenant',
required: searchTerms.length > 0,
where: searchTerms.length > 0 ? {
[Op.or]: [
@ -568,7 +708,7 @@ module.exports = class TenantsDBApi {
include = [
{
model: db.audit_logs,
as: 'audit_logs_filter',
as: 'audit_logs_tenant',
required: searchTerms.length > 0,
where: searchTerms.length > 0 ? {
[Op.or]: [
@ -613,11 +753,6 @@ module.exports = class TenantsDBApi {
if (globalAccess) {
delete where.organizationsId;
}
const queryOptions = {
where,
include,
@ -649,13 +784,21 @@ module.exports = class TenantsDBApi {
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
let where = {};
let include = [];
if (!globalAccess && organizationId) {
where.organizationId = organizationId;
include = [
{
model: db.organizations,
as: 'organizations_filter',
required: true,
where: {
id: Utils.uuid(organizationId),
},
},
];
}
if (query) {
where = {
[Op.or]: [
@ -672,6 +815,7 @@ module.exports = class TenantsDBApi {
const records = await db.tenants.findAll({
attributes: [ 'id', 'name' ],
where,
include,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
orderBy: [['name', 'ASC']],

View File

@ -12,6 +12,57 @@ const config = require('../../config');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
const resolveCurrentOrganizationContext = async (output, transaction) => {
const directOrganization = output.organizations;
if (directOrganization && directOrganization.id) {
output.organization = output.organization || directOrganization;
output.organizationId = output.organizationId || directOrganization.id;
output.organizationsId = output.organizationsId || directOrganization.id;
output.organizationName = output.organizationName || directOrganization.name || null;
return output;
}
const membership = (Array.isArray(output.organization_memberships_user)
? [...output.organization_memberships_user]
: [])
.sort((left, right) => {
if (Boolean(left?.is_primary) !== Boolean(right?.is_primary)) {
return left?.is_primary ? -1 : 1;
}
if (Boolean(left?.is_active) !== Boolean(right?.is_active)) {
return left?.is_active ? -1 : 1;
}
return 0;
})
.find((item) => item?.organizationId);
if (!membership?.organizationId) {
return output;
}
const organization = await db.organizations.findByPk(membership.organizationId, {
transaction,
});
if (!organization) {
return output;
}
const organizationData = organization.get({ plain: true });
output.organizations = organizationData;
output.organization = organizationData;
output.organizationId = organizationData.id;
output.organizationsId = organizationData.id;
output.organizationName = output.organizationName || organizationData.name || null;
return output;
};
module.exports = class UsersDBApi {
static async create(data,globalAccess, options) {
@ -519,7 +570,7 @@ module.exports = class UsersDBApi {
transaction
});
await resolveCurrentOrganizationContext(output, transaction);
return output;
}

View File

@ -0,0 +1,89 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.\"tenantsOrganizationsOrganizations\"') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
const tableName = rows[0].regclass_name;
if (tableName) {
await transaction.commit();
return;
}
await queryInterface.createTable(
'tenantsOrganizationsOrganizations',
{
createdAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
tenants_organizationsId: {
type: Sequelize.DataTypes.UUID,
allowNull: false,
primaryKey: true,
references: {
model: 'tenants',
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
organizationId: {
type: Sequelize.DataTypes.UUID,
allowNull: false,
primaryKey: true,
references: {
model: 'organizations',
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
},
{ transaction },
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.\"tenantsOrganizationsOrganizations\"') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
const tableName = rows[0].regclass_name;
if (!tableName) {
await transaction.commit();
return;
}
await queryInterface.dropTable('tenantsOrganizationsOrganizations', { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
};

View File

@ -0,0 +1,45 @@
'use strict';
module.exports = {
async up(queryInterface) {
const sequelize = queryInterface.sequelize;
const [roles] = await sequelize.query(
`SELECT "id" FROM "roles" WHERE "name" = 'Administrator' LIMIT 1;`,
);
if (!roles.length) {
return;
}
const [permissions] = await sequelize.query(
`SELECT "id", "name" FROM "permissions" WHERE "name" IN ('READ_TENANTS', 'READ_ORGANIZATIONS');`,
);
const now = new Date();
for (const permission of permissions) {
await sequelize.query(
`INSERT INTO "rolesPermissionsPermissions" ("createdAt", "updatedAt", "roles_permissionsId", "permissionId")
SELECT :createdAt, :updatedAt, :roleId, :permissionId
WHERE NOT EXISTS (
SELECT 1
FROM "rolesPermissionsPermissions"
WHERE "roles_permissionsId" = :roleId
AND "permissionId" = :permissionId
);`,
{
replacements: {
createdAt: now,
updatedAt: now,
roleId: roles[0].id,
permissionId: permission.id,
},
},
);
}
},
async down() {
// Intentionally left blank. This protects live permission assignments.
},
};

View File

@ -0,0 +1,114 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tableDefinitions = [
{
tableName: 'tenantsPropertiesProperties',
sourceKey: 'tenants_propertiesId',
sourceTable: 'tenants',
targetKey: 'propertyId',
targetTable: 'properties',
},
{
tableName: 'tenantsAudit_logsAudit_logs',
sourceKey: 'tenants_audit_logsId',
sourceTable: 'tenants',
targetKey: 'auditLogId',
targetTable: 'audit_logs',
},
];
for (const definition of tableDefinitions) {
const rows = await queryInterface.sequelize.query(
`SELECT to_regclass('public."${definition.tableName}"') AS regclass_name;`,
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
const tableName = rows[0].regclass_name;
if (tableName) {
continue;
}
await queryInterface.createTable(
definition.tableName,
{
createdAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DataTypes.DATE,
allowNull: false,
},
[definition.sourceKey]: {
type: Sequelize.DataTypes.UUID,
allowNull: false,
primaryKey: true,
references: {
model: definition.sourceTable,
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
[definition.targetKey]: {
type: Sequelize.DataTypes.UUID,
allowNull: false,
primaryKey: true,
references: {
model: definition.targetTable,
key: 'id',
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
},
},
{ transaction },
);
}
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tableNames = [
'tenantsPropertiesProperties',
'tenantsAudit_logsAudit_logs',
];
for (const tableName of tableNames) {
const rows = await queryInterface.sequelize.query(
`SELECT to_regclass('public."${tableName}"') AS regclass_name;`,
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
const existingTableName = rows[0].regclass_name;
if (!existingTableName) {
continue;
}
await queryInterface.dropTable(tableName, { transaction });
}
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
};

View File

@ -0,0 +1,119 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const rows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.\"tenantsOrganizationsOrganizations\"') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
const tableName = rows[0].regclass_name;
if (!tableName) {
await transaction.commit();
return;
}
await queryInterface.sequelize.query(
`
INSERT INTO "tenantsOrganizationsOrganizations" (
"tenants_organizationsId",
"organizationId",
"createdAt",
"updatedAt"
)
SELECT DISTINCT
relation_pairs.tenant_id,
relation_pairs.organization_id,
NOW(),
NOW()
FROM (
SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id
FROM booking_requests
WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id
FROM reservations
WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id
FROM documents
WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id
FROM invoices
WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id
FROM role_assignments
WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id
FROM activity_comments
WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id
FROM service_requests
WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id
FROM properties
WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id
FROM audit_logs
WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id
FROM notifications
WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id
FROM checklists
WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL
UNION
SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id
FROM job_runs
WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL
) AS relation_pairs
ON CONFLICT ("tenants_organizationsId", "organizationId") DO NOTHING;
`,
{ transaction },
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
async down() {
return Promise.resolve();
},
};

View File

@ -31,6 +31,8 @@ const rolePermissionMatrix = {
'CREATE_DOCUMENTS',
'READ_DOCUMENTS',
'UPDATE_DOCUMENTS',
'READ_ORGANIZATIONS',
'READ_TENANTS',
'READ_PROPERTIES',
'READ_UNITS',
'READ_NEGOTIATED_RATES',

View File

@ -13,7 +13,9 @@ import {
import BaseButton from './BaseButton'
import BaseIcon from './BaseIcon'
import CardBoxModal from './CardBoxModal'
import ClickOutside from './ClickOutside'
import ConnectedEntityCard from './ConnectedEntityCard'
import TenantStatusChip from './TenantStatusChip'
import { useAppSelector } from '../stores/hooks'
import {
@ -31,6 +33,7 @@ const CurrentWorkspaceChip = () => {
const [linkedTenantSummary, setLinkedTenantSummary] = useState(emptyOrganizationTenantSummary)
const [isLoadingTenants, setIsLoadingTenants] = useState(false)
const [isPopoverActive, setIsPopoverActive] = useState(false)
const [isWorkspaceModalActive, setIsWorkspaceModalActive] = useState(false)
const organizationId = useMemo(
() =>
@ -90,16 +93,18 @@ const CurrentWorkspaceChip = () => {
useEffect(() => {
setIsPopoverActive(false)
setIsWorkspaceModalActive(false)
}, [router.asPath])
useEffect(() => {
if (!isPopoverActive) {
if (!isPopoverActive && !isWorkspaceModalActive) {
return () => undefined
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsPopoverActive(false)
setIsWorkspaceModalActive(false)
}
}
@ -108,7 +113,7 @@ const CurrentWorkspaceChip = () => {
return () => {
document.removeEventListener('keydown', handleEscape)
}
}, [isPopoverActive])
}, [isPopoverActive, isWorkspaceModalActive])
if (!currentUser) {
return null
@ -120,6 +125,17 @@ const CurrentWorkspaceChip = () => {
currentUser?.organizationName ||
'No organization assigned yet'
const organizationSlug =
currentUser?.organizations?.slug || currentUser?.organization?.slug || currentUser?.organizationSlug || ''
const organizationDomain =
currentUser?.organizations?.domain ||
currentUser?.organization?.domain ||
currentUser?.organizations?.primary_domain ||
currentUser?.organization?.primary_domain ||
currentUser?.organizationDomain ||
''
const appRoleName = currentUser?.app_role?.name || 'User'
const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : []
const linkedTenantCount = linkedTenantSummary.count || 0
@ -130,173 +146,331 @@ const CurrentWorkspaceChip = () => {
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS')
const organizationHref =
canViewOrganizations && organizationId ? getOrganizationViewHref(organizationId) : '/profile'
const hasOrganizationMetadata = Boolean(organizationSlug || organizationDomain)
const handleOpenWorkspaceModal = () => {
setIsPopoverActive(false)
setIsWorkspaceModalActive(true)
}
const handleCloseWorkspaceModal = () => {
setIsWorkspaceModalActive(false)
}
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>
<>
<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 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>
<span className="flex-none text-blue-800 dark:text-blue-200">
<BaseIcon path={isPopoverActive ? mdiChevronUp : mdiChevronDown} size="16" />
</span>
</button>
<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
{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-sm font-semibold text-gray-900 dark:text-gray-100">
<p className="mt-1 truncate text-base 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 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>
{organizationSlug ? (
<span className="inline-flex items-center gap-1 rounded-full bg-white/80 px-2.5 py-1 dark:bg-dark-800">
Slug: {organizationSlug}
</span>
) : null}
{organizationDomain ? (
<span className="inline-flex items-center gap-1 rounded-full bg-white/80 px-2.5 py-1 dark:bg-dark-800">
Domain: {organizationDomain}
</span>
) : null}
</div>
</div>
<BaseButton
href={organizationHref}
label={canViewOrganizations && organizationId ? 'Open' : 'Profile'}
color="info"
outline
small
icon={mdiOpenInNew}
/>
</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
/>
<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>
{hasOrganizationMetadata ? (
<div className="mt-2 flex flex-wrap gap-2">
{organizationSlug ? (
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-900 dark:text-gray-100">
Slug: {organizationSlug}
</span>
) : null}
{organizationDomain ? (
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-900 dark:text-gray-100">
Domain: {organizationDomain}
</span>
) : 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.
) : null}
</div>
)}
</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 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>
<div className="flex items-center gap-3">
<button
type="button"
className="text-xs font-semibold text-blue-700 hover:text-blue-800 dark:text-blue-200 dark:hover:text-blue-100"
onClick={handleOpenWorkspaceModal}
>
Workspace details
</button>
<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>
<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>
<CardBoxModal
title="Workspace details"
buttonColor="info"
buttonLabel="Done"
isActive={isWorkspaceModalActive}
onConfirm={handleCloseWorkspaceModal}
onCancel={handleCloseWorkspaceModal}
>
<div className="space-y-4">
<div>
<p className="text-sm text-gray-600 dark:text-gray-300">
This view expands the same workspace summary from the navbar so the user can inspect their organization context without leaving the current page.
</p>
</div>
<ConnectedEntityCard
entityLabel="Workspace"
title={organizationName}
titleFallback="No organization assigned yet"
badges={[
<span
key="workspace-role"
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"
>
Role: {appRoleName}
</span>,
<span
key="workspace-tenants"
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"
>
{tenantLabel}
</span>,
]}
details={[
{ label: 'Email', value: currentUser?.email },
{ label: 'Slug', value: organizationSlug },
{ label: 'Domain', value: organizationDomain },
]}
actions={[
{
href: organizationHref,
label: canViewOrganizations && organizationId ? 'Open workspace' : 'Open profile',
color: 'info',
},
{
href: '/profile',
label: 'View profile',
color: 'info',
outline: true,
},
]}
helperText={
organizationId
? 'This is the organization currently attached to the signed-in account context.'
: 'This account does not have an organization link yet.'
}
/>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-300">
Linked tenants
</p>
{linkedTenantCount > linkedTenants.length && !isLoadingTenants ? (
<p className="text-xs text-gray-500 dark:text-gray-300">
Showing {linkedTenants.length} of {linkedTenantCount} linked tenants.
</p>
) : null}
</div>
</ClickOutside>
{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 this workspace
</div>
) : linkedTenants.length ? (
<div className="space-y-3">
{linkedTenants.map((tenant) => (
<ConnectedEntityCard
key={tenant.id}
entityLabel="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: 'Open tenant',
color: 'info',
outline: true,
},
]
: []
}
helperText="This tenant is linked behind the organization context attached to the current 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 this workspace.
</div>
)}
</div>
</div>
) : null}
</div>
</CardBoxModal>
</>
)
}

View File

@ -43,7 +43,7 @@ const initialState: StyleState = {
navBarItemLabelHoverStyle: styles.midnightBlueTheme.navBarItemLabelHover,
navBarItemLabelActiveColorStyle: styles.midnightBlueTheme.navBarItemLabelActiveColor,
overlayStyle: styles.midnightBlueTheme.overlay,
darkMode: false,
darkMode: true,
bgLayoutColor: styles.midnightBlueTheme.bgLayoutColor,
iconsColor: styles.midnightBlueTheme.iconsColor,
cardsColor: styles.midnightBlueTheme.cardsColor,