Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions __tests__/e2e/admin/tenant-selector/role-based.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
tenantSelectorTest as test,
} from '../../fixtures/tenant-selector.fixture'
import { AdminUrlUtil, CollectionSlugs } from '../../helpers'
import { TenantIds } from '../../helpers/tenant-cookie'
import { TenantSlugs } from '../../helpers/tenant-cookie'

/**
* Role-Based Test Cases
Expand Down Expand Up @@ -180,7 +180,7 @@ test.describe('Single-Center Admin', () => {

// Cookie should be set to their tenant (nwac)
const cookie = await getTenantCookie(page)
expect(cookie).toBe(TenantIds.nwac)
expect(cookie).toBe(TenantSlugs.nwac)

await page.context().close()
})
Expand All @@ -200,7 +200,7 @@ test.describe('Single-Center Admin', () => {
await page.waitForLoadState('networkidle')

const cookie = await getTenantCookie(page)
expect(cookie).toBe(TenantIds.nwac)
expect(cookie).toBe(TenantSlugs.nwac)
}

await page.context().close()
Expand All @@ -226,7 +226,7 @@ test.describe('Forecaster Role', () => {

// Cookie should be set to NWAC
const cookie = await getTenantCookie(page)
expect(cookie).toBe(TenantIds.nwac)
expect(cookie).toBe(TenantSlugs.nwac)

await page.context().close()
})
Expand All @@ -251,7 +251,7 @@ test.describe('Staff Role', () => {

// Cookie should be set to NWAC
const cookie = await getTenantCookie(page)
expect(cookie).toBe(TenantIds.nwac)
expect(cookie).toBe(TenantSlugs.nwac)

await page.context().close()
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { TenantIds } from '__tests__/e2e/helpers/tenant-cookie'
import {
expect,
TenantNames,
Expand Down Expand Up @@ -99,7 +98,7 @@ test.describe('Tenant Cookie Edge Cases', () => {
await selectTenant(page, TenantNames.dvac)
await page.waitForLoadState('networkidle')

// Cookie should be set after selecting a tenant (stores tenant ID, not slug)
// Cookie should be set after selecting a tenant (stores tenant slug)
const cookieAfterSelect = await getTenantCookie(page)
expect(cookieAfterSelect).toBeTruthy()

Expand Down Expand Up @@ -127,7 +126,7 @@ test.describe('Tenant Cookie Edge Cases', () => {
const page = await loginAs('superAdmin')
const url = new AdminUrlUtil('http://localhost:3000', CollectionSlugs.pages)

// Select a tenant via UI (sets cookie to tenant ID)
// Select a tenant via UI (sets cookie to tenant slug)
await page.goto(url.list)
await page.waitForLoadState('networkidle')
await selectTenant(page, TenantNames.snfac)
Expand Down Expand Up @@ -171,7 +170,7 @@ test.describe('Navigation & State Consistency', () => {

// Get the cookie while viewing the document
const docTenantCookie = await getTenantCookie(page)
expect(docTenantCookie).toBe(TenantIds.nwac)
expect(docTenantCookie).toBe(TenantSlugs.nwac)

// Navigate away and back using browser history
await page.goBack()
Expand Down Expand Up @@ -254,9 +253,9 @@ test.describe('Dashboard View', () => {
await selectTenant(page, TenantNames.snfac)
await page.waitForLoadState('networkidle')

// Cookie should be set after selecting a tenant (stores tenant ID, not slug)
// Cookie should be set after selecting a tenant (stores tenant slug)
const cookie = await getTenantCookie(page)
expect(cookie).toBe(TenantIds.snfac)
expect(cookie).toBe(TenantSlugs.snfac)

await page.context().close()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
tenantSelectorTest as test,
} from '../../fixtures/tenant-selector.fixture'
import { AdminUrlUtil, CollectionSlugs } from '../../helpers'
import { TenantIds } from '../../helpers/tenant-cookie'
import { TenantSlugs } from '../../helpers/tenant-cookie'

/**
* Tenant-Required Collection Tests
Expand Down Expand Up @@ -68,7 +68,7 @@ test.describe('Tenant-Required Collection', () => {

// Verify cookie was updated
const cookie = await getTenantCookie(page)
expect(cookie).toBe(TenantIds.nwac)
expect(cookie).toBe(TenantSlugs.nwac)

await page.context().close()
})
Expand Down
8 changes: 0 additions & 8 deletions __tests__/e2e/helpers/tenant-cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,3 @@ export const TenantNames = {
sac: 'Sierra Avalanche Center',
snfac: 'Sawtooth Avalanche Center',
} as const

// Hopefully remove soon
export const TenantIds = {
dvac: '1',
nwac: '2',
sac: '3',
snfac: '4',
} as const
50 changes: 50 additions & 0 deletions __tests__/server/findCenterByDomain.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { findCenterByDomain } from '../../src/utilities/tenancy/avalancheCenters'

describe('findCenterByDomain', () => {
it('finds a center by exact domain match (no www)', () => {
expect(findCenterByDomain('nwac.us')).toBe('nwac')
expect(findCenterByDomain('alaskasnow.org')).toBe('aaic')
expect(findCenterByDomain('avalanche.state.co.us')).toBe('caic')
})

it('finds a center when domain has www. prefix', () => {
expect(findCenterByDomain('www.nwac.us')).toBe('nwac')
expect(findCenterByDomain('www.cnfaic.org')).toBe('cnfaic')
expect(findCenterByDomain('www.sierraavalanchecenter.org')).toBe('sac')
})

it('finds a center when stored domain has www. but input does not', () => {
expect(findCenterByDomain('cnfaic.org')).toBe('cnfaic')
expect(findCenterByDomain('sierraavalanchecenter.org')).toBe('sac')
})

it('is case insensitive', () => {
expect(findCenterByDomain('NWAC.US')).toBe('nwac')
expect(findCenterByDomain('NwAc.Us')).toBe('nwac')
expect(findCenterByDomain('WWW.CNFAIC.ORG')).toBe('cnfaic')
expect(findCenterByDomain('www.SierrAAvAlAnchECentEr.Org')).toBe('sac')
})

it('handles domains shared by multiple centers (returns first match)', () => {
// Multiple centers use 'alaskasnow.org': aaic, cac, earac, hac, vac
// Should return the first one found (aaic)
const result = findCenterByDomain('alaskasnow.org')
expect(result).toBe('aaic')
})
Comment on lines +28 to +33
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I just realized how problematic this is. I don't think we should hold up this PR or #947 based on this because this is the same behavior we had before and we only need to solve for this when we onboard any of the alaskasnow.org centers. But I'll start a thread in Slack to discuss.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is fine to side step for now.


it('returns undefined for non-existent domains', () => {
expect(findCenterByDomain('example.com')).toBeUndefined()
expect(findCenterByDomain('notarealcenter.org')).toBeUndefined()
expect(findCenterByDomain('www.doesnotexist.com')).toBeUndefined()
})

it('returns undefined for empty string', () => {
expect(findCenterByDomain('')).toBeUndefined()
})

it('handles domains with trailing/leading whitespace', () => {
expect(findCenterByDomain(' nwac.us')).toBe('nwac')
expect(findCenterByDomain('nwac.us ')).toBe('nwac')
expect(findCenterByDomain(' nwac.us ')).toBe('nwac')
})
})
24 changes: 24 additions & 0 deletions __tests__/server/getEmailDomain.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { AVALANCHE_CENTERS, getEmailDomain } from '../../src/utilities/tenancy/avalancheCenters'

describe('getEmailDomain', () => {
it('returns the domain as-is when there is no www. prefix', () => {
expect(getEmailDomain('nwac')).toBe('nwac.us')
})

it('strips only the www. prefix, not other subdomains', () => {
expect(getEmailDomain('cnfaic')).toBe('cnfaic.org')

expect(getEmailDomain('aaic')).toBe('alaskasnow.org')
})

it('strips port numbers from domains (for local development)', () => {
// Temporarily modify dvac's customDomain to include a port
const originalDomain = AVALANCHE_CENTERS.dvac.customDomain
AVALANCHE_CENTERS.dvac.customDomain = 'www.deathvalleyac.local:3000'

expect(getEmailDomain('dvac')).toBe('deathvalleyac.local')

// Restore original value
AVALANCHE_CENTERS.dvac.customDomain = originalDomain
})
})
40 changes: 8 additions & 32 deletions __tests__/server/getHostnameFromTenant.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,21 @@ describe('server-side utilities: getHostnameFromTenant', () => {
expect(result).toBe('envvar.localhost:3000')
})

it('returns custom domain for production tenants', () => {
// Use a valid tenant slug from AVALANCHE_CENTERS
it('returns custom domain from AVALANCHE_CENTERS for production tenants', () => {
PRODUCTION_TENANTS.length = 0
PRODUCTION_TENANTS.push('nwac')

const tenant = buildTenant({ slug: 'nwac', customDomain: 'nwac.us' })
const tenant = buildTenant({ slug: 'nwac' })

const result = getHostnameFromTenant(tenant)
expect(result).toBe('nwac.us')
})

it('returns subdomain format for non-production tenants', () => {
// Only nwac is a production tenant; sac is valid but not in PRODUCTION_TENANTS
PRODUCTION_TENANTS.length = 0
PRODUCTION_TENANTS.push('nwac')

const tenant = buildTenant({
slug: 'sac',
customDomain: 'sierraavalanchecenter.org',
})
const tenant = buildTenant({ slug: 'sac' })

const result = getHostnameFromTenant(tenant)
expect(result).toBe('sac.envvar.localhost:3000')
Expand All @@ -56,38 +51,19 @@ describe('server-side utilities: getHostnameFromTenant', () => {
PRODUCTION_TENANTS.length = 0
PRODUCTION_TENANTS.push('nwac', 'sac', 'uac')

const tenant1 = buildTenant({
slug: 'nwac',
customDomain: 'nwac.us',
})
const tenant2 = buildTenant({
slug: 'sac',
customDomain: 'sierraavalanchecenter.org',
})
const nonProductionTenant = buildTenant({
slug: 'btac',
customDomain: 'bridgertetonavalanchecenter.org',
})
const tenant1 = buildTenant({ slug: 'nwac' })
const tenant2 = buildTenant({ slug: 'sac' })
const nonProductionTenant = buildTenant({ slug: 'btac' })

expect(getHostnameFromTenant(tenant1)).toBe('nwac.us')
expect(getHostnameFromTenant(tenant2)).toBe('sierraavalanchecenter.org')
expect(getHostnameFromTenant(tenant2)).toBe('www.sierraavalanchecenter.org')
expect(getHostnameFromTenant(nonProductionTenant)).toBe('btac.envvar.localhost:3000')
})

it('handles empty production tenants list', () => {
PRODUCTION_TENANTS.length = 0

const tenant = buildTenant({ slug: 'nwac', customDomain: 'nwac.us' })

const result = getHostnameFromTenant(tenant)
expect(result).toBe('nwac.envvar.localhost:3000')
})

it('handles tenant with empty custom domain by falling back to tenant subdomain', () => {
PRODUCTION_TENANTS.length = 0
PRODUCTION_TENANTS.push('nwac')

const tenant = buildTenant({ slug: 'nwac', customDomain: '' })
const tenant = buildTenant({ slug: 'nwac' })

const result = getHostnameFromTenant(tenant)
expect(result).toBe('nwac.envvar.localhost:3000')
Expand Down
1 change: 0 additions & 1 deletion consistent-type-assertions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ src/collections/Users/components/InviteUserDrawer.tsx
src/collections/Users/components/inviteUserAction.ts
src/collections/Users/components/resendInviteActions.ts
src/components/Header/utils.ts
src/endpoints/seed/index.ts
src/endpoints/seed/upsert.ts
src/globals/Diagnostics/actions/revalidateCache.ts
src/utilities/removeNonDeterministicKeys.ts
1 change: 0 additions & 1 deletion src/app/(frontend)/[center]/[...segments]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ const queryPageBySlug = cache(async ({ center, slug }: { center: string; slug: s
tenants: {
slug: true,
name: true,
customDomain: true,
},
},
where: {
Expand Down
1 change: 0 additions & 1 deletion src/app/(frontend)/[center]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,6 @@ const queryPageBySlug = cache(async ({ center, slug }: { center: string; slug: s
tenants: {
slug: true,
name: true,
customDomain: true,
},
},
where: {
Expand Down
1 change: 0 additions & 1 deletion src/app/(frontend)/[center]/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ const queryPostBySlug = async ({ center, slug }: { center: string; slug: string
tenants: {
slug: true,
name: true,
customDomain: true,
},
},
where: { and: conditions },
Expand Down
1 change: 0 additions & 1 deletion src/app/(frontend)/[center]/events/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,6 @@ const queryEventBySlug = async ({ center, slug }: { center: string; slug: string
tenants: {
slug: true,
name: true,
customDomain: true,
},
},
where: {
Expand Down
1 change: 0 additions & 1 deletion src/app/(frontend)/[center]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ export async function generateMetadata({ params }: Args): Promise<Metadata> {
populate: {
tenants: {
slug: true,
customDomain: true,
name: true,
},
},
Expand Down
1 change: 0 additions & 1 deletion src/app/api/[center]/og/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ export async function GET(
populate: {
tenants: {
slug: true,
customDomain: true,
name: true,
},
},
Expand Down
13 changes: 3 additions & 10 deletions src/collections/Tenants/hooks/revalidateTenants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,14 @@ export const revalidateTenantsAfterChange: CollectionAfterChangeHook = async ({
}

const nameChange = doc.name !== previousDoc.name
const customDomainChange = doc.customDomain !== previousDoc.customDomain

if (nameChange || customDomainChange) {
const changedFields = [nameChange && 'name', customDomainChange && 'customDomain'].filter(
Boolean,
)

payload.logger.info(
`Revalidating all paths for tenant ${doc.slug} due to ${changedFields.join(', ')} change`,
)
if (nameChange) {
payload.logger.info(`Revalidating all paths for tenant ${doc.slug} due to name change`)

// Revalidate the tenant's root path and all nested paths
revalidatePath(`/${doc.slug}`, 'layout')

// Tenant name and customDomain are used at the root for tenant listings
// Tenant name is used at the root for tenant listings
revalidatePath('/', 'layout')
}
}
Expand Down
6 changes: 0 additions & 6 deletions src/collections/Tenants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export const Tenants: CollectionConfig = {
// update src/utilities/isTenantValue.ts if this changes
defaultPopulate: {
slug: true,
customDomain: true, // required for byGlobalRoleOrTenantRoleAssignment
},
hooks: {
afterChange: [revalidateTenantsAfterChange],
Expand All @@ -35,11 +34,6 @@ export const Tenants: CollectionConfig = {
type: 'text',
required: true,
},
{
name: 'customDomain',
type: 'text',
label: 'Custom Domain',
},
{
name: 'slug',
type: 'select',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { getDocumentById } from '@/utilities/getDocumentById'
import { isProviderManager } from '@/utilities/rbac/isProviderManager'
import { roleAssignmentsForUser } from '@/utilities/rbac/roleAssignmentsForUser'
import { ruleMatches, ruleMethod } from '@/utilities/rbac/ruleMatches'
import { getEmailDomain, isValidTenantSlug } from '@/utilities/tenancy/avalancheCenters'
import { Access, CollectionConfig, Where } from 'payload'

// byGlobalRoleOrTenantRoleAssignmentOrDomain combines both tenant domain matching and role-based access
// Users will have their tenant scoped role assignments for the users collection apply if they either:
// 1. Have a role assignment for the same tenant, OR
// 2. Have a matching email domain to the tenant's customDomain
// 2. Have a matching email domain (derived from AVALANCHE_CENTERS)
export const byGlobalRoleOrTenantRoleAssignmentOrDomain: (method: ruleMethod) => Access =
(method: ruleMethod): Access =>
async (args) => {
Expand Down Expand Up @@ -57,9 +58,8 @@ export const byGlobalRoleOrTenantRoleAssignmentOrDomain: (method: ruleMethod) =>
const matchingTenantDomains = userCollectionRoleAssignments
.map((assignment) => assignment.tenant)
.filter((tenant) => typeof tenant !== 'number')
.map((tenant) => tenant.customDomain)
.map((tenant) => (isValidTenantSlug(tenant.slug) ? getEmailDomain(tenant.slug) : null))
.filter(Boolean)
.flat()

if (matchingTenantDomains.length > 0) {
conditions.push(
Expand Down
Loading