mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
refactor ui
This commit is contained in:
parent
9676088cb0
commit
6cc9d0b267
17 changed files with 212 additions and 340 deletions
|
|
@ -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"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,89 +8,60 @@ 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) {
|
|
||||||
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({
|
|
||||||
where: {
|
where: {
|
||||||
provider: identityProvider.provider,
|
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
provider: {
|
||||||
|
in: integrationProviderConfigs.map(p => p.provider)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
select: {
|
||||||
|
provider: true,
|
||||||
|
providerAccountId: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!linkedAccount) {
|
const integrationProviderState: IntegrationIdentityProviderState[] = [];
|
||||||
logger.info(`Required integration identity provider ${identityProvider.provider} account info not found for user ${user.id}`);
|
for (const integrationProviderConfig of integrationProviderConfigs) {
|
||||||
return true;
|
if (integrationProviderConfig.purpose === "integration") {
|
||||||
}
|
const linkedAccount = linkedAccounts.find(
|
||||||
}
|
account => account.provider === integrationProviderConfig.provider
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getUnlinkedIntegrationProviders = async () => sew(() =>
|
const isLinked = !!linkedAccount;
|
||||||
withAuthV2(async ({ prisma, role, user }) =>
|
const isRequired = integrationProviderConfig.required ?? true;
|
||||||
withMinimumOrgRole(role, OrgRole.MEMBER, async () => {
|
integrationProviderState.push({
|
||||||
const config = await loadConfig(env.CONFIG_PATH);
|
id: integrationProviderConfig.provider,
|
||||||
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,
|
required: isRequired,
|
||||||
});
|
isLinked,
|
||||||
}
|
linkedAccountId: linkedAccount?.providerAccountId
|
||||||
|
} as IntegrationIdentityProviderState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return unlinkedProviders;
|
return integrationProviderState;
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
export const unlinkIntegrationProvider = async (provider: string) => sew(() =>
|
export const unlinkIntegrationProvider = async (provider: string) => 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 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 };
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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 ? (
|
|
||||||
<>
|
|
||||||
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.
|
Link the following accounts to enable permission syncing and access all features.
|
||||||
<br />
|
<br />
|
||||||
You can manage your linked accounts later in <strong>Settings → Linked Accounts.</strong>
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,80 +44,14 @@ 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
|
|
||||||
);
|
|
||||||
const isLinked = !!linkedAccount;
|
|
||||||
const isRequired = 'required' in provider ? (provider.required as boolean) : true;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={provider.provider}>
|
<IntegrationProviderCard
|
||||||
<CardHeader>
|
key={state.id}
|
||||||
<div className="flex items-center justify-between">
|
integrationProviderState={state}
|
||||||
<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>
|
||||||
|
|
|
||||||
6
packages/web/src/ee/features/permissionSyncing/types.ts
Normal file
6
packages/web/src/ee/features/permissionSyncing/types.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export type IntegrationIdentityProviderState = {
|
||||||
|
id: string;
|
||||||
|
required: boolean;
|
||||||
|
isLinked: boolean;
|
||||||
|
linkedAccountId?: string;
|
||||||
|
};
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue