diff --git a/packages/schemas/src/v3/connection.schema.ts b/packages/schemas/src/v3/connection.schema.ts index 858a28db..de1a5c70 100644 --- a/packages/schemas/src/v3/connection.schema.ts +++ b/packages/schemas/src/v3/connection.schema.ts @@ -62,6 +62,7 @@ const schema = { "type": "string", "pattern": "^[\\w.-]+$" }, + "default": [], "examples": [ [ "my-org-name" diff --git a/packages/schemas/src/v3/github.schema.ts b/packages/schemas/src/v3/github.schema.ts index 2b6b2e83..412ea9d3 100644 --- a/packages/schemas/src/v3/github.schema.ts +++ b/packages/schemas/src/v3/github.schema.ts @@ -58,6 +58,7 @@ const schema = { "type": "string", "pattern": "^[\\w.-]+$" }, + "default": [], "examples": [ [ "my-org-name" diff --git a/packages/web/package.json b/packages/web/package.json index 93bd9aa0..b262b52b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -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", diff --git a/packages/web/public/github_pat_creation.png b/packages/web/public/github_pat_creation.png new file mode 100644 index 00000000..e9295fca Binary files /dev/null and b/packages/web/public/github_pat_creation.png differ diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 50de4458..d664d6d7 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -201,6 +201,22 @@ export const createSecret = async (key: string, value: string, domain: string): } })); +export const checkIfSecretExists = async (key: string, domain: string): Promise => + 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 }) => { @@ -441,7 +457,7 @@ export const flagRepoForIndex = async (repoId: number, domain: string): Promise< await prisma.repo.update({ where: { - id: repoId, + id: repoId, }, data: { repoIndexingStatus: RepoIndexingStatus.NEW, diff --git a/packages/web/src/app/[domain]/components/configEditor.tsx b/packages/web/src/app/[domain]/components/configEditor.tsx index 28f47f58..bc876ad6 100644 --- a/packages/web/src/app/[domain]/components/configEditor.tsx +++ b/packages/web/src/app/[domain]/components/configEditor.tsx @@ -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 = (previous: T) => T; export type QuickAction = { name: string; fn: QuickActionFn; + description?: string | ReactNode; }; interface ConfigEditorProps { @@ -46,80 +48,74 @@ const customAutocompleteStyle = EditorView.baseTheme({ } }); +export function onQuickAction( + action: QuickActionFn, + config: string, + view: EditorView, + options?: { + focusEditor?: boolean; + moveCursor?: boolean; + } +) { + const { + focusEditor = false, + moveCursor = true, + } = options ?? {}; -export function ConfigEditor({ - value, - onChange, - actions, - schema, -}: ConfigEditorProps) { - const editorRef = useRef(null); - const keymapExtension = useKeymapExtension(editorRef.current?.view); - const { theme } = useThemeNormalized(); + let previousConfig: T; + try { + previousConfig = JSON.parse(config) as T; + } catch { + return; + } - const isQuickActionsDisabled = useMemo(() => { - try { - JSON.parse(value); - return false; - } catch { - return true; - } - }, [value]); - - const onQuickAction = (action: QuickActionFn) => { - let previousConfig: T; - try { - previousConfig = JSON.parse(value) as T; - } catch { - return; - } + const nextConfig = action(previousConfig); + const next = JSON.stringify(nextConfig, null, 2); - const nextConfig = action(previousConfig); - const next = JSON.stringify(nextConfig, null, 2); + if (focusEditor) { + view.focus(); + } - const cursorPos = next.lastIndexOf(`""`) + 1; + const cursorPos = next.lastIndexOf(`""`) + 1; + view.dispatch({ + changes: { + from: 0, + to: config.length, + insert: next, + } + }); - editorRef.current?.view?.focus(); - editorRef.current?.view?.dispatch({ - changes: { - from: 0, - to: value.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 = (props: ConfigEditorProps, forwardedRef: Ref) => { + const { value, onChange, actions, schema } = props; + + const editorRef = useRef(null); + useImperativeHandle( + forwardedRef, + () => editorRef.current as ReactCodeMirrorRef + ); + + const keymapExtension = useKeymapExtension(editorRef.current?.view); + const { theme } = useThemeNormalized(); return ( - <> -
- {actions.map(({ name, fn }, index) => ( -
- - {index !== actions.length - 1 && ( - - )} -
- ))} -
- +
+ ({ theme={theme === "dark" ? "dark" : "light"} /> - + +
+ + {actions.map(({ name, fn, description }, index) => ( +
+ + + + + + + {index !== actions.length - 1 && ( + + )} +
+ ))} +
+
+
) -} \ No newline at end of file +}; + +// @see: https://stackoverflow.com/a/78692562 +export default forwardRef(ConfigEditor) as ( + props: ConfigEditorProps & { ref?: Ref }, +) => ReturnType; diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx new file mode 100644 index 00000000..df425788 --- /dev/null +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx @@ -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 ( + <> + + + + + + + {isLoading && ( +
+ +
+ )} + {secrets && !isServiceError(secrets) && secrets.length > 0 && ( + <> + + setSearchFilter(value)} + /> + + +

No secrets found

+

{`Your search term "${searchFilter}" did not match any secrets.`}

+
+ + {secrets.map(({ key }) => ( + { + onSecretChange(key); + }} + > + {key} + + + ))} + +
+
+ + + )} + +
+
+ + + ) +} + +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>({ + resolver: zodResolver(formSchema), + defaultValues: { + key: "", + value: "", + }, + }); + const { isSubmitting } = form.formState; + + const onSubmit = useCallback(async (data: z.infer) => { + 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 ; + case 'gitlab': + return ; + case 'gitea': + return ; + case 'gerrit': + return null; + } + }, [codeHostType]); + + + return ( + + + + Import a secret + + Secrets are used to authenticate with a code host. They are encrypted at rest using AES-256-CBC. + Checkout our security docs for more information. + + + +
+ { + event.stopPropagation(); + form.handleSubmit(onSubmit)(event); + }} + > + {codeHostSpecificStep} + + + ( + + Value + +
+ + +
+
+ + The secret value to store securely. + + +
+ )} + /> +
+ + + ( + + Key + + + + + A unique name to identify this secret. + + + + )} + /> + + +
+ +
+
+ +
+
+ ) +} + +const GitHubPATCreationStep = ({ step }: { step: number }) => { + return ( + Navigate to here on github.com (or your enterprise instance) and create a new personal access token. Sourcebot needs the repo scope in order to access private repositories: + > + Create a personal access token + + ) +} + +const GitLabPATCreationStep = ({ step }: { step: number }) => { + return ( + +

todo

+
+ ) +} + +const GiteaPATCreationStep = ({ step }: { step: number }) => { + return ( + +

todo

+
+ ) +} + +interface SecretCreationStepProps { + step: number; + title: string; + description: string | React.ReactNode; + children: React.ReactNode; +} + +const SecretCreationStep = ({ step, title, description, children }: SecretCreationStepProps) => { + return ( +
+
+ {step} + +
+

+ {title} +

+

+ {description} +

+ {children} +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx index 76c19a11..fd7441cc 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx @@ -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 { - type: 'github' | 'gitlab' | 'gitea' | 'gerrit'; + type: CodeHostType; defaultValues: { name: string; config: string; @@ -35,6 +38,7 @@ interface SharedConnectionCreationFormProps { onCreated?: (id: number) => void; } + export default function SharedConnectionCreationForm({ type, defaultValues, @@ -44,14 +48,21 @@ export default function SharedConnectionCreationForm({ className, onCreated, }: SharedConnectionCreationFormProps) { - const { toast } = useToast(); const domain = useDomain(); + const editorRef = useRef(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({ } }, [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 (
@@ -88,14 +120,14 @@ export default function SharedConnectionCreationForm({ {...form} >
-
+
( Display Name - This is the {`connection's`} display name within Sourcebot. Examples: public-github, self-hosted-gitlab, gerrit-other, etc. + This is the {`connection's`} display name within Sourcebot. ({ )} /> + {isAuthSupportedForCodeHost(type) && ( + ( + + Secret (optional) + {strings.createSecretDescription} + + { + 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 + } + ); + }} + /> + + + + )} + /> + )} { + render={({ field: { value } }) => { return ( Configuration - {/* @todo : refactor this description into a shared file */} - Code hosts are configured via a....TODO + {strings.connectionConfigDescription} + ref={editorRef} value={value} - onChange={onChange} + onChange={onConfigChange} actions={quickActions ?? []} schema={schema} /> @@ -130,14 +205,16 @@ export default function SharedConnectionCreationForm({ }} />
- +
+ +
diff --git a/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx b/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx index 786de4c2..c63b2263 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/configSetting.tsx @@ -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 {...props} + type="github" quickActions={githubQuickActions} schema={githubSchema} />; @@ -46,6 +49,7 @@ export const ConfigSetting = (props: ConfigSettingProps) => { if (type === 'gitlab') { return {...props} + type="gitlab" quickActions={gitlabQuickActions} schema={gitlabSchema} />; @@ -54,6 +58,7 @@ export const ConfigSetting = (props: ConfigSettingProps) => { if (type === 'gitea') { return {...props} + type="gitea" quickActions={giteaQuickActions} schema={giteaSchema} />; @@ -62,6 +67,7 @@ export const ConfigSetting = (props: ConfigSettingProps) => { if (type === 'gerrit') { return {...props} + type="gerrit" quickActions={gerritQuickActions} schema={gerritSchema} />; @@ -76,16 +82,28 @@ function ConfigSettingInternal({ config, quickActions, schema, + type, }: ConfigSettingProps & { quickActions?: QuickAction[], schema: Schema, + type: CodeHostType, }) { const { toast } = useToast(); const router = useRouter(); const domain = useDomain(); + const editorRef = useRef(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({ }) }, [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 (
+

Configuration

- + + {isAuthSupportedForCodeHost(type) && ( + ( + + Secret (optional) + {strings.createSecretDescription} + + { + 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 + } + ); + }} + /> + + + + )} + /> + )} ( + render={({ field: { value } }) => ( - Configuration - {/* @todo : refactor this description into a shared file */} - Code hosts are configured via a....TODO + {isAuthSupportedForCodeHost(type) && ( + Configuration + )} + {strings.connectionConfigDescription} + ref={editorRef} value={value} - onChange={onChange} + onChange={onConfigChange} schema={schema} actions={quickActions ?? []} /> diff --git a/packages/web/src/app/[domain]/connections/[id]/page.tsx b/packages/web/src/app/[domain]/connections/[id]/page.tsx index ccc79f88..77a34c55 100644 --- a/packages/web/src/app/[domain]/connections/[id]/page.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/page.tsx @@ -149,7 +149,9 @@ export default function ConnectionManagementPage() { currentTab={currentTab} /> - +

Overview

@@ -219,7 +221,15 @@ export default function ConnectionManagementPage() {
- + { router.push(`/${domain}/connections`); diff --git a/packages/web/src/app/[domain]/connections/quickActions.ts b/packages/web/src/app/[domain]/connections/quickActions.tsx similarity index 65% rename from packages/web/src/app/[domain]/connections/quickActions.ts rename to packages/web/src/app/[domain]/connections/quickActions.tsx index 565db033..fc54df7e 100644 --- a/packages/web/src/app/[domain]/connections/quickActions.ts +++ b/packages/web/src/app/[domain]/connections/quickActions.tsx @@ -3,24 +3,52 @@ 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 ( + + {children} + + ) +} export const githubQuickActions: QuickAction[] = [ { fn: (previous: GithubConnectionConfig) => ({ ...previous, - orgs: [ - ...(previous.orgs ?? []), - "" - ] + url: previous.url ?? "", }), - name: "Add an organization", + name: "Set a custom url", + description: Set a custom GitHub host. Defaults to https://github.com. }, { fn: (previous: GithubConnectionConfig) => ({ ...previous, - url: previous.url ?? "", + orgs: [ + ...(previous.orgs ?? []), + "" + ] }), - name: "Set a custom url", + name: "Add an organization", + description: ( +
+ Add an organization to sync with. All repositories in the organization visible to the provided token (if any) will be synced. + Examples: +
+ {[ + "commaai", + "sourcebot", + "vercel" + ].map((org) => ( + {org} + ))} +
+
+ ) }, { fn: (previous: GithubConnectionConfig) => ({ @@ -31,16 +59,33 @@ export const githubQuickActions: QuickAction[] = [ ] }), name: "Add a repo", + description: ( +
+ Add a individual repository to sync with. Ensure the repository is visible to the provided token (if any). + Examples: +
+ {[ + "sourcebot/sourcebot", + "vercel/next.js", + "torvalds/linux" + ].map((repo) => ( + {repo} + ))} +
+
+ ) }, { fn: (previous: GithubConnectionConfig) => ({ ...previous, - token: previous.token ?? { - secret: "", - }, + users: [ + ...(previous.users ?? []), + "" + ] }), - name: "Add a secret", - } + name: "Add a user", + description: Add a user to sync with. All repositories that the user owns visible to the provided token (if any) will be synced. + }, ]; export const gitlabQuickActions: QuickAction[] = [ @@ -156,4 +201,3 @@ export const gerritQuickActions: QuickAction[] = [ name: "Exclude a project", } ] - diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 7d38a5b3..de099e82 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -117,4 +117,15 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + + + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } } \ No newline at end of file diff --git a/packages/web/src/app/onboard/components/orgCreateForm.tsx b/packages/web/src/app/onboard/components/orgCreateForm.tsx index a56c5b10..e05c9486 100644 --- a/packages/web/src/app/onboard/components/orgCreateForm.tsx +++ b/packages/web/src/app/onboard/components/orgCreateForm.tsx @@ -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" diff --git a/packages/web/src/components/ui/popover.tsx b/packages/web/src/components/ui/popover.tsx new file mode 100644 index 00000000..a0ec48be --- /dev/null +++ b/packages/web/src/components/ui/popover.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts new file mode 100644 index 00000000..9fbcfd47 --- /dev/null +++ b/packages/web/src/lib/strings.ts @@ -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; diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 52c7fdb7..34834bfe 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -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 && diff --git a/schemas/v3/github.json b/schemas/v3/github.json index 22dd4cc7..ec4a9f4f 100644 --- a/schemas/v3/github.json +++ b/schemas/v3/github.json @@ -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": [ [ diff --git a/yarn.lock b/yarn.lock index 016346d6..ac27c28b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"