Skip to content

Commit 22b389a

Browse files
authored
workos org sync (#2412)
1 parent e8206aa commit 22b389a

File tree

6 files changed

+167
-7
lines changed

6 files changed

+167
-7
lines changed

ui/src/api/orchestrator_orgs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export async function syncOrgToBackend(orgId: string, orgName: string, adminEmai
1515
});
1616

1717
if (!response.ok) {
18-
throw new Error(`Failed to sync organization: ${response.statusText}`);
18+
throw new Error(`Failed to sync organization to backend: ${response.statusText}`);
1919
}
2020

2121
return response.json();

ui/src/api/statesman_orgs.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
11

22

3-
export async function syncOrgToStatesman(orgId: string, orgName: string, displayName: string, userId: string, adminEmail: string) {
3+
export async function syncOrgToStatesman(orgId: string, orgName: string, displayName: string, userId: string | null, adminEmail: string | null) {
44
const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/orgs`, {
55
method: 'POST',
66
headers: {
77
'Content-Type': 'application/json',
88
'Authorization': `Bearer ${process.env.STATESMAN_BACKEND_WEBHOOK_SECRET}`,
99
'X-Org-ID': "",
10-
'X-User-ID': userId,
11-
'X-Email': adminEmail,
10+
'X-User-ID': userId ?? '',
11+
'X-Email': adminEmail ?? '',
1212
},
1313
body: JSON.stringify({
1414
"external_org_id": orgId,
1515
"name": orgName,
1616
"display_name": displayName,
1717
})
18-
})
18+
})
1919

2020
if (response.status === 409) {
2121
console.log("Org already exists in statesman")
2222
return response.json();
2323
}
2424

2525
if (!response.ok) {
26-
throw new Error(`Failed to sync organization to statesman: ${response.statusText}`);
26+
throw new Error(`Failed to sync organization to statesman: ${response.statusText}`, { cause: await response.text() });
2727
}
2828

2929
return response.json();

ui/src/components/sign-in-button.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,24 @@ export default function SignInButton({ large, user, url }: { large?: boolean; us
1515

1616
return (
1717
<Button asChild size={large ? '3' : '2'} className="cursor-pointer">
18-
<a href={url}>Sign In To Get Started</a>
18+
<a
19+
href={url}
20+
style={{
21+
color: 'white',
22+
background:
23+
'linear-gradient(90deg, #6D28D9 0%, #3B82F6 100%)',
24+
padding: '0.65em 2em',
25+
borderRadius: '8px',
26+
fontWeight: 'bold',
27+
letterSpacing: '0.03em',
28+
fontSize: '1.1em',
29+
boxShadow: '0 2px 16px 0 rgba(59, 130, 246, 0.35)',
30+
border: 'none',
31+
textShadow: '0 2px 8px rgba(59,130,246,0.17)'
32+
}}
33+
>
34+
Sign In To Get Started
35+
</a>
1936
</Button>
2037
);
2138
}

ui/src/routeTree.gen.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import { Route as LogoutRouteImport } from './routes/logout'
1313
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
1414
import { Route as IndexRouteImport } from './routes/index'
1515
import { Route as TfeSplatRouteImport } from './routes/tfe/$'
16+
import { Route as ManualTerraformWellKnownRouteImport } from './routes/manual/terraformWellKnown'
1617
import { Route as OrchestratorJob_artefactsRouteImport } from './routes/_orchestrator/job_artefacts'
1718
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/_dashboard'
1819
import { Route as OrchestratorGithubWebhookRouteImport } from './routes/orchestrator/github/webhook'
1920
import { Route as OrchestratorGithubCallbackRouteImport } from './routes/orchestrator/github/callback'
2021
import { Route as AppSettingsTokensRouteImport } from './routes/app/settings.tokens'
22+
import { Route as ApiInternalSyncWorkosOrgsRouteImport } from './routes/api/internal/sync-workos-orgs'
2123
import { Route as ApiAuthCallbackRouteImport } from './routes/api/auth/callback'
2224
import { Route as ApiAuthWorkosWebhooksRouteImport } from './routes/api/auth/workos/webhooks'
2325
import { Route as ApiAuthWorkosSwitchOrgRouteImport } from './routes/api/auth/workos/switch-org'
@@ -63,6 +65,12 @@ const TfeSplatRoute = TfeSplatRouteImport.update({
6365
path: '/tfe/$',
6466
getParentRoute: () => rootRouteImport,
6567
} as any)
68+
const ManualTerraformWellKnownRoute =
69+
ManualTerraformWellKnownRouteImport.update({
70+
id: '/manual/terraformWellKnown',
71+
path: '/manual/terraformWellKnown',
72+
getParentRoute: () => rootRouteImport,
73+
} as any)
6674
const OrchestratorJob_artefactsRoute =
6775
OrchestratorJob_artefactsRouteImport.update({
6876
id: '/_orchestrator/job_artefacts',
@@ -90,6 +98,12 @@ const AppSettingsTokensRoute = AppSettingsTokensRouteImport.update({
9098
path: '/app/settings/tokens',
9199
getParentRoute: () => rootRouteImport,
92100
} as any)
101+
const ApiInternalSyncWorkosOrgsRoute =
102+
ApiInternalSyncWorkosOrgsRouteImport.update({
103+
id: '/api/internal/sync-workos-orgs',
104+
path: '/api/internal/sync-workos-orgs',
105+
getParentRoute: () => rootRouteImport,
106+
} as any)
93107
const ApiAuthCallbackRoute = ApiAuthCallbackRouteImport.update({
94108
id: '/api/auth/callback',
95109
path: '/api/auth/callback',
@@ -244,8 +258,10 @@ export interface FileRoutesByFullPath {
244258
'/': typeof IndexRoute
245259
'/logout': typeof LogoutRoute
246260
'/job_artefacts': typeof OrchestratorJob_artefactsRoute
261+
'/manual/terraformWellKnown': typeof ManualTerraformWellKnownRoute
247262
'/tfe/$': typeof TfeSplatRoute
248263
'/api/auth/callback': typeof ApiAuthCallbackRoute
264+
'/api/internal/sync-workos-orgs': typeof ApiInternalSyncWorkosOrgsRoute
249265
'/app/settings/tokens': typeof AppSettingsTokensRoute
250266
'/orchestrator/github/callback': typeof OrchestratorGithubCallbackRoute
251267
'/orchestrator/github/webhook': typeof OrchestratorGithubWebhookRoute
@@ -278,8 +294,10 @@ export interface FileRoutesByTo {
278294
'/': typeof IndexRoute
279295
'/logout': typeof LogoutRoute
280296
'/job_artefacts': typeof OrchestratorJob_artefactsRoute
297+
'/manual/terraformWellKnown': typeof ManualTerraformWellKnownRoute
281298
'/tfe/$': typeof TfeSplatRoute
282299
'/api/auth/callback': typeof ApiAuthCallbackRoute
300+
'/api/internal/sync-workos-orgs': typeof ApiInternalSyncWorkosOrgsRoute
283301
'/app/settings/tokens': typeof AppSettingsTokensRoute
284302
'/orchestrator/github/callback': typeof OrchestratorGithubCallbackRoute
285303
'/orchestrator/github/webhook': typeof OrchestratorGithubWebhookRoute
@@ -313,8 +331,10 @@ export interface FileRoutesById {
313331
'/logout': typeof LogoutRoute
314332
'/_authenticated/_dashboard': typeof AuthenticatedDashboardRouteWithChildren
315333
'/_orchestrator/job_artefacts': typeof OrchestratorJob_artefactsRoute
334+
'/manual/terraformWellKnown': typeof ManualTerraformWellKnownRoute
316335
'/tfe/$': typeof TfeSplatRoute
317336
'/api/auth/callback': typeof ApiAuthCallbackRoute
337+
'/api/internal/sync-workos-orgs': typeof ApiInternalSyncWorkosOrgsRoute
318338
'/app/settings/tokens': typeof AppSettingsTokensRoute
319339
'/orchestrator/github/callback': typeof OrchestratorGithubCallbackRoute
320340
'/orchestrator/github/webhook': typeof OrchestratorGithubWebhookRoute
@@ -349,8 +369,10 @@ export interface FileRouteTypes {
349369
| '/'
350370
| '/logout'
351371
| '/job_artefacts'
372+
| '/manual/terraformWellKnown'
352373
| '/tfe/$'
353374
| '/api/auth/callback'
375+
| '/api/internal/sync-workos-orgs'
354376
| '/app/settings/tokens'
355377
| '/orchestrator/github/callback'
356378
| '/orchestrator/github/webhook'
@@ -383,8 +405,10 @@ export interface FileRouteTypes {
383405
| '/'
384406
| '/logout'
385407
| '/job_artefacts'
408+
| '/manual/terraformWellKnown'
386409
| '/tfe/$'
387410
| '/api/auth/callback'
411+
| '/api/internal/sync-workos-orgs'
388412
| '/app/settings/tokens'
389413
| '/orchestrator/github/callback'
390414
| '/orchestrator/github/webhook'
@@ -417,8 +441,10 @@ export interface FileRouteTypes {
417441
| '/logout'
418442
| '/_authenticated/_dashboard'
419443
| '/_orchestrator/job_artefacts'
444+
| '/manual/terraformWellKnown'
420445
| '/tfe/$'
421446
| '/api/auth/callback'
447+
| '/api/internal/sync-workos-orgs'
422448
| '/app/settings/tokens'
423449
| '/orchestrator/github/callback'
424450
| '/orchestrator/github/webhook'
@@ -453,8 +479,10 @@ export interface RootRouteChildren {
453479
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
454480
LogoutRoute: typeof LogoutRoute
455481
OrchestratorJob_artefactsRoute: typeof OrchestratorJob_artefactsRoute
482+
ManualTerraformWellKnownRoute: typeof ManualTerraformWellKnownRoute
456483
TfeSplatRoute: typeof TfeSplatRoute
457484
ApiAuthCallbackRoute: typeof ApiAuthCallbackRoute
485+
ApiInternalSyncWorkosOrgsRoute: typeof ApiInternalSyncWorkosOrgsRoute
458486
AppSettingsTokensRoute: typeof AppSettingsTokensRoute
459487
OrchestratorGithubCallbackRoute: typeof OrchestratorGithubCallbackRoute
460488
OrchestratorGithubWebhookRoute: typeof OrchestratorGithubWebhookRoute
@@ -498,6 +526,13 @@ declare module '@tanstack/react-router' {
498526
preLoaderRoute: typeof TfeSplatRouteImport
499527
parentRoute: typeof rootRouteImport
500528
}
529+
'/manual/terraformWellKnown': {
530+
id: '/manual/terraformWellKnown'
531+
path: '/manual/terraformWellKnown'
532+
fullPath: '/manual/terraformWellKnown'
533+
preLoaderRoute: typeof ManualTerraformWellKnownRouteImport
534+
parentRoute: typeof rootRouteImport
535+
}
501536
'/_orchestrator/job_artefacts': {
502537
id: '/_orchestrator/job_artefacts'
503538
path: '/job_artefacts'
@@ -533,6 +568,13 @@ declare module '@tanstack/react-router' {
533568
preLoaderRoute: typeof AppSettingsTokensRouteImport
534569
parentRoute: typeof rootRouteImport
535570
}
571+
'/api/internal/sync-workos-orgs': {
572+
id: '/api/internal/sync-workos-orgs'
573+
path: '/api/internal/sync-workos-orgs'
574+
fullPath: '/api/internal/sync-workos-orgs'
575+
preLoaderRoute: typeof ApiInternalSyncWorkosOrgsRouteImport
576+
parentRoute: typeof rootRouteImport
577+
}
536578
'/api/auth/callback': {
537579
id: '/api/auth/callback'
538580
path: '/api/auth/callback'
@@ -836,8 +878,10 @@ const rootRouteChildren: RootRouteChildren = {
836878
AuthenticatedRoute: AuthenticatedRouteWithChildren,
837879
LogoutRoute: LogoutRoute,
838880
OrchestratorJob_artefactsRoute: OrchestratorJob_artefactsRoute,
881+
ManualTerraformWellKnownRoute: ManualTerraformWellKnownRoute,
839882
TfeSplatRoute: TfeSplatRoute,
840883
ApiAuthCallbackRoute: ApiAuthCallbackRoute,
884+
ApiInternalSyncWorkosOrgsRoute: ApiInternalSyncWorkosOrgsRoute,
841885
AppSettingsTokensRoute: AppSettingsTokensRoute,
842886
OrchestratorGithubCallbackRoute: OrchestratorGithubCallbackRoute,
843887
OrchestratorGithubWebhookRoute: OrchestratorGithubWebhookRoute,
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { syncOrgToBackend } from '@/api/orchestrator_orgs';
2+
import { syncOrgToStatesman } from '@/api/statesman_orgs';
3+
import { getWorkOS } from '@/authkit/ssr/workos';
4+
import { createFileRoute } from '@tanstack/react-router'
5+
import { syncUserToBackend } from '@/api/orchestrator_users';
6+
import { syncUserToStatesman } from '@/api/statesman_users';
7+
8+
async function syncWorkosOrgs() {
9+
const workos = await getWorkOS()
10+
11+
let after: string | undefined = undefined;
12+
let totalSynced = 0;
13+
14+
do {
15+
const page = await workos.organizations.listOrganizations({ after });
16+
for (const org of page.data) {
17+
try {
18+
// Determine oldest member's email to use as adminEmail
19+
let adminEmail: string | null = null;
20+
let oldestUserId: string | null = null;
21+
let orgMemberships: any[] = [];
22+
try {
23+
// List memberships for this organization
24+
const memberships = await getWorkOS().userManagement.listOrganizationMemberships({
25+
organizationId: org.id,
26+
} as any);
27+
orgMemberships = (memberships as any)?.data ?? [];
28+
29+
const sorted = orgMemberships.slice?.().sort?.((a: any, b: any) => {
30+
const aTime = new Date(a?.createdAt ?? a?.created_at ?? 0).getTime();
31+
const bTime = new Date(b?.createdAt ?? b?.created_at ?? 0).getTime();
32+
return aTime - bTime;
33+
}) ?? [];
34+
const oldest = sorted[0] as any;
35+
if (oldest?.userId || oldest?.user?.id) {
36+
const userId = oldest?.userId ?? oldest?.user?.id;
37+
oldestUserId = userId ?? null;
38+
try {
39+
const user = await getWorkOS().userManagement.getUser(userId);
40+
adminEmail = (user as any)?.email ?? null;
41+
} catch {
42+
adminEmail = (oldest as any)?.user?.email ?? null;
43+
}
44+
}
45+
} catch (err) {
46+
console.warn('Could not resolve oldest org member for adminEmail', { orgId: org.id, err: err instanceof Error ? err.message : String(err) });
47+
}
48+
49+
await syncOrgToBackend(org.id, org.name, adminEmail);
50+
await syncOrgToStatesman(org.id, org.name, org.name, oldestUserId, adminEmail);
51+
52+
// After org sync completes, sync all users for this org to backend and statesman
53+
for (const m of orgMemberships) {
54+
const uid = (m as any)?.userId ?? (m as any)?.user?.id;
55+
if (!uid) continue;
56+
let email = (m as any)?.user?.email ?? '';
57+
if (!email) {
58+
try {
59+
const user = await getWorkOS().userManagement.getUser(uid);
60+
email = (user as any)?.email ?? '';
61+
} catch {}
62+
}
63+
if (!email || email === adminEmail) continue;
64+
await Promise.allSettled([
65+
syncUserToBackend(uid, email, org.id),
66+
syncUserToStatesman(uid, email, org.id),
67+
]);
68+
}
69+
console.log("Synced organization to backend", { id: org.id, name: org.name });
70+
totalSynced += 1;
71+
} catch (error) {
72+
console.error("Failed to sync organization", { id: org.id, name: org.name, error: error instanceof Error ? error.message : String(error) });
73+
}
74+
}
75+
after = page.listMetadata?.after ?? undefined;
76+
} while (after);
77+
78+
console.log(`Completed WorkOS organization resync`, { totalSynced });
79+
}
80+
81+
export const Route = createFileRoute('/api/internal/sync-workos-orgs' as any)({
82+
server: {
83+
handlers: {
84+
POST: async ({ request }) => {
85+
const internalTasksSecret = request.headers.get('Authorization')?.replace('Bearer ', '');
86+
if (internalTasksSecret !== process.env.INTERNAL_TASKS_SECRET) {
87+
return new Response('Unauthorized', { status: 401 });
88+
}
89+
syncWorkosOrgs();
90+
return new Response('OK');
91+
}
92+
},
93+
},
94+
})
95+

ui/src/routes/manual/terraformWellKnown.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { createFileRoute, createRoute } from '@tanstack/react-router'
22
import { Route as rootRoute } from '@/routes/__root'
33

4+
// File-route shim to satisfy the file-based router scanner
5+
export const Route = createFileRoute('/manual/terraformWellKnown' as any)({})
6+
7+
48
export const terraformRoute = createRoute({
59
getParentRoute: () => rootRoute,
610
path: '/.well-known/terraform.json',

0 commit comments

Comments
 (0)