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)}
+
` : `
-
+ ${!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