Inline secret creation (#207)

This commit is contained in:
Brendan Kellam 2025-02-22 10:37:59 -08:00 committed by GitHub
parent ced6c527ba
commit 0ff34d105d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 905 additions and 117 deletions

View file

@ -62,6 +62,7 @@ const schema = {
"type": "string",
"pattern": "^[\\w.-]+$"
},
"default": [],
"examples": [
[
"my-org-name"

View file

@ -58,6 +58,7 @@ const schema = {
"type": "string",
"pattern": "^[\\w.-]+$"
},
"default": [],
"examples": [
[
"my-org-name"

View file

@ -50,6 +50,7 @@
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View file

@ -201,6 +201,22 @@ export const createSecret = async (key: string, value: string, domain: string):
}
}));
export const checkIfSecretExists = async (key: string, domain: string): Promise<boolean | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const secret = await prisma.secret.findUnique({
where: {
orgId_key: {
orgId,
key,
}
}
});
return !!secret;
})
);
export const deleteSecret = async (key: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {

View file

@ -14,15 +14,17 @@ import {
jsonSchemaLinter,
stateExtensions
} from "codemirror-json-schema";
import { useMemo, useRef } from "react";
import { useRef, forwardRef, useImperativeHandle, Ref, ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Schema } from "ajv";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
export type QuickActionFn<T> = (previous: T) => T;
export type QuickAction<T> = {
name: string;
fn: QuickActionFn<T>;
description?: string | ReactNode;
};
interface ConfigEditorProps<T> {
@ -46,30 +48,23 @@ const customAutocompleteStyle = EditorView.baseTheme({
}
});
export function ConfigEditor<T>({
value,
onChange,
actions,
schema,
}: ConfigEditorProps<T>) {
const editorRef = useRef<ReactCodeMirrorRef>(null);
const keymapExtension = useKeymapExtension(editorRef.current?.view);
const { theme } = useThemeNormalized();
const isQuickActionsDisabled = useMemo(() => {
try {
JSON.parse(value);
return false;
} catch {
return true;
export function onQuickAction<T>(
action: QuickActionFn<T>,
config: string,
view: EditorView,
options?: {
focusEditor?: boolean;
moveCursor?: boolean;
}
}, [value]);
) {
const {
focusEditor = false,
moveCursor = true,
} = options ?? {};
const onQuickAction = (action: QuickActionFn<T>) => {
let previousConfig: T;
try {
previousConfig = JSON.parse(value) as T;
previousConfig = JSON.parse(config) as T;
} catch {
return;
}
@ -77,49 +72,50 @@ export function ConfigEditor<T>({
const nextConfig = action(previousConfig);
const next = JSON.stringify(nextConfig, null, 2);
const cursorPos = next.lastIndexOf(`""`) + 1;
if (focusEditor) {
view.focus();
}
editorRef.current?.view?.focus();
editorRef.current?.view?.dispatch({
const cursorPos = next.lastIndexOf(`""`) + 1;
view.dispatch({
changes: {
from: 0,
to: value.length,
to: config.length,
insert: next,
}
});
editorRef.current?.view?.dispatch({
if (moveCursor) {
view.dispatch({
selection: { anchor: cursorPos, head: cursorPos }
});
}
}
export const isConfigValidJson = (config: string) => {
try {
JSON.parse(config);
return true;
} catch (_e) {
return false;
}
}
const ConfigEditor = <T,>(props: ConfigEditorProps<T>, forwardedRef: Ref<ReactCodeMirrorRef>) => {
const { value, onChange, actions, schema } = props;
const editorRef = useRef<ReactCodeMirrorRef>(null);
useImperativeHandle(
forwardedRef,
() => editorRef.current as ReactCodeMirrorRef
);
const keymapExtension = useKeymapExtension(editorRef.current?.view);
const { theme } = useThemeNormalized();
return (
<>
<div className="flex flex-row items-center flex-wrap w-full">
{actions.map(({ name, fn }, index) => (
<div
key={index}
className="flex flex-row items-center"
>
<Button
variant="ghost"
className="disabled:opacity-100 disabled:pointer-events-auto disabled:cursor-not-allowed"
disabled={isQuickActionsDisabled}
onClick={(e) => {
e.preventDefault();
onQuickAction(fn);
}}
>
{name}
</Button>
{index !== actions.length - 1 && (
<Separator
orientation="vertical" className="h-4 mx-1"
/>
)}
</div>
))}
</div>
<ScrollArea className="rounded-md border p-1 overflow-auto flex-1 h-64">
<div className="border rounded-md">
<ScrollArea className="p-1 overflow-auto flex-1 h-56">
<CodeMirror
ref={editorRef}
value={value}
@ -144,6 +140,56 @@ export function ConfigEditor<T>({
theme={theme === "dark" ? "dark" : "light"}
/>
</ScrollArea>
</>
)
<Separator />
<div className="flex flex-row items-center flex-wrap w-full p-1">
<TooltipProvider>
{actions.map(({ name, fn, description }, index) => (
<div
key={index}
className="flex flex-row items-center"
>
<Tooltip
delayDuration={100}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="disabled:opacity-100 disabled:pointer-events-auto disabled:cursor-not-allowed text-sm font-mono tracking-tight"
size="sm"
disabled={!isConfigValidJson(value)}
onClick={(e) => {
e.preventDefault();
if (editorRef.current?.view) {
onQuickAction(fn, value, editorRef.current.view, {
focusEditor: true,
});
}
}}
>
{name}
</Button>
</TooltipTrigger>
<TooltipContent
hidden={!description}
className="max-w-xs"
>
{description}
</TooltipContent>
</Tooltip>
{index !== actions.length - 1 && (
<Separator
orientation="vertical" className="h-4 mx-1"
/>
)}
</div>
))}
</TooltipProvider>
</div>
</div>
)
};
// @see: https://stackoverflow.com/a/78692562
export default forwardRef(ConfigEditor) as <T>(
props: ConfigEditorProps<T> & { ref?: Ref<ReactCodeMirrorRef> },
) => ReturnType<typeof ConfigEditor>;

View file

@ -0,0 +1,415 @@
'use client';
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Button } from "@/components/ui/button";
import { cn, isServiceError } from "@/lib/utils";
import { ChevronsUpDown, Check, PlusCircleIcon, Loader2, Eye, EyeOff, TriangleAlert } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { Separator } from "@/components/ui/separator";
import { useQuery } from "@tanstack/react-query";
import { checkIfSecretExists, createSecret, getSecrets } from "@/actions";
import { useDomain } from "@/hooks/useDomain";
import { Dialog, DialogTitle, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog";
import Link from "next/link";
import { Form, FormLabel, FormControl, FormDescription, FormItem, FormField, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useToast } from "@/components/hooks/use-toast";
import Image from "next/image";
import githubPatCreation from "@/public/github_pat_creation.png"
import { CodeHostType } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { isDefined } from '@/lib/utils'
interface SecretComboBoxProps {
isDisabled: boolean;
codeHostType: CodeHostType;
secretKey?: string;
onSecretChange: (secretKey: string) => void;
}
export const SecretCombobox = ({
isDisabled,
codeHostType,
secretKey,
onSecretChange,
}: SecretComboBoxProps) => {
const [searchFilter, setSearchFilter] = useState("");
const domain = useDomain();
const [isCreateSecretDialogOpen, setIsCreateSecretDialogOpen] = useState(false);
const { data: secrets, isLoading, refetch } = useQuery({
queryKey: ["secrets"],
queryFn: () => getSecrets(domain),
});
const onSecretCreated = useCallback((key: string) => {
onSecretChange(key);
refetch();
}, [onSecretChange, refetch]);
const isSecretNotFoundWarningVisible = useMemo(() => {
if (!isDefined(secretKey)) {
return false;
}
if (isServiceError(secrets)) {
return false;
}
return !secrets?.some(({ key }) => key === secretKey);
}, [secretKey, secrets]);
return (
<>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[300px] overflow-hidden",
!secretKey && "text-muted-foreground"
)}
disabled={isDisabled}
>
{isSecretNotFoundWarningVisible && (
<TooltipProvider>
<Tooltip
delayDuration={100}
>
<TooltipTrigger
onClick={(e) => e.preventDefault()}
>
<TriangleAlert className="h-4 w-4 text-yellow-700 dark:text-yellow-400" />
</TooltipTrigger>
<TooltipContent>
<p>The secret you selected does not exist.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<span className="truncate">{isDefined(secretKey) ? secretKey : "Select secret"}</span>
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0.5">
{isLoading && (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{secrets && !isServiceError(secrets) && secrets.length > 0 && (
<>
<Command className="mb-2">
<CommandInput
placeholder="Search secrets..."
value={searchFilter}
onValueChange={(value) => setSearchFilter(value)}
/>
<CommandList>
<CommandEmpty>
<p className="text-sm">No secrets found</p>
<p className="text-sm text-muted-foreground">{`Your search term "${searchFilter}" did not match any secrets.`}</p>
</CommandEmpty>
<CommandGroup
heading="Secrets"
>
{secrets.map(({ key }) => (
<CommandItem
className="cursor-pointer"
value={key}
key={key}
onSelect={() => {
onSecretChange(key);
}}
>
{key}
<Check
className={cn(
"ml-auto",
key === secretKey
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<Separator className="mt-2" />
</>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setIsCreateSecretDialogOpen(true)}
className={cn(
"w-full justify-start gap-1.5 p-2",
secrets && !isServiceError(secrets) && secrets.length > 0 && "my-2"
)}
>
<PlusCircleIcon className="h-5 w-5 text-muted-foreground mr-1" />
Import a secret
</Button>
</PopoverContent>
</Popover>
<ImportSecretDialog
open={isCreateSecretDialogOpen}
onOpenChange={setIsCreateSecretDialogOpen}
onSecretCreated={onSecretCreated}
codeHostType={codeHostType}
/>
</>
)
}
interface ImportSecretDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSecretCreated: (key: string) => void;
codeHostType: CodeHostType;
}
const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHostType }: ImportSecretDialogProps) => {
const [showValue, setShowValue] = useState(false);
const domain = useDomain();
const { toast } = useToast();
const formSchema = z.object({
key: z.string().min(1).refine(async (key) => {
const doesSecretExist = await checkIfSecretExists(key, domain);
return isServiceError(doesSecretExist) || !doesSecretExist;
}, "A secret with this key already exists."),
value: z.string().min(1),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
key: "",
value: "",
},
});
const { isSubmitting } = form.formState;
const onSubmit = useCallback(async (data: z.infer<typeof formSchema>) => {
const response = await createSecret(data.key, data.value, domain);
if (isServiceError(response)) {
toast({
description: `❌ Failed to create secret`
});
} else {
toast({
description: `✅ Secret created successfully!`
});
form.reset();
onOpenChange(false);
onSecretCreated(data.key);
}
}, [domain, toast, onOpenChange, onSecretCreated, form]);
const codeHostSpecificStep = useMemo(() => {
switch (codeHostType) {
case 'github':
return <GitHubPATCreationStep step={1} />;
case 'gitlab':
return <GitLabPATCreationStep step={1} />;
case 'gitea':
return <GiteaPATCreationStep step={1} />;
case 'gerrit':
return null;
}
}, [codeHostType]);
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<DialogContent
className="p-16 max-w-2xl max-h-[80vh] overflow-scroll"
>
<DialogHeader>
<DialogTitle className="text-2xl font-semibold">Import a secret</DialogTitle>
<DialogDescription>
Secrets are used to authenticate with a code host. They are encrypted at rest using <Link href="https://en.wikipedia.org/wiki/Advanced_Encryption_Standard" target="_blank" className="underline">AES-256-CBC</Link>.
Checkout our <Link href="https://sourcebot.dev/security" target="_blank" className="underline">security docs</Link> for more information.
</DialogDescription>
</DialogHeader>
<Form
{...form}
>
<form
className="space-y-4 flex flex-col mt-4 gap-4"
onSubmit={(event) => {
event.stopPropagation();
form.handleSubmit(onSubmit)(event);
}}
>
{codeHostSpecificStep}
<SecretCreationStep
step={2}
title="Import the secret"
description="Copy the generated token and paste it below."
>
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>Value</FormLabel>
<FormControl>
<div className="relative">
<Input
{...field}
type={showValue ? "text" : "password"}
placeholder="Enter your secret value"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-2 top-1/2 -translate-y-1/2"
onClick={() => setShowValue(!showValue)}
>
{showValue ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</FormControl>
<FormDescription>
The secret value to store securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SecretCreationStep>
<SecretCreationStep
step={3}
title="Name the secret"
description="Give the secret a unique name so that it can be referenced in a connection config."
>
<FormField
control={form.control}
name="key"
render={({ field }) => (
<FormItem>
<FormLabel>Key</FormLabel>
<FormControl>
<Input
placeholder="my-github-token"
{...field}
/>
</FormControl>
<FormDescription>
A unique name to identify this secret.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SecretCreationStep>
<div className="flex justify-end w-full">
<Button
type="submit"
disabled={isSubmitting}
>
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Import Secret
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
const GitHubPATCreationStep = ({ step }: { step: number }) => {
return (
<SecretCreationStep
step={step}
title="Create a Personal Access Token"
description=<span>Navigate to <Link href="https://github.com/settings/tokens/new" target="_blank" className="underline">here on github.com</Link> (or your enterprise instance) and create a new personal access token. Sourcebot needs the <strong>repo</strong> scope in order to access private repositories:</span>
>
<Image
className="mx-auto"
src={githubPatCreation}
alt="Create a personal access token"
width={500}
height={500}
/>
</SecretCreationStep>
)
}
const GitLabPATCreationStep = ({ step }: { step: number }) => {
return (
<SecretCreationStep
step={step}
title="Create a Personal Access Token"
description="todo"
>
<p>todo</p>
</SecretCreationStep>
)
}
const GiteaPATCreationStep = ({ step }: { step: number }) => {
return (
<SecretCreationStep
step={step}
title="Create a Personal Access Token"
description="todo"
>
<p>todo</p>
</SecretCreationStep>
)
}
interface SecretCreationStepProps {
step: number;
title: string;
description: string | React.ReactNode;
children: React.ReactNode;
}
const SecretCreationStep = ({ step, title, description, children }: SecretCreationStepProps) => {
return (
<div className="relative flex flex-col gap-2">
<div className="absolute -left-10 flex flex-col items-center gap-2 h-full">
<span className="text-md font-semibold border rounded-full px-2">{step}</span>
<Separator className="h-5/6" orientation="vertical" />
</div>
<h3 className="text-md font-semibold">
{title}
</h3>
<p className="text-sm text-muted-foreground">
{description}
</p>
{children}
</div>
)
}

View file

@ -1,26 +1,29 @@
'use client';
import { createConnection } from "@/actions";
import { checkIfSecretExists, createConnection } from "@/actions";
import { ConnectionIcon } from "@/app/[domain]/connections/components/connectionIcon";
import { createZodConnectionConfigValidator } from "@/app/[domain]/connections/utils";
import { useToast } from "@/components/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { isServiceError } from "@/lib/utils";
import { CodeHostType, isServiceError, isAuthSupportedForCodeHost } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { Schema } from "ajv";
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ConfigEditor, QuickActionFn } from "../configEditor";
import ConfigEditor, { isConfigValidJson, onQuickAction, QuickActionFn } from "../configEditor";
import { useDomain } from "@/hooks/useDomain";
import { Loader2 } from "lucide-react";
import { ReactCodeMirrorRef } from "@uiw/react-codemirror";
import { SecretCombobox } from "./secretCombobox";
import strings from "@/lib/strings";
interface SharedConnectionCreationFormProps<T> {
type: 'github' | 'gitlab' | 'gitea' | 'gerrit';
type: CodeHostType;
defaultValues: {
name: string;
config: string;
@ -35,6 +38,7 @@ interface SharedConnectionCreationFormProps<T> {
onCreated?: (id: number) => void;
}
export default function SharedConnectionCreationForm<T>({
type,
defaultValues,
@ -44,14 +48,21 @@ export default function SharedConnectionCreationForm<T>({
className,
onCreated,
}: SharedConnectionCreationFormProps<T>) {
const { toast } = useToast();
const domain = useDomain();
const editorRef = useRef<ReactCodeMirrorRef>(null);
const formSchema = useMemo(() => {
return z.object({
name: z.string().min(1),
config: createZodConnectionConfigValidator(schema),
secretKey: z.string().optional().refine(async (secretKey) => {
if (!secretKey) {
return true;
}
return checkIfSecretExists(secretKey, domain);
}, { message: "Secret not found" }),
});
}, [schema]);
@ -75,6 +86,27 @@ export default function SharedConnectionCreationForm<T>({
}
}, [domain, toast, type, onCreated]);
const onConfigChange = useCallback((value: string) => {
form.setValue("config", value);
const isValid = isConfigValidJson(value);
setIsSecretsDisabled(!isValid);
if (isValid) {
const configJson = JSON.parse(value);
if (configJson.token?.secret !== undefined) {
form.setValue("secretKey", configJson.token.secret);
} else {
form.setValue("secretKey", undefined);
}
}
}, [form]);
// Run onConfigChange on mount to set the initial secret key
useEffect(() => {
onConfigChange(defaultValues.config);
}, [defaultValues, onConfigChange]);
const [isSecretsDisabled, setIsSecretsDisabled] = useState(false);
return (
<div className={cn("flex flex-col max-w-3xl mx-auto bg-background border rounded-lg p-6", className)}>
<div className="flex flex-row items-center gap-3 mb-6">
@ -88,14 +120,14 @@ export default function SharedConnectionCreationForm<T>({
{...form}
>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormDescription>This is the {`connection's`} display name within Sourcebot. Examples: <b>public-github</b>, <b>self-hosted-gitlab</b>, <b>gerrit-other</b>, etc.</FormDescription>
<FormDescription>This is the {`connection's`} display name within Sourcebot.</FormDescription>
<FormControl>
<Input
{...field}
@ -107,19 +139,62 @@ export default function SharedConnectionCreationForm<T>({
</FormItem>
)}
/>
{isAuthSupportedForCodeHost(type) && (
<FormField
control={form.control}
name="secretKey"
render={({ field: { value } }) => (
<FormItem>
<FormLabel>Secret (optional)</FormLabel>
<FormDescription>{strings.createSecretDescription}</FormDescription>
<FormControl>
<SecretCombobox
isDisabled={isSecretsDisabled}
secretKey={value}
codeHostType={type}
onSecretChange={(secretKey) => {
const view = editorRef.current?.view;
if (!view) {
return;
}
onQuickAction(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(previous: any) => {
return {
...previous,
token: {
secret: secretKey,
}
}
},
form.getValues("config"),
view,
{
focusEditor: false
}
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="config"
render={({ field: { value, onChange } }) => {
render={({ field: { value } }) => {
return (
<FormItem>
<FormLabel>Configuration</FormLabel>
{/* @todo : refactor this description into a shared file */}
<FormDescription>Code hosts are configured via a....TODO</FormDescription>
<FormDescription>{strings.connectionConfigDescription}</FormDescription>
<FormControl>
<ConfigEditor<T>
ref={editorRef}
value={value}
onChange={onChange}
onChange={onConfigChange}
actions={quickActions ?? []}
schema={schema}
/>
@ -130,6 +205,7 @@ export default function SharedConnectionCreationForm<T>({
}}
/>
</div>
<div className="flex flex-row justify-end">
<Button
className="mt-5"
type="submit"
@ -138,6 +214,7 @@ export default function SharedConnectionCreationForm<T>({
{isSubmitting && <Loader2 className="animate-spin w-4 h-4 mr-2" />}
Submit
</Button>
</div>
</form>
</Form>
</div>

View file

@ -5,10 +5,10 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For
import { zodResolver } from "@hookform/resolvers/zod";
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
import { Loader2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ConfigEditor, QuickAction } from "../../../components/configEditor";
import ConfigEditor, { isConfigValidJson, onQuickAction, QuickAction } from "../../../components/configEditor";
import { createZodConnectionConfigValidator } from "../../utils";
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
@ -17,14 +17,16 @@ import { githubQuickActions, gitlabQuickActions, giteaQuickActions, gerritQuickA
import { Schema } from "ajv";
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
import { updateConnectionConfigAndScheduleSync } from "@/actions";
import { checkIfSecretExists, updateConnectionConfigAndScheduleSync } from "@/actions";
import { useToast } from "@/components/hooks/use-toast";
import { isServiceError } from "@/lib/utils";
import { isServiceError, CodeHostType, isAuthSupportedForCodeHost } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
import { useDomain } from "@/hooks/useDomain";
import { SecretCombobox } from "@/app/[domain]/components/connectionCreationForms/secretCombobox";
import { ReactCodeMirrorRef } from "@uiw/react-codemirror";
import strings from "@/lib/strings";
interface ConfigSettingProps {
connectionId: number;
@ -38,6 +40,7 @@ export const ConfigSetting = (props: ConfigSettingProps) => {
if (type === 'github') {
return <ConfigSettingInternal<GithubConnectionConfig>
{...props}
type="github"
quickActions={githubQuickActions}
schema={githubSchema}
/>;
@ -46,6 +49,7 @@ export const ConfigSetting = (props: ConfigSettingProps) => {
if (type === 'gitlab') {
return <ConfigSettingInternal<GitlabConnectionConfig>
{...props}
type="gitlab"
quickActions={gitlabQuickActions}
schema={gitlabSchema}
/>;
@ -54,6 +58,7 @@ export const ConfigSetting = (props: ConfigSettingProps) => {
if (type === 'gitea') {
return <ConfigSettingInternal<GiteaConnectionConfig>
{...props}
type="gitea"
quickActions={giteaQuickActions}
schema={giteaSchema}
/>;
@ -62,6 +67,7 @@ export const ConfigSetting = (props: ConfigSettingProps) => {
if (type === 'gerrit') {
return <ConfigSettingInternal<GerritConnectionConfig>
{...props}
type="gerrit"
quickActions={gerritQuickActions}
schema={gerritSchema}
/>;
@ -76,16 +82,28 @@ function ConfigSettingInternal<T>({
config,
quickActions,
schema,
type,
}: ConfigSettingProps & {
quickActions?: QuickAction<T>[],
schema: Schema,
type: CodeHostType,
}) {
const { toast } = useToast();
const router = useRouter();
const domain = useDomain();
const editorRef = useRef<ReactCodeMirrorRef>(null);
const [isSecretsDisabled, setIsSecretsDisabled] = useState(false);
const formSchema = useMemo(() => {
return z.object({
config: createZodConnectionConfigValidator(schema),
secretKey: z.string().optional().refine(async (secretKey) => {
if (!secretKey) {
return true;
}
return checkIfSecretExists(secretKey, domain);
}, { message: "Secret not found" })
});
}, [schema]);
@ -118,25 +136,99 @@ function ConfigSettingInternal<T>({
})
}, [connectionId, domain, router, toast]);
const onConfigChange = useCallback((value: string) => {
form.setValue("config", value);
const isValid = isConfigValidJson(value);
setIsSecretsDisabled(!isValid);
if (isValid) {
const configJson = JSON.parse(value);
if (configJson.token?.secret !== undefined) {
form.setValue("secretKey", configJson.token.secret);
} else {
form.setValue("secretKey", undefined);
}
}
}, [form]);
useEffect(() => {
onConfigChange(config);
}, [config, onConfigChange]);
useEffect(() => {
console.log("mount");
return () => {
console.log("unmount");
}
}, []);
return (
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
<h3 className="text-lg font-semibold mb-2">Configuration</h3>
<Form
{...form}
>
<form onSubmit={form.handleSubmit(onSubmit)}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
{isAuthSupportedForCodeHost(type) && (
<FormField
control={form.control}
name="secretKey"
render={({ field: { value } }) => (
<FormItem>
<FormLabel>Secret (optional)</FormLabel>
<FormDescription>{strings.createSecretDescription}</FormDescription>
<FormControl>
<SecretCombobox
isDisabled={isSecretsDisabled}
secretKey={value}
codeHostType={type}
onSecretChange={(secretKey) => {
const view = editorRef.current?.view;
if (!view) {
return;
}
onQuickAction(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(previous: any) => {
return {
...previous,
token: {
secret: secretKey,
}
}
},
form.getValues("config"),
view,
{
focusEditor: false
}
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="config"
render={({ field: { value, onChange } }) => (
render={({ field: { value } }) => (
<FormItem>
<FormItem>
<FormLabel className="text-lg font-semibold">Configuration</FormLabel>
{/* @todo : refactor this description into a shared file */}
<FormDescription>Code hosts are configured via a....TODO</FormDescription>
{isAuthSupportedForCodeHost(type) && (
<FormLabel>Configuration</FormLabel>
)}
<FormDescription>{strings.connectionConfigDescription}</FormDescription>
<FormControl>
<ConfigEditor<T>
ref={editorRef}
value={value}
onChange={onChange}
onChange={onConfigChange}
schema={schema}
actions={quickActions ?? []}
/>

View file

@ -149,7 +149,9 @@ export default function ConnectionManagementPage() {
currentTab={currentTab}
/>
</Header>
<TabsContent value="overview">
<TabsContent
value="overview"
>
<h1 className="font-semibold text-lg">Overview</h1>
<div className="mt-4 flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4">
@ -219,7 +221,15 @@ export default function ConnectionManagementPage() {
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="settings" className="flex flex-col gap-6">
<TabsContent
value="settings"
// @note: There was some bugginess with the ConfigEditor ref not being set again
// after the parent component was unmounted and remounted. This workarouns makes it
// s.t., hide the settings tab when it is inactive, instead of unmounting it.
// @see: https://github.com/radix-ui/primitives/issues/2359#issuecomment-2481321719
className="flex flex-col gap-6 data-[state=inactive]:hidden"
forceMount={true}
>
<DisplayNameSetting connectionId={connection.id} name={connection.name} />
<ConfigSetting
connectionId={connection.id}

View file

@ -14,8 +14,8 @@ export default function NewConnectionPage({
params
}: { params: { type: string } }) {
const { type } = params;
const domain = useDomain();
const router = useRouter();
const domain = useDomain();
const onCreated = useCallback(() => {
router.push(`/${domain}/connections`);

View file

@ -3,8 +3,28 @@ import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
import { QuickAction } from "../components/configEditor";
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type";
import { cn } from "@/lib/utils";
const Code = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => {
return (
<code
className={cn("bg-gray-100 dark:bg-gray-700 w-fit rounded-md font-mono px-2 py-0.5", className)}
title={title}
>
{children}
</code>
)
}
export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
url: previous.url ?? "",
}),
name: "Set a custom url",
description: <span>Set a custom GitHub host. Defaults to <Code>https://github.com</Code>.</span>
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
@ -14,13 +34,21 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
]
}),
name: "Add an organization",
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
url: previous.url ?? "",
}),
name: "Set a custom url",
description: (
<div className="flex flex-col">
<span>Add an organization to sync with. All repositories in the organization visible to the provided <Code>token</Code> (if any) will be synced.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-row gap-1 items-center">
{[
"commaai",
"sourcebot",
"vercel"
].map((org) => (
<Code key={org}>{org}</Code>
))}
</div>
</div>
)
},
{
fn: (previous: GithubConnectionConfig) => ({
@ -31,16 +59,33 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
]
}),
name: "Add a repo",
description: (
<div className="flex flex-col">
<span>Add a individual repository to sync with. Ensure the repository is visible to the provided <Code>token</Code> (if any).</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"sourcebot/sourcebot",
"vercel/next.js",
"torvalds/linux"
].map((repo) => (
<Code key={repo}>{repo}</Code>
))}
</div>
</div>
)
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
token: previous.token ?? {
secret: "",
},
users: [
...(previous.users ?? []),
""
]
}),
name: "Add a secret",
}
name: "Add a user",
description: <span>Add a user to sync with. All repositories that the user owns visible to the provided <Code>token</Code> (if any) will be synced.</span>
},
];
export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
@ -156,4 +201,3 @@ export const gerritQuickActions: QuickAction<GerritConnectionConfig>[] = [
name: "Exclude a project",
}
]

View file

@ -118,3 +118,14 @@
overflow: hidden;
text-overflow: ellipsis;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -8,7 +8,6 @@ import { useForm } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useCallback } from "react";
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
import { isServiceError } from "@/lib/utils"
import { Loader2 } from "lucide-react"
import { useToast } from "@/components/hooks/use-toast"

View file

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View file

@ -0,0 +1,7 @@
export const strings = {
connectionConfigDescription: "Configure what repositories, organizations, users, etc. you want to sync with Sourcebot. Use the quick actions below to help you configure your connection.",
createSecretDescription: "Secrets are used to authenticate with the code host, allowing Sourcebot to access private repositories.",
}
export default strings;

View file

@ -126,6 +126,17 @@ export const getCodeHostIcon = (codeHostType: CodeHostType): { src: string, clas
}
}
export const isAuthSupportedForCodeHost = (codeHostType: CodeHostType): boolean => {
switch (codeHostType) {
case "github":
case "gitlab":
case "gitea":
return true;
case "gerrit":
return false;
}
}
export const isServiceError = (data: unknown): data is ServiceError => {
return typeof data === 'object' &&
data !== null &&

View file

@ -33,6 +33,7 @@
"type": "string",
"pattern": "^[\\w.-]+$"
},
"default": [],
"examples": [
[
"torvalds",
@ -47,6 +48,7 @@
"type": "string",
"pattern": "^[\\w.-]+$"
},
"default": [],
"examples": [
[
"my-org-name"
@ -64,6 +66,7 @@
"type": "string",
"pattern": "^[\\w.-]+\\/[\\w.-]+$"
},
"default": [],
"description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'."
},
"topics": {
@ -72,6 +75,7 @@
"type": "string"
},
"minItems": 1,
"default": [],
"description": "List of repository topics to include when syncing. Only repositories that match at least one of the provided `topics` will be synced. If not specified, all repositories will be synced, unless explicitly defined in the `exclude` property. Glob patterns are supported.",
"examples": [
[
@ -106,6 +110,7 @@
"items": {
"type": "string"
},
"default": [],
"description": "List of repository topics to exclude when syncing. Repositories that match one of the provided `topics` will be excluded from syncing. Glob patterns are supported.",
"examples": [
[

View file

@ -2106,6 +2106,27 @@
"@radix-ui/react-use-previous" "1.1.0"
"@radix-ui/react-visually-hidden" "1.1.0"
"@radix-ui/react-popover@^1.1.6":
version "1.1.6"
resolved "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz#699634dbc7899429f657bb590d71fb3ca0904087"
integrity sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==
dependencies:
"@radix-ui/primitive" "1.1.1"
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-dismissable-layer" "1.1.5"
"@radix-ui/react-focus-guards" "1.1.1"
"@radix-ui/react-focus-scope" "1.1.2"
"@radix-ui/react-id" "1.1.0"
"@radix-ui/react-popper" "1.2.2"
"@radix-ui/react-portal" "1.1.4"
"@radix-ui/react-presence" "1.1.2"
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-slot" "1.1.2"
"@radix-ui/react-use-controllable-state" "1.1.0"
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-popper@1.2.0":
version "1.2.0"
resolved "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz"