refactor ui

This commit is contained in:
msukkari 2025-11-03 17:21:04 -08:00
parent 9676088cb0
commit 6cc9d0b267
17 changed files with 212 additions and 340 deletions

View file

@ -229,8 +229,7 @@
"provider",
"purpose",
"clientId",
"clientSecret",
"baseUrl"
"clientSecret"
]
},
"GoogleIdentityProviderConfig": {
@ -887,8 +886,7 @@
"provider",
"purpose",
"clientId",
"clientSecret",
"baseUrl"
"clientSecret"
]
},
{

View file

@ -4633,8 +4633,7 @@
"provider",
"purpose",
"clientId",
"clientSecret",
"baseUrl"
"clientSecret"
]
},
"GoogleIdentityProviderConfig": {
@ -5291,8 +5290,7 @@
"provider",
"purpose",
"clientId",
"clientSecret",
"baseUrl"
"clientSecret"
]
},
{

View file

@ -228,8 +228,7 @@ const schema = {
"provider",
"purpose",
"clientId",
"clientSecret",
"baseUrl"
"clientSecret"
]
},
"GoogleIdentityProviderConfig": {
@ -886,8 +885,7 @@ const schema = {
"provider",
"purpose",
"clientId",
"clientSecret",
"baseUrl"
"clientSecret"
]
},
{

View file

@ -83,7 +83,7 @@ export interface GitLabIdentityProviderConfig {
*/
googleCloudSecret: string;
};
baseUrl:
baseUrl?:
| {
/**
* The name of the environment variable that contains the token.

View file

@ -4632,8 +4632,7 @@ const schema = {
"provider",
"purpose",
"clientId",
"clientSecret",
"baseUrl"
"clientSecret"
]
},
"GoogleIdentityProviderConfig": {
@ -5290,8 +5289,7 @@ const schema = {
"provider",
"purpose",
"clientId",
"clientSecret",
"baseUrl"
"clientSecret"
]
},
{

View file

@ -1190,7 +1190,7 @@ export interface GitLabIdentityProviderConfig {
*/
googleCloudSecret: string;
};
baseUrl:
baseUrl?:
| {
/**
* The name of the environment variable that contains the token.

View file

@ -23,7 +23,7 @@ import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { GitHubStarToast } from "./components/githubStarToast";
import { UpgradeToast } from "./components/upgradeToast";
import { getUnlinkedIntegrationProviders, userNeedsToLinkIdentityProvider } from "@/ee/features/permissionSyncing/actions";
import { getIntegrationProviderStates } from "@/ee/features/permissionSyncing/actions";
import { LinkAccounts } from "@/ee/features/permissionSyncing/linkAccounts";
interface LayoutProps {
@ -126,16 +126,16 @@ export default async function Layout(props: LayoutProps) {
}
if (hasEntitlement("permission-syncing")) {
const unlinkedAccounts = await getUnlinkedIntegrationProviders();
if (isServiceError(unlinkedAccounts)) {
const integrationProviderStates = await getIntegrationProviderStates();
if (isServiceError(integrationProviderStates)) {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
<div className="bg-red-50 border border-red-200 rounded-md p-6 max-w-md w-full text-center">
<h2 className="text-lg font-semibold text-red-800 mb-2">An error occurred</h2>
<p className="text-red-700 mb-1">
{typeof unlinkedAccounts.message === 'string'
? unlinkedAccounts.message
{typeof integrationProviderStates.message === 'string'
? integrationProviderStates.message
: "A server error occurred while checking your account status. Please try again or contact support."}
</p>
</div>
@ -143,25 +143,18 @@ export default async function Layout(props: LayoutProps) {
)
}
if (unlinkedAccounts.length > 0) {
// Separate required and optional providers
const requiredProviders = unlinkedAccounts.filter(p => p.required !== false);
const hasRequiredProviders = requiredProviders.length > 0;
// Check if user has skipped optional providers
const hasUnlinkedProviders = integrationProviderStates.some(state => state.isLinked === false);
if (hasUnlinkedProviders) {
const cookieStore = await cookies();
const hasSkippedOptional = cookieStore.has(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME);
// Show LinkAccounts if:
// 1. There are required providers, OR
// 2. There are only optional providers AND user hasn't skipped yet
const shouldShowLinkAccounts = hasRequiredProviders || !hasSkippedOptional;
const hasUnlinkedRequiredProviders = integrationProviderStates.some(state => state.required && !state.isLinked)
const shouldShowLinkAccounts = hasUnlinkedRequiredProviders || !hasSkippedOptional;
if (shouldShowLinkAccounts) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
<LinkAccounts unlinkedAccounts={unlinkedAccounts} />
<LinkAccounts integrationProviderStates={integrationProviderStates} callbackUrl={`/${domain}`}/>
</div>
)
}

View file

@ -1,30 +0,0 @@
'use client';
import { Button } from "@/components/ui/button";
import { Link2 } from "lucide-react";
import { signIn } from "next-auth/react";
interface LinkButtonProps {
provider: string;
providerName: string;
callbackUrl: string;
}
export const LinkButton = ({ provider, providerName, callbackUrl }: LinkButtonProps) => {
const handleLink = () => {
signIn(provider, {
redirectTo: callbackUrl
});
};
return (
<Button
variant="outline"
size="sm"
onClick={handleLink}
>
<Link2 className="h-4 w-4 mr-1" />
Link
</Button>
);
};

View file

@ -2,19 +2,11 @@ import { hasEntitlement } from "@sourcebot/shared";
import { notFound } from "@/lib/serviceError";
import { LinkedAccountsSettings } from "@/ee/features/permissionSyncing/linkedAccountsSettings";
interface PermissionSyncingPageProps {
params: Promise<{
domain: string;
}>
}
export default async function PermissionSyncingPage(props: PermissionSyncingPageProps) {
const params = await props.params;
export default async function PermissionSyncingPage() {
const hasPermissionSyncingEntitlement = await hasEntitlement("permission-syncing");
if (!hasPermissionSyncingEntitlement) {
notFound();
}
return <LinkedAccountsSettings domain={params.domain} />;
return <LinkedAccountsSettings />;
}

View file

@ -8,89 +8,60 @@ import { env } from "@/env.mjs";
import { OrgRole } from "@sourcebot/db";
import { cookies } from "next/headers";
import { OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME } from "@/lib/constants";
import { IntegrationIdentityProviderState } from "@/ee/features/permissionSyncing/types";
const logger = createLogger('web-ee-permission-syncing-actions');
export const userNeedsToLinkIdentityProvider = async () => sew(() =>
export const getIntegrationProviderStates = async () => sew(() =>
withAuthV2(async ({ prisma, role, user }) =>
withMinimumOrgRole(role, OrgRole.MEMBER, async () => {
const config = await loadConfig(env.CONFIG_PATH);
const identityProviders = config.identityProviders ?? [];
for (const identityProvider of identityProviders) {
if (identityProvider.purpose === "integration") {
// Only check required providers (default to true if not specified)
const isRequired = 'required' in identityProvider ? identityProvider.required : true;
if (!isRequired) {
continue;
}
const linkedAccount = await prisma.account.findFirst({
const integrationProviderConfigs = config.identityProviders ?? [];
const linkedAccounts = await prisma.account.findMany({
where: {
provider: identityProvider.provider,
userId: user.id,
provider: {
in: integrationProviderConfigs.map(p => p.provider)
}
},
select: {
provider: true,
providerAccountId: true
}
});
if (!linkedAccount) {
logger.info(`Required integration identity provider ${identityProvider.provider} account info not found for user ${user.id}`);
return true;
}
}
}
return false;
})
)
const integrationProviderState: IntegrationIdentityProviderState[] = [];
for (const integrationProviderConfig of integrationProviderConfigs) {
if (integrationProviderConfig.purpose === "integration") {
const linkedAccount = linkedAccounts.find(
account => account.provider === integrationProviderConfig.provider
);
export const getUnlinkedIntegrationProviders = async () => sew(() =>
withAuthV2(async ({ prisma, role, user }) =>
withMinimumOrgRole(role, OrgRole.MEMBER, async () => {
const config = await loadConfig(env.CONFIG_PATH);
const identityProviders = config.identityProviders ?? [];
const unlinkedProviders = [];
for (const identityProvider of identityProviders) {
if (identityProvider.purpose === "integration") {
const linkedAccount = await prisma.account.findFirst({
where: {
provider: identityProvider.provider,
userId: user.id,
},
});
if (!linkedAccount) {
const isRequired = 'required' in identityProvider ? identityProvider.required as boolean : true;
logger.info(`Integration identity provider ${identityProvider.provider} not linked for user ${user.id}`);
unlinkedProviders.push({
id: identityProvider.provider,
name: identityProvider.provider,
purpose: "integration" as const,
const isLinked = !!linkedAccount;
const isRequired = integrationProviderConfig.required ?? true;
integrationProviderState.push({
id: integrationProviderConfig.provider,
required: isRequired,
});
}
isLinked,
linkedAccountId: linkedAccount?.providerAccountId
} as IntegrationIdentityProviderState);
}
}
return unlinkedProviders;
return integrationProviderState;
})
)
);
export const unlinkIntegrationProvider = async (provider: string) => sew(() =>
withAuthV2(async ({ prisma, role, user }) =>
withMinimumOrgRole(role, OrgRole.MEMBER, async () => {
const config = await loadConfig(env.CONFIG_PATH);
const identityProviders = config.identityProviders ?? [];
// Verify this is an integration provider
const isIntegrationProvider = identityProviders.some(
idp => idp.provider === provider && idp.purpose === "integration"
);
if (!isIntegrationProvider) {
const providerConfig = identityProviders.find(idp => idp.provider === provider)
if (!providerConfig || !('purpose' in providerConfig) || providerConfig.purpose !== "integration") {
throw new Error("Provider is not an integration provider");
}
@ -104,6 +75,14 @@ export const unlinkIntegrationProvider = async (provider: string) => sew(() =>
logger.info(`Unlinked integration provider ${provider} for user ${user.id}. Deleted ${result.count} account(s).`);
// If we're unlinking a required identity provider then we want to wipe the optional skip cookie if it exists so that we give the
// user the option of linking optional providers in the same link accounts screen
const isRequired = providerConfig.required ?? true;
if (isRequired) {
const cookieStore = await cookies();
cookieStore.delete(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME);
}
return { success: true, count: result.count };
})
)

View file

@ -1,5 +1,4 @@
import { getAuthProviderInfo } from "@/lib/utils";
import { ProviderIcon } from "./providerIcon";
import { ProviderBadge } from "./providerBadge";
interface ProviderInfoProps {

View file

@ -0,0 +1,90 @@
import { getAuthProviderInfo } from "@/lib/utils";
import { Check, X } from "lucide-react";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ProviderIcon } from "./components/providerIcon";
import { ProviderInfo } from "./components/providerInfo";
import { UnlinkButton } from "./unlinkButton";
import { LinkButton } from "./linkButton";
import { IntegrationIdentityProviderState } from "@/ee/features/permissionSyncing/types"
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
interface IntegrationProviderCardProps {
integrationProviderState: IntegrationIdentityProviderState;
callbackUrl?: string;
}
export function IntegrationProviderCard({
integrationProviderState,
callbackUrl,
}: IntegrationProviderCardProps) {
const providerInfo = getAuthProviderInfo(integrationProviderState.id);
const defaultCallbackUrl = `/${SINGLE_TENANT_ORG_DOMAIN}/settings/permission-syncing`;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="flex-shrink-0">
<ProviderIcon
icon={providerInfo.icon}
displayName={providerInfo.displayName}
size="lg"
/>
</div>
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
<CardTitle className="text-base">
<ProviderInfo
providerId={integrationProviderState.id}
required={integrationProviderState.required}
showBadge={true}
/>
</CardTitle>
<CardDescription className="text-xs">
{integrationProviderState.isLinked? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Check className="h-3.5 w-3.5 text-green-600 dark:text-green-500" />
<span className="text-green-600 dark:text-green-500 font-medium">
Connected
</span>
</div>
{integrationProviderState.linkedAccountId && (
<>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground font-mono truncate">
{integrationProviderState.linkedAccountId}
</span>
</>
)}
</div>
) : (
<div className="flex items-center gap-1.5">
<X className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
Not connected
</span>
</div>
)}
</CardDescription>
</div>
</div>
<div className="flex-shrink-0 ml-4">
{integrationProviderState.isLinked? (
<UnlinkButton
provider={integrationProviderState.id}
providerName={providerInfo.displayName}
/>
) : (
<LinkButton
provider={integrationProviderState.id}
callbackUrl={callbackUrl ?? defaultCallbackUrl}
/>
)}
</div>
</div>
</CardHeader>
</Card>
);
}

View file

@ -1,108 +1,58 @@
'use client';
import { signIn } from "next-auth/react";
import { getAuthProviderInfo } from "@/lib/utils";
import type { IdentityProviderMetadata } from "@/lib/identityProviders";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowRight } from "lucide-react";
import { skipOptionalProvidersLink } from "./actions";
import { skipOptionalProvidersLink } from "@/ee/features/permissionSyncing/actions";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { ProviderIcon } from "./components/providerIcon";
import { ProviderInfo } from "./components/providerInfo";
import { IntegrationProviderCard } from "./integrationProviderCard";
import { IntegrationIdentityProviderState } from "@/ee/features/permissionSyncing/types";
interface LinkAccountsProps {
unlinkedAccounts: IdentityProviderMetadata[];
integrationProviderStates: IntegrationIdentityProviderState[]
callbackUrl?: string;
}
export const LinkAccounts = ({ unlinkedAccounts, callbackUrl }: LinkAccountsProps) => {
export const LinkAccounts = ({ integrationProviderStates, callbackUrl }: LinkAccountsProps) => {
const router = useRouter();
const [isSkipping, setIsSkipping] = useState(false);
const handleSignIn = (providerId: string) => {
signIn(providerId, {
redirectTo: callbackUrl ?? "/"
});
};
const handleSkip = async () => {
setIsSkipping(true);
try {
await skipOptionalProvidersLink();
router.refresh();
} catch (error) {
console.error("Failed to skip optional providers:", error);
} finally {
setIsSkipping(false);
router.refresh()
}
};
// Separate required and optional providers
const requiredProviders = unlinkedAccounts.filter(p => p.required !== false);
const optionalProviders = unlinkedAccounts.filter(p => p.required === false);
const hasOnlyOptionalProviders = requiredProviders.length === 0 && optionalProviders.length > 0;
const renderProviderButton = (provider: IdentityProviderMetadata) => {
const providerInfo = getAuthProviderInfo(provider.id);
const isRequired = provider.required !== false;
return (
<button
key={provider.id}
onClick={() => handleSignIn(provider.id)}
className="group w-full flex items-center gap-4 p-4 rounded-lg border border-border bg-card hover:bg-accent hover:border-accent-foreground/20 transition-all duration-200"
>
<div className="flex-shrink-0 group-hover:border-primary/20 transition-colors">
<ProviderIcon
icon={providerInfo.icon}
displayName={providerInfo.displayName}
size="md"
/>
</div>
<div className="flex-1 min-w-0 text-left">
<ProviderInfo
providerId={provider.id}
required={isRequired}
showBadge={true}
/>
<div className="text-xs text-muted-foreground mt-0.5">
Click to connect
</div>
</div>
<div className="flex-shrink-0">
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</button>
);
};
const canSkip = !integrationProviderStates.some(state => state.required && !state.isLinked);
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Connect Your Accounts</CardTitle>
<CardDescription>
{hasOnlyOptionalProviders ? (
<>
The following optional accounts can be linked to enhance your experience.
<br />
You can link them now or skip and manage them later in <strong>Settings Linked Accounts.</strong>
</>
) : (
<>
Link the following accounts to enable permission syncing and access all features.
<br />
You can manage your linked accounts later in <strong>Settings Linked Accounts.</strong>
</>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
{requiredProviders.map(renderProviderButton)}
{optionalProviders.map(renderProviderButton)}
{integrationProviderStates
.sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0))
.map(state => (
<IntegrationProviderCard
key={state.id}
integrationProviderState={state}
callbackUrl={callbackUrl}
/>
))}
</div>
{hasOnlyOptionalProviders && (
{canSkip && (
<Button
variant="outline"
className="w-full"

View file

@ -6,11 +6,10 @@ import { signIn } from "next-auth/react";
interface LinkButtonProps {
provider: string;
providerName: string;
callbackUrl: string;
}
export const LinkButton = ({ provider, providerName, callbackUrl }: LinkButtonProps) => {
export const LinkButton = ({ provider, callbackUrl }: LinkButtonProps) => {
const handleLink = () => {
signIn(provider, {
redirectTo: callbackUrl

View file

@ -1,55 +1,24 @@
import { withAuthV2 } from "@/withAuthV2";
import { sew } from "@/actions";
import { isServiceError, getAuthProviderInfo } from "@/lib/utils";
import { loadConfig } from "@sourcebot/shared";
import { env } from "@/env.mjs";
import { Check, X, ShieldCheck } from "lucide-react";
import { getUnlinkedIntegrationProviders } from "./actions";
import { UnlinkButton } from "./unlinkButton";
import { LinkButton } from "./linkButton";
import { ServiceErrorException } from "@/lib/serviceError";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ProviderIcon } from "./components/providerIcon";
import { ProviderInfo } from "./components/providerInfo";
import { ShieldCheck } from "lucide-react";
import { getIntegrationProviderStates } from "@/ee/features/permissionSyncing/actions"
import { Card, CardContent } from "@/components/ui/card";
import { IntegrationProviderCard } from "./integrationProviderCard";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { isServiceError } from "@/lib/utils";
interface LinkedAccountsSettingsProps {
domain: string;
}
export async function LinkedAccountsSettings({ domain }: LinkedAccountsSettingsProps) {
const config = await loadConfig(env.CONFIG_PATH);
const integrationProviders = (config.identityProviders ?? [])
.filter(provider => provider.purpose === "integration");
// Get user's linked accounts
const getLinkedAccounts = async () => sew(() =>
withAuthV2(async ({ prisma, user }) => {
const accounts = await prisma.account.findMany({
where: {
userId: user.id,
provider: {
in: integrationProviders.map(p => p.provider)
}
},
select: {
provider: true,
providerAccountId: true,
}
});
return accounts;
})
);
const linkedAccountsResult = await getLinkedAccounts();
if (isServiceError(linkedAccountsResult)) {
throw new ServiceErrorException(linkedAccountsResult);
}
const linkedAccounts = linkedAccountsResult;
const unlinkedProvidersResult = await getUnlinkedIntegrationProviders();
if (isServiceError(unlinkedProvidersResult)) {
throw new ServiceErrorException(unlinkedProvidersResult);
export async function LinkedAccountsSettings() {
const integrationProviderStates = await getIntegrationProviderStates();
if (isServiceError(integrationProviderStates)) {
return <div className="min-h-screen flex flex-col items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
<div className="bg-red-50 border border-red-200 rounded-md p-6 max-w-md w-full text-center">
<h2 className="text-lg font-semibold text-red-800 mb-2">An error occurred</h2>
<p className="text-red-700 mb-1">
{typeof integrationProviderStates.message === 'string'
? integrationProviderStates.message
: "A server error occurred while checking your account status. Please try again or contact support."}
</p>
</div>
</div>
}
return (
@ -57,12 +26,11 @@ export async function LinkedAccountsSettings({ domain }: LinkedAccountsSettingsP
<div>
<h3 className="text-lg font-medium">Linked Accounts</h3>
<p className="text-sm text-muted-foreground mt-1">
Manage your linked integration accounts for permission syncing and code host access.
Manage your linked account integrations for permission syncing.
</p>
</div>
{/* Show linked accounts as separate cards */}
{integrationProviders.length === 0 ? (
{integrationProviderStates.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-muted p-3 mb-4">
@ -76,80 +44,14 @@ export async function LinkedAccountsSettings({ domain }: LinkedAccountsSettingsP
</Card>
) : (
<div className="space-y-4">
{integrationProviders.map((provider) => {
const providerInfo = getAuthProviderInfo(provider.provider);
const linkedAccount = linkedAccounts.find(
account => account.provider === provider.provider
);
const isLinked = !!linkedAccount;
const isRequired = 'required' in provider ? (provider.required as boolean) : true;
{integrationProviderStates
.sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0))
.map((state) => {
return (
<Card key={provider.provider}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="flex-shrink-0">
<ProviderIcon
icon={providerInfo.icon}
displayName={providerInfo.displayName}
size="lg"
<IntegrationProviderCard
key={state.id}
integrationProviderState={state}
/>
</div>
<div className="flex flex-col gap-1.5 min-w-0 flex-1">
<CardTitle className="text-base">
<ProviderInfo
providerId={provider.provider}
required={isRequired}
showBadge={true}
/>
</CardTitle>
<CardDescription className="text-xs">
{isLinked ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Check className="h-3.5 w-3.5 text-green-600 dark:text-green-500" />
<span className="text-green-600 dark:text-green-500 font-medium">
Connected
</span>
</div>
{linkedAccount.providerAccountId && (
<>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground font-mono truncate">
{linkedAccount.providerAccountId}
</span>
</>
)}
</div>
) : (
<div className="flex items-center gap-1.5">
<X className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">
Not connected
</span>
</div>
)}
</CardDescription>
</div>
</div>
<div className="flex-shrink-0 ml-4">
{isLinked ? (
<UnlinkButton
provider={provider.provider}
providerName={providerInfo.displayName}
/>
) : (
<LinkButton
provider={provider.provider}
providerName={providerInfo.displayName}
callbackUrl={`/${domain}/settings/permission-syncing`}
/>
)}
</div>
</div>
</CardHeader>
</Card>
);
})}
</div>

View file

@ -0,0 +1,6 @@
export type IntegrationIdentityProviderState = {
id: string;
required: boolean;
isLinked: boolean;
linkedAccountId?: string;
};

View file

@ -50,7 +50,7 @@
"default": true
}
},
"required": ["provider", "purpose", "clientId", "clientSecret", "baseUrl"]
"required": ["provider", "purpose", "clientId", "clientSecret"]
},
"GoogleIdentityProviderConfig": {
"type": "object",