diff --git a/.changeset/late-teeth-divide.md b/.changeset/late-teeth-divide.md new file mode 100644 index 000000000..836280767 --- /dev/null +++ b/.changeset/late-teeth-divide.md @@ -0,0 +1,4 @@ +--- +--- + +Improve domain health admin page UX: inline domain linking, www normalization, and verify button for existing domains. diff --git a/server/public/admin-domain-health.html b/server/public/admin-domain-health.html index 7e237967d..9474b8bd7 100644 --- a/server/public/admin-domain-health.html +++ b/server/public/admin-domain-health.html @@ -171,6 +171,18 @@ padding: var(--space-1) var(--space-2); font-size: var(--text-xs); } + .btn-success { + background: var(--color-success-500); + color: white; + } + .btn-error { + background: var(--color-error-500); + color: white; + } + .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } /* Organization Links */ .org-link { @@ -363,6 +375,105 @@

Domain Health

showMergeModal(orgIds, domain); }); }); + + // Set up event handlers for link domain buttons + document.querySelectorAll('.link-domain-btn').forEach(btn => { + btn.addEventListener('click', () => { + const orgId = btn.dataset.orgId; + const orgName = btn.dataset.orgName; + const domain = btn.dataset.domain; + linkDomainToOrg(btn, orgId, orgName, domain); + }); + }); + + // Set up event handlers for link domain selects + document.querySelectorAll('.link-domain-select').forEach(select => { + select.addEventListener('change', () => { + const orgId = select.value; + if (!orgId) return; + const option = select.options[select.selectedIndex]; + const orgName = option.dataset.orgName; + const domain = select.dataset.domain; + linkDomainToOrg(select, orgId, orgName, domain); + }); + }); + } + + async function linkDomainToOrg(element, orgId, orgName, domain) { + // Validate orgId format before using in URL + if (!isValidOrgId(orgId)) { + alert('Invalid organization ID format'); + return; + } + + const originalText = element.tagName === 'BUTTON' ? element.textContent : null; + const originalDisabled = element.disabled; + + // Disable and show loading state + element.disabled = true; + if (element.tagName === 'BUTTON') { + element.textContent = 'Linking...'; + } + + try { + const response = await fetch(`/api/admin/organizations/${encodeURIComponent(orgId)}/domains`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domain: domain.toLowerCase(), is_primary: true }) + }); + + if (!response.ok) { + if (response.status === 401) { + window.AdminSidebar?.redirectToLogin(); + return; + } + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || errorData.error || 'Failed to link domain'); + } + + // Success - show success state briefly then reload data + element.classList.remove('btn-primary'); + element.classList.add('btn-success'); + if (element.tagName === 'BUTTON') { + element.textContent = 'Linked!'; + } else { + // For select, reset and disable + element.value = ''; + const firstOption = element.options[0]; + if (firstOption) firstOption.textContent = 'Linked!'; + } + + // Reload after brief delay to show success + setTimeout(() => { + loadDomainHealth(); + }, 1000); + } catch (error) { + // Show error state + element.classList.remove('btn-primary'); + element.classList.add('btn-error'); + if (element.tagName === 'BUTTON') { + element.textContent = 'Failed'; + element.title = error.message; + } else { + element.value = ''; + const firstOption = element.options[0]; + if (firstOption) firstOption.textContent = 'Failed: ' + error.message; + } + + // Reset after delay + setTimeout(() => { + element.disabled = originalDisabled; + element.classList.remove('btn-error'); + element.classList.add('btn-primary'); + if (element.tagName === 'BUTTON') { + element.textContent = originalText; + element.title = ''; + } else { + const firstOption = element.options[0]; + if (firstOption) firstOption.textContent = 'Link to org...'; + } + }, 3000); + } } function renderSummary() { @@ -456,12 +567,12 @@

Domain Health

${hasExistingOrgs ? `
${nonPersonalOrgs.length === 1 ? ` - Link to ${escapeHtml(nonPersonalOrgs[0].name)} + ` : ` - ${nonPersonalOrgs.map(o => ` - + `).join('')} `} diff --git a/server/public/admin-org-detail.html b/server/public/admin-org-detail.html index 8ab21e561..53d06d665 100644 --- a/server/public/admin-org-detail.html +++ b/server/public/admin-org-detail.html @@ -1804,8 +1804,9 @@

Member Context

${badges.join(' ')}
+ ${!d.verified ? `` : ''} ${!d.is_primary && !d.workos_only ? `` : ''} - ${d.source !== 'workos' ? `` : ''} +
`; @@ -1830,6 +1831,8 @@

Member Context

setDomainPrimary(domain, btn); } else if (action === 'remove') { removeDomain(domain, btn); + } else if (action === 'verify') { + verifyDomain(domain, btn); } } @@ -1946,6 +1949,39 @@

Member Context

} } + async function verifyDomain(domain, btn) { + const originalText = btn ? btn.textContent : ''; + if (btn) { + btn.disabled = true; + btn.textContent = 'Verifying...'; + } + + try { + // Reuse the add domain endpoint - it handles verification of existing domains + const response = await fetch(`/api/admin/organizations/${orgId}/domains`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domain: domain.toLowerCase() }) + }); + + if (!response.ok) { + if (response.status === 401) { + window.AdminNav.redirectToLogin(); + return; + } + throw new Error(await getErrorMessage(response, 'Failed to verify domain')); + } + + await refreshDomains(); + } catch (error) { + alert('Error: ' + error.message); + if (btn) { + btn.disabled = false; + btn.textContent = originalText; + } + } + } + // ======================================== // ENGAGEMENT SIGNALS // ======================================== diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts index 87e1c377e..c7c625e57 100644 --- a/server/src/routes/admin/domains.ts +++ b/server/src/routes/admin/domains.ts @@ -887,7 +887,7 @@ export function setupDomainRoutes( return res.status(500).json({ error: "WorkOS not configured" }); } - const normalizedDomain = domain.toLowerCase().trim(); + const normalizedDomain = domain.toLowerCase().trim().replace(/^www\./, ''); // Validate domain format const domainRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z]{2,})+$/; @@ -939,15 +939,35 @@ export function setupDomainRoutes( // Add to WorkOS first - this is the source of truth try { const workosOrg = await workos.organizations.getOrganization(orgId); - const existingDomains = workosOrg.domains.map(d => ({ - domain: d.domain, - state: d.state === 'verified' ? DomainDataState.Verified : DomainDataState.Pending - })); - await workos.organizations.updateOrganization({ - organization: orgId, - domainData: [...existingDomains, { domain: normalizedDomain, state: DomainDataState.Verified }], - }); + // Check if domain already exists in WorkOS for this org + const existingDomain = workosOrg.domains.find(d => d.domain.toLowerCase() === normalizedDomain); + + if (existingDomain) { + // Domain already exists - just verify it if not already verified + if (existingDomain.state !== 'verified') { + const updatedDomains = workosOrg.domains.map(d => ({ + domain: d.domain, + state: d.domain.toLowerCase() === normalizedDomain ? DomainDataState.Verified : (d.state === 'verified' ? DomainDataState.Verified : DomainDataState.Pending) + })); + await workos.organizations.updateOrganization({ + organization: orgId, + domainData: updatedDomains, + }); + } + // If already verified, no WorkOS update needed + } else { + // Domain doesn't exist - add it + const existingDomains = workosOrg.domains.map(d => ({ + domain: d.domain, + state: d.state === 'verified' ? DomainDataState.Verified : DomainDataState.Pending + })); + + await workos.organizations.updateOrganization({ + organization: orgId, + domainData: [...existingDomains, { domain: normalizedDomain, state: DomainDataState.Verified }], + }); + } } catch (workosErr) { logger.error({ err: workosErr, domain: normalizedDomain, orgId }, "Failed to add domain to WorkOS"); return res.status(500).json({ @@ -1209,9 +1229,9 @@ export function setupDomainRoutes( GROUP BY LOWER(SPLIT_PART(om.email, '@', 2)) ), claimed_domains AS ( - SELECT LOWER(domain) as domain FROM organization_domains + SELECT REGEXP_REPLACE(LOWER(domain), '^www\\.', '') as domain FROM organization_domains UNION - SELECT LOWER(email_domain) FROM organizations WHERE email_domain IS NOT NULL + SELECT REGEXP_REPLACE(LOWER(email_domain), '^www\\.', '') FROM organizations WHERE email_domain IS NOT NULL ), domain_orgs AS ( -- Find which orgs users with each domain actually belong to