diff --git a/backend/constants/defaultTenants.js b/backend/constants/defaultTenants.js index c4da4ffe..23c93878 100644 --- a/backend/constants/defaultTenants.js +++ b/backend/constants/defaultTenants.js @@ -85,7 +85,7 @@ function normalizeTenantRow(row = {}) { const status = TENANT_STATUSES.has(row?.status) ? row.status : 'active'; const tenantType = TENANT_TYPES.has(row?.tenantType) ? row.tenantType : 'campus'; - return { + const normalized = { tenantKey, name: String(row?.name || tenantKey).trim(), subdomain: String(row?.subdomain || tenantKey).trim().toLowerCase(), diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index a8476b60..4a646d4d 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -62,8 +62,9 @@ async function bindAuthTenant(req) { }; } - req.school = tenantKey; - req.db = await connectToDatabase(tenantKey); + const dbRouteKey = tenant.subdomain || tenant.tenantKey; + req.school = tenant.tenantKey; + req.db = await connectToDatabase(dbRouteKey); return null; } const { getFriendRequests } = require('../utilities/friendUtils'); diff --git a/backend/routes/platformTenantRoutes.js b/backend/routes/platformTenantRoutes.js index 9c4f0c0b..8c711f5d 100644 --- a/backend/routes/platformTenantRoutes.js +++ b/backend/routes/platformTenantRoutes.js @@ -20,6 +20,7 @@ const { normalizePivotDropOverrides, } = require('../constants/defaultTenants'); const { invalidateTenantConnection } = require('../connectionsManager'); +const { renameTenantKey } = require('../services/tenantKeyRenameService'); const { listReferralCodesForTenant, createReferralCode, @@ -122,12 +123,31 @@ router.post('/admin/platform/tenants', verifyToken, requirePlatformAdmin, async router.put('/admin/platform/tenants/:tenantKey', verifyToken, requirePlatformAdmin, async (req, res) => { try { - const tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); - const existing = await getTenantByKey(req, tenantKey); + let tenantKey = String(req.params.tenantKey || '').trim().toLowerCase(); + let existing = await getTenantByKey(req, tenantKey); if (!existing) { return res.status(404).json({ success: false, message: 'Tenant not found.' }); } + const updatedBy = req.user.globalUserId || req.user.userId || null; + const requestedTenantKey = req.body.newTenantKey ?? req.body.tenantKey; + if (requestedTenantKey !== undefined) { + const nextTenantKey = String(requestedTenantKey).trim().toLowerCase(); + if (nextTenantKey !== tenantKey) { + const renameResult = await renameTenantKey(req, tenantKey, nextTenantKey, updatedBy); + if (renameResult.error) { + return res.status(renameResult.status || 400).json({ + success: false, + message: renameResult.error, + code: renameResult.code, + data: renameResult.updates, + }); + } + tenantKey = renameResult.tenantKey; + existing = await getTenantByKey(req, tenantKey); + } + } + const metadataValidation = validateTenantMetadataUpdate(req.body); if (metadataValidation.error) { return res.status(400).json({ success: false, message: metadataValidation.error }); @@ -171,12 +191,18 @@ router.put('/admin/platform/tenants/:tenantKey', verifyToken, requirePlatformAdm }); } - const updatedBy = req.user.globalUserId || req.user.userId || null; invalidateTenantConnection(tenantKey); + if (updated.subdomain && updated.subdomain !== tenantKey) { + invalidateTenantConnection(updated.subdomain); + } const saved = await upsertStoredTenantRow(req, updated, updatedBy); const health = await pingTenantDatabase(tenantKey, saved); - res.json({ success: true, data: enrichTenantForAdmin(saved, { health }) }); + res.json({ + success: true, + data: enrichTenantForAdmin(saved, { health }), + renamedFrom: req.params.tenantKey !== tenantKey ? req.params.tenantKey : undefined, + }); } catch (err) { console.error('PUT /admin/platform/tenants/:tenantKey failed:', err); res.status(500).json({ success: false, message: err.message }); diff --git a/backend/services/tenantConfigService.js b/backend/services/tenantConfigService.js index 0df777f1..9572f5eb 100644 --- a/backend/services/tenantConfigService.js +++ b/backend/services/tenantConfigService.js @@ -150,9 +150,18 @@ async function getMergedTenants(req) { return mergeTenantRows(DEFAULT_TENANTS, doc?.tenants || []); } -async function getTenantByKey(req, tenantKey) { +async function getTenantByKey(req, tenantKeyOrSubdomain, options = {}) { + const key = String(tenantKeyOrSubdomain || '').trim().toLowerCase(); + if (!key) return null; const tenants = await getMergedTenants(req); - return tenants.find((row) => row.tenantKey === tenantKey) || null; + if (options.exact) { + return tenants.find((row) => row.tenantKey === key) || null; + } + return ( + tenants.find((row) => row.tenantKey === key) || + tenants.find((row) => (row.subdomain || row.tenantKey) === key) || + null + ); } async function syncTenantUriCache(req) { @@ -160,7 +169,12 @@ async function syncTenantUriCache(req) { const cache = {}; tenants.forEach((tenant) => { const uri = deriveMongoUriForTenant(tenant.tenantKey, tenant); - if (uri) cache[tenant.tenantKey] = uri; + if (!uri) return; + cache[tenant.tenantKey] = uri; + const subdomain = String(tenant.subdomain || tenant.tenantKey).trim().toLowerCase(); + if (subdomain && subdomain !== tenant.tenantKey) { + cache[subdomain] = uri; + } }); setTenantUriCache(cache); return cache; @@ -381,6 +395,8 @@ module.exports = { validateNewTenantPayload, validateTenantMetadataUpdate, upsertStoredTenantRow, + toStoredTenantRow, + getStoredTenantRows, DEFAULT_TENANTS, mergeTenantRows, normalizeTenantRows, diff --git a/backend/services/tenantKeyRenameService.js b/backend/services/tenantKeyRenameService.js new file mode 100644 index 00000000..4a03eb84 --- /dev/null +++ b/backend/services/tenantKeyRenameService.js @@ -0,0 +1,189 @@ +const getGlobalModels = require('./getGlobalModelService'); +const getModels = require('./getModelService'); +const { + DEFAULT_TENANTS, + normalizeTenantRow, +} = require('../constants/defaultTenants'); +const { + getTenantByKey, + getMergedTenants, + getStoredTenantRows, + saveTenantRows, + syncTenantUriCache, + toStoredTenantRow, +} = require('./tenantConfigService'); +const { + connectToDatabase, + invalidateTenantConnection, +} = require('../connectionsManager'); + +const DEFAULT_TENANT_KEYS = new Set(DEFAULT_TENANTS.map((row) => row.tenantKey)); +const RESERVED_TENANT_KEYS = new Set([...DEFAULT_TENANT_KEYS, 'www']); + +function validateTenantKeyFormat(tenantKey) { + const key = String(tenantKey || '').trim().toLowerCase(); + if (!/^[a-z][a-z0-9_-]{1,31}$/.test(key)) { + return { + error: + 'tenantKey must be 2–32 chars, start with a letter, lowercase alphanumeric/underscore/hyphen.', + }; + } + if (key === 'www') { + return { error: 'tenantKey "www" is reserved.' }; + } + return { ok: true, tenantKey: key }; +} + +function isDefaultTenantKey(tenantKey) { + return DEFAULT_TENANT_KEYS.has(String(tenantKey || '').trim().toLowerCase()); +} + +async function updatePivotWeeklySnapshotTenantKeys(req, oldTenantKey, newTenantKey) { + const { PivotWeeklySnapshot } = getGlobalModels(req, 'PivotWeeklySnapshot'); + const result = await PivotWeeklySnapshot.updateMany( + { 'tenants.tenantKey': oldTenantKey }, + { $set: { 'tenants.$[elem].tenantKey': newTenantKey } }, + { arrayFilters: [{ 'elem.tenantKey': oldTenantKey }] }, + ); + return result.modifiedCount || 0; +} + +async function updateTenantDbSchoolFields(tenant, oldTenantKey, newTenantKey) { + const dbRouteKey = tenant.subdomain || tenant.tenantKey; + let db; + try { + db = await connectToDatabase(dbRouteKey); + } catch (error) { + return { + error: `Could not connect to tenant database to update school fields: ${error.message}`, + }; + } + + const reqLike = { db, school: oldTenantKey }; + const { SAMLConfig, ShuttleConfig } = getModels(reqLike, 'SAMLConfig', 'ShuttleConfig'); + + const [samlResult, shuttleResult] = await Promise.all([ + SAMLConfig.updateMany({ school: oldTenantKey }, { $set: { school: newTenantKey } }), + ShuttleConfig.updateMany({ school: oldTenantKey }, { $set: { school: newTenantKey } }), + ]); + + return { + samlConfigsUpdated: samlResult.modifiedCount || 0, + shuttleConfigsUpdated: shuttleResult.modifiedCount || 0, + }; +} + +function invalidateTenantConnections(tenant, oldTenantKey, newTenantKey) { + const keys = new Set( + [ + oldTenantKey, + newTenantKey, + tenant?.subdomain, + tenant?.tenantKey, + ] + .filter(Boolean) + .map((value) => String(value).trim().toLowerCase()), + ); + keys.forEach((key) => invalidateTenantConnection(key)); +} + +/** + * Rename a dynamically provisioned tenant and cascade tenantKey updates to global + * and tenant-scoped documents that reference the old key. + */ +async function renameTenantKey(req, oldTenantKey, newTenantKey, updatedBy = null) { + const oldKey = String(oldTenantKey || '').trim().toLowerCase(); + const formatValidation = validateTenantKeyFormat(newTenantKey); + if (formatValidation.error) { + return { error: formatValidation.error, status: 400 }; + } + const newKey = formatValidation.tenantKey; + + if (oldKey === newKey) { + return { ok: true, tenantKey: newKey, renamed: false }; + } + + if (isDefaultTenantKey(oldKey)) { + return { + error: 'Built-in tenants (rpi, tvcog) cannot be renamed.', + status: 403, + code: 'DEFAULT_TENANT_IMMUTABLE', + }; + } + + if (RESERVED_TENANT_KEYS.has(newKey)) { + return { + error: `Tenant key "${newKey}" is reserved.`, + status: 400, + code: 'TENANT_KEY_RESERVED', + }; + } + + const existing = await getTenantByKey(req, oldKey, { exact: true }); + if (!existing) { + return { error: 'Tenant not found.', status: 404, code: 'TENANT_NOT_FOUND' }; + } + + const tenants = await getMergedTenants(req); + if (tenants.some((row) => row.tenantKey === newKey)) { + return { + error: `Tenant "${newKey}" already exists.`, + status: 409, + code: 'TENANT_EXISTS', + }; + } + + const { + TenantMembership, + PivotReferralCode, + } = getGlobalModels(req, 'TenantMembership', 'PivotReferralCode'); + + const [membershipResult, referralResult, snapshotsUpdated, tenantDbResult] = await Promise.all([ + TenantMembership.updateMany({ tenantKey: oldKey }, { $set: { tenantKey: newKey } }), + PivotReferralCode.updateMany({ tenantKey: oldKey }, { $set: { tenantKey: newKey } }), + updatePivotWeeklySnapshotTenantKeys(req, oldKey, newKey), + updateTenantDbSchoolFields(existing, oldKey, newKey), + ]); + + if (tenantDbResult?.error) { + return { error: tenantDbResult.error, status: 400, code: 'TENANT_DB_UPDATE_FAILED' }; + } + + const renamedTenant = normalizeTenantRow({ ...existing, tenantKey: newKey }); + const stored = await getStoredTenantRows(req); + const withoutOld = stored.filter((row) => row.tenantKey !== oldKey); + const newStoredRow = toStoredTenantRow(renamedTenant); + if (!newStoredRow) { + return { + error: 'Unable to persist renamed tenant row.', + status: 500, + code: 'TENANT_CONFIG_PERSIST_FAILED', + }; + } + await saveTenantRows(req, [...withoutOld, newStoredRow], updatedBy); + + invalidateTenantConnections(existing, oldKey, newKey); + await syncTenantUriCache(req); + + return { + ok: true, + renamed: true, + tenantKey: newKey, + previousTenantKey: oldKey, + updates: { + tenantMemberships: membershipResult.modifiedCount || 0, + pivotReferralCodes: referralResult.modifiedCount || 0, + pivotWeeklySnapshots: snapshotsUpdated, + samlConfigs: tenantDbResult.samlConfigsUpdated, + shuttleConfigs: tenantDbResult.shuttleConfigsUpdated, + }, + }; +} + +module.exports = { + DEFAULT_TENANT_KEYS, + RESERVED_TENANT_KEYS, + validateTenantKeyFormat, + isDefaultTenantKey, + renameTenantKey, +}; diff --git a/backend/tests/unit/tenantKeyRenameService.test.js b/backend/tests/unit/tenantKeyRenameService.test.js new file mode 100644 index 00000000..f680fba5 --- /dev/null +++ b/backend/tests/unit/tenantKeyRenameService.test.js @@ -0,0 +1,143 @@ +jest.mock('../../services/tenantConfigService', () => ({ + getTenantByKey: jest.fn(), + getMergedTenants: jest.fn(), + getStoredTenantRows: jest.fn(), + saveTenantRows: jest.fn(), + syncTenantUriCache: jest.fn(), + toStoredTenantRow: jest.fn(), +})); + +jest.mock('../../services/getGlobalModelService', () => jest.fn()); +jest.mock('../../services/getModelService', () => jest.fn()); +jest.mock('../../connectionsManager', () => ({ + connectToDatabase: jest.fn(), + invalidateTenantConnection: jest.fn(), +})); + +const getGlobalModels = require('../../services/getGlobalModelService'); +const getModels = require('../../services/getModelService'); +const { connectToDatabase } = require('../../connectionsManager'); +const tenantConfigService = require('../../services/tenantConfigService'); +const { + validateTenantKeyFormat, + isDefaultTenantKey, + renameTenantKey, +} = require('../../services/tenantKeyRenameService'); + +const iowaCityTenant = { + tenantKey: 'iowa-city', + name: 'Iowa City', + subdomain: 'ic', + location: 'Iowa City, IA', + tenantType: 'pivot', + pivotPilot: true, + mongoDatabaseName: 'ic', + status: 'active', +}; + +describe('tenantKeyRenameService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('validateTenantKeyFormat', () => { + it('accepts valid tenant keys', () => { + expect(validateTenantKeyFormat('ic')).toEqual({ ok: true, tenantKey: 'ic' }); + }); + + it('rejects reserved www key', () => { + expect(validateTenantKeyFormat('www').error).toMatch(/reserved/i); + }); + }); + + describe('isDefaultTenantKey', () => { + it('blocks built-in tenants', () => { + expect(isDefaultTenantKey('rpi')).toBe(true); + expect(isDefaultTenantKey('iowa-city')).toBe(false); + }); + }); + + describe('renameTenantKey', () => { + it('renames tenant config and cascades global references', async () => { + tenantConfigService.getTenantByKey.mockResolvedValue(iowaCityTenant); + tenantConfigService.getMergedTenants.mockResolvedValue([iowaCityTenant]); + tenantConfigService.getStoredTenantRows.mockResolvedValue([ + { tenantKey: 'iowa-city', subdomain: 'ic', tenantType: 'pivot' }, + ]); + tenantConfigService.toStoredTenantRow.mockReturnValue({ + tenantKey: 'ic', + subdomain: 'ic', + tenantType: 'pivot', + }); + tenantConfigService.saveTenantRows.mockResolvedValue([]); + tenantConfigService.syncTenantUriCache.mockResolvedValue({}); + + const membershipUpdateMany = jest.fn().mockResolvedValue({ modifiedCount: 2 }); + const referralUpdateMany = jest.fn().mockResolvedValue({ modifiedCount: 1 }); + const snapshotUpdateMany = jest.fn().mockResolvedValue({ modifiedCount: 1 }); + const samlUpdateMany = jest.fn().mockResolvedValue({ modifiedCount: 0 }); + const shuttleUpdateMany = jest.fn().mockResolvedValue({ modifiedCount: 0 }); + + getGlobalModels.mockReturnValue({ + TenantMembership: { updateMany: membershipUpdateMany }, + PivotReferralCode: { updateMany: referralUpdateMany }, + PivotWeeklySnapshot: { updateMany: snapshotUpdateMany }, + }); + + connectToDatabase.mockResolvedValue({}); + getModels.mockReturnValue({ + SAMLConfig: { updateMany: samlUpdateMany }, + ShuttleConfig: { updateMany: shuttleUpdateMany }, + }); + + const result = await renameTenantKey({ globalDb: {} }, 'iowa-city', 'ic', 'admin-1'); + + expect(result).toMatchObject({ + ok: true, + renamed: true, + tenantKey: 'ic', + previousTenantKey: 'iowa-city', + updates: { + tenantMemberships: 2, + pivotReferralCodes: 1, + pivotWeeklySnapshots: 1, + }, + }); + expect(tenantConfigService.getTenantByKey).toHaveBeenCalledWith( + { globalDb: {} }, + 'iowa-city', + { exact: true }, + ); + expect(membershipUpdateMany).toHaveBeenCalledWith( + { tenantKey: 'iowa-city' }, + { $set: { tenantKey: 'ic' } }, + ); + expect(tenantConfigService.saveTenantRows).toHaveBeenCalled(); + }); + + it('rejects renaming built-in tenants', async () => { + const result = await renameTenantKey({ globalDb: {} }, 'rpi', 'renamed', 'admin-1'); + expect(result.code).toBe('DEFAULT_TENANT_IMMUTABLE'); + }); + }); +}); + +describe('getTenantByKey subdomain resolution', () => { + it('resolves tenant by subdomain alias', async () => { + tenantConfigService.getMergedTenants.mockResolvedValue([iowaCityTenant]); + tenantConfigService.getTenantByKey.mockImplementation(async (_req, key, options = {}) => { + const tenants = await tenantConfigService.getMergedTenants(_req); + if (options.exact) { + return tenants.find((row) => row.tenantKey === key) || null; + } + return ( + tenants.find((row) => row.tenantKey === key) || + tenants.find((row) => (row.subdomain || row.tenantKey) === key) || + null + ); + }); + + const tenant = await tenantConfigService.getTenantByKey({ globalDb: {} }, 'ic'); + expect(tenant?.tenantKey).toBe('iowa-city'); + }); +}); diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx index 2fc16aa3..8545b25e 100644 --- a/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantManagementPage.jsx @@ -531,7 +531,18 @@ function TenantManagementPage() { }); return false; } - addNotification({ title: 'Saved', message: `${tenantKey} details updated`, type: 'success' }); + const savedKey = res.data?.tenantKey || tenantKey; + const renameNote = res.renamedFrom + ? ` Renamed from ${res.renamedFrom}.` + : ''; + addNotification({ + title: 'Saved', + message: `${savedKey} details updated.${renameNote}`, + type: 'success', + }); + if (res.renamedFrom && savedKey !== selectedKey) { + setSelectedKey(savedKey); + } try { const cfgRes = await fetch('/api/tenant-config', { credentials: 'include' }); const cfgPayload = await cfgRes.json(); @@ -539,7 +550,7 @@ function TenantManagementPage() { } catch (_) {} refetch(); return true; - }, [addNotification, refetch]); + }, [addNotification, refetch, selectedKey]); const saveVisibility = useCallback(async (tenantKey, { status, statusMessage }) => { setSavingVisibilityKey(tenantKey); diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/TenantMetadataModal/TenantMetadataModal.scss b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantMetadataModal/TenantMetadataModal.scss index 8160c8d0..7f9c8870 100644 --- a/frontend/src/pages/PlatformAdmin/TenantManagement/TenantMetadataModal/TenantMetadataModal.scss +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantMetadataModal/TenantMetadataModal.scss @@ -51,6 +51,19 @@ color: #6b6f76; } + &__hint { + font-size: 11px; + line-height: 1.45; + color: #8b8f96; + + code { + font-size: 10px; + padding: 1px 4px; + border-radius: 4px; + background: #f3f4f6; + } + } + &__input { width: 100%; box-sizing: border-box; diff --git a/frontend/src/pages/PlatformAdmin/TenantManagement/TenantMetadataModal/TenantMetadataModalContent.jsx b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantMetadataModal/TenantMetadataModalContent.jsx index 72fc85fe..d9c24d50 100644 --- a/frontend/src/pages/PlatformAdmin/TenantManagement/TenantMetadataModal/TenantMetadataModalContent.jsx +++ b/frontend/src/pages/PlatformAdmin/TenantManagement/TenantMetadataModal/TenantMetadataModalContent.jsx @@ -1,12 +1,14 @@ import React, { useEffect, useState } from 'react'; import PivotDropScheduleFields from '../../shared/PivotDropScheduleFields'; import { tenantToDropFormFields, mergeTenantMetadataPayload } from '../../shared/pivotDropScheduleForm'; +import { isDefaultTenantKey } from '../tenantPivotUtils'; import './TenantMetadataModal.scss'; import '../../shared/PivotDropScheduleFields.scss'; function tenantToForm(tenant) { const isPivot = tenant.pivotPilot === true || tenant.tenantType === 'pivot'; return { + tenantKey: tenant.tenantKey || '', name: tenant.name || '', location: tenant.location || '', subdomain: tenant.subdomain || tenant.tenantKey || '', @@ -20,6 +22,10 @@ function tenantToForm(tenant) { function TenantMetadataModalContent({ tenant, saving, onSave, handleClose = () => {} }) { const [form, setForm] = useState(() => tenantToForm(tenant)); + const tenantKeyLocked = isDefaultTenantKey(tenant.tenantKey); + const tenantKeyChanged = + !tenantKeyLocked && + form.tenantKey.trim().toLowerCase() !== String(tenant.tenantKey || '').trim().toLowerCase(); useEffect(() => { setForm(tenantToForm(tenant)); @@ -71,6 +77,10 @@ function TenantMetadataModalContent({ tenant, saving, onSave, handleClose = () = includeDropConfig: form.tenantType === 'pivot', }); + if (tenantKeyChanged) { + payload.newTenantKey = form.tenantKey.trim().toLowerCase(); + } + const ok = await onSave(payload); if (ok !== false) handleClose(); }; @@ -81,9 +91,34 @@ function TenantMetadataModalContent({ tenant, saving, onSave, handleClose = () =
Update display and infrastructure metadata for {tenant.tenantKey}. Status and lifecycle controls are unchanged. + {tenantKeyChanged ? ( + <> + {' '} + Renaming the tenant key updates referral codes, memberships, and Pivot snapshots that reference it. + > + ) : null}