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", "provider",
"purpose", "purpose",
"clientId", "clientId",
"clientSecret", "clientSecret"
"baseUrl"
] ]
}, },
"GoogleIdentityProviderConfig": { "GoogleIdentityProviderConfig": {
@ -887,8 +886,7 @@
"provider", "provider",
"purpose", "purpose",
"clientId", "clientId",
"clientSecret", "clientSecret"
"baseUrl"
] ]
}, },
{ {

View file

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

View file

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

View file

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

View file

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

View file

@ -1190,7 +1190,7 @@ export interface GitLabIdentityProviderConfig {
*/ */
googleCloudSecret: string; googleCloudSecret: string;
}; };
baseUrl: baseUrl?:
| { | {
/** /**
* The name of the environment variable that contains the token. * 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 { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { GitHubStarToast } from "./components/githubStarToast"; import { GitHubStarToast } from "./components/githubStarToast";
import { UpgradeToast } from "./components/upgradeToast"; 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"; import { LinkAccounts } from "@/ee/features/permissionSyncing/linkAccounts";
interface LayoutProps { interface LayoutProps {
@ -126,16 +126,16 @@ export default async function Layout(props: LayoutProps) {
} }
if (hasEntitlement("permission-syncing")) { if (hasEntitlement("permission-syncing")) {
const unlinkedAccounts = await getUnlinkedIntegrationProviders(); const integrationProviderStates = await getIntegrationProviderStates();
if (isServiceError(unlinkedAccounts)) { if (isServiceError(integrationProviderStates)) {
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center p-6"> <div className="min-h-screen flex flex-col items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 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"> <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> <h2 className="text-lg font-semibold text-red-800 mb-2">An error occurred</h2>
<p className="text-red-700 mb-1"> <p className="text-red-700 mb-1">
{typeof unlinkedAccounts.message === 'string' {typeof integrationProviderStates.message === 'string'
? unlinkedAccounts.message ? integrationProviderStates.message
: "A server error occurred while checking your account status. Please try again or contact support."} : "A server error occurred while checking your account status. Please try again or contact support."}
</p> </p>
</div> </div>
@ -143,25 +143,18 @@ export default async function Layout(props: LayoutProps) {
) )
} }
if (unlinkedAccounts.length > 0) { const hasUnlinkedProviders = integrationProviderStates.some(state => state.isLinked === false);
// Separate required and optional providers if (hasUnlinkedProviders) {
const requiredProviders = unlinkedAccounts.filter(p => p.required !== false);
const hasRequiredProviders = requiredProviders.length > 0;
// Check if user has skipped optional providers
const cookieStore = await cookies(); const cookieStore = await cookies();
const hasSkippedOptional = cookieStore.has(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME); const hasSkippedOptional = cookieStore.has(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME);
// Show LinkAccounts if: const hasUnlinkedRequiredProviders = integrationProviderStates.some(state => state.required && !state.isLinked)
// 1. There are required providers, OR const shouldShowLinkAccounts = hasUnlinkedRequiredProviders || !hasSkippedOptional;
// 2. There are only optional providers AND user hasn't skipped yet
const shouldShowLinkAccounts = hasRequiredProviders || !hasSkippedOptional;
if (shouldShowLinkAccounts) { if (shouldShowLinkAccounts) {
return ( return (
<div className="min-h-screen flex items-center justify-center p-6"> <div className="min-h-screen flex items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" /> <LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
<LinkAccounts unlinkedAccounts={unlinkedAccounts} /> <LinkAccounts integrationProviderStates={integrationProviderStates} callbackUrl={`/${domain}`}/>
</div> </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 { notFound } from "@/lib/serviceError";
import { LinkedAccountsSettings } from "@/ee/features/permissionSyncing/linkedAccountsSettings"; import { LinkedAccountsSettings } from "@/ee/features/permissionSyncing/linkedAccountsSettings";
interface PermissionSyncingPageProps { export default async function PermissionSyncingPage() {
params: Promise<{
domain: string;
}>
}
export default async function PermissionSyncingPage(props: PermissionSyncingPageProps) {
const params = await props.params;
const hasPermissionSyncingEntitlement = await hasEntitlement("permission-syncing"); const hasPermissionSyncingEntitlement = await hasEntitlement("permission-syncing");
if (!hasPermissionSyncingEntitlement) { if (!hasPermissionSyncingEntitlement) {
notFound(); notFound();
} }
return <LinkedAccountsSettings domain={params.domain} />; return <LinkedAccountsSettings />;
} }

View file

@ -8,76 +8,51 @@ import { env } from "@/env.mjs";
import { OrgRole } from "@sourcebot/db"; import { OrgRole } from "@sourcebot/db";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME } from "@/lib/constants"; 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'); const logger = createLogger('web-ee-permission-syncing-actions');
export const userNeedsToLinkIdentityProvider = async () => sew(() => export const getIntegrationProviderStates = async () => sew(() =>
withAuthV2(async ({ prisma, role, user }) => withAuthV2(async ({ prisma, role, user }) =>
withMinimumOrgRole(role, OrgRole.MEMBER, async () => { withMinimumOrgRole(role, OrgRole.MEMBER, async () => {
const config = await loadConfig(env.CONFIG_PATH); const config = await loadConfig(env.CONFIG_PATH);
const identityProviders = config.identityProviders ?? []; const integrationProviderConfigs = config.identityProviders ?? [];
const linkedAccounts = await prisma.account.findMany({
for (const identityProvider of identityProviders) { where: {
if (identityProvider.purpose === "integration") { userId: user.id,
// Only check required providers (default to true if not specified) provider: {
const isRequired = 'required' in identityProvider ? identityProvider.required : true; in: integrationProviderConfigs.map(p => p.provider)
if (!isRequired) {
continue;
} }
},
select: {
provider: true,
providerAccountId: true
}
});
const linkedAccount = await prisma.account.findFirst({ const integrationProviderState: IntegrationIdentityProviderState[] = [];
where: { for (const integrationProviderConfig of integrationProviderConfigs) {
provider: identityProvider.provider, if (integrationProviderConfig.purpose === "integration") {
userId: user.id, const linkedAccount = linkedAccounts.find(
}, account => account.provider === integrationProviderConfig.provider
}); );
if (!linkedAccount) { const isLinked = !!linkedAccount;
logger.info(`Required integration identity provider ${identityProvider.provider} account info not found for user ${user.id}`); const isRequired = integrationProviderConfig.required ?? true;
return true; integrationProviderState.push({
} id: integrationProviderConfig.provider,
required: isRequired,
isLinked,
linkedAccountId: linkedAccount?.providerAccountId
} as IntegrationIdentityProviderState);
} }
} }
return false; return integrationProviderState;
}) })
) )
); );
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,
required: isRequired,
});
}
}
}
return unlinkedProviders;
})
)
);
export const unlinkIntegrationProvider = async (provider: string) => sew(() => export const unlinkIntegrationProvider = async (provider: string) => sew(() =>
withAuthV2(async ({ prisma, role, user }) => withAuthV2(async ({ prisma, role, user }) =>
@ -85,12 +60,8 @@ export const unlinkIntegrationProvider = async (provider: string) => sew(() =>
const config = await loadConfig(env.CONFIG_PATH); const config = await loadConfig(env.CONFIG_PATH);
const identityProviders = config.identityProviders ?? []; const identityProviders = config.identityProviders ?? [];
// Verify this is an integration provider const providerConfig = identityProviders.find(idp => idp.provider === provider)
const isIntegrationProvider = identityProviders.some( if (!providerConfig || !('purpose' in providerConfig) || providerConfig.purpose !== "integration") {
idp => idp.provider === provider && idp.purpose === "integration"
);
if (!isIntegrationProvider) {
throw new Error("Provider is not an integration provider"); 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).`); 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 }; return { success: true, count: result.count };
}) })
) )

View file

@ -1,5 +1,4 @@
import { getAuthProviderInfo } from "@/lib/utils"; import { getAuthProviderInfo } from "@/lib/utils";
import { ProviderIcon } from "./providerIcon";
import { ProviderBadge } from "./providerBadge"; import { ProviderBadge } from "./providerBadge";
interface ProviderInfoProps { 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'; '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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowRight } from "lucide-react"; import { skipOptionalProvidersLink } from "@/ee/features/permissionSyncing/actions";
import { skipOptionalProvidersLink } from "./actions";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { ProviderIcon } from "./components/providerIcon"; import { IntegrationProviderCard } from "./integrationProviderCard";
import { ProviderInfo } from "./components/providerInfo"; import { IntegrationIdentityProviderState } from "@/ee/features/permissionSyncing/types";
interface LinkAccountsProps { interface LinkAccountsProps {
unlinkedAccounts: IdentityProviderMetadata[]; integrationProviderStates: IntegrationIdentityProviderState[]
callbackUrl?: string; callbackUrl?: string;
} }
export const LinkAccounts = ({ unlinkedAccounts, callbackUrl }: LinkAccountsProps) => { export const LinkAccounts = ({ integrationProviderStates, callbackUrl }: LinkAccountsProps) => {
const router = useRouter(); const router = useRouter();
const [isSkipping, setIsSkipping] = useState(false); const [isSkipping, setIsSkipping] = useState(false);
const handleSignIn = (providerId: string) => {
signIn(providerId, {
redirectTo: callbackUrl ?? "/"
});
};
const handleSkip = async () => { const handleSkip = async () => {
setIsSkipping(true); setIsSkipping(true);
try { try {
await skipOptionalProvidersLink(); await skipOptionalProvidersLink();
router.refresh();
} catch (error) { } catch (error) {
console.error("Failed to skip optional providers:", error); console.error("Failed to skip optional providers:", error);
} finally {
setIsSkipping(false); setIsSkipping(false);
router.refresh()
} }
}; };
// Separate required and optional providers const canSkip = !integrationProviderStates.some(state => state.required && !state.isLinked);
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>
);
};
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Connect Your Accounts</CardTitle> <CardTitle className="text-base">Connect Your Accounts</CardTitle>
<CardDescription> <CardDescription>
{hasOnlyOptionalProviders ? ( Link the following accounts to enable permission syncing and access all features.
<> <br />
The following optional accounts can be linked to enhance your experience. You can manage your linked accounts later in <strong>Settings Linked Accounts.</strong>
<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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
{requiredProviders.map(renderProviderButton)} {integrationProviderStates
{optionalProviders.map(renderProviderButton)} .sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0))
.map(state => (
<IntegrationProviderCard
key={state.id}
integrationProviderState={state}
callbackUrl={callbackUrl}
/>
))}
</div> </div>
{hasOnlyOptionalProviders && ( {canSkip && (
<Button <Button
variant="outline" variant="outline"
className="w-full" className="w-full"

View file

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

View file

@ -1,55 +1,24 @@
import { withAuthV2 } from "@/withAuthV2"; import { ShieldCheck } from "lucide-react";
import { sew } from "@/actions"; import { getIntegrationProviderStates } from "@/ee/features/permissionSyncing/actions"
import { isServiceError, getAuthProviderInfo } from "@/lib/utils"; import { Card, CardContent } from "@/components/ui/card";
import { loadConfig } from "@sourcebot/shared"; import { IntegrationProviderCard } from "./integrationProviderCard";
import { env } from "@/env.mjs"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { Check, X, ShieldCheck } from "lucide-react"; import { isServiceError } from "@/lib/utils";
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";
interface LinkedAccountsSettingsProps { export async function LinkedAccountsSettings() {
domain: string; const integrationProviderStates = await getIntegrationProviderStates();
} if (isServiceError(integrationProviderStates)) {
return <div className="min-h-screen flex flex-col items-center justify-center p-6">
export async function LinkedAccountsSettings({ domain }: LinkedAccountsSettingsProps) { <LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
const config = await loadConfig(env.CONFIG_PATH); <div className="bg-red-50 border border-red-200 rounded-md p-6 max-w-md w-full text-center">
const integrationProviders = (config.identityProviders ?? []) <h2 className="text-lg font-semibold text-red-800 mb-2">An error occurred</h2>
.filter(provider => provider.purpose === "integration"); <p className="text-red-700 mb-1">
{typeof integrationProviderStates.message === 'string'
// Get user's linked accounts ? integrationProviderStates.message
const getLinkedAccounts = async () => sew(() => : "A server error occurred while checking your account status. Please try again or contact support."}
withAuthV2(async ({ prisma, user }) => { </p>
const accounts = await prisma.account.findMany({ </div>
where: { </div>
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);
} }
return ( return (
@ -57,12 +26,11 @@ export async function LinkedAccountsSettings({ domain }: LinkedAccountsSettingsP
<div> <div>
<h3 className="text-lg font-medium">Linked Accounts</h3> <h3 className="text-lg font-medium">Linked Accounts</h3>
<p className="text-sm text-muted-foreground mt-1"> <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> </p>
</div> </div>
{/* Show linked accounts as separate cards */} {integrationProviderStates.length === 0 ? (
{integrationProviders.length === 0 ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> <CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-muted p-3 mb-4"> <div className="rounded-full bg-muted p-3 mb-4">
@ -76,82 +44,16 @@ export async function LinkedAccountsSettings({ domain }: LinkedAccountsSettingsP
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{integrationProviders.map((provider) => { {integrationProviderStates
const providerInfo = getAuthProviderInfo(provider.provider); .sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0))
const linkedAccount = linkedAccounts.find( .map((state) => {
account => account.provider === provider.provider return (
); <IntegrationProviderCard
const isLinked = !!linkedAccount; key={state.id}
const isRequired = 'required' in provider ? (provider.required as boolean) : true; integrationProviderState={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"
/>
</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> </div>
)} )}
</div> </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 "default": true
} }
}, },
"required": ["provider", "purpose", "clientId", "clientSecret", "baseUrl"] "required": ["provider", "purpose", "clientId", "clientSecret"]
}, },
"GoogleIdentityProviderConfig": { "GoogleIdentityProviderConfig": {
"type": "object", "type": "object",