diff --git a/.changeset/funny-sheep-remain.md b/.changeset/funny-sheep-remain.md
new file mode 100644
index 0000000000..a845151cc8
--- /dev/null
+++ b/.changeset/funny-sheep-remain.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/server/public/admin-domain-health.html b/server/public/admin-domain-health.html
index ddaa49d77d..6e89af7ddf 100644
--- a/server/public/admin-domain-health.html
+++ b/server/public/admin-domain-health.html
@@ -636,6 +636,64 @@
Domain Health
}
}
+ 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 +
@@ -795,7 +853,10 @@ Domain Health
${misaligned_users.map(d => `
- | ${escapeHtml(d.domain)} |
+
+ ${escapeHtml(d.domain)}
+ ${d.target_org_name ? ` → ${escapeHtml(d.target_org_name)}` : ''}
+ |
${d.user_count} |
@@ -807,7 +868,18 @@ Domain Health
|
-
+ ${d.target_org_id ? `
+
+ ` : `
+
+ `}
|
@@ -816,6 +888,16 @@ Domain Health
`;
+
+ // 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() {
@@ -836,6 +918,9 @@ Domain Health
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 = `
@@ -848,7 +933,12 @@ Domain Health
- ${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 `
${escapeHtml(o.name)}
@@ -866,10 +956,21 @@ Domain Health
|
- View Org
+
+ ${suggestedDomain ? `
+
+ ` : ''}
+ View Org
+
|
- `).join('')}
+ `}).join('')}
`;
diff --git a/server/src/routes/admin/domains.ts b/server/src/routes/admin/domains.ts
index a067e6414e..1c0782ac7f 100644
--- a/server/src/routes/admin/domains.ts
+++ b/server/src/routes/admin/domains.ts
@@ -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
)
@@ -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))
@@ -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,
diff --git a/server/src/routes/admin/organizations.ts b/server/src/routes/admin/organizations.ts
index 2c097f1652..8675afeb50 100644
--- a/server/src/routes/admin/organizations.ts
+++ b/server/src/routes/admin/organizations.ts
@@ -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",
+ });
+ }
+ }
+ );
}