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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/funny-sheep-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
111 changes: 106 additions & 5 deletions server/public/admin-domain-health.html
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,64 @@ <h1>Domain Health</h1>
}
}

async function addUsersToOrg(button, orgId, orgName, userIds) {
// Validate inputs
if (!isValidOrgId(orgId)) {
alert('Invalid organization ID format');
return;
}

if (!confirm(`Add ${userIds.length} user(s) to "${orgName}"?\n\nThis will add them to the company organization. They will keep access to their personal workspace.`)) {
return;
}

const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Adding...';

try {
const response = await fetch(`/api/admin/organizations/${encodeURIComponent(orgId)}/add-users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_ids: userIds })
});

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 add users');
}

const result = await response.json();

// Success
button.classList.remove('btn-primary');
button.classList.add('btn-success');
button.textContent = `Added ${result.added_count || userIds.length}!`;

// Reload after brief delay
setTimeout(() => {
loadDomainHealth();
}, 1500);
} catch (error) {
button.classList.remove('btn-primary');
button.classList.add('btn-error');
button.textContent = 'Failed';
button.title = error.message;

setTimeout(() => {
button.disabled = false;
button.classList.remove('btn-error');
button.classList.add('btn-primary');
button.textContent = originalText;
button.title = '';
}, 3000);
}
}

function renderSummary() {
const { summary } = healthData;
const totalIssues = summary.issues.orphan_domains + summary.issues.misaligned_users +
Expand Down Expand Up @@ -795,7 +853,10 @@ <h1>Domain Health</h1>
<tbody>
${misaligned_users.map(d => `
<tr>
<td><span class="domain-name">${escapeHtml(d.domain)}</span></td>
<td>
<span class="domain-name">${escapeHtml(d.domain)}</span>
${d.target_org_name ? `<br><small style="color: var(--color-text-muted);">→ <a href="/admin/accounts/${escapeHtml(d.target_org_id)}" class="org-link">${escapeHtml(d.target_org_name)}</a></small>` : ''}
</td>
<td><span class="user-count">${d.user_count}</span></td>
<td>
<ul class="user-list">
Expand All @@ -807,7 +868,18 @@ <h1>Domain Health</h1>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: var(--space-2);">
<button class="btn btn-secondary btn-sm create-prospect-btn" data-domain="${escapeHtml(d.domain)}">Create Prospect</button>
${d.target_org_id ? `
<button class="btn btn-primary btn-sm add-to-org-btn"
data-domain="${escapeHtml(d.domain)}"
data-org-id="${escapeHtml(d.target_org_id)}"
data-org-name="${escapeHtml(d.target_org_name)}"
data-user-ids="${d.users.map(u => escapeHtml(u.user_id)).join(',')}"
title="Add all ${d.user_count} user(s) to ${escapeHtml(d.target_org_name)}">
Add to ${escapeHtml(d.target_org_name)}
</button>
` : `
<button class="btn btn-secondary btn-sm create-prospect-btn" data-domain="${escapeHtml(d.domain)}">Create Prospect</button>
`}
<button class="btn btn-secondary btn-sm mark-personal-btn" data-domain="${escapeHtml(d.domain)}" title="Mark this domain as a personal email domain (not corporate)">Mark as Personal</button>
</div>
</td>
Expand All @@ -816,6 +888,16 @@ <h1>Domain Health</h1>
</tbody>
</table>
`;

// Set up event handlers for add to org buttons
document.querySelectorAll('.add-to-org-btn').forEach(btn => {
btn.addEventListener('click', () => {
const orgId = btn.dataset.orgId;
const orgName = btn.dataset.orgName;
const userIds = btn.dataset.userIds.split(',').filter(Boolean);
addUsersToOrg(btn, orgId, orgName, userIds);
});
});
}

function renderUnverifiedOrgs() {
Expand All @@ -836,6 +918,9 @@ <h1>Domain Health</h1>
return;
}

// Common personal email domains to filter out
const personalDomains = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'icloud.com', 'aol.com', 'protonmail.com', 'me.com', 'live.com', 'msn.com'];

content.innerHTML = `
<table class="issue-table">
<thead>
Expand All @@ -848,7 +933,12 @@ <h1>Domain Health</h1>
</tr>
</thead>
<tbody>
${unverified_orgs.map(o => `
${unverified_orgs.map(o => {
// Find the first corporate domain (not a personal email domain)
const corporateDomains = (o.user_domains || []).filter(d => d && !personalDomains.includes(d.toLowerCase()));
const suggestedDomain = corporateDomains[0] || null;

return `
<tr>
<td>
<a href="/admin/org/${encodeURIComponent(o.org_id)}" class="org-link">${escapeHtml(o.name)}</a>
Expand All @@ -866,10 +956,21 @@ <h1>Domain Health</h1>
</span>
</td>
<td>
<a href="/admin/org/${encodeURIComponent(o.org_id)}" class="btn btn-secondary btn-sm">View Org</a>
<div style="display: flex; flex-direction: column; gap: var(--space-2);">
${suggestedDomain ? `
<button class="btn btn-primary btn-sm link-domain-btn"
data-org-id="${escapeHtml(o.org_id)}"
data-org-name="${escapeHtml(o.name)}"
data-domain="${escapeHtml(suggestedDomain)}"
title="Link ${escapeHtml(suggestedDomain)} to ${escapeHtml(o.name)}">
Link ${escapeHtml(suggestedDomain)}
</button>
` : ''}
<a href="/admin/accounts/${encodeURIComponent(o.org_id)}" class="btn btn-secondary btn-sm">View Org</a>
</div>
</td>
</tr>
`).join('')}
`}).join('')}
</tbody>
</table>
`;
Expand Down
18 changes: 15 additions & 3 deletions server/src/routes/admin/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1299,12 +1299,19 @@ export function setupDomainRoutes(
const misalignedResult = await pool.query(`
WITH company_domains AS (
-- All domains that have been claimed by non-personal organizations
SELECT DISTINCT LOWER(domain) as domain
-- Include org info so we know which org they should join
SELECT DISTINCT
LOWER(od.domain) as domain,
o.workos_organization_id as target_org_id,
o.name as target_org_name
FROM organization_domains od
JOIN organizations o ON o.workos_organization_id = od.workos_organization_id
WHERE o.is_personal = false
UNION
SELECT DISTINCT LOWER(email_domain) as domain
SELECT DISTINCT
LOWER(email_domain) as domain,
workos_organization_id as target_org_id,
name as target_org_name
FROM organizations
WHERE is_personal = false AND email_domain IS NOT NULL
)
Expand All @@ -1315,7 +1322,9 @@ export function setupDomainRoutes(
om.workos_user_id,
LOWER(SPLIT_PART(om.email, '@', 2)) as email_domain,
o.name as workspace_name,
om.workos_organization_id
om.workos_organization_id,
cd.target_org_id,
cd.target_org_name
FROM organization_memberships om
JOIN organizations o ON o.workos_organization_id = om.workos_organization_id
JOIN company_domains cd ON cd.domain = LOWER(SPLIT_PART(om.email, '@', 2))
Expand Down Expand Up @@ -1460,6 +1469,9 @@ export function setupDomainRoutes(
misaligned_users: Object.entries(misalignedByDomain).map(([domain, users]) => ({
domain,
user_count: users.length,
// Target org that owns this domain (users should join this org)
target_org_id: users[0]?.target_org_id || null,
target_org_name: users[0]?.target_org_name || null,
users: users.map(u => ({
email: u.email,
first_name: u.first_name,
Expand Down
133 changes: 133 additions & 0 deletions server/src/routes/admin/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1070,4 +1070,137 @@ export function setupOrganizationRoutes(
}
}
);

// POST /api/admin/organizations/:orgId/add-users - Add users to an organization
// Used by Domain Health to move users from personal workspaces to company orgs
apiRouter.post(
"/organizations/:orgId/add-users",
requireAuth,
requireAdmin,
async (req, res) => {
try {
const { orgId } = req.params;
const { user_ids } = req.body;

if (!Array.isArray(user_ids) || user_ids.length === 0) {
return res.status(400).json({
error: "Invalid request",
message: "user_ids must be a non-empty array",
});
}

if (user_ids.length > 100) {
return res.status(400).json({
error: "Too many users",
message: "Cannot add more than 100 users at once",
});
}

const pool = getPool();

// Verify the target org exists and is not a personal workspace
const orgCheck = await pool.query(
`SELECT workos_organization_id, name, is_personal FROM organizations WHERE workos_organization_id = $1`,
[orgId]
);

if (orgCheck.rows.length === 0) {
return res.status(404).json({
error: "Organization not found",
});
}

if (orgCheck.rows[0].is_personal) {
return res.status(400).json({
error: "Invalid target",
message: "Cannot add users to a personal workspace",
});
}

const orgName = orgCheck.rows[0].name;

// Process each user
let addedCount = 0;
const errors: string[] = [];

for (const userId of user_ids) {
try {
// Validate user ID format
if (typeof userId !== "string" || !/^[\w-]+$/.test(userId)) {
errors.push(`Invalid user ID format`);
continue;
}

// Check if user exists
const userCheck = await pool.query(
`SELECT workos_user_id, email, first_name, last_name, workos_organization_id
FROM organization_memberships
WHERE workos_user_id = $1`,
[userId]
);

if (userCheck.rows.length === 0) {
errors.push(`User ${userId} not found`);
continue;
}

const user = userCheck.rows[0];

// Check if user is already in this org
const existingMembership = await pool.query(
`SELECT id FROM organization_memberships
WHERE workos_user_id = $1 AND workos_organization_id = $2`,
[userId, orgId]
);

if (existingMembership.rows.length > 0) {
// User already in target org, skip
addedCount++;
continue;
}

// Add membership to the new org
await pool.query(
`INSERT INTO organization_memberships
(workos_user_id, workos_organization_id, email, first_name, last_name, role)
VALUES ($1, $2, $3, $4, $5, 'member')
ON CONFLICT (workos_user_id, workos_organization_id)
DO UPDATE SET email = EXCLUDED.email, first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name`,
[userId, orgId, user.email, user.first_name, user.last_name]
);

addedCount++;

logger.info(
{
userId,
userEmail: user.email,
targetOrgId: orgId,
targetOrgName: orgName,
previousOrgId: user.workos_organization_id,
adminEmail: req.user!.email,
},
"Admin added user to organization"
);
} catch (err) {
logger.error({ err, userId }, "Error adding user to org");
errors.push(`Failed to add user ${userId}`);
}
}

res.json({
success: true,
added_count: addedCount,
total_requested: user_ids.length,
errors: errors.length > 0 ? errors : undefined,
});
} catch (error) {
logger.error({ err: error }, "Error adding users to organization");
res.status(500).json({
error: "Internal server error",
message: "Unable to add users to organization",
});
}
}
);
}