Skip to content

Commit 873cf11

Browse files
feat(web): generalize linked accounts with Linear-style UI
- Introduces `LinkedAccount` type in `ee/features/sso/actions.ts` covering all OAuth providers (SSO + account_linking), replacing the narrower `LinkedAccountProviderState` - Rewrites linked accounts UI with Linear-style Connect / Connected dropdown pattern; dropdown includes Disconnect and Refresh Permissions actions - Adds `triggerAccountPermissionSync` server action and worker API call for per-account permission refresh - Renames settings page from "Permission Syncing" to "Linked Accounts" - Removes `getLinkedAccountProviderStates` in favour of `getLinkedAccounts` - Moves SSO-related components and actions from `ee/features/permissionSyncing/` to `ee/features/sso/` Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a8f64eb commit 873cf11

File tree

24 files changed

+367
-456
lines changed

24 files changed

+367
-456
lines changed

packages/backend/src/api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { PrismaClient, RepoIndexingJobType } from '@sourcebot/db';
2-
import { createLogger, env, hasEntitlement } from '@sourcebot/shared';
2+
import { createLogger, env, hasEntitlement, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from '@sourcebot/shared';
33
import express, { Request, Response } from 'express';
44
import 'express-async-errors';
55
import * as http from "http";
@@ -10,7 +10,7 @@ import { PromClient } from './promClient.js';
1010
import { RepoIndexManager } from './repoIndexManager.js';
1111
import { createGitHubRepoRecord } from './repoCompileUtils.js';
1212
import { Octokit } from '@octokit/rest';
13-
import { PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS, SINGLE_TENANT_ORG_ID } from './constants.js';
13+
import { SINGLE_TENANT_ORG_ID } from './constants.js';
1414

1515
const logger = createLogger('api');
1616
const PORT = 3060;

packages/backend/src/constants.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,8 @@
1-
import { CodeHostType } from "@sourcebot/db";
2-
import { env, IdentityProviderType } from "@sourcebot/shared";
1+
import { env } from "@sourcebot/shared";
32
import path from "path";
43

54
export const SINGLE_TENANT_ORG_ID = 1;
65

7-
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES: CodeHostType[] = [
8-
'github',
9-
'gitlab',
10-
'bitbucketCloud',
11-
'bitbucketServer',
12-
];
13-
14-
export const PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS: IdentityProviderType[] = [
15-
'github',
16-
'gitlab',
17-
'bitbucket-cloud',
18-
'bitbucket-server',
19-
];
20-
216
export const REPOS_CACHE_DIR = path.join(env.DATA_CACHE_DIR, 'repos');
227
export const INDEX_CACHE_DIR = path.join(env.DATA_CACHE_DIR, 'index');
238

packages/backend/src/ee/accountPermissionSyncer.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import * as Sentry from "@sentry/node";
22
import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db";
3-
import { env, hasEntitlement, createLogger, loadConfig, decryptOAuthToken } from "@sourcebot/shared";
3+
import { env, hasEntitlement, createLogger, loadConfig, decryptOAuthToken, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared";
44
import { Job, Queue, Worker } from "bullmq";
55
import { Redis } from "ioredis";
6-
import { PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "../constants.js";
76
import {
87
createOctokitFromToken,
98
getOAuthScopesForAuthenticatedUser as getGitHubOAuthScopesForAuthenticatedUser,

packages/backend/src/ee/repoPermissionSyncer.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import * as Sentry from "@sentry/node";
22
import { PermissionSyncSource, PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db";
3-
import { createLogger } from "@sourcebot/shared";
3+
import { createLogger, PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "@sourcebot/shared";
44
import { env, hasEntitlement } from "@sourcebot/shared";
55
import { Job, Queue, Worker } from 'bullmq';
66
import { Redis } from 'ioredis';
7-
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
87
import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js";
98
import { createGitLabFromPersonalAccessToken, getProjectMembers } from "../gitlab.js";
109
import { createBitbucketCloudClient, createBitbucketServerClient, getExplicitUserPermissionsForCloudRepo, getUserPermissionsForServerRepo } from "../bitbucket.js";

packages/shared/src/constants.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { ConfigSettings } from "./types.js";
1+
import { CodeHostType } from "@sourcebot/db";
2+
import { ConfigSettings, IdentityProviderType } from "./types.js";
23

34
export const SOURCEBOT_SUPPORT_EMAIL = 'team@sourcebot.dev';
45

@@ -25,3 +26,17 @@ export const DEFAULT_CONFIG_SETTINGS: ConfigSettings = {
2526
maxAccountPermissionSyncJobConcurrency: 8,
2627
maxRepoPermissionSyncJobConcurrency: 8,
2728
}
29+
30+
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES: CodeHostType[] = [
31+
'github',
32+
'gitlab',
33+
'bitbucketCloud',
34+
'bitbucketServer',
35+
];
36+
37+
export const PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS: IdentityProviderType[] = [
38+
'github',
39+
'gitlab',
40+
'bitbucket-cloud',
41+
'bitbucket-server',
42+
];

packages/web/src/app/[domain]/layout.tsx

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
2323
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
2424
import { GitHubStarToast } from "./components/githubStarToast";
2525
import { UpgradeToast } from "./components/upgradeToast";
26-
import { getLinkedAccountProviderStates } from "@/ee/features/permissionSyncing/actions";
27-
import { LinkAccounts } from "@/ee/features/permissionSyncing/components/linkAccounts";
26+
import { getLinkedAccounts } from "@/ee/features/sso/actions";
2827
import { PermissionSyncBanner } from "./components/permissionSyncBanner";
2928
import { getPermissionSyncStatus } from "../api/(server)/ee/permissionSyncStatus/api";
29+
import { ServiceErrorException } from "@/lib/serviceError";
30+
import { ConnectAccountsCard } from "@/ee/features/sso/components/connectAccountsCard";
3031

3132
interface LayoutProps {
3233
children: React.ReactNode,
@@ -127,36 +128,24 @@ export default async function Layout(props: LayoutProps) {
127128
)
128129
}
129130

130-
if (session && hasEntitlement("permission-syncing")) {
131-
const linkedAccountProviderStates = await getLinkedAccountProviderStates();
132-
if (isServiceError(linkedAccountProviderStates)) {
133-
return (
134-
<div className="min-h-screen flex flex-col items-center justify-center p-6">
135-
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
136-
<div className="bg-red-50 border border-red-200 rounded-md p-6 max-w-md w-full text-center">
137-
<h2 className="text-lg font-semibold text-red-800 mb-2">An error occurred</h2>
138-
<p className="text-red-700 mb-1">
139-
{typeof linkedAccountProviderStates.message === 'string'
140-
? linkedAccountProviderStates.message
141-
: "A server error occurred while checking your account status. Please try again or contact support."}
142-
</p>
143-
</div>
144-
</div>
145-
)
131+
if (session && hasEntitlement("sso")) {
132+
const linkedAccounts = await getLinkedAccounts();
133+
if (isServiceError(linkedAccounts)) {
134+
throw new ServiceErrorException(linkedAccounts);
146135
}
147136

148-
const hasUnlinkedProviders = linkedAccountProviderStates.some(state => state.isLinked === false);
149-
if (hasUnlinkedProviders) {
137+
// First, grab a list of all unlinked providers.
138+
const unlinkedProviders = linkedAccounts.filter(a => !a.isLinked && a.isAccountLinkingProvider);
139+
if (unlinkedProviders.length > 0) {
150140
const cookieStore = await cookies();
151141
const hasSkippedOptional = cookieStore.has(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME);
152142

153-
const hasUnlinkedRequiredProviders = linkedAccountProviderStates.some(state => state.required && !state.isLinked)
154-
const shouldShowLinkAccounts = hasUnlinkedRequiredProviders || !hasSkippedOptional;
155-
if (shouldShowLinkAccounts) {
143+
const hasRequiredUnlinkedProviders = unlinkedProviders.some(a => a.required);
144+
if (hasRequiredUnlinkedProviders || !hasSkippedOptional) {
156145
return (
157146
<div className="min-h-screen flex items-center justify-center p-6">
158147
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
159-
<LinkAccounts linkedAccountProviderStates={linkedAccountProviderStates} callbackUrl={`/${domain}`} />
148+
<ConnectAccountsCard linkedAccounts={linkedAccounts} callbackUrl={`/${domain}`} />
160149
</div>
161150
)
162151
}

packages/web/src/app/[domain]/settings/layout.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,6 @@ export default async function SettingsLayout(
6868
throw new ServiceErrorException(connectionStats);
6969
}
7070

71-
const hasPermissionSyncingEntitlement = hasEntitlement("permission-syncing");
72-
7371
const sidebarNavItems: SidebarNavItem[] = [
7472
{
7573
title: "General",
@@ -88,7 +86,7 @@ export default async function SettingsLayout(
8886
}
8987
] : []),
9088
...(userRoleInOrg === OrgRole.OWNER ? [{
91-
title:"Members",
89+
title: "Members",
9290
isNotificationDotVisible: numJoinRequests !== undefined && numJoinRequests > 0,
9391
href: `/${domain}/settings/members`,
9492
}] : []),
@@ -108,10 +106,10 @@ export default async function SettingsLayout(
108106
title: "Analytics",
109107
href: `/${domain}/settings/analytics`,
110108
},
111-
...(hasPermissionSyncingEntitlement ? [
109+
...(hasEntitlement("sso") ? [
112110
{
113111
title: "Linked Accounts",
114-
href: `/${domain}/settings/permission-syncing`,
112+
href: `/${domain}/settings/linked-accounts`,
115113
}
116114
] : []),
117115
{

packages/web/src/ee/features/permissionSyncing/components/linkedAccountsSettings.tsx renamed to packages/web/src/app/[domain]/settings/linked-accounts/page.tsx

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
1-
import { ShieldCheck } from "lucide-react";
2-
import { getLinkedAccountProviderStates } from "@/ee/features/permissionSyncing/actions"
31
import { Card, CardContent } from "@/components/ui/card";
4-
import { LinkedAccountProviderCard } from "./linkedAccountProviderCard";
5-
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
2+
import { getLinkedAccounts } from "@/ee/features/sso/actions";
63
import { isServiceError } from "@/lib/utils";
4+
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
5+
import { ShieldCheck } from "lucide-react";
6+
import { LinkedAccountProviderCard } from "@/ee/features/sso/components/linkedAccountProviderCard";
77

8-
export async function LinkedAccountsSettings() {
9-
const linkedAccountProviderStates = await getLinkedAccountProviderStates();
10-
if (isServiceError(linkedAccountProviderStates)) {
8+
export default async function LinkedAccountsPage() {
9+
const linkedAccounts = await getLinkedAccounts();
10+
if (isServiceError(linkedAccounts)) {
1111
return <div className="min-h-screen flex flex-col items-center justify-center p-6">
1212
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
1313
<div className="bg-red-50 border border-red-200 rounded-md p-6 max-w-md w-full text-center">
1414
<h2 className="text-lg font-semibold text-red-800 mb-2">An error occurred</h2>
1515
<p className="text-red-700 mb-1">
16-
{typeof linkedAccountProviderStates.message === 'string'
17-
? linkedAccountProviderStates.message
16+
{typeof linkedAccounts.message === 'string'
17+
? linkedAccounts.message
1818
: "A server error occurred while checking your account status. Please try again or contact support."}
1919
</p>
2020
</div>
@@ -30,30 +30,28 @@ export async function LinkedAccountsSettings() {
3030
</p>
3131
</div>
3232

33-
{linkedAccountProviderStates.length === 0 ? (
33+
{linkedAccounts.length === 0 ? (
3434
<Card>
3535
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
3636
<div className="rounded-full bg-muted p-3 mb-4">
3737
<ShieldCheck className="h-6 w-6 text-muted-foreground" />
3838
</div>
39-
<p className="text-sm font-medium text-foreground mb-1">No linked accounts configured</p>
39+
<p className="text-sm font-medium text-foreground mb-1">No linked accounts</p>
4040
<p className="text-sm text-muted-foreground max-w-sm">
41-
Contact your administrator to configure linked account providers for your organization.
41+
Sign in with an OAuth provider to see your linked accounts here.
4242
</p>
4343
</CardContent>
4444
</Card>
4545
) : (
4646
<div className="space-y-4">
47-
{linkedAccountProviderStates
47+
{linkedAccounts
4848
.sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0))
49-
.map((state) => {
50-
return (
51-
<LinkedAccountProviderCard
52-
key={state.id}
53-
linkedAccountProviderState={state}
54-
/>
55-
);
56-
})}
49+
.map((account) => (
50+
<LinkedAccountProviderCard
51+
key={account.provider}
52+
linkedAccount={account}
53+
/>
54+
))}
5755
</div>
5856
)}
5957
</div>

packages/web/src/app/[domain]/settings/permission-syncing/page.tsx

Lines changed: 0 additions & 12 deletions
This file was deleted.

packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { apiHandler } from "@/lib/apiHandler";
2-
import { serviceErrorResponse } from "@/lib/serviceError";
2+
import { ServiceError, serviceErrorResponse } from "@/lib/serviceError";
33
import { isServiceError } from "@/lib/utils";
44
import { StatusCodes } from "http-status-codes";
55
import { getPermissionSyncStatus } from "./api";
@@ -16,4 +16,4 @@ export const GET = apiHandler(async () => {
1616
}
1717

1818
return Response.json(result, { status: StatusCodes.OK });
19-
});
19+
});

0 commit comments

Comments
 (0)