Autosave: 20260404-051420
This commit is contained in:
parent
33f59460fd
commit
95c088fa21
@ -1,7 +1,5 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
@ -9,6 +7,149 @@ const Utils = require('../utils');
|
|||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
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 {
|
module.exports = class TenantsDBApi {
|
||||||
|
|
||||||
|
|
||||||
@ -66,19 +207,29 @@ module.exports = class TenantsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const organizationIds = normalizeRelationIds(data.organizations);
|
||||||
await tenants.setOrganizations(data.organizations || [], {
|
|
||||||
|
await tenants.setOrganizations(organizationIds, {
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
|
||||||
await tenants.setProperties(data.properties || [], {
|
await syncTenantOwnedChildren({
|
||||||
|
tenantId: tenants.id,
|
||||||
|
model: db.properties,
|
||||||
|
selectedIds: data.properties || [],
|
||||||
|
organizationIds,
|
||||||
transaction,
|
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,
|
transaction,
|
||||||
|
currentUserId: currentUser.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -148,8 +299,6 @@ module.exports = class TenantsDBApi {
|
|||||||
static async update(id, data, options) {
|
static async update(id, data, options) {
|
||||||
const currentUser = (options && options.currentUser) || {id: null};
|
const currentUser = (options && options.currentUser) || {id: null};
|
||||||
const transaction = (options && options.transaction) || undefined;
|
const transaction = (options && options.transaction) || undefined;
|
||||||
const globalAccess = currentUser.app_role?.globalAccess;
|
|
||||||
|
|
||||||
const tenants = await db.tenants.findByPk(id, {}, {transaction});
|
const tenants = await db.tenants.findByPk(id, {}, {transaction});
|
||||||
|
|
||||||
|
|
||||||
@ -185,18 +334,37 @@ module.exports = class TenantsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let organizationIds = null;
|
||||||
|
|
||||||
if (data.organizations !== undefined) {
|
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) {
|
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) {
|
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({
|
output.organizations = await tenants.getOrganizations({
|
||||||
transaction
|
transaction
|
||||||
});
|
});
|
||||||
|
|
||||||
|
output.properties = output.properties_tenant;
|
||||||
output.properties = await tenants.getProperties({
|
|
||||||
transaction
|
output.audit_logs = output.audit_logs_tenant;
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
output.audit_logs = await tenants.getAudit_logs({
|
|
||||||
transaction
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -380,41 +542,19 @@ module.exports = class TenantsDBApi {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (userOrganizations) {
|
|
||||||
if (options?.currentUser?.organizationsId) {
|
|
||||||
where.organizationsId = options.currentUser.organizationsId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
let include = [
|
let include = [
|
||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
model: db.organizations,
|
model: db.organizations,
|
||||||
as: '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) {
|
if (filter) {
|
||||||
@ -545,7 +685,7 @@ module.exports = class TenantsDBApi {
|
|||||||
include = [
|
include = [
|
||||||
{
|
{
|
||||||
model: db.properties,
|
model: db.properties,
|
||||||
as: 'properties_filter',
|
as: 'properties_tenant',
|
||||||
required: searchTerms.length > 0,
|
required: searchTerms.length > 0,
|
||||||
where: searchTerms.length > 0 ? {
|
where: searchTerms.length > 0 ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -568,7 +708,7 @@ module.exports = class TenantsDBApi {
|
|||||||
include = [
|
include = [
|
||||||
{
|
{
|
||||||
model: db.audit_logs,
|
model: db.audit_logs,
|
||||||
as: 'audit_logs_filter',
|
as: 'audit_logs_tenant',
|
||||||
required: searchTerms.length > 0,
|
required: searchTerms.length > 0,
|
||||||
where: searchTerms.length > 0 ? {
|
where: searchTerms.length > 0 ? {
|
||||||
[Op.or]: [
|
[Op.or]: [
|
||||||
@ -612,11 +752,6 @@ module.exports = class TenantsDBApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (globalAccess) {
|
|
||||||
delete where.organizationsId;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const queryOptions = {
|
const queryOptions = {
|
||||||
where,
|
where,
|
||||||
@ -649,12 +784,20 @@ module.exports = class TenantsDBApi {
|
|||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) {
|
||||||
let where = {};
|
let where = {};
|
||||||
|
let include = [];
|
||||||
|
|
||||||
if (!globalAccess && organizationId) {
|
if (!globalAccess && organizationId) {
|
||||||
where.organizationId = organizationId;
|
include = [
|
||||||
|
{
|
||||||
|
model: db.organizations,
|
||||||
|
as: 'organizations_filter',
|
||||||
|
required: true,
|
||||||
|
where: {
|
||||||
|
id: Utils.uuid(organizationId),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
where = {
|
where = {
|
||||||
@ -672,6 +815,7 @@ module.exports = class TenantsDBApi {
|
|||||||
const records = await db.tenants.findAll({
|
const records = await db.tenants.findAll({
|
||||||
attributes: [ 'id', 'name' ],
|
attributes: [ 'id', 'name' ],
|
||||||
where,
|
where,
|
||||||
|
include,
|
||||||
limit: limit ? Number(limit) : undefined,
|
limit: limit ? Number(limit) : undefined,
|
||||||
offset: offset ? Number(offset) : undefined,
|
offset: offset ? Number(offset) : undefined,
|
||||||
orderBy: [['name', 'ASC']],
|
orderBy: [['name', 'ASC']],
|
||||||
|
|||||||
@ -12,6 +12,57 @@ const config = require('../../config');
|
|||||||
const Sequelize = db.Sequelize;
|
const Sequelize = db.Sequelize;
|
||||||
const Op = Sequelize.Op;
|
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 {
|
module.exports = class UsersDBApi {
|
||||||
|
|
||||||
static async create(data,globalAccess, options) {
|
static async create(data,globalAccess, options) {
|
||||||
@ -519,7 +570,7 @@ module.exports = class UsersDBApi {
|
|||||||
transaction
|
transaction
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await resolveCurrentOrganizationContext(output, transaction);
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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.
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -31,6 +31,8 @@ const rolePermissionMatrix = {
|
|||||||
'CREATE_DOCUMENTS',
|
'CREATE_DOCUMENTS',
|
||||||
'READ_DOCUMENTS',
|
'READ_DOCUMENTS',
|
||||||
'UPDATE_DOCUMENTS',
|
'UPDATE_DOCUMENTS',
|
||||||
|
'READ_ORGANIZATIONS',
|
||||||
|
'READ_TENANTS',
|
||||||
'READ_PROPERTIES',
|
'READ_PROPERTIES',
|
||||||
'READ_UNITS',
|
'READ_UNITS',
|
||||||
'READ_NEGOTIATED_RATES',
|
'READ_NEGOTIATED_RATES',
|
||||||
|
|||||||
@ -13,7 +13,9 @@ import {
|
|||||||
|
|
||||||
import BaseButton from './BaseButton'
|
import BaseButton from './BaseButton'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
import CardBoxModal from './CardBoxModal'
|
||||||
import ClickOutside from './ClickOutside'
|
import ClickOutside from './ClickOutside'
|
||||||
|
import ConnectedEntityCard from './ConnectedEntityCard'
|
||||||
import TenantStatusChip from './TenantStatusChip'
|
import TenantStatusChip from './TenantStatusChip'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppSelector } from '../stores/hooks'
|
||||||
import {
|
import {
|
||||||
@ -31,6 +33,7 @@ const CurrentWorkspaceChip = () => {
|
|||||||
const [linkedTenantSummary, setLinkedTenantSummary] = useState(emptyOrganizationTenantSummary)
|
const [linkedTenantSummary, setLinkedTenantSummary] = useState(emptyOrganizationTenantSummary)
|
||||||
const [isLoadingTenants, setIsLoadingTenants] = useState(false)
|
const [isLoadingTenants, setIsLoadingTenants] = useState(false)
|
||||||
const [isPopoverActive, setIsPopoverActive] = useState(false)
|
const [isPopoverActive, setIsPopoverActive] = useState(false)
|
||||||
|
const [isWorkspaceModalActive, setIsWorkspaceModalActive] = useState(false)
|
||||||
|
|
||||||
const organizationId = useMemo(
|
const organizationId = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -90,16 +93,18 @@ const CurrentWorkspaceChip = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsPopoverActive(false)
|
setIsPopoverActive(false)
|
||||||
|
setIsWorkspaceModalActive(false)
|
||||||
}, [router.asPath])
|
}, [router.asPath])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPopoverActive) {
|
if (!isPopoverActive && !isWorkspaceModalActive) {
|
||||||
return () => undefined
|
return () => undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
setIsPopoverActive(false)
|
setIsPopoverActive(false)
|
||||||
|
setIsWorkspaceModalActive(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,7 +113,7 @@ const CurrentWorkspaceChip = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleEscape)
|
document.removeEventListener('keydown', handleEscape)
|
||||||
}
|
}
|
||||||
}, [isPopoverActive])
|
}, [isPopoverActive, isWorkspaceModalActive])
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return null
|
return null
|
||||||
@ -120,6 +125,17 @@ const CurrentWorkspaceChip = () => {
|
|||||||
currentUser?.organizationName ||
|
currentUser?.organizationName ||
|
||||||
'No organization assigned yet'
|
'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 appRoleName = currentUser?.app_role?.name || 'User'
|
||||||
const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : []
|
const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : []
|
||||||
const linkedTenantCount = linkedTenantSummary.count || 0
|
const linkedTenantCount = linkedTenantSummary.count || 0
|
||||||
@ -130,173 +146,331 @@ const CurrentWorkspaceChip = () => {
|
|||||||
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS')
|
const canViewTenants = hasPermission(currentUser, 'READ_TENANTS')
|
||||||
const organizationHref =
|
const organizationHref =
|
||||||
canViewOrganizations && organizationId ? getOrganizationViewHref(organizationId) : '/profile'
|
canViewOrganizations && organizationId ? getOrganizationViewHref(organizationId) : '/profile'
|
||||||
|
const hasOrganizationMetadata = Boolean(organizationSlug || organizationDomain)
|
||||||
|
|
||||||
|
const handleOpenWorkspaceModal = () => {
|
||||||
|
setIsPopoverActive(false)
|
||||||
|
setIsWorkspaceModalActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseWorkspaceModal = () => {
|
||||||
|
setIsWorkspaceModalActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative hidden xl:block" ref={triggerRef}>
|
<>
|
||||||
<button
|
<div className="relative hidden xl:block" ref={triggerRef}>
|
||||||
type="button"
|
<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"
|
type="button"
|
||||||
title={`Current workspace: ${organizationName}`}
|
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"
|
||||||
aria-haspopup="dialog"
|
title={`Current workspace: ${organizationName}`}
|
||||||
aria-expanded={isPopoverActive}
|
aria-haspopup="dialog"
|
||||||
onClick={() => setIsPopoverActive((currentValue) => !currentValue)}
|
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 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">
|
||||||
</span>
|
<BaseIcon path={mdiOfficeBuilding} size="18" />
|
||||||
|
</span>
|
||||||
|
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block text-[10px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100">
|
<span className="block text-[10px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100">
|
||||||
Current workspace
|
Current workspace
|
||||||
</span>
|
</span>
|
||||||
<span className="block truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<span className="block truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{organizationName}
|
{organizationName}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
|
<span className="mt-0.5 flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
<BaseIcon path={mdiDomain} size="14" />
|
<BaseIcon path={mdiDomain} size="14" />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{tenantLabel} • {appRoleName}
|
{tenantLabel} • {appRoleName}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex-none text-blue-800 dark:text-blue-200">
|
<span className="flex-none text-blue-800 dark:text-blue-200">
|
||||||
<BaseIcon path={isPopoverActive ? mdiChevronUp : mdiChevronDown} size="16" />
|
<BaseIcon path={isPopoverActive ? mdiChevronUp : mdiChevronDown} size="16" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isPopoverActive ? (
|
{isPopoverActive ? (
|
||||||
<div className="absolute right-0 top-full z-40 mt-2 w-[26rem] max-w-[calc(100vw-2rem)]">
|
<div className="absolute right-0 top-full z-40 mt-2 w-[26rem] max-w-[calc(100vw-2rem)]">
|
||||||
<ClickOutside onClickOutside={() => setIsPopoverActive(false)} excludedElements={[triggerRef]}>
|
<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="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="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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100">
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-blue-900 dark:text-blue-100">
|
||||||
Current workspace
|
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>
|
||||||
<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}
|
{organizationName}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-700 dark:text-gray-200">
|
||||||
<BaseIcon path={mdiEmailOutline} size="14" />
|
<span className="inline-flex items-center gap-1 rounded-full bg-white/80 px-2.5 py-1 dark:bg-dark-800">
|
||||||
<span className="truncate">{currentUser?.email || 'No email surfaced yet'}</span>
|
<BaseIcon path={mdiShieldAccount} size="14" />
|
||||||
</p>
|
{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>
|
</div>
|
||||||
|
|
||||||
|
<BaseButton
|
||||||
|
href={organizationHref}
|
||||||
|
label={canViewOrganizations && organizationId ? 'Open' : 'Profile'}
|
||||||
|
color="info"
|
||||||
|
outline
|
||||||
|
small
|
||||||
|
icon={mdiOpenInNew}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-4 px-4 py-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="rounded-xl border border-gray-200 bg-gray-50 px-3 py-3 dark:border-dark-700 dark:bg-dark-800">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-500 dark:text-gray-300">
|
<div className="flex items-start gap-3">
|
||||||
Linked tenants
|
<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">
|
||||||
</p>
|
<BaseIcon path={mdiOfficeBuilding} size="16" />
|
||||||
<Link
|
</span>
|
||||||
href="/profile"
|
<div className="min-w-0 flex-1">
|
||||||
className="text-xs font-semibold text-blue-700 hover:text-blue-800 dark:text-blue-200 dark:hover:text-blue-100"
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-500 dark:text-gray-300">
|
||||||
>
|
Organization access
|
||||||
View profile context
|
</p>
|
||||||
</Link>
|
<p className="mt-1 truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
</div>
|
{organizationName}
|
||||||
|
</p>
|
||||||
<div className="mt-3 space-y-2">
|
<p className="mt-1 flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
{isLoadingTenants ? (
|
<BaseIcon path={mdiEmailOutline} size="14" />
|
||||||
<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">
|
<span className="truncate">{currentUser?.email || 'No email surfaced yet'}</span>
|
||||||
Loading tenant context…
|
</p>
|
||||||
</div>
|
{hasOrganizationMetadata ? (
|
||||||
) : linkedTenants.length ? (
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
linkedTenants.slice(0, 3).map((tenant) => (
|
{organizationSlug ? (
|
||||||
<div
|
<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">
|
||||||
key={tenant.id}
|
Slug: {organizationSlug}
|
||||||
className="rounded-xl border border-gray-200 bg-white px-3 py-3 dark:border-dark-700 dark:bg-dark-800"
|
</span>
|
||||||
>
|
) : null}
|
||||||
<div className="flex items-start justify-between gap-3">
|
{organizationDomain ? (
|
||||||
<div className="min-w-0 flex-1">
|
<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">
|
||||||
<p className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
|
Domain: {organizationDomain}
|
||||||
{tenant.name || 'Unnamed tenant'}
|
</span>
|
||||||
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<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>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLoadingTenants && linkedTenantCount > 3 ? (
|
<div>
|
||||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-300">
|
<div className="flex items-center justify-between gap-2">
|
||||||
Showing 3 of {linkedTenantCount} linked tenants in this quick view.
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-500 dark:text-gray-300">
|
||||||
</p>
|
Linked tenants
|
||||||
) : null}
|
</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>
|
||||||
</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>
|
</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>
|
</div>
|
||||||
) : null}
|
</CardBoxModal>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -43,7 +43,7 @@ const initialState: StyleState = {
|
|||||||
navBarItemLabelHoverStyle: styles.midnightBlueTheme.navBarItemLabelHover,
|
navBarItemLabelHoverStyle: styles.midnightBlueTheme.navBarItemLabelHover,
|
||||||
navBarItemLabelActiveColorStyle: styles.midnightBlueTheme.navBarItemLabelActiveColor,
|
navBarItemLabelActiveColorStyle: styles.midnightBlueTheme.navBarItemLabelActiveColor,
|
||||||
overlayStyle: styles.midnightBlueTheme.overlay,
|
overlayStyle: styles.midnightBlueTheme.overlay,
|
||||||
darkMode: false,
|
darkMode: true,
|
||||||
bgLayoutColor: styles.midnightBlueTheme.bgLayoutColor,
|
bgLayoutColor: styles.midnightBlueTheme.bgLayoutColor,
|
||||||
iconsColor: styles.midnightBlueTheme.iconsColor,
|
iconsColor: styles.midnightBlueTheme.iconsColor,
|
||||||
cardsColor: styles.midnightBlueTheme.cardsColor,
|
cardsColor: styles.midnightBlueTheme.cardsColor,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user