From 95c088fa218cfcf4d64c403748c90eec25c6d660 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 4 Apr 2026 05:14:20 +0000 Subject: [PATCH] Autosave: 20260404-051420 --- backend/src/db/api/tenants.js | 274 +++++++--- backend/src/db/api/users.js | 53 +- ...create-tenants-organizations-join-table.js | 89 ++++ ...ator-workspace-summary-read-permissions.js | 45 ++ ...ts-properties-and-auditlogs-join-tables.js | 114 +++++ ...ckfill-tenants-organizations-join-table.js | 119 +++++ ...260403090000-align-business-role-matrix.js | 2 + .../src/components/CurrentWorkspaceChip.tsx | 466 ++++++++++++------ frontend/src/stores/styleSlice.ts | 2 +- 9 files changed, 951 insertions(+), 213 deletions(-) create mode 100644 backend/src/db/migrations/20260404050000-create-tenants-organizations-join-table.js create mode 100644 backend/src/db/migrations/20260404051500-grant-administrator-workspace-summary-read-permissions.js create mode 100644 backend/src/db/migrations/20260404053000-create-tenants-properties-and-auditlogs-join-tables.js create mode 100644 backend/src/db/migrations/20260404061000-backfill-tenants-organizations-join-table.js diff --git a/backend/src/db/api/tenants.js b/backend/src/db/api/tenants.js index e44a97b..eab8880 100644 --- a/backend/src/db/api/tenants.js +++ b/backend/src/db/api/tenants.js @@ -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,19 +207,29 @@ module.exports = class TenantsDBApi { - - await tenants.setOrganizations(data.organizations || [], { + const organizationIds = normalizeRelationIds(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, }); - @@ -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, + }); } @@ -349,16 +517,10 @@ module.exports = class TenantsDBApi { output.organizations = await tenants.getOrganizations({ transaction }); - - - output.properties = await tenants.getProperties({ - transaction - }); - - - output.audit_logs = await tenants.getAudit_logs({ - transaction - }); + + output.properties = output.properties_tenant; + + 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]: [ @@ -612,11 +752,6 @@ module.exports = class TenantsDBApi { } - - if (globalAccess) { - delete where.organizationsId; - } - const queryOptions = { where, @@ -649,12 +784,20 @@ 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 = { @@ -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']], diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index c716367..fdb3744 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -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; } diff --git a/backend/src/db/migrations/20260404050000-create-tenants-organizations-join-table.js b/backend/src/db/migrations/20260404050000-create-tenants-organizations-join-table.js new file mode 100644 index 0000000..4842458 --- /dev/null +++ b/backend/src/db/migrations/20260404050000-create-tenants-organizations-join-table.js @@ -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; + } + }, +}; diff --git a/backend/src/db/migrations/20260404051500-grant-administrator-workspace-summary-read-permissions.js b/backend/src/db/migrations/20260404051500-grant-administrator-workspace-summary-read-permissions.js new file mode 100644 index 0000000..09d92cd --- /dev/null +++ b/backend/src/db/migrations/20260404051500-grant-administrator-workspace-summary-read-permissions.js @@ -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. + }, +}; diff --git a/backend/src/db/migrations/20260404053000-create-tenants-properties-and-auditlogs-join-tables.js b/backend/src/db/migrations/20260404053000-create-tenants-properties-and-auditlogs-join-tables.js new file mode 100644 index 0000000..8ea2843 --- /dev/null +++ b/backend/src/db/migrations/20260404053000-create-tenants-properties-and-auditlogs-join-tables.js @@ -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; + } + }, +}; diff --git a/backend/src/db/migrations/20260404061000-backfill-tenants-organizations-join-table.js b/backend/src/db/migrations/20260404061000-backfill-tenants-organizations-join-table.js new file mode 100644 index 0000000..c41643c --- /dev/null +++ b/backend/src/db/migrations/20260404061000-backfill-tenants-organizations-join-table.js @@ -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(); + }, +}; diff --git a/backend/src/db/seeders/20260403090000-align-business-role-matrix.js b/backend/src/db/seeders/20260403090000-align-business-role-matrix.js index ce5f5d0..cad7890 100644 --- a/backend/src/db/seeders/20260403090000-align-business-role-matrix.js +++ b/backend/src/db/seeders/20260403090000-align-business-role-matrix.js @@ -31,6 +31,8 @@ const rolePermissionMatrix = { 'CREATE_DOCUMENTS', 'READ_DOCUMENTS', 'UPDATE_DOCUMENTS', + 'READ_ORGANIZATIONS', + 'READ_TENANTS', 'READ_PROPERTIES', 'READ_UNITS', 'READ_NEGOTIATED_RATES', diff --git a/frontend/src/components/CurrentWorkspaceChip.tsx b/frontend/src/components/CurrentWorkspaceChip.tsx index 9b3aff3..8f524ba 100644 --- a/frontend/src/components/CurrentWorkspaceChip.tsx +++ b/frontend/src/components/CurrentWorkspaceChip.tsx @@ -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 ( -
- + + + + - {isPopoverActive ? ( -
- setIsPopoverActive(false)} excludedElements={[triggerRef]}> -
-
-
-
-

- Current workspace -

-

- {organizationName} -

-
- - - {appRoleName} - - - - {tenantLabel} - -
-
- - -
-
- -
-
-
- - - -
-

- Organization access + {isPopoverActive ? ( +

+ setIsPopoverActive(false)} excludedElements={[triggerRef]}> +
+
+
+
+

+ Current workspace

-

+

{organizationName}

-

- - {currentUser?.email || 'No email surfaced yet'} -

+
+ + + {appRoleName} + + + + {tenantLabel} + + {organizationSlug ? ( + + Slug: {organizationSlug} + + ) : null} + {organizationDomain ? ( + + Domain: {organizationDomain} + + ) : null} +
+ +
-
-
-

- Linked tenants -

- - View profile context - -
- -
- {isLoadingTenants ? ( -
- Loading tenant context… -
- ) : linkedTenants.length ? ( - linkedTenants.slice(0, 3).map((tenant) => ( -
-
-
-

- {tenant.name || 'Unnamed tenant'} -

-
- - {tenant.slug ? ( - - Slug: {tenant.slug} - - ) : null} - {tenant.primary_domain ? ( - - Domain: {tenant.primary_domain} - - ) : null} -
-
- - {canViewTenants && tenant.id ? ( - +
+
+
+ + + +
+

+ Organization access +

+

+ {organizationName} +

+

+ + {currentUser?.email || 'No email surfaced yet'} +

+ {hasOrganizationMetadata ? ( +
+ {organizationSlug ? ( + + Slug: {organizationSlug} + + ) : null} + {organizationDomain ? ( + + Domain: {organizationDomain} + ) : null}
-
- )) - ) : ( -
- No tenant link surfaced yet for this workspace. + ) : null}
- )} +
- {!isLoadingTenants && linkedTenantCount > 3 ? ( -

- Showing 3 of {linkedTenantCount} linked tenants in this quick view. -

- ) : null} +
+
+

+ Linked tenants +

+
+ + + View profile context + +
+
+ +
+ {isLoadingTenants ? ( +
+ Loading tenant context… +
+ ) : linkedTenants.length ? ( + linkedTenants.slice(0, 3).map((tenant) => ( +
+
+
+

+ {tenant.name || 'Unnamed tenant'} +

+
+ + {tenant.slug ? ( + + Slug: {tenant.slug} + + ) : null} + {tenant.primary_domain ? ( + + Domain: {tenant.primary_domain} + + ) : null} +
+
+ + {canViewTenants && tenant.id ? ( + + ) : null} +
+
+ )) + ) : ( +
+ No tenant link surfaced yet for this workspace. +
+ )} +
+ + {!isLoadingTenants && linkedTenantCount > 3 ? ( +

+ Showing 3 of {linkedTenantCount} linked tenants in this quick view. +

+ ) : null} +
+ +
+ ) : null} +
+ + +
+
+

+ This view expands the same workspace summary from the navbar so the user can inspect their organization context without leaving the current page. +

+
+ + + Role: {appRoleName} + , + + {tenantLabel} + , + ]} + 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.' + } + /> + +
+
+

+ Linked tenants +

+ {linkedTenantCount > linkedTenants.length && !isLoadingTenants ? ( +

+ Showing {linkedTenants.length} of {linkedTenantCount} linked tenants. +

+ ) : null}
- + + {isLoadingTenants ? ( +
+ Loading tenant context for this workspace… +
+ ) : linkedTenants.length ? ( +
+ {linkedTenants.map((tenant) => ( + ]} + 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." + /> + ))} +
+ ) : ( +
+ No tenant link surfaced yet for this workspace. +
+ )} +
- ) : null} -
+ + ) } diff --git a/frontend/src/stores/styleSlice.ts b/frontend/src/stores/styleSlice.ts index e786387..d738b00 100644 --- a/frontend/src/stores/styleSlice.ts +++ b/frontend/src/stores/styleSlice.ts @@ -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,