From 032f42c9864283d3963bcce3d8f47f17695c9c5b Mon Sep 17 00:00:00 2001 From: Kellen Busby Date: Mon, 16 Feb 2026 11:37:10 -0800 Subject: [PATCH] Revert "Revert #897" --- .env.example | 9 - .../getHostnameFromTenant.server.test.ts | 50 ++-- consistent-type-assertions.txt | 3 - .../008-edge-config-tenant-lookup.md | 2 +- docs/decisions/013-hardcoded-tenant-lookup.md | 80 +++++++ docs/tenant-selector.md | 203 ++++++++++++++++ src/access/byTenantRole.ts | 18 +- src/access/filterByTenant.ts | 14 +- src/collections/BuiltInPages/index.ts | 6 +- .../DuplicatePageForDrawer.tsx | 38 ++- .../Pages/endpoints/duplicatePageToTenant.ts | 9 +- src/collections/Pages/index.ts | 2 +- .../Tenants/endpoints/cachedPublicTenants.ts | 54 ----- .../Tenants/hooks/updateEdgeConfig.ts | 93 -------- src/collections/Tenants/index.ts | 26 +-- .../access/filterByTenantScopedDomain.ts | 4 +- .../TenantSelector/TenantSelector.client.tsx | 10 +- src/endpoints/seed/index.ts | 29 +-- .../tenantField/TenantFieldComponent.tsx | 55 +++-- src/fields/tenantField/index.ts | 20 +- src/middleware.ts | 218 +++++++----------- src/payload-types.ts | 35 ++- src/payload.config.ts | 15 +- src/plugins/tenantFieldPlugin.ts | 8 +- .../TenantSelectionProvider/index.client.tsx | 87 ++++--- .../TenantSelectionProvider/index.tsx | 24 +- src/services/vercel.ts | 75 ------ src/utilities/collectionFilters.ts | 65 ++++-- src/utilities/tenancy/avalancheCenters.ts | 86 +++++++ src/utilities/tenancy/getCollectionIDType.ts | 9 - .../tenancy/getGlobalViewRedirect.ts | 30 ++- .../tenancy/getHostnameFromTenant.ts | 4 +- .../tenancy/getProductionTenantUrls.ts | 12 +- src/utilities/tenancy/getTenantFromCookie.ts | 32 +-- .../tenancy/getTenantIdFromCookie.ts | 13 -- src/utilities/tenancy/getTenantOptions.ts | 1 + .../tenancy/getTenantSubdomainUrls.ts | 11 +- src/utilities/tenancy/getTenants.ts | 14 -- src/utilities/tenancy/resolveTenant.ts | 19 +- src/utilities/tenancy/tenants.ts | 29 ++- src/utilities/useTenantLookup.ts | 64 +++++ 41 files changed, 902 insertions(+), 674 deletions(-) create mode 100644 docs/decisions/013-hardcoded-tenant-lookup.md create mode 100644 docs/tenant-selector.md delete mode 100644 src/collections/Tenants/endpoints/cachedPublicTenants.ts delete mode 100644 src/collections/Tenants/hooks/updateEdgeConfig.ts delete mode 100644 src/services/vercel.ts create mode 100644 src/utilities/tenancy/avalancheCenters.ts delete mode 100644 src/utilities/tenancy/getCollectionIDType.ts delete mode 100644 src/utilities/tenancy/getTenantIdFromCookie.ts delete mode 100644 src/utilities/tenancy/getTenants.ts create mode 100644 src/utilities/useTenantLookup.ts diff --git a/.env.example b/.env.example index e98920acf..3cfeec488 100644 --- a/.env.example +++ b/.env.example @@ -58,15 +58,6 @@ INVITE_TOKEN_EXPIRATION_MS= # Not needed locally. Used by the sanitize-db script in the development.yaml workflow. NON_PROD_SYNC_PASSWORD= -# Connection string for the Vercel Edge Config used to store tenants for middleware. -VERCEL_EDGE_CONFIG= - -# API token for Vercel. Primarily used to write values to the Vercel Edge Config but could be used for other operations. -VERCEL_TOKEN= - -# Vercel Team ID. Access on the team's settings page. -VERCEL_TEAM_ID= - # Local flags: these are optional flags you can set locally to do things like run production builds, enable more # logging for Next.js caching, etc. # Commented out by default. diff --git a/__tests__/server/getHostnameFromTenant.server.test.ts b/__tests__/server/getHostnameFromTenant.server.test.ts index 1f994339c..641871326 100644 --- a/__tests__/server/getHostnameFromTenant.server.test.ts +++ b/__tests__/server/getHostnameFromTenant.server.test.ts @@ -28,58 +28,60 @@ describe('server-side utilities: getHostnameFromTenant', () => { }) it('returns custom domain for production tenants', () => { + // Use a valid tenant slug from AVALANCHE_CENTERS PRODUCTION_TENANTS.length = 0 - PRODUCTION_TENANTS.push('production-tenant') + PRODUCTION_TENANTS.push('nwac') // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const tenant = { - slug: 'production-tenant', - customDomain: 'productiondomain.com', + slug: 'nwac', + customDomain: 'nwac.us', } as Tenant const result = getHostnameFromTenant(tenant) - expect(result).toBe('productiondomain.com') + 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('production-tenant') + PRODUCTION_TENANTS.push('nwac') // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const tenant = { - slug: 'development-tenant', - customDomain: 'productiondomain.com', + slug: 'sac', + customDomain: 'sierraavalanchecenter.org', } as Tenant const result = getHostnameFromTenant(tenant) - expect(result).toBe('development-tenant.envvar.localhost:3000') + expect(result).toBe('sac.envvar.localhost:3000') }) it('handles multiple production tenants correctly', () => { PRODUCTION_TENANTS.length = 0 - PRODUCTION_TENANTS.push('tenant1', 'tenant2', 'tenant3') + PRODUCTION_TENANTS.push('nwac', 'sac', 'uac') // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const tenant1: Tenant = { - slug: 'tenant1', - customDomain: 'tenant1productiondomain.com', + slug: 'nwac', + customDomain: 'nwac.us', } as Tenant // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const tenant2: Tenant = { - slug: 'tenant2', - customDomain: 'tenant2productiondomain.com', + slug: 'sac', + customDomain: 'sierraavalanchecenter.org', } as Tenant // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const nonProductionTenant: Tenant = { - slug: 'non-production', - customDomain: 'tenant3productiondomain.com', + slug: 'btac', + customDomain: 'bridgertetonavalanchecenter.org', } as Tenant - expect(getHostnameFromTenant(tenant1)).toBe('tenant1productiondomain.com') - expect(getHostnameFromTenant(tenant2)).toBe('tenant2productiondomain.com') - expect(getHostnameFromTenant(nonProductionTenant)).toBe('non-production.envvar.localhost:3000') + expect(getHostnameFromTenant(tenant1)).toBe('nwac.us') + expect(getHostnameFromTenant(tenant2)).toBe('sierraavalanchecenter.org') + expect(getHostnameFromTenant(nonProductionTenant)).toBe('btac.envvar.localhost:3000') }) it('handles empty production tenants list', () => { @@ -87,25 +89,25 @@ describe('server-side utilities: getHostnameFromTenant', () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const tenant: Tenant = { - slug: 'any-tenant', - customDomain: 'custom.example.com', + slug: 'nwac', + customDomain: 'nwac.us', } as Tenant const result = getHostnameFromTenant(tenant) - expect(result).toBe('any-tenant.envvar.localhost:3000') + 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('production-tenant') + PRODUCTION_TENANTS.push('nwac') // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const tenant: Tenant = { - slug: 'production-tenant', + slug: 'nwac', customDomain: '', } as Tenant const result = getHostnameFromTenant(tenant) - expect(result).toBe('production-tenant.envvar.localhost:3000') + expect(result).toBe('nwac.envvar.localhost:3000') }) }) diff --git a/consistent-type-assertions.txt b/consistent-type-assertions.txt index dd480dc3a..73b548851 100644 --- a/consistent-type-assertions.txt +++ b/consistent-type-assertions.txt @@ -14,10 +14,7 @@ src/collections/Users/components/InviteUserDrawer.tsx src/collections/Users/components/inviteUserAction.ts src/collections/Users/components/resendInviteActions.ts src/components/Header/utils.ts -src/components/TenantSelector/TenantSelector.client.tsx src/endpoints/seed/index.ts src/endpoints/seed/upsert.ts src/globals/Diagnostics/actions/revalidateCache.ts -src/middleware.ts -src/services/vercel.ts src/utilities/removeNonDeterministicKeys.ts diff --git a/docs/decisions/008-edge-config-tenant-lookup.md b/docs/decisions/008-edge-config-tenant-lookup.md index d70164218..2cd581d75 100644 --- a/docs/decisions/008-edge-config-tenant-lookup.md +++ b/docs/decisions/008-edge-config-tenant-lookup.md @@ -2,7 +2,7 @@ Date: 2025-07-26 -Status: accepted +Status: superseded by [013-hardcoded-tenant-lookup.md](./013-hardcoded-tenant-lookup.md) Supersedes: [007-dynamic-tenants-middleware.md](./007-dynamic-tenants-middleware.md) diff --git a/docs/decisions/013-hardcoded-tenant-lookup.md b/docs/decisions/013-hardcoded-tenant-lookup.md new file mode 100644 index 000000000..b3f631390 --- /dev/null +++ b/docs/decisions/013-hardcoded-tenant-lookup.md @@ -0,0 +1,80 @@ +# Hardcoded Tenant Lookup + +Date: 2026-01-22 + +Status: accepted + +Supersedes: [008-edge-config-tenant-lookup.md](./008-edge-config-tenant-lookup.md) + +## Context + +We're essentially migrating back to the approach we used before [007-dynamic-tenants-middleware.md](./007-dynamic-tenants-middleware.md) which is to have a hardcoded tenant list. We moved to a dynamic tenant lookup because we were using the multi-tenant plugin from Payload at that point which needed to use the tenants collection's id for the tenant cookie. + +Since we [moved away from the multi-tenant plugin](./010-remove-multi-tenant-plugin.md), we can use a hardcoded list of all possible avalanche centers and use the slug for the `payload-tenant` cookie because we control the code. The biggest issue that [007-dynamic-tenants-middleware.md](./007-dynamic-tenants-middleware.md) accomplished was to avoid issues where ids were different between environments due to order in which they were created / the tenants that were actually created in a given environment. This isn't an issue when using the slug. + +We got a little too complicated with the Edge Config lookup. The Edge Config-based tenant lookup approach had several drawbacks: + +1. **External dependency**: Required Vercel Edge Config service and API tokens +2. **Multiple failure modes**: Edge Config unavailable → cached API fallback → database query +3. **Sync complexity**: Hooks needed to update Edge Config on tenant changes +4. **Middleware latency**: Async lookups added latency to every request +5. **Cookie stored tenant ID**: Required ID-to-slug conversions for URL routing +6. **Seemed to have issues with static route generation**: because of the Edge Config call + +The list of US avalanche centers is finite and rarely changes. New centers joining are infrequent events that can be handled via code deployment. Requiring a code change for a new center being created is reasonable and we already have to [make a code change for when tenants go to production](../onboarding.md). + +## Decision + +Replace Edge Config with a **hardcoded list of all US avalanche center slugs** directly in the codebase. + +### Key Changes + +1. **Single source of truth**: `src/utilities/tenancy/avalancheCenters.ts` contains all valid tenant slugs and custom domains +2. **Synchronous middleware**: Tenant matching is now purely synchronous with no external lookups +3. **Slug-based cookies**: The `payload-tenant` cookie now stores the tenant slug (e.g., `nwac`) instead of numeric ID +4. **Database relationships unchanged**: Tenant relationships still use numeric IDs internally + +### Data Flow + +``` +Request → Middleware matches slug from subdomain/domain + → Sets cookie: payload-tenant=nwac + ↓ +Admin/API reads slug from cookie + → Queries tenant by slug when ID is needed + → Uses ID for relationship operations +``` + +### Files Removed + +- `src/services/vercel.ts` - Edge Config API integration +- `src/utilities/tenancy/getTenants.ts` - Async tenant fetching +- `src/collections/Tenants/hooks/updateEdgeConfig.ts` - Edge Config sync hooks +- `src/collections/Tenants/endpoints/cachedPublicTenants.ts` - Cached API fallback +- `src/utilities/tenancy/getCollectionIDType.ts` - ID type helper +- `src/utilities/tenancy/getTenantIdFromCookie.ts` - Replaced by slug-based function + +### Environment Variables Removed + +- `VERCEL_EDGE_CONFIG` +- `VERCEL_TOKEN` +- `VERCEL_TEAM_ID` + +## Consequences + +**Benefits:** + +- **Zero external dependencies** for tenant resolution +- **Faster middleware** - synchronous slug matching instead of async API calls +- **Simpler architecture** - no hooks, no fallbacks, no caching layers +- **Type safety** - `ValidTenantSlug` type ensures only valid slugs are used + +**Trade-offs:** + +- **Code deployment required** to add new avalanche centers +- **Custom domain changes** require code deployment (this was the case before too due to whitelisting for images in the `next.config.ts`) + +**Migration notes:** + +- Existing `payload-tenant` cookies with numeric IDs will be replaced with slugs on next visit +- The Tenants collection `slug` field is now a select field with predefined options diff --git a/docs/tenant-selector.md b/docs/tenant-selector.md new file mode 100644 index 000000000..a07cecc08 --- /dev/null +++ b/docs/tenant-selector.md @@ -0,0 +1,203 @@ +# Tenant Selector + +This document describes the tenant selector behavior in the Payload admin panel and provides manual test cases for verification. + +## Overview + +The tenant selector allows users with access to multiple avalanche centers to switch between tenants in the admin panel. It appears in the admin sidebar and controls: + +1. Which tenant's documents are shown in list views +2. Which tenant is pre-selected when creating new documents +3. The `payload-tenant` cookie value + +It also indicates which tenant a document is for in the document view. + +## Collection Type Classifications + +### Tenant-Required Collections + +Collections with `tenantField()` but NOT `unique: true`. Each tenant can have multiple documents. + +**Examples:** Pages, Posts, Media, Documents, Sponsors, Tags, Events, Biographies, Teams, Redirects + +**Behavior:** +- List view: Filtered by selected tenant +- Document view: Tenant selector is read-only (locked to document's tenant) + +### Global Collections (One Per Tenant) + +Collections with `tenantField()` AND `unique: true`. Each tenant has exactly one document. + +**Examples:** Settings, Navigations, HomePages + +**Behavior:** +- Tenant selector remains enabled on document view +- Changing tenant redirects to that tenant's document + +### Non-Tenant Collections + +Collections without a tenant field. Documents are shared across all tenants. + +**Examples:** Users, Tenants, GlobalRoles, GlobalRoleAssignments, Roles, Courses, Providers + +**Behavior:** +- Tenant selector is hidden +- All documents visible (subject to user permissions) + +### Payload Globals + +Single-document globals (not collections). + +**Examples:** A3Management, NACWidgetsConfig, Diagnostics + +**Behavior:** +- Tenant selector is hidden +- Tenant cookie unchanged when visiting + +## Manual Test Cases + +### Test Matrix Dimensions + +- **Collection types:** Tenant-required, Global collection, Non-tenant, Payload global +- **Roles:** Super admin, Multi-center admin, Single-center admin +- **Domains:** Root domain, Tenant subdomain + +--- + +### Tenant-Required Collection (e.g., Pages, Posts) + +#### List View + +- [ ] The tenant selector should be visible and enabled +- [ ] Changing the tenant selector should filter the list to show only that tenant's documents +- [ ] The list should only show documents matching the selected tenant cookie + +#### Document View (existing document) + +- [ ] The tenant selector should be visible but disabled/read-only +- [ ] You should not be able to clear the tenant selector +- [ ] You should not be able to change the tenant selector +- [ ] Visiting a document should set the tenant cookie to that document's tenant field value +- [ ] The tenant field in the form should match the tenant selector value + +#### Document View (create new) + +- [ ] The tenant selector should be visible but disabled/read-only +- [ ] The tenant field should be pre-populated with the current cookie value + +--- + +### Global Collection (e.g., Settings, Navigations, HomePages) + +#### List View + +- [ ] The tenant selector should be visible and enabled +- [ ] Changing the tenant selector should filter the list + +#### Document View + +- [ ] The tenant selector should be visible and enabled +- [ ] You should not be able to clear the tenant selector +- [ ] You should be able to change the tenant selector +- [ ] Changing the tenant selector should redirect to that tenant's document for this collection + +--- + +### Non-Tenant Collection (e.g., Users, Tenants, GlobalRoles) + +#### List View + +- [ ] The tenant selector should be hidden +- [ ] Tenant cookie value should NOT be changed when visiting +- [ ] All documents should be visible (subject to user's access permissions) + +#### Document View + +- [ ] The tenant selector should be hidden +- [ ] Tenant cookie value should NOT be changed when visiting + +--- + +### Payload Global (e.g., A3Management, NACWidgetsConfig) + +- [ ] Tenant selector should be hidden +- [ ] Tenant cookie value should NOT be changed when visiting +- [ ] Document should be accessible regardless of current tenant cookie + +--- + +### Dashboard View + +- [ ] The tenant selector should be visible and enabled (if user has multiple tenants) +- [ ] Changing the tenant selector should update the cookie +- [ ] Dashboard widgets/recent documents should reflect the selected tenant + +--- + +## Role-Based Test Cases + +### Super Admin + +- [ ] Should see all tenants in the tenant selector dropdown +- [ ] Should be able to switch between any tenant +- [ ] Should have access to all collections including non-tenant ones + +### Multi-Center Admin (user with roles in 2+ tenants) + +- [ ] Should see only their assigned tenants in the dropdown +- [ ] Should be able to switch between their tenants +- [ ] Should NOT see tenants they don't have access to in the dropdown +- [ ] Attempting to manually navigate to a document from an unauthorized tenant should result in access denied + +### Single-Center Admin (user with role in exactly 1 tenant) + +- [ ] The tenant selector should be hidden (only 1 option available) +- [ ] The tenant cookie should automatically be set to their single tenant's value +- [ ] All tenant-scoped operations should use their single tenant + +--- + +## Domain-Based Test Cases + +### Root Domain (localhost:3000/admin) + +- [ ] Tenant selector visible for multi-tenant users +- [ ] All tenant switching functionality works +- [ ] Cookie persists across navigation + +### Tenant Subdomain (e.g., nwac.localhost:3000/admin) + +- [ ] If domain-scoped deployment: tenant selector should be hidden +- [ ] Tenant should be automatically determined from subdomain +- [ ] User should only see documents for that subdomain's tenant + +--- + +## Cookie Edge Cases + +- [ ] **No cookie initially:** First admin visit should auto-select first available tenant +- [ ] **Invalid cookie value:** Cookie with non-existent tenant slug should fallback gracefully by auto-selecting first available tenant +- [ ] **Cookie persistence:** Cookie survives browser close/reopen (1-year expiration) +- [ ] **Cookie cleared:** Manually clearing cookie and revisiting admin should auto-select first available tenant + +--- + +## Navigation & State Consistency + +- [ ] **Browser back/forward:** Tenant state should remain consistent with URL/document +- [ ] **Direct URL access:** Navigating directly to a document URL should set correct tenant +- [ ] **Cross-collection navigation:** Switching from tenant-required to non-tenant collection should hide selector +- [ ] **Multiple tabs:** Changing tenant in one tab affects cookie, may affect other tabs on refresh +- [ ] **Login/logout:** Tenant state should persist after re-authentication + +--- + +## Implementation Reference + +Key files for the tenant selector implementation: + +- `src/components/TenantSelector/TenantSelector.tsx` - Server component +- `src/components/TenantSelector/TenantSelector.client.tsx` - Client component with visibility logic +- `src/providers/TenantSelectionProvider/` - React context provider +- `src/fields/tenantField/TenantFieldComponent.tsx` - Form field integration +- `src/utilities/tenancy/` - Cookie and tenant resolution utilities diff --git a/src/access/byTenantRole.ts b/src/access/byTenantRole.ts index 3561327ef..113e4e577 100644 --- a/src/access/byTenantRole.ts +++ b/src/access/byTenantRole.ts @@ -1,7 +1,7 @@ import { globalRoleAssignmentsForUser } from '@/utilities/rbac/globalRoleAssignmentsForUser' import { roleAssignmentsForUser } from '@/utilities/rbac/roleAssignmentsForUser' import { ruleCollection, ruleMatches, ruleMethod } from '@/utilities/rbac/ruleMatches' -import { getTenantFromCookie } from '@/utilities/tenancy/getTenantFromCookie' +import { getTenantSlugFromCookie } from '@/utilities/tenancy/getTenantFromCookie' import { Access, CollectionConfig } from 'payload' // byTenantRole walks the roles bound to the user to determine if they have permissions @@ -30,7 +30,7 @@ export const byTenantRole: (method: ruleMethod, collection: ruleCollection) => A } const roleAssignments = roleAssignmentsForUser(payload.logger, user) - const matchingTenantIds = roleAssignments + const matchingTenants = roleAssignments .filter( (assignment) => assignment.role && @@ -39,13 +39,15 @@ export const byTenantRole: (method: ruleMethod, collection: ruleCollection) => A ) .map((assignment) => assignment.tenant) .filter((tenant) => typeof tenant !== 'number') // captured in the getter - .map((tenant) => tenant.id) + + const matchingTenantIds = matchingTenants.map((tenant) => tenant.id) + const matchingTenantSlugs = matchingTenants.map((tenant) => tenant.slug) if (matchingTenantIds.length > 0) { - const tenantFromCookie = getTenantFromCookie(headers, 'number') + const tenantSlug = getTenantSlugFromCookie(headers) - if (tenantFromCookie) { - return matchingTenantIds.includes(tenantFromCookie) + if (tenantSlug) { + return matchingTenantSlugs.includes(tenantSlug) } else { return { tenant: { @@ -63,8 +65,8 @@ export const accessByTenantRole: (collection: ruleCollection) => CollectionConfi ) => { return { create: ({ req }) => { - const tenantFromCookie = getTenantFromCookie(req.headers, 'number') - return tenantFromCookie ? byTenantRole('create', collection)({ req }) : false + const tenantSlug = getTenantSlugFromCookie(req.headers) + return tenantSlug ? byTenantRole('create', collection)({ req }) : false }, read: byTenantRole('read', collection), update: byTenantRole('update', collection), diff --git a/src/access/filterByTenant.ts b/src/access/filterByTenant.ts index c721541f4..442636c4c 100644 --- a/src/access/filterByTenant.ts +++ b/src/access/filterByTenant.ts @@ -1,17 +1,17 @@ -import { getTenantFromCookie } from '@/utilities/tenancy/getTenantFromCookie' -import { BaseListFilter } from 'payload' +import { getTenantSlugFromCookie } from '@/utilities/tenancy/getTenantFromCookie' +import { BaseFilter } from 'payload' // filterByTenant implements per-tenant data filtering from the 'payload-tenant' cookie // by setting the base list filters that users can add to but not remove from. Access // control defines the set of data to be filtered, so this filter is on-top and has no // risk of letting users see data they cannot access. -export const filterByTenant: BaseListFilter = async ({ req }) => { - const selectedTenant = getTenantFromCookie(req.headers, 'number') +export const filterByTenant: BaseFilter = async ({ req }) => { + const tenantSlug = getTenantSlugFromCookie(req.headers) - if (selectedTenant) { + if (tenantSlug) { return { - tenant: { - equals: selectedTenant, + 'tenant.slug': { + equals: tenantSlug, }, } } diff --git a/src/collections/BuiltInPages/index.ts b/src/collections/BuiltInPages/index.ts index 80127446f..4a72d2bf3 100644 --- a/src/collections/BuiltInPages/index.ts +++ b/src/collections/BuiltInPages/index.ts @@ -7,7 +7,7 @@ import { contentHashField } from '@/fields/contentHashField' import { tenantField } from '@/fields/tenantField' import { titleField } from '@/fields/title' import { populatePublishedAt } from '@/hooks/populatePublishedAt' -import { getTenantFromCookie } from '@/utilities/tenancy/getTenantFromCookie' +import { getTenantSlugFromCookie } from '@/utilities/tenancy/getTenantFromCookie' export const BuiltInPages: CollectionConfig<'pages'> = { slug: 'builtInPages', @@ -17,8 +17,8 @@ export const BuiltInPages: CollectionConfig<'pages'> = { }, access: { create: ({ req }) => { - const tenantFromCookie = getTenantFromCookie(req.headers, 'number') - return tenantFromCookie ? byGlobalRole('create', 'builtInPages')({ req }) : false + const tenantSlug = getTenantSlugFromCookie(req.headers) + return tenantSlug ? byGlobalRole('create', 'builtInPages')({ req }) : false }, read: byTenantRole('read', 'builtInPages'), update: byGlobalRole('update', 'builtInPages'), diff --git a/src/collections/Pages/components/DuplicatePageFor/DuplicatePageForDrawer.tsx b/src/collections/Pages/components/DuplicatePageFor/DuplicatePageForDrawer.tsx index 4e11967af..44166909b 100644 --- a/src/collections/Pages/components/DuplicatePageFor/DuplicatePageForDrawer.tsx +++ b/src/collections/Pages/components/DuplicatePageFor/DuplicatePageForDrawer.tsx @@ -1,6 +1,7 @@ 'use client' import { useTenantSelection } from '@/providers/TenantSelectionProvider/index.client' +import { useTenantLookup } from '@/utilities/useTenantLookup' import { Banner, Button, @@ -15,13 +16,13 @@ import { } from '@payloadcms/ui' import { useRouter } from 'next/navigation' import { formatAdminURL } from 'payload/shared' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' // TODOs // - Remove photos from blocks or use a global photo? export const DuplicatePageForDrawer = () => { - const { savedDocumentData: pageData } = useDocumentInfo() + const { data: pageData } = useDocumentInfo() const modified = useFormModified() const router = useRouter() const { options } = useTenantSelection() @@ -31,18 +32,33 @@ export const DuplicatePageForDrawer = () => { routes: { admin: adminRoute }, }, } = useConfig() + const { lookupTenantSlugById } = useTenantLookup() - const tenantOptions = options.filter((option) => option.value !== pageData?.tenant) + // Filter out the current page's tenant from options + const [currentTenantSlug, setCurrentTenantSlug] = useState(null) + + useEffect(() => { + const fetchCurrentTenantSlug = async () => { + // pageData.tenant is an ID (number) + if (pageData?.tenant && typeof pageData.tenant === 'number') { + const slug = await lookupTenantSlugById(pageData.tenant) + setCurrentTenantSlug(slug) + } + } + fetchCurrentTenantSlug() + }, [pageData?.tenant, lookupTenantSlugById]) + + const tenantOptions = options.filter((option) => option.value !== currentTenantSlug) const drawerSlug = 'duplicate-page-drawer' const { openModal, closeModal } = useModal() - const [selectedTenantId, setSelectedTenantId] = useState('') + const [selectedTenantSlug, setSelectedTenantSlug] = useState('') const handleDuplicate = useCallback( async (e?: React.FormEvent) => { if (e) e.preventDefault() - if (!selectedTenantId) { + if (!selectedTenantSlug) { toast.error('Please select a tenant.') return } @@ -50,14 +66,14 @@ export const DuplicatePageForDrawer = () => { try { const newPage = { layout: pageData?.layout, title: pageData?.title, slug: pageData?.slug } - const createRes = await fetch(`/api/pages/duplicate-to-tenant/${selectedTenantId}`, { + const createRes = await fetch(`/api/pages/duplicate-to-tenant/${selectedTenantSlug}`, { method: 'POST', body: JSON.stringify({ newPage }), }) if (createRes.ok) { const { res } = await createRes.json() - setSelectedTenantId('') + setSelectedTenantSlug('') closeModal(drawerSlug) toast.success('Page duplicated to tenant!') return startRouteTransition(() => @@ -76,7 +92,7 @@ export const DuplicatePageForDrawer = () => { toast.error(err instanceof Error ? err.message : 'An unexpected error occurred.') } }, - [adminRoute, closeModal, pageData, router, selectedTenantId, startRouteTransition], + [adminRoute, closeModal, pageData, router, selectedTenantSlug, startRouteTransition], ) return ( @@ -106,8 +122,8 @@ export const DuplicatePageForDrawer = () => { type="radio" name="tenant" value={option.value} - checked={selectedTenantId === option.value} - onChange={() => setSelectedTenantId(option.value)} + checked={selectedTenantSlug === option.value} + onChange={() => setSelectedTenantSlug(String(option.value))} /> {String(option.label)} @@ -117,7 +133,7 @@ export const DuplicatePageForDrawer = () => { - diff --git a/src/collections/Pages/endpoints/duplicatePageToTenant.ts b/src/collections/Pages/endpoints/duplicatePageToTenant.ts index 689d5f60a..8c8f3546e 100644 --- a/src/collections/Pages/endpoints/duplicatePageToTenant.ts +++ b/src/collections/Pages/endpoints/duplicatePageToTenant.ts @@ -4,16 +4,19 @@ import configPromise from '@payload-config' import { getPayload, PayloadRequest } from 'payload' export async function duplicatePageToTenant(req: PayloadRequest) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const selectedTenantId = req.routeParams?.selectedTenantId as number + const tenantSlug = req.routeParams?.tenantSlug const { newPage } = await req.json?.() const payload = await getPayload({ config: configPromise }) const tenant = await payload - .find({ collection: 'tenants', where: { id: { equals: selectedTenantId } } }) + .find({ collection: 'tenants', where: { slug: { equals: tenantSlug } }, limit: 1 }) .then((res) => res.docs[0]) + if (!tenant) { + throw new Error(`Tenant not found: ${tenantSlug}`) + } + const newPageSansIds = removeIdKey(newPage) return await payload.create({ diff --git a/src/collections/Pages/index.ts b/src/collections/Pages/index.ts index 9720ac4ff..ffad2d488 100644 --- a/src/collections/Pages/index.ts +++ b/src/collections/Pages/index.ts @@ -131,7 +131,7 @@ export const Pages: CollectionConfig<'pages'> = { ], endpoints: [ { - path: '/duplicate-to-tenant/:selectedTenantId', + path: '/duplicate-to-tenant/:tenantSlug', method: 'post', handler: async (req) => { diff --git a/src/collections/Tenants/endpoints/cachedPublicTenants.ts b/src/collections/Tenants/endpoints/cachedPublicTenants.ts deleted file mode 100644 index 59fa007c1..000000000 --- a/src/collections/Tenants/endpoints/cachedPublicTenants.ts +++ /dev/null @@ -1,54 +0,0 @@ -import configPromise from '@payload-config' -import { unstable_cache } from 'next/cache' -import { getPayload } from 'payload' - -export const getCachedTenants = unstable_cache( - async () => { - const payload = await getPayload({ config: configPromise }) - - const tenantsRes = await payload.find({ - collection: 'tenants', - limit: 1000, - pagination: false, - select: { - id: true, - slug: true, - customDomain: true, - }, - }) - - return tenantsRes.docs.map((tenant) => ({ - id: tenant.id, - slug: tenant.slug, - customDomain: tenant.customDomain || null, - })) - }, - ['tenants-data'], - { - tags: ['tenants'], - revalidate: 300, // 5 minutes - }, -) - -export const cachedPublicTenants = async (): Promise => { - try { - const tenants = await getCachedTenants() - - return Response.json(tenants, { - headers: { - // ISR: Cache for 5 minutes, then regenerate in background - 'Cache-Control': 's-maxage=300, stale-while-revalidate=86400', - 'CDN-Cache-Control': 'max-age=300', - }, - }) - } catch (error) { - console.error('Error fetching tenants:', error) - - // TODO: should this throw an error instead? Hmm when would this fail? - return Response.json([], { - headers: { - 'Cache-Control': 'no-cache', - }, - }) - } -} diff --git a/src/collections/Tenants/hooks/updateEdgeConfig.ts b/src/collections/Tenants/hooks/updateEdgeConfig.ts deleted file mode 100644 index 3cfcc47af..000000000 --- a/src/collections/Tenants/hooks/updateEdgeConfig.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { updateEdgeConfig } from '@/services/vercel' -import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload' - -interface EdgeConfigPayload { - items: Array<{ - key: string - value?: unknown - operation: 'upsert' | 'delete' - }> -} - -export const updateEdgeConfigAfterChange: CollectionAfterChangeHook = async ({ - req, - doc, - previousDoc, - operation, -}) => { - try { - const tenant = { - id: doc.id, - slug: doc.slug, - customDomain: doc.customDomain || null, - } - - const items: EdgeConfigPayload['items'] = [] - - // If this is an update and slug or customDomain changed, remove old keys - if (operation === 'update' && previousDoc) { - const previousTenant = { - id: previousDoc.id, - slug: previousDoc.slug, - customDomain: previousDoc.customDomain || null, - } - - const oldTenantKey = `tenant_${previousTenant.slug}` - const newTenantKey = `tenant_${tenant.slug}` - - // If slug changed, remove old key - if (oldTenantKey !== newTenantKey) { - items.push({ - key: oldTenantKey, - operation: 'delete', - }) - } - } - - // Add tenant with tenant_ prefix - const tenantKey = `tenant_${tenant.slug}` - items.push({ - key: tenantKey, - value: tenant, - operation: 'upsert', - }) - - if (items.length > 0) { - await updateEdgeConfig({ items }) - req.payload.logger.info( - `Successfully updated Edge Config after ${operation} on tenant: ${doc.slug} (${items.length} operations)`, - ) - } else { - req.payload.logger.info(`No Edge Config updates needed for tenant: ${doc.slug}`) - } - } catch (error) { - req.payload.logger.error({ err: error }, `Error updating Edge Config for tenant ${doc.slug}`) - } -} - -export const updateEdgeConfigAfterDelete: CollectionAfterDeleteHook = async ({ req, doc }) => { - try { - const tenant = { - id: doc.id, - slug: doc.slug, - customDomain: doc.customDomain || null, - } - - const tenantKey = `tenant_${tenant.slug}` - const items: EdgeConfigPayload['items'] = [ - { - key: tenantKey, - operation: 'delete' as const, - }, - ] - - if (items.length > 0) { - await updateEdgeConfig({ items }) - req.payload.logger.info( - `Successfully removed tenant ${doc.slug} from Edge Config (${items.length} keys deleted)`, - ) - } - } catch (error) { - req.payload.logger.error({ err: error }, `Error removing tenant ${doc.slug} from Edge Config`) - } -} diff --git a/src/collections/Tenants/index.ts b/src/collections/Tenants/index.ts index 1ff075756..18d713084 100644 --- a/src/collections/Tenants/index.ts +++ b/src/collections/Tenants/index.ts @@ -1,15 +1,11 @@ import { accessByGlobalRoleOrTenantIds } from '@/collections/Tenants/access/byGlobalRoleOrTenantIds' -import { cachedPublicTenants } from '@/collections/Tenants/endpoints/cachedPublicTenants' import { revalidateTenantsAfterChange, revalidateTenantsAfterDelete, } from '@/collections/Tenants/hooks/revalidateTenants' -import { - updateEdgeConfigAfterChange, - updateEdgeConfigAfterDelete, -} from '@/collections/Tenants/hooks/updateEdgeConfig' import { contentHashField } from '@/fields/contentHashField' import { hasReadOnlyAccess } from '@/utilities/rbac/hasReadOnlyAccess' +import { AVALANCHE_CENTERS, VALID_TENANT_SLUGS } from '@/utilities/tenancy/avalancheCenters' import type { CollectionConfig } from 'payload' export const Tenants: CollectionConfig = { @@ -29,16 +25,9 @@ export const Tenants: CollectionConfig = { slug: true, customDomain: true, // required for byGlobalRoleOrTenantRoleAssignment }, - endpoints: [ - { - path: '/cached-public', - method: 'get', - handler: cachedPublicTenants, - }, - ], hooks: { - afterChange: [revalidateTenantsAfterChange, updateEdgeConfigAfterChange], - afterDelete: [revalidateTenantsAfterDelete, updateEdgeConfigAfterDelete], + afterChange: [revalidateTenantsAfterChange], + afterDelete: [revalidateTenantsAfterDelete], }, fields: [ { @@ -53,11 +42,14 @@ export const Tenants: CollectionConfig = { }, { name: 'slug', - type: 'text', + type: 'select', admin: { - description: - 'Used for subdomains and url paths for previews. This is a unique identifier for a tenant.', + description: 'Avalanche center identifier. Used for subdomains and URL paths.', }, + options: VALID_TENANT_SLUGS.map((slug) => ({ + label: `${AVALANCHE_CENTERS[slug].name} (${slug})`, + value: slug, + })), index: true, required: true, unique: true, diff --git a/src/collections/Users/access/filterByTenantScopedDomain.ts b/src/collections/Users/access/filterByTenantScopedDomain.ts index b823815b8..a758f6d51 100644 --- a/src/collections/Users/access/filterByTenantScopedDomain.ts +++ b/src/collections/Users/access/filterByTenantScopedDomain.ts @@ -1,10 +1,10 @@ import { byGlobalRole } from '@/access/byGlobalRole' import { isTenantDomainScoped } from '@/utilities/tenancy/isTenantDomainScoped' -import { BaseListFilter, Where } from 'payload' +import { BaseFilter, Where } from 'payload' // filterByTenantScopedDomain filters the users collection if the current domain is a tenant-scoped // subdomain or custom domain. -export const filterByTenantScopedDomain: BaseListFilter = async ({ req }) => { +export const filterByTenantScopedDomain: BaseFilter = async ({ req }) => { const { tenant } = await isTenantDomainScoped() if (!tenant) { diff --git a/src/components/TenantSelector/TenantSelector.client.tsx b/src/components/TenantSelector/TenantSelector.client.tsx index 775c9725e..120e5802e 100644 --- a/src/components/TenantSelector/TenantSelector.client.tsx +++ b/src/components/TenantSelector/TenantSelector.client.tsx @@ -11,7 +11,7 @@ import './index.scss' const TenantSelectorClient = ({ label }: { label: string }) => { const { config } = useConfig() const params = useParams() - const { options, selectedTenantID, setTenant } = useTenantSelection() + const { options, selectedTenantSlug, setTenant } = useTenantSelection() const isGlobal = params.segments && params.segments[0] === 'globals' const isCollection = params.segments && params.segments[0] === 'collections' @@ -34,10 +34,9 @@ const TenantSelectorClient = ({ label }: { label: string }) => { const handleChange = useCallback( (option: ReactSelectOption | ReactSelectOption[]) => { if (option && 'value' in option) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - setTenant({ id: option.value as string, refresh: true }) + setTenant({ slug: String(option.value), refresh: true }) } else { - setTenant({ id: undefined, refresh: true }) + setTenant({ slug: undefined, refresh: true }) } }, [setTenant], @@ -63,8 +62,7 @@ const TenantSelectorClient = ({ label }: { label: string }) => { options={options} path="setTenant" readOnly={isReadOnly} - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - value={selectedTenantID as string | undefined} + value={selectedTenantSlug} /> ) diff --git a/src/endpoints/seed/index.ts b/src/endpoints/seed/index.ts index ae722e3b3..a300e7189 100644 --- a/src/endpoints/seed/index.ts +++ b/src/endpoints/seed/index.ts @@ -446,10 +446,9 @@ export const seed = async ({ .flat(), ]) - // Settings - const settingsData: Record< - Tenant['slug'], - Partial> + // Settings - only define data for seeded tenants, not all possible slugs + const settingsData: Partial< + Record>> > = { dvac: { description: @@ -502,23 +501,27 @@ export const seed = async ({ incremental, tenantsById, (obj) => (typeof obj.tenant === 'object' ? obj.tenant.slug : 'UNKNOWN'), - Object.values(tenants).map( - (tenant): RequiredDataFromCollectionSlug<'settings'> => ({ + Object.values(tenants).map((tenant): RequiredDataFromCollectionSlug<'settings'> => { + const data = settingsData[tenant.slug] + if (!data) { + throw new Error(`Missing settings data for tenant ${tenant.slug}`) + } + return { tenant: tenant.id, - description: settingsData[tenant.slug].description, + description: data.description, footerForm: { type: 'none', }, - address: settingsData[tenant.slug].address, - phone: settingsData[tenant.slug].phone, - email: settingsData[tenant.slug].email, - socialMedia: settingsData[tenant.slug].socialMedia, + address: data.address, + phone: data.phone, + email: data.email, + socialMedia: data.socialMedia, logo: brandImages[tenant.slug]['logo'].id, icon: brandImages[tenant.slug]['icon'].id, banner: brandImages[tenant.slug]['banner'].id, usfsLogo: brandImages[tenant.slug]['usfs logo']?.id, - }), - ), + } + }), ) if (!process.env.PAYLOAD_SEED_PASSWORD && process.env.ALLOW_SIMPLE_PASSWORDS !== 'true') { diff --git a/src/fields/tenantField/TenantFieldComponent.tsx b/src/fields/tenantField/TenantFieldComponent.tsx index b7562f275..7d12708d7 100644 --- a/src/fields/tenantField/TenantFieldComponent.tsx +++ b/src/fields/tenantField/TenantFieldComponent.tsx @@ -6,6 +6,7 @@ import { RelationshipField, useField, useFormInitializing, useFormModified } fro import { useEffect, useRef } from 'react' import { useTenantSelection } from '@/providers/TenantSelectionProvider/index.client' +import { useTenantLookup } from '@/utilities/useTenantLookup' import './index.scss' const baseClass = 'tenantField' @@ -22,7 +23,7 @@ export const TenantFieldComponent = (args: Props) => { const formInitializingContext = useFormInitializing() const { options, - selectedTenantID, + selectedTenantSlug, setEntityType: setEntityType, setModified, setTenant, @@ -30,6 +31,7 @@ export const TenantFieldComponent = (args: Props) => { const isGlobalCollection = !!unique const hasSetValueRef = useRef(false) + const { lookupTenantIdBySlug, lookupTenantSlugById } = useTenantLookup() // Track whether form has finished initializing const formReady = !formInitializing && !formInitializingContext @@ -40,29 +42,42 @@ export const TenantFieldComponent = (args: Props) => { return } - if (!hasSetValueRef.current && !isGlobalCollection) { - // set value on load - if (value && value !== selectedTenantID) { - setTenant({ id: value, refresh: unique }) - } else { - // in the document view, the tenant field should always have a value - const defaultValue = selectedTenantID || options[0]?.value - setTenant({ id: defaultValue, refresh: unique }) - // Also set the field value when form is ready if we have a tenant selected - // This handles the case where selectedTenantID is already correct from the cookie - // and setTenant won't trigger a re-render - if (defaultValue) { - setValue(defaultValue, true) + const syncTenantValue = async () => { + if (!hasSetValueRef.current && !isGlobalCollection) { + // Set value on load + if (value && typeof value === 'number') { + // Tenant field is already set as the tenant ID - sync provider with document's tenant + const slug = await lookupTenantSlugById(value) + if (slug && slug !== selectedTenantSlug) { + setTenant({ slug, refresh: false }) + } + hasSetValueRef.current = true + return + } + + // Get tenant ID for the selected slug + const slug = selectedTenantSlug || (options[0]?.value ? options[0].value : null) + if (slug) { + setTenant({ slug, refresh: unique }) + const tenantId = await lookupTenantIdBySlug(slug) + if (tenantId) { + setValue(tenantId, true) + } + } + hasSetValueRef.current = true + } else if (selectedTenantSlug) { + // Update the field on the document value when the tenant is changed + const tenantId = await lookupTenantIdBySlug(selectedTenantSlug) + if (tenantId && value !== tenantId) { + setValue(tenantId, !value || value === tenantId) } } - hasSetValueRef.current = true - } else if (!value || value !== selectedTenantID) { - // Update the field on the document value when the tenant is changed - setValue(selectedTenantID, !value || value === selectedTenantID) } + + syncTenantValue() }, [ value, - selectedTenantID, + selectedTenantSlug, setTenant, setValue, options, @@ -71,6 +86,8 @@ export const TenantFieldComponent = (args: Props) => { formReady, formInitializing, formInitializingContext, + lookupTenantIdBySlug, + lookupTenantSlugById, ]) useEffect(() => { diff --git a/src/fields/tenantField/index.ts b/src/fields/tenantField/index.ts index d2937b783..cd5b2921f 100644 --- a/src/fields/tenantField/index.ts +++ b/src/fields/tenantField/index.ts @@ -1,4 +1,4 @@ -import { getTenantFromCookie } from '@/utilities/tenancy/getTenantFromCookie' +import { getTenantSlugFromCookie } from '@/utilities/tenancy/getTenantFromCookie' import { APIError, RelationshipField } from 'payload' export const tenantField = ({ @@ -30,11 +30,21 @@ export const tenantField = ({ maxDepth: 3, hooks: { beforeChange: [ - ({ req, value }) => { + async ({ req, value }) => { if (!value) { - const tenantFromCookie = getTenantFromCookie(req.headers, 'number') - if (tenantFromCookie) { - return tenantFromCookie + const tenantSlug = getTenantSlugFromCookie(req.headers) + if (tenantSlug) { + // Look up tenant ID by slug + const { docs } = await req.payload.find({ + collection: 'tenants', + where: { slug: { equals: tenantSlug } }, + limit: 1, + depth: 0, + req, + }) + if (docs[0]) { + return docs[0].id + } } throw new APIError('You must select a tenant', 400, null, true) } diff --git a/src/middleware.ts b/src/middleware.ts index 24ed6d7c5..146794244 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,11 @@ -import { createClient } from '@vercel/edge-config' import { NextRequest, NextResponse } from 'next/server' import { getURL } from './utilities/getURL' -import { PRODUCTION_TENANTS } from './utilities/tenancy/tenants' +import { + findCenterByDomain, + isValidTenantSlug, + ValidTenantSlug, +} from './utilities/tenancy/avalancheCenters' +import { getProductionCustomDomain, isProductionTenant } from './utilities/tenancy/tenants' export const config = { matcher: [ @@ -22,63 +26,26 @@ export const config = { ], } -export type TenantData = Array<{ id: number; slug: string; customDomain: string | null }> - -async function getTenants(): Promise<{ - tenants: TenantData - source: 'edge-config' | 'api' | 'error' - duration: number -}> { - const start = performance.now() - - try { - const edgeConfigClient = createClient(process.env.VERCEL_EDGE_CONFIG) - const allItems = await edgeConfigClient.getAll() - if (!allItems) throw new Error('No items in edge config.') - - const tenants: TenantData = [] - for (const [key, value] of Object.entries(allItems)) { - if (key.startsWith('tenant_') && value) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - tenants.push(value as TenantData[number]) - } - } - - const duration = performance.now() - start - return { tenants, source: 'edge-config', duration } - } catch (error) { - console.warn( - 'Failed to get all tenants from Edge Config, falling back to cached API route:', - error instanceof Error ? error.message : error, - ) - } - - try { - const baseUrl = getURL() - const response = await fetch(`${baseUrl}/api/tenants/cached-public`, { - signal: AbortSignal.timeout(500), // 500 ms timeout to keep max middleware execution time low +/** + * Sets the payload-tenant cookie if needed and returns whether it was set. + * Cookie stores the tenant slug. + */ +function setCookieIfNeeded( + response: NextResponse, + slug: ValidTenantSlug, + existingCookie: string | undefined, +): boolean { + if (existingCookie !== slug) { + response.cookies.set('payload-tenant', slug, { + path: '/', + sameSite: 'lax', }) - - if (response.ok) { - const freshTenants = await response.json() - - if (Array.isArray(freshTenants)) { - const duration = performance.now() - start - return { tenants: freshTenants, source: 'api', duration } - } - } - } catch (error) { - console.warn( - 'Failed to refresh tenants from API:', - error instanceof Error ? error.message : error, - ) + return true } - - const duration = performance.now() - start - return { tenants: [], source: 'error', duration } + return false } -export default async function middleware(req: NextRequest) { +export default function middleware(req: NextRequest) { const middlewareStart = performance.now() const logCompletion = (action: string) => { @@ -93,34 +60,19 @@ export default async function middleware(req: NextRequest) { const isDraftMode = req.cookies.has('__prerender_bypass') const hasNextInPath = req.nextUrl.pathname.includes('/next/') const isSeedEndpoint = req.nextUrl.pathname.includes('/next/seed') + const existingCookieValue = req.cookies.get('payload-tenant')?.value - const { tenants: TENANTS, source, duration: getTenantsDuration } = await getTenants() - - console.log( - `[Middleware] getTenants: ${getTenantsDuration.toFixed(2)}ms (${source}) - ${req.nextUrl.pathname}`, - ) - - // If request is to a custom domain + // If request is to a custom domain (not the root domain and not a subdomain of root) if (host && requestedHost && requestedHost !== host && !requestedHost.includes(`.${host}`)) { - // Only handle tenants in PRODUCTION_TENANTS - const customDomainTenant = TENANTS.find( - (tenant) => PRODUCTION_TENANTS.includes(tenant.slug) && tenant.customDomain === requestedHost, - ) + // Look up tenant by custom domain from hardcoded list + const tenantSlug = findCenterByDomain(requestedHost) - if (customDomainTenant && !hasNextInPath) { + if (tenantSlug && isProductionTenant(tenantSlug) && !hasNextInPath) { const pathname = req.nextUrl.pathname - const existingPayloadTenantCookie = req.cookies.get('payload-tenant') - const shouldSetCookie = - existingPayloadTenantCookie?.value !== customDomainTenant.id.toString() - if (pathname.startsWith('/admin')) { - if (shouldSetCookie) { - const response = NextResponse.next() - response.cookies.set('payload-tenant', customDomainTenant.id.toString(), { - path: '/', - sameSite: 'lax', - }) + const response = NextResponse.next() + if (setCookieIfNeeded(response, tenantSlug, existingCookieValue)) { logCompletion('custom-domain-admin-cookie-set') return response } @@ -130,15 +82,11 @@ export default async function middleware(req: NextRequest) { } const rewrite = req.nextUrl.clone() - rewrite.pathname = `/${customDomainTenant.slug}${rewrite.pathname}` + rewrite.pathname = `/${tenantSlug}${rewrite.pathname}` console.log(`rewrote custom domain ${req.nextUrl.toString()} to ${rewrite.toString()}`) - if (shouldSetCookie) { - const response = NextResponse.rewrite(rewrite) - response.cookies.set('payload-tenant', customDomainTenant.id.toString(), { - path: '/', - sameSite: 'lax', - }) + const response = NextResponse.rewrite(rewrite) + if (setCookieIfNeeded(response, tenantSlug, existingCookieValue)) { logCompletion('custom-domain-rewrite-with-cookie') return response } @@ -152,28 +100,26 @@ export default async function middleware(req: NextRequest) { if (host && requestedHost && requestedHost === host) { const pathSegments = req.nextUrl.pathname.split('/').filter(Boolean) - // Check if the first path segment matches a tenant slug + // Check if the first path segment matches a valid tenant slug if (pathSegments.length > 0) { const potentialTenant = pathSegments[0] - const tenant = TENANTS.find((t) => t.slug === potentialTenant) - if (tenant && !isDraftMode && !hasNextInPath) { + if (isValidTenantSlug(potentialTenant) && !isDraftMode && !hasNextInPath) { // Redirect to the tenant's subdomain or custom domain const redirectUrl = new URL(req.nextUrl.clone()) // For production tenants: use custom domain if available, otherwise fall back to subdomain // For non-production tenants: always use subdomain - if (PRODUCTION_TENANTS.includes(tenant.slug)) { - if (tenant.customDomain) { - redirectUrl.host = tenant.customDomain - } else { + const customDomain = getProductionCustomDomain(potentialTenant) + if (customDomain) { + redirectUrl.host = customDomain + } else { + if (isProductionTenant(potentialTenant)) { console.error( - `Production tenant "${tenant.slug}" is missing a custom domain. Falling back to subdomain.`, + `Production tenant "${potentialTenant}" is missing a custom domain. Falling back to subdomain.`, ) - redirectUrl.host = `${tenant.slug}.${host}` } - } else { - redirectUrl.host = `${tenant.slug}.${host}` + redirectUrl.host = `${potentialTenant}.${host}` } // Remove the tenant slug from the path for the redirect @@ -188,59 +134,51 @@ export default async function middleware(req: NextRequest) { // If request is to subdomain on root domain if (host && requestedHost && !isSeedEndpoint) { - for (const { id, slug, customDomain } of TENANTS) { - if (requestedHost === `${slug}.${host}`) { - // Redirect to custom domain if tenant is in PRODUCTION_TENANTS and has a custom domain configured - if (PRODUCTION_TENANTS.includes(slug) && customDomain) { - const redirectUrl = new URL(req.nextUrl.clone()) - redirectUrl.host = customDomain - - console.log(`redirecting ${req.nextUrl.toString()} to ${redirectUrl.toString()}`) - logCompletion('custom-domain-redirect') - return NextResponse.redirect( - redirectUrl, - process.env.NODE_ENV === 'production' ? 308 : 302, - ) - } + // Extract subdomain: remove the root domain suffix - pattern: slug.host + const subdomainMatch = requestedHost.match( + new RegExp(`^([^.]+)\\.${host.replace('.', '\\.')}$`), + ) + const subdomain = subdomainMatch?.[1] - const original = req.nextUrl.clone() - original.host = requestedHost - const rewrite = req.nextUrl.clone() - - const existingPayloadTenantCookie = req.cookies.get('payload-tenant') - const shouldSetCookie = existingPayloadTenantCookie?.value !== id.toString() - - if (req.nextUrl.pathname.startsWith('/admin')) { - if (shouldSetCookie) { - const response = NextResponse.next() - response.cookies.set('payload-tenant', id.toString(), { - path: '/', - sameSite: 'lax', - }) - logCompletion('admin-cookie-set') - return response - } + if (subdomain && isValidTenantSlug(subdomain)) { + const slug = subdomain + const customDomain = getProductionCustomDomain(slug) - logCompletion('admin-passthrough') - return - } + // Redirect to custom domain if tenant is a production tenant and has a custom domain configured + if (customDomain) { + const redirectUrl = new URL(req.nextUrl.clone()) + redirectUrl.host = customDomain - rewrite.pathname = `/${slug}${rewrite.pathname}` - console.log(`rewrote ${original.toString()} to ${rewrite.toString()}`) + console.log(`redirecting ${req.nextUrl.toString()} to ${redirectUrl.toString()}`) + logCompletion('custom-domain-redirect') + return NextResponse.redirect(redirectUrl, process.env.NODE_ENV === 'production' ? 308 : 302) + } - if (shouldSetCookie) { - const response = NextResponse.rewrite(rewrite) - response.cookies.set('payload-tenant', id.toString(), { - path: '/', - sameSite: 'lax', - }) - logCompletion('rewrite-with-cookie') + if (req.nextUrl.pathname.startsWith('/admin')) { + const response = NextResponse.next() + if (setCookieIfNeeded(response, slug, existingCookieValue)) { + logCompletion('admin-cookie-set') return response } - logCompletion('rewrite') - return NextResponse.rewrite(rewrite) + logCompletion('admin-passthrough') + return } + + const original = req.nextUrl.clone() + original.host = requestedHost + const rewrite = req.nextUrl.clone() + rewrite.pathname = `/${slug}${rewrite.pathname}` + console.log(`rewrote ${original.toString()} to ${rewrite.toString()}`) + + const response = NextResponse.rewrite(rewrite) + if (setCookieIfNeeded(response, slug, existingCookieValue)) { + logCompletion('rewrite-with-cookie') + return response + } + + logCompletion('rewrite') + return NextResponse.rewrite(rewrite) } } diff --git a/src/payload-types.ts b/src/payload-types.ts index 59f510a23..a19fe142e 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -300,9 +300,40 @@ export interface Tenant { name: string; customDomain?: string | null; /** - * Used for subdomains and url paths for previews. This is a unique identifier for a tenant. + * Avalanche center identifier. Used for subdomains and URL paths. */ - slug: string; + slug: + | 'aaic' + | 'bac' + | 'btac' + | 'cac' + | 'caic' + | 'caac' + | 'cbac' + | 'cnfaic' + | 'coaa' + | 'dvac' + | 'earac' + | 'esac' + | 'ewyaix' + | 'fac' + | 'gnfac' + | 'hac' + | 'hpac' + | 'ipac' + | 'kpac' + | 'msac' + | 'mwac' + | 'nwac' + | 'pac' + | 'sac' + | 'snfac' + | 'soaix' + | 'tac' + | 'uac' + | 'vac' + | 'wac' + | 'wcmac'; contentHash?: string | null; updatedAt: string; createdAt: string; diff --git a/src/payload.config.ts b/src/payload.config.ts index dd5e435c2..1018f2391 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -188,17 +188,10 @@ export default buildConfig({ Settings, Redirects, ], - cors: [ - 'api.avalanche.org', - 'api.snowobs.com', - getURL(), - ...(await getProductionTenantUrls()), - ].filter(Boolean), - csrf: [ - getURL(), - ...(await getTenantSubdomainUrls()), - ...(await getProductionTenantUrls()), - ].filter(Boolean), + cors: ['api.avalanche.org', 'api.snowobs.com', getURL(), ...getProductionTenantUrls()].filter( + Boolean, + ), + csrf: [getURL(), ...getTenantSubdomainUrls(), ...getProductionTenantUrls()].filter(Boolean), globals: [NACWidgetsConfig, DiagnosticsConfig, A3Management], graphQL: { disable: true, diff --git a/src/plugins/tenantFieldPlugin.ts b/src/plugins/tenantFieldPlugin.ts index d8eb650e6..96e7d458b 100644 --- a/src/plugins/tenantFieldPlugin.ts +++ b/src/plugins/tenantFieldPlugin.ts @@ -1,10 +1,10 @@ import { filterByTenant } from '@/access/filterByTenant' import { tenantField } from '@/fields/tenantField' -import { BaseListFilter, Config, Where } from 'payload' +import { BaseFilter, Config, Where } from 'payload' type Args = { - baseListFilter?: BaseListFilter - customFilter: BaseListFilter + baseListFilter?: BaseFilter + customFilter: BaseFilter } /** * Combines a base list filter with a tenant list filter @@ -12,7 +12,7 @@ type Args = { * Combines where constraints inside of an AND operator */ export const combineListFilters = - ({ baseListFilter, customFilter }: Args): BaseListFilter => + ({ baseListFilter, customFilter }: Args): BaseFilter => async (args) => { const filterConstraints: Where[] = [] diff --git a/src/providers/TenantSelectionProvider/index.client.tsx b/src/providers/TenantSelectionProvider/index.client.tsx index 178860753..e97c144b0 100644 --- a/src/providers/TenantSelectionProvider/index.client.tsx +++ b/src/providers/TenantSelectionProvider/index.client.tsx @@ -22,13 +22,13 @@ type ContextType = { */ modified?: boolean /** - * Array of options to select from + * Array of options to select from (value is tenant slug) */ options: OptionObject[] /** - * The currently selected tenant ID + * The currently selected tenant slug */ - selectedTenantID: number | string | undefined + selectedTenantSlug: string | undefined /** * Sets the entityType when a document is loaded and sets it to undefined when the document unmounts. */ @@ -38,26 +38,26 @@ type ContextType = { */ setModified: React.Dispatch> /** - * Sets the selected tenant ID + * Sets the selected tenant by slug * - * @param args.id - The ID of the tenant to select + * @param args.slug - The slug of the tenant to select * @param args.refresh - Whether to refresh the page after changing the tenant */ - setTenant: (args: { id: number | string | undefined; refresh?: boolean }) => void + setTenant: (args: { slug: string | undefined; refresh?: boolean }) => void /** * Used to sync tenants displayed in the tenant selector when updates are made to the tenants collection. */ syncTenants: () => Promise /** - * + * Updates a tenant's label in the local options */ - updateTenants: (args: { id: number | string; label: string }) => void + updateTenants: (args: { slug: string; label: string }) => void } const Context = createContext({ entityType: undefined, options: [], - selectedTenantID: undefined, + selectedTenantSlug: undefined, setEntityType: () => undefined, setModified: () => undefined, setTenant: () => null, @@ -107,12 +107,10 @@ export const TenantSelectionProviderClient = ({ }: { children: React.ReactNode initialTenantOptions: OptionObject[] - initialValue?: number | string + initialValue?: string tenantsCollectionSlug: string }) => { - const [selectedTenantID, setSelectedTenantID] = useState( - initialValue, - ) + const [selectedTenantSlug, setSelectedTenantSlug] = useState(initialValue) const [modified, setModified] = useState(false) const [entityType, setEntityType] = useState<'document' | 'global' | undefined>(undefined) const { user } = useAuth() @@ -123,15 +121,15 @@ export const TenantSelectionProviderClient = ({ const userChanged = userID !== prevUserID.current const [tenantOptions, setTenantOptions] = useState(() => initialTenantOptions) const selectedTenantLabel = useMemo( - () => tenantOptions.find((option) => option.value === selectedTenantID)?.label, - [selectedTenantID, tenantOptions], + () => tenantOptions.find((option) => option.value === selectedTenantSlug)?.label, + [selectedTenantSlug, tenantOptions], ) const setTenantAndCookie = useCallback( - ({ id, refresh }: { id: number | string | undefined; refresh?: boolean }) => { - setSelectedTenantID(id) - if (id !== undefined) { - setTenantCookie({ value: String(id) }) + ({ slug, refresh }: { slug: string | undefined; refresh?: boolean }) => { + setSelectedTenantSlug(slug) + if (slug !== undefined) { + setTenantCookie({ value: slug }) } else { deleteTenantCookie() } @@ -143,24 +141,24 @@ export const TenantSelectionProviderClient = ({ ) const setTenant = useCallback( - ({ id, refresh }) => { - if (id === undefined) { + ({ slug, refresh }) => { + if (slug === undefined) { if (tenantOptions.length > 1 || tenantOptions.length === 0) { // users with multiple tenants can clear the tenant selection - setTenantAndCookie({ id: undefined, refresh }) + setTenantAndCookie({ slug: undefined, refresh }) } else if (tenantOptions[0]) { // if there is only one tenant, auto-select that tenant - setTenantAndCookie({ id: tenantOptions[0].value, refresh: true }) + setTenantAndCookie({ slug: tenantOptions[0].value, refresh: true }) } - } else if (!tenantOptions.find((option) => option.value === id)) { + } else if (!tenantOptions.find((option) => option.value === slug)) { // if the tenant is invalid, set the first tenant as selected setTenantAndCookie({ - id: tenantOptions[0]?.value, + slug: tenantOptions[0]?.value ? tenantOptions[0].value : undefined, refresh, }) } else { // if the tenant is in the options, set it as selected - setTenantAndCookie({ id, refresh }) + setTenantAndCookie({ slug, refresh }) } }, [tenantOptions, setTenantAndCookie], @@ -169,7 +167,7 @@ export const TenantSelectionProviderClient = ({ const syncTenants = useCallback(async () => { try { const req = await fetch( - `${config.serverURL}${config.routes.api}/${tenantsCollectionSlug}/?select[id]=true&select[name]=true&limit=0&depth=0&sort=name`, + `${config.serverURL}${config.routes.api}/${tenantsCollectionSlug}/?select[slug]=true&select[name]=true&limit=0&depth=0&sort=name`, { credentials: 'include', method: 'GET', @@ -180,15 +178,16 @@ export const TenantSelectionProviderClient = ({ if (result.docs && userID) { setTenantOptions( - result.docs.map((doc: Record) => ({ + result.docs.map((doc: Record) => ({ label: doc.name, - value: doc.id, + value: doc.slug, })), ) if (result.docs.length === 1) { - setSelectedTenantID(result.docs[0].value) - setTenantCookie({ value: String(result.docs[0].value) }) + const firstSlug = result.docs[0].slug + setSelectedTenantSlug(firstSlug) + setTenantCookie({ value: firstSlug }) } } } catch (e) { @@ -197,13 +196,13 @@ export const TenantSelectionProviderClient = ({ }, [config.serverURL, config.routes.api, tenantsCollectionSlug, userID]) const updateTenants = useCallback( - ({ id, label }) => { + ({ slug, label }) => { setTenantOptions((prev) => { return prev.map((currentTenant) => { - if (id === currentTenant.value) { + if (slug === currentTenant.value) { return { label, - value: id, + value: slug, } } return currentTenant @@ -216,13 +215,13 @@ export const TenantSelectionProviderClient = ({ ) useEffect(() => { - if (userChanged || (initialValue && String(initialValue) !== getTenantCookie())) { + if (userChanged || (initialValue && initialValue !== getTenantCookie())) { if (userID) { // user logging in void syncTenants() } else { // user logging out - setSelectedTenantID(undefined) + setSelectedTenantSlug(undefined) deleteTenantCookie() if (tenantOptions.length > 0) { setTenantOptions([]) @@ -235,30 +234,30 @@ export const TenantSelectionProviderClient = ({ /** * If there is no initial value, clear the tenant and refresh the router. - * Needed for stale tenantIDs set as a cookie. + * Needed for stale tenant slugs set as a cookie. */ useEffect(() => { if (!initialValue) { - setTenant({ id: undefined, refresh: true }) + setTenant({ slug: undefined, refresh: true }) } }, [initialValue, setTenant]) /** - * If there is no selected tenant ID and the entity type is 'global', set the first tenant as selected. + * If there is no selected tenant slug and the entity type is 'global', set the first tenant as selected. * This ensures that the global tenant is always set when the component mounts. */ useEffect(() => { - if (!selectedTenantID && tenantOptions.length > 0 && entityType === 'global') { + if (!selectedTenantSlug && tenantOptions.length > 0 && entityType === 'global') { setTenant({ - id: tenantOptions[0]?.value, + slug: tenantOptions[0]?.value ? tenantOptions[0].value : undefined, refresh: true, }) } - }, [selectedTenantID, tenantOptions, entityType, setTenant]) + }, [selectedTenantSlug, tenantOptions, entityType, setTenant]) return ( ({ label: String(doc[useAsTitle]), - value: doc.id, + value: doc.slug, })) } catch (_) { // user likely does not have access } const cookies = await getCookies() - let tenantCookie = cookies.get('payload-tenant')?.value - let initialValue = undefined + const tenantCookie = cookies.get('payload-tenant')?.value + let initialValue: string | undefined = undefined - /** - * Ensure the cookie is a valid tenant - */ - if (tenantCookie) { - const matchingOption = tenantOptions.find((option) => String(option.value) === tenantCookie) + // Validate the cookie contains a valid tenant slug that the user has access to + if (tenantCookie && isValidTenantSlug(tenantCookie)) { + const matchingOption = tenantOptions.find((option) => option.value === tenantCookie) if (matchingOption) { - initialValue = matchingOption.value + initialValue = tenantCookie } } - /** - * If the there was no cookie or the cookie was an invalid tenantID set intialValue - */ + // If no valid cookie or user doesn't have access to that tenant, auto-select if only one option if (!initialValue) { - tenantCookie = undefined - initialValue = tenantOptions.length > 1 ? undefined : tenantOptions[0]?.value + initialValue = tenantOptions.length === 1 ? tenantOptions[0]?.value : undefined } return ( diff --git a/src/services/vercel.ts b/src/services/vercel.ts deleted file mode 100644 index 7b19272e9..000000000 --- a/src/services/vercel.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { TenantData } from '@/middleware' -import { normalizePath } from '@/utilities/path' -import { createClient } from '@vercel/edge-config' - -const token = process.env.VERCEL_TOKEN -const teamId = process.env.VERCEL_TEAM_ID - -export async function vercelFetch(path: string, options?: RequestInit) { - const { headers: headersFromOptions, ...optionsSansHeaders } = options || {} - - const response = await fetch(`https://api.vercel.com/v1/${normalizePath(path)}`, { - headers: { - Authorization: `Bearer ${token}`, - ...headersFromOptions, - }, - ...optionsSansHeaders, - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Request to ${path} failed: ${response.status} ${errorText}`) - } - - const data = await response.json() - return data -} - -export async function updateEdgeConfig(payload: Record): Promise { - const edgeConfigUrl = new URL(process.env.VERCEL_EDGE_CONFIG) - const edgeConfigId = edgeConfigUrl.pathname.replace('/', '') - - if (!edgeConfigId || !token || !teamId) { - console.warn( - 'VERCEL_EDGE_CONFIG, VERCEL_TOKEN, OR VERCEL_TEAM_ID not available, skipping Edge Config update', - ) - return - } - - try { - await vercelFetch(`/edge-config/${edgeConfigId}/items?teamId=${teamId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }) - } catch (error) { - console.error('Error updating Edge Config:', error instanceof Error ? error.message : error) - throw error - } -} - -export async function getAllTenantsFromEdgeConfig() { - try { - const edgeConfigClient = createClient(process.env.VERCEL_EDGE_CONFIG) - const allItems = await edgeConfigClient.getAll() - if (!allItems) throw new Error('No items in edge config.') - - const tenants: TenantData = [] - for (const [key, value] of Object.entries(allItems)) { - if (key.startsWith('tenant_') && value) { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - tenants.push(value as TenantData[number]) - } - } - - return tenants - } catch (error) { - console.error( - 'Error getting all tenants from Edge Config:', - error instanceof Error ? error.message : error, - ) - return [] - } -} diff --git a/src/utilities/collectionFilters.ts b/src/utilities/collectionFilters.ts index 7b116d06d..8a8f15349 100644 --- a/src/utilities/collectionFilters.ts +++ b/src/utilities/collectionFilters.ts @@ -1,29 +1,56 @@ -import { FilterOptionsProps } from 'payload' -import { getTenantIdFromCookie } from './tenancy/getTenantIdFromCookie' +import { FilterOptionsProps, Where } from 'payload' +import { getTenantSlugFromCookie } from './tenancy/getTenantFromCookie' export const getImageTypeFilter = () => ({ mimeType: { contains: 'image' }, }) -export const getTenantFilter = ({ data, req }: FilterOptionsProps) => { - let tenantId = data.tenant - - if (!tenantId) { - tenantId = getTenantIdFromCookie(req.headers) +export const getTenantFilter = ({ data, req }: FilterOptionsProps): Where => { + // If the document already has a tenant relationship set, use that + if (data.tenant) { + return { + tenant: { + equals: data.tenant, + }, + } } - return { - tenant: { - equals: tenantId, - }, + // Otherwise, filter by tenant slug from cookie + const tenantSlug = getTenantSlugFromCookie(req.headers) + if (tenantSlug) { + return { + 'tenant.slug': { + equals: tenantSlug, + }, + } } + + return {} } -export const getTenantAndIdFilter = ({ id, data }: FilterOptionsProps) => ({ - id: { - not_in: [id], - }, - tenant: { - equals: data.tenant, - }, -}) +export const getTenantAndIdFilter = ({ id, data, req }: FilterOptionsProps): Where => { + const idFilter = { id: { not_in: [id] } } + + // If the document already has a tenant relationship set, use that + if (data.tenant) { + return { + ...idFilter, + tenant: { + equals: data.tenant, + }, + } + } + + // Otherwise, filter by tenant slug from cookie + const tenantSlug = getTenantSlugFromCookie(req.headers) + if (tenantSlug) { + return { + ...idFilter, + 'tenant.slug': { + equals: tenantSlug, + }, + } + } + + return idFilter +} diff --git a/src/utilities/tenancy/avalancheCenters.ts b/src/utilities/tenancy/avalancheCenters.ts new file mode 100644 index 000000000..0b88ded9a --- /dev/null +++ b/src/utilities/tenancy/avalancheCenters.ts @@ -0,0 +1,86 @@ +/** + * Hardcoded list of all US avalanche centers + * + * This serves as the single source of truth for valid tenant slugs. + * Custom domains are used for production routing - they should match + * the actual domains configured in Vercel. + */ +type AvalancheCenterInfo = { + readonly name: string + readonly customDomain: string +} + +export const AVALANCHE_CENTERS = { + aaic: { name: 'Alaska Avalanche Information Center', customDomain: 'alaskasnow.org' }, + bac: { name: 'Bridgeport Avalanche Center', customDomain: 'bridgeportavalanchecenter.org' }, + btac: { name: 'Bridger-Teton Avalanche Center', customDomain: 'bridgertetonavalanchecenter.org' }, + cac: { name: 'Cordova Avalanche Center', customDomain: 'alaskasnow.org' }, + caic: { name: 'Colorado Avalanche Information Center', customDomain: 'avalanche.state.co.us' }, + caac: { name: 'Coastal Alaska Avalanche Center', customDomain: 'coastalakavalanche.org' }, + cbac: { name: 'Crested Butte Avalanche Center', customDomain: 'cbavalanchecenter.org' }, + cnfaic: { name: 'Chugach National Forest Avalanche Center', customDomain: 'www.cnfaic.org' }, + coaa: { name: 'Central Oregon Avalanche Center', customDomain: 'www.coavalanche.org' }, + dvac: { name: 'Death Valley Avalanche Center', customDomain: 'www.avy-fx-demo.org' }, // The "template tenant" - not a real avalanche center + earac: { name: 'Eastern Alaska Range Avalanche Center', customDomain: 'alaskasnow.org' }, + esac: { name: 'Eastern Sierra Avalanche Center', customDomain: 'www.esavalanche.org' }, + ewyaix: { name: 'Eastern Wyoming Avalanche Info Exchange', customDomain: 'ewyoavalanche.org' }, + fac: { name: 'Flathead Avalanche Center', customDomain: 'www.flatheadavalanche.org' }, + gnfac: { name: 'Gallatin NF Avalanche Center', customDomain: 'www.mtavalanche.com' }, + hac: { name: 'Haines Avalanche Center', customDomain: 'alaskasnow.org' }, + hpac: { name: 'Hatcher Pass Avalanche Center', customDomain: 'hpavalanche.org' }, + ipac: { + name: 'Idaho Panhandle Avalanche Center', + customDomain: 'www.idahopanhandleavalanche.org', + }, + kpac: { name: 'Kachina Peaks Avalanche Center', customDomain: 'kachinapeaks.org' }, + msac: { name: 'Mount Shasta Avalanche Center', customDomain: 'www.shastaavalanche.org' }, + mwac: { + name: 'Mount Washington Avalanche Center', + customDomain: 'www.mountwashingtonavalanchecenter.org', + }, + nwac: { name: 'Northwest Avalanche Center', customDomain: 'nwac.us' }, + pac: { name: 'Payette Avalanche Center', customDomain: 'payetteavalanche.org' }, + sac: { name: 'Sierra Avalanche Center', customDomain: 'www.sierraavalanchecenter.org' }, + snfac: { name: 'Sawtooth Avalanche Center', customDomain: 'www.sawtoothavalanche.com' }, + soaix: { name: 'Southern Oregon Avalanche Info Exchange', customDomain: 'oregonsnow.org' }, + tac: { name: 'Taos Avalanche Center', customDomain: 'taosavalanchecenter.org' }, + uac: { name: 'Utah Avalanche Center', customDomain: 'utahavalanchecenter.org' }, + vac: { name: 'Valdez Avalanche Center', customDomain: 'alaskasnow.org' }, + wac: { name: 'Wallowa Avalanche Center', customDomain: 'wallowaavalanchecenter.org' }, + wcmac: { name: 'West Central Montana Avalanche Center', customDomain: 'missoulaavalanche.org' }, +} satisfies Record + +export type ValidTenantSlug = keyof typeof AVALANCHE_CENTERS + +/** + * Check if a string is a valid tenant slug + */ +export function isValidTenantSlug(slug: string): slug is ValidTenantSlug { + return slug in AVALANCHE_CENTERS +} + +/** + * Array of all valid tenant slugs. + */ +export const VALID_TENANT_SLUGS: ValidTenantSlug[] = + Object.keys(AVALANCHE_CENTERS).filter(isValidTenantSlug) + +/** + * Lookup a center by its custom domain + */ +export function findCenterByDomain(domain: string): ValidTenantSlug | undefined { + // Normalize both input and stored domains by removing www. prefix + // Regex matches 'www.' at start of string + // .toLowerCase is just a precaution since modern browsers lowercase domains + const normalizedInput = domain.toLowerCase().replace(/^www\./, '') + + for (const slug of VALID_TENANT_SLUGS) { + const normalizedStored = AVALANCHE_CENTERS[slug].customDomain + .toLowerCase() + .replace(/^www\./, '') + if (normalizedStored === normalizedInput) { + return slug + } + } + return undefined +} diff --git a/src/utilities/tenancy/getCollectionIDType.ts b/src/utilities/tenancy/getCollectionIDType.ts deleted file mode 100644 index 02ed72f73..000000000 --- a/src/utilities/tenancy/getCollectionIDType.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { CollectionSlug, Payload } from 'payload' - -type Args = { - collectionSlug: CollectionSlug - payload: Payload -} -export const getCollectionIDType = ({ collectionSlug, payload }: Args): 'number' | 'text' => { - return payload.collections[collectionSlug]?.customIDType ?? payload.db.defaultIDType -} diff --git a/src/utilities/tenancy/getGlobalViewRedirect.ts b/src/utilities/tenancy/getGlobalViewRedirect.ts index e317895b0..84b621ed7 100644 --- a/src/utilities/tenancy/getGlobalViewRedirect.ts +++ b/src/utilities/tenancy/getGlobalViewRedirect.ts @@ -3,8 +3,7 @@ import type { CollectionSlug, Payload, TypedUser, ViewTypes } from 'payload' import { formatAdminURL } from 'payload/shared' -import { getCollectionIDType } from './getCollectionIDType' -import { getTenantFromCookie } from './getTenantFromCookie' +import { getTenantSlugFromCookie } from './getTenantFromCookie' import { getTenantOptions } from './getTenantOptions' type Args = { @@ -31,14 +30,21 @@ export async function getGlobalViewRedirect({ user, view, }: Args): Promise { - const idType = getCollectionIDType({ - collectionSlug: tenantsCollectionSlug, - payload, - }) - let tenant = getTenantFromCookie(headers, idType) - let redirectRoute: `/${string}` | void = undefined + // Get tenant slug from cookie and look up tenant ID + const tenantSlug = getTenantSlugFromCookie(headers) + let tenantId: number | string | null = null - if (!tenant) { + if (tenantSlug) { + const { docs } = await payload.find({ + collection: tenantsCollectionSlug, + where: { slug: { equals: tenantSlug } }, + limit: 1, + depth: 0, + }) + tenantId = docs[0]?.id ?? null + } + + if (!tenantId) { const tenantsQuery = await getTenantOptions({ limit: 1, payload, @@ -47,9 +53,11 @@ export async function getGlobalViewRedirect({ user, }) - tenant = tenantsQuery.docs[0]?.id || null + tenantId = tenantsQuery.docs[0]?.id || null } + let redirectRoute: `/${string}` | void = undefined + try { const { docs } = await payload.find({ collection: slug, @@ -60,7 +68,7 @@ export async function getGlobalViewRedirect({ user, where: { [tenantFieldName]: { - equals: tenant, + equals: tenantId, }, }, }) diff --git a/src/utilities/tenancy/getHostnameFromTenant.ts b/src/utilities/tenancy/getHostnameFromTenant.ts index e5df4374b..3b7904d63 100644 --- a/src/utilities/tenancy/getHostnameFromTenant.ts +++ b/src/utilities/tenancy/getHostnameFromTenant.ts @@ -1,11 +1,11 @@ import { Tenant } from '@/payload-types' import { ROOT_DOMAIN } from '../domain' -import { PRODUCTION_TENANTS } from './tenants' +import { isProductionTenant } from './tenants' export function getHostnameFromTenant(tenant: Tenant | null) { if (!tenant) return ROOT_DOMAIN - if (PRODUCTION_TENANTS.includes(tenant.slug) && tenant.customDomain) { + if (isProductionTenant(tenant.slug) && tenant.customDomain) { return tenant.customDomain } diff --git a/src/utilities/tenancy/getProductionTenantUrls.ts b/src/utilities/tenancy/getProductionTenantUrls.ts index e51489412..4c2b6baf4 100644 --- a/src/utilities/tenancy/getProductionTenantUrls.ts +++ b/src/utilities/tenancy/getProductionTenantUrls.ts @@ -1,9 +1,11 @@ import { getURL } from '../getURL' -import { getTenants } from './getTenants' +import { AVALANCHE_CENTERS } from './avalancheCenters' import { PRODUCTION_TENANTS } from './tenants' -export async function getProductionTenantUrls() { - const tenants = await getTenants() - const productionTenantDefs = tenants.filter((tenant) => PRODUCTION_TENANTS.includes(tenant.slug)) - return productionTenantDefs.map(({ customDomain }) => getURL(customDomain)) +/** + * Get all production tenant custom domain URLs for CORS configuration. + * Uses the hardcoded list of avalanche centers and production tenants. + */ +export function getProductionTenantUrls(): string[] { + return PRODUCTION_TENANTS.map((slug) => getURL(AVALANCHE_CENTERS[slug].customDomain)) } diff --git a/src/utilities/tenancy/getTenantFromCookie.ts b/src/utilities/tenancy/getTenantFromCookie.ts index c06b34d9d..510960c52 100644 --- a/src/utilities/tenancy/getTenantFromCookie.ts +++ b/src/utilities/tenancy/getTenantFromCookie.ts @@ -1,29 +1,17 @@ +import { isValidTenantSlug, ValidTenantSlug } from '@/utilities/tenancy/avalancheCenters' import { parseCookies } from 'payload' -import { isNumber } from 'payload/shared' /** - * A function that takes request headers and an idType and returns the current tenant ID from the cookie - * - * @param headers Headers, usually derived from req.headers or next/headers - * @param idType can be 'number' | 'text', usually derived from payload.db.defaultIDType - * @returns number | null when idType is 'number', string | null when idType is 'text' + * Returns the tenant slug from the 'payload-tenant' cookie. + * The cookie stores a tenant slug string (e.g., 'nwac', 'sac'). */ -export function getTenantFromCookie(headers: Headers, idType: 'number'): number | null -export function getTenantFromCookie(headers: Headers, idType: 'text'): string | null -export function getTenantFromCookie( - headers: Headers, - idType: 'number' | 'text', -): number | string | null -export function getTenantFromCookie( - headers: Headers, - idType: 'number' | 'text', -): number | string | null { +export function getTenantSlugFromCookie(headers: Headers): ValidTenantSlug | null { const cookies = parseCookies(headers) const selectedTenant = cookies.get('payload-tenant') || null - const result = selectedTenant - ? idType === 'number' && isNumber(selectedTenant) - ? parseFloat(selectedTenant) - : selectedTenant - : null - return result + + if (selectedTenant && isValidTenantSlug(selectedTenant)) { + return selectedTenant + } + + return null } diff --git a/src/utilities/tenancy/getTenantIdFromCookie.ts b/src/utilities/tenancy/getTenantIdFromCookie.ts deleted file mode 100644 index c06b8036c..000000000 --- a/src/utilities/tenancy/getTenantIdFromCookie.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getTenantFromCookie } from '@/utilities/tenancy/getTenantFromCookie' - -// Light wrapper around the multi tenant plugin's getTenantFromCookie that enforces the id type as number -// which is how we store our tenant identifier in the payload-tenant cookie -export function getTenantIdFromCookie(headers: Headers): number | null { - const tenantCookieVal = getTenantFromCookie(headers, 'number') - - if (typeof tenantCookieVal === 'string') { - return !!tenantCookieVal ? Number(tenantCookieVal) : null - } - - return tenantCookieVal -} diff --git a/src/utilities/tenancy/getTenantOptions.ts b/src/utilities/tenancy/getTenantOptions.ts index 07042e799..338ece721 100644 --- a/src/utilities/tenancy/getTenantOptions.ts +++ b/src/utilities/tenancy/getTenantOptions.ts @@ -21,6 +21,7 @@ export const getTenantOptions = async ({ overrideAccess: false, select: { [useAsTitle]: true, + slug: true, }, sort: useAsTitle, user, diff --git a/src/utilities/tenancy/getTenantSubdomainUrls.ts b/src/utilities/tenancy/getTenantSubdomainUrls.ts index 53f370157..d831bfb9f 100644 --- a/src/utilities/tenancy/getTenantSubdomainUrls.ts +++ b/src/utilities/tenancy/getTenantSubdomainUrls.ts @@ -1,7 +1,10 @@ import { PROTOCOL, ROOT_DOMAIN } from '../domain' -import { getTenants } from './getTenants' +import { VALID_TENANT_SLUGS } from './avalancheCenters' -export async function getTenantSubdomainUrls() { - const tenants = await getTenants() - return tenants.map(({ slug }) => `${PROTOCOL}://${slug}.${ROOT_DOMAIN}`) +/** + * Get all possible tenant subdomain URLs for CSRF configuration. + * Uses the hardcoded list of valid tenant slugs. + */ +export function getTenantSubdomainUrls(): string[] { + return VALID_TENANT_SLUGS.map((slug) => `${PROTOCOL}://${slug}.${ROOT_DOMAIN}`) } diff --git a/src/utilities/tenancy/getTenants.ts b/src/utilities/tenancy/getTenants.ts deleted file mode 100644 index ed1c6755b..000000000 --- a/src/utilities/tenancy/getTenants.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { TenantData } from '@/middleware' -import { getAllTenantsFromEdgeConfig } from '@/services/vercel' - -export async function getTenants(): Promise { - try { - return getAllTenantsFromEdgeConfig() - } catch (error) { - console.error( - 'Failed to get all tenants from Edge Config, make sure you have set the VERCEL_EDGE_CONFIG env var. Error: ', - error instanceof Error ? error.message : error, - ) - return [] - } -} diff --git a/src/utilities/tenancy/resolveTenant.ts b/src/utilities/tenancy/resolveTenant.ts index 37a795bcf..afd93407a 100644 --- a/src/utilities/tenancy/resolveTenant.ts +++ b/src/utilities/tenancy/resolveTenant.ts @@ -3,7 +3,7 @@ import { getPayload } from 'payload' import type { Tenant } from '@/payload-types' import { SelectFromCollectionSlug } from 'node_modules/payload/dist/collections/config/types' -import { getTenantIdFromCookie } from './getTenantIdFromCookie' +import { getTenantSlugFromCookie } from './getTenantFromCookie' export const resolveTenant = async ( tenant: number | Tenant, @@ -25,10 +25,21 @@ export const resolveTenantFromCookie = async ( headers: Headers, options?: { select: SelectFromCollectionSlug<'tenants'> }, ): Promise => { - const tenantIdFromCookie = getTenantIdFromCookie(headers) - if (!tenantIdFromCookie) { + const tenantSlug = getTenantSlugFromCookie(headers) + if (!tenantSlug) { return null } - return resolveTenant(tenantIdFromCookie, options) + const payload = await getPayload({ config: configPromise }) + const { docs } = await payload.find({ + collection: 'tenants', + where: { + slug: { equals: tenantSlug }, + }, + limit: 1, + depth: 0, + select: options?.select ?? undefined, + }) + + return docs[0] || null } diff --git a/src/utilities/tenancy/tenants.ts b/src/utilities/tenancy/tenants.ts index 18068b5b5..f7af87d09 100644 --- a/src/utilities/tenancy/tenants.ts +++ b/src/utilities/tenancy/tenants.ts @@ -1,4 +1,10 @@ -export function validateProductionTenants(productionTenantsEnv?: string): string[] { +import { AVALANCHE_CENTERS, isValidTenantSlug, ValidTenantSlug } from './avalancheCenters' + +/** + * Validates production tenants from environment variable. + * Only slugs that exist in AVALANCHE_CENTERS are considered valid. + */ +export function validateProductionTenants(productionTenantsEnv?: string): ValidTenantSlug[] { const envValue = productionTenantsEnv ?? process.env.PRODUCTION_TENANTS ?? '' if (!envValue.trim()) { @@ -8,9 +14,28 @@ export function validateProductionTenants(productionTenantsEnv?: string): string const tenantSlugs = envValue .split(',') .map((str) => str.trim()) - .filter((str) => str.length > 0) + .filter(isValidTenantSlug) return tenantSlugs } export const PRODUCTION_TENANTS = validateProductionTenants() + +/** + * Check if a slug is a production tenant. + * Works with any string input (doesn't require ValidTenantSlug type). + */ +export function isProductionTenant(slug: string): slug is ValidTenantSlug { + return isValidTenantSlug(slug) && PRODUCTION_TENANTS.includes(slug) +} + +/** + * Get custom domain for a production tenant. + * Returns undefined if tenant is not in PRODUCTION_TENANTS or has no custom domain. + */ +export function getProductionCustomDomain(slug: string): string | undefined { + if (!isValidTenantSlug(slug) || !PRODUCTION_TENANTS.includes(slug)) { + return undefined + } + return AVALANCHE_CENTERS[slug].customDomain +} diff --git a/src/utilities/useTenantLookup.ts b/src/utilities/useTenantLookup.ts new file mode 100644 index 000000000..c04504d64 --- /dev/null +++ b/src/utilities/useTenantLookup.ts @@ -0,0 +1,64 @@ +'use client' + +import * as Sentry from '@sentry/nextjs' +import { useCallback, useState } from 'react' + +/** + * Hook for looking up tenant slugs by ID and vice versa. + * Caches results to avoid repeated API calls. + */ +export const useTenantLookup = () => { + const [tenantIdBySlug, setTenantIdBySlug] = useState>({}) + const [tenantSlugById, setTenantSlugById] = useState>({}) + + const lookupTenantIdBySlug = useCallback( + async (slug: string): Promise => { + if (tenantIdBySlug[slug]) { + return tenantIdBySlug[slug] + } + + try { + const response = await fetch( + `/api/tenants?where[slug][equals]=${encodeURIComponent(slug)}&limit=1&depth=0`, + { credentials: 'include' }, + ) + const result = await response.json() + if (result.docs && result.docs.length > 0) { + const id = result.docs[0].id + setTenantIdBySlug((prev) => ({ ...prev, [slug]: id })) + return id + } + } catch (error) { + Sentry.captureException(error, { extra: { slug } }) + } + return null + }, + [tenantIdBySlug], + ) + + const lookupTenantSlugById = useCallback( + async (id: number): Promise => { + if (tenantSlugById[id]) { + return tenantSlugById[id] + } + + try { + const response = await fetch(`/api/tenants/${id}?depth=0`, { credentials: 'include' }) + const result = await response.json() + if (result.slug) { + setTenantSlugById((prev) => ({ ...prev, [id]: result.slug })) + return result.slug + } + } catch (error) { + Sentry.captureException(error, { extra: { id } }) + } + return null + }, + [tenantSlugById], + ) + + return { + lookupTenantIdBySlug, + lookupTenantSlugById, + } +}