mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
Inline secret creation (#207)
This commit is contained in:
parent
ced6c527ba
commit
0ff34d105d
19 changed files with 905 additions and 117 deletions
|
|
@ -62,6 +62,7 @@ const schema = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[\\w.-]+$"
|
"pattern": "^[\\w.-]+$"
|
||||||
},
|
},
|
||||||
|
"default": [],
|
||||||
"examples": [
|
"examples": [
|
||||||
[
|
[
|
||||||
"my-org-name"
|
"my-org-name"
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ const schema = {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[\\w.-]+$"
|
"pattern": "^[\\w.-]+$"
|
||||||
},
|
},
|
||||||
|
"default": [],
|
||||||
"examples": [
|
"examples": [
|
||||||
[
|
[
|
||||||
"my-org-name"
|
"my-org-name"
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@
|
||||||
"@radix-ui/react-icons": "^1.3.0",
|
"@radix-ui/react-icons": "^1.3.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.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-scroll-area": "^1.1.0",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
|
|
|
||||||
BIN
packages/web/public/github_pat_creation.png
Normal file
BIN
packages/web/public/github_pat_creation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
|
|
@ -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> =>
|
export const deleteSecret = async (key: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||||
withAuth((session) =>
|
withAuth((session) =>
|
||||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
|
|
@ -441,7 +457,7 @@ export const flagRepoForIndex = async (repoId: number, domain: string): Promise<
|
||||||
|
|
||||||
await prisma.repo.update({
|
await prisma.repo.update({
|
||||||
where: {
|
where: {
|
||||||
id: repoId,
|
id: repoId,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
repoIndexingStatus: RepoIndexingStatus.NEW,
|
repoIndexingStatus: RepoIndexingStatus.NEW,
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,17 @@ import {
|
||||||
jsonSchemaLinter,
|
jsonSchemaLinter,
|
||||||
stateExtensions
|
stateExtensions
|
||||||
} from "codemirror-json-schema";
|
} from "codemirror-json-schema";
|
||||||
import { useMemo, useRef } from "react";
|
import { useRef, forwardRef, useImperativeHandle, Ref, ReactNode } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Schema } from "ajv";
|
import { Schema } from "ajv";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
export type QuickActionFn<T> = (previous: T) => T;
|
export type QuickActionFn<T> = (previous: T) => T;
|
||||||
export type QuickAction<T> = {
|
export type QuickAction<T> = {
|
||||||
name: string;
|
name: string;
|
||||||
fn: QuickActionFn<T>;
|
fn: QuickActionFn<T>;
|
||||||
|
description?: string | ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ConfigEditorProps<T> {
|
interface ConfigEditorProps<T> {
|
||||||
|
|
@ -46,80 +48,74 @@ const customAutocompleteStyle = EditorView.baseTheme({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function onQuickAction<T>(
|
||||||
|
action: QuickActionFn<T>,
|
||||||
|
config: string,
|
||||||
|
view: EditorView,
|
||||||
|
options?: {
|
||||||
|
focusEditor?: boolean;
|
||||||
|
moveCursor?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
focusEditor = false,
|
||||||
|
moveCursor = true,
|
||||||
|
} = options ?? {};
|
||||||
|
|
||||||
export function ConfigEditor<T>({
|
let previousConfig: T;
|
||||||
value,
|
try {
|
||||||
onChange,
|
previousConfig = JSON.parse(config) as T;
|
||||||
actions,
|
} catch {
|
||||||
schema,
|
return;
|
||||||
}: ConfigEditorProps<T>) {
|
}
|
||||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
|
||||||
const keymapExtension = useKeymapExtension(editorRef.current?.view);
|
|
||||||
const { theme } = useThemeNormalized();
|
|
||||||
|
|
||||||
const isQuickActionsDisabled = useMemo(() => {
|
const nextConfig = action(previousConfig);
|
||||||
try {
|
const next = JSON.stringify(nextConfig, null, 2);
|
||||||
JSON.parse(value);
|
|
||||||
return false;
|
if (focusEditor) {
|
||||||
} catch {
|
view.focus();
|
||||||
return true;
|
}
|
||||||
|
|
||||||
|
const cursorPos = next.lastIndexOf(`""`) + 1;
|
||||||
|
view.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: config.length,
|
||||||
|
insert: next,
|
||||||
}
|
}
|
||||||
}, [value]);
|
});
|
||||||
|
|
||||||
const onQuickAction = (action: QuickActionFn<T>) => {
|
if (moveCursor) {
|
||||||
let previousConfig: T;
|
view.dispatch({
|
||||||
try {
|
|
||||||
previousConfig = JSON.parse(value) as T;
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextConfig = action(previousConfig);
|
|
||||||
const next = JSON.stringify(nextConfig, null, 2);
|
|
||||||
|
|
||||||
const cursorPos = next.lastIndexOf(`""`) + 1;
|
|
||||||
|
|
||||||
editorRef.current?.view?.focus();
|
|
||||||
editorRef.current?.view?.dispatch({
|
|
||||||
changes: {
|
|
||||||
from: 0,
|
|
||||||
to: value.length,
|
|
||||||
insert: next,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
editorRef.current?.view?.dispatch({
|
|
||||||
selection: { anchor: cursorPos, head: cursorPos }
|
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 (
|
return (
|
||||||
<>
|
<div className="border rounded-md">
|
||||||
<div className="flex flex-row items-center flex-wrap w-full">
|
<ScrollArea className="p-1 overflow-auto flex-1 h-56">
|
||||||
{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">
|
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={value}
|
value={value}
|
||||||
|
|
@ -144,6 +140,56 @@ export function ConfigEditor<T>({
|
||||||
theme={theme === "dark" ? "dark" : "light"}
|
theme={theme === "dark" ? "dark" : "light"}
|
||||||
/>
|
/>
|
||||||
</ScrollArea>
|
</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>;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,29 @@
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createConnection } from "@/actions";
|
import { checkIfSecretExists, createConnection } from "@/actions";
|
||||||
import { ConnectionIcon } from "@/app/[domain]/connections/components/connectionIcon";
|
import { ConnectionIcon } from "@/app/[domain]/connections/components/connectionIcon";
|
||||||
import { createZodConnectionConfigValidator } from "@/app/[domain]/connections/utils";
|
import { createZodConnectionConfigValidator } from "@/app/[domain]/connections/utils";
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { CodeHostType, isServiceError, isAuthSupportedForCodeHost } from "@/lib/utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Schema } from "ajv";
|
import { Schema } from "ajv";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ConfigEditor, QuickActionFn } from "../configEditor";
|
import ConfigEditor, { isConfigValidJson, onQuickAction, QuickActionFn } from "../configEditor";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { ReactCodeMirrorRef } from "@uiw/react-codemirror";
|
||||||
|
import { SecretCombobox } from "./secretCombobox";
|
||||||
|
import strings from "@/lib/strings";
|
||||||
|
|
||||||
interface SharedConnectionCreationFormProps<T> {
|
interface SharedConnectionCreationFormProps<T> {
|
||||||
type: 'github' | 'gitlab' | 'gitea' | 'gerrit';
|
type: CodeHostType;
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: string;
|
name: string;
|
||||||
config: string;
|
config: string;
|
||||||
|
|
@ -35,6 +38,7 @@ interface SharedConnectionCreationFormProps<T> {
|
||||||
onCreated?: (id: number) => void;
|
onCreated?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function SharedConnectionCreationForm<T>({
|
export default function SharedConnectionCreationForm<T>({
|
||||||
type,
|
type,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
|
|
@ -44,14 +48,21 @@ export default function SharedConnectionCreationForm<T>({
|
||||||
className,
|
className,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: SharedConnectionCreationFormProps<T>) {
|
}: SharedConnectionCreationFormProps<T>) {
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
|
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||||
|
|
||||||
const formSchema = useMemo(() => {
|
const formSchema = useMemo(() => {
|
||||||
return z.object({
|
return z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
config: createZodConnectionConfigValidator(schema),
|
config: createZodConnectionConfigValidator(schema),
|
||||||
|
secretKey: z.string().optional().refine(async (secretKey) => {
|
||||||
|
if (!secretKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkIfSecretExists(secretKey, domain);
|
||||||
|
}, { message: "Secret not found" }),
|
||||||
});
|
});
|
||||||
}, [schema]);
|
}, [schema]);
|
||||||
|
|
||||||
|
|
@ -75,6 +86,27 @@ export default function SharedConnectionCreationForm<T>({
|
||||||
}
|
}
|
||||||
}, [domain, toast, type, onCreated]);
|
}, [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 (
|
return (
|
||||||
<div className={cn("flex flex-col max-w-3xl mx-auto bg-background border rounded-lg p-6", className)}>
|
<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">
|
<div className="flex flex-row items-center gap-3 mb-6">
|
||||||
|
|
@ -88,14 +120,14 @@ export default function SharedConnectionCreationForm<T>({
|
||||||
{...form}
|
{...form}
|
||||||
>
|
>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-6">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Display Name</FormLabel>
|
<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>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
|
|
@ -107,19 +139,62 @@ export default function SharedConnectionCreationForm<T>({
|
||||||
</FormItem>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config"
|
name="config"
|
||||||
render={({ field: { value, onChange } }) => {
|
render={({ field: { value } }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Configuration</FormLabel>
|
<FormLabel>Configuration</FormLabel>
|
||||||
{/* @todo : refactor this description into a shared file */}
|
<FormDescription>{strings.connectionConfigDescription}</FormDescription>
|
||||||
<FormDescription>Code hosts are configured via a....TODO</FormDescription>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ConfigEditor<T>
|
<ConfigEditor<T>
|
||||||
|
ref={editorRef}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onConfigChange}
|
||||||
actions={quickActions ?? []}
|
actions={quickActions ?? []}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
/>
|
/>
|
||||||
|
|
@ -130,14 +205,16 @@ export default function SharedConnectionCreationForm<T>({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex flex-row justify-end">
|
||||||
className="mt-5"
|
<Button
|
||||||
type="submit"
|
className="mt-5"
|
||||||
disabled={isSubmitting}
|
type="submit"
|
||||||
>
|
disabled={isSubmitting}
|
||||||
{isSubmitting && <Loader2 className="animate-spin w-4 h-4 mr-2" />}
|
>
|
||||||
Submit
|
{isSubmitting && <Loader2 className="animate-spin w-4 h-4 mr-2" />}
|
||||||
</Button>
|
Submit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
|
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
|
||||||
import { Loader2 } from "lucide-react";
|
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 { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ConfigEditor, QuickAction } from "../../../components/configEditor";
|
import ConfigEditor, { isConfigValidJson, onQuickAction, QuickAction } from "../../../components/configEditor";
|
||||||
import { createZodConnectionConfigValidator } from "../../utils";
|
import { createZodConnectionConfigValidator } from "../../utils";
|
||||||
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
||||||
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
||||||
|
|
@ -17,14 +17,16 @@ import { githubQuickActions, gitlabQuickActions, giteaQuickActions, gerritQuickA
|
||||||
import { Schema } from "ajv";
|
import { Schema } from "ajv";
|
||||||
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
|
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
|
||||||
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
|
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 { useToast } from "@/components/hooks/use-toast";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError, CodeHostType, isAuthSupportedForCodeHost } from "@/lib/utils";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
|
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
|
||||||
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
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 {
|
interface ConfigSettingProps {
|
||||||
connectionId: number;
|
connectionId: number;
|
||||||
|
|
@ -38,6 +40,7 @@ export const ConfigSetting = (props: ConfigSettingProps) => {
|
||||||
if (type === 'github') {
|
if (type === 'github') {
|
||||||
return <ConfigSettingInternal<GithubConnectionConfig>
|
return <ConfigSettingInternal<GithubConnectionConfig>
|
||||||
{...props}
|
{...props}
|
||||||
|
type="github"
|
||||||
quickActions={githubQuickActions}
|
quickActions={githubQuickActions}
|
||||||
schema={githubSchema}
|
schema={githubSchema}
|
||||||
/>;
|
/>;
|
||||||
|
|
@ -46,6 +49,7 @@ export const ConfigSetting = (props: ConfigSettingProps) => {
|
||||||
if (type === 'gitlab') {
|
if (type === 'gitlab') {
|
||||||
return <ConfigSettingInternal<GitlabConnectionConfig>
|
return <ConfigSettingInternal<GitlabConnectionConfig>
|
||||||
{...props}
|
{...props}
|
||||||
|
type="gitlab"
|
||||||
quickActions={gitlabQuickActions}
|
quickActions={gitlabQuickActions}
|
||||||
schema={gitlabSchema}
|
schema={gitlabSchema}
|
||||||
/>;
|
/>;
|
||||||
|
|
@ -54,6 +58,7 @@ export const ConfigSetting = (props: ConfigSettingProps) => {
|
||||||
if (type === 'gitea') {
|
if (type === 'gitea') {
|
||||||
return <ConfigSettingInternal<GiteaConnectionConfig>
|
return <ConfigSettingInternal<GiteaConnectionConfig>
|
||||||
{...props}
|
{...props}
|
||||||
|
type="gitea"
|
||||||
quickActions={giteaQuickActions}
|
quickActions={giteaQuickActions}
|
||||||
schema={giteaSchema}
|
schema={giteaSchema}
|
||||||
/>;
|
/>;
|
||||||
|
|
@ -62,6 +67,7 @@ export const ConfigSetting = (props: ConfigSettingProps) => {
|
||||||
if (type === 'gerrit') {
|
if (type === 'gerrit') {
|
||||||
return <ConfigSettingInternal<GerritConnectionConfig>
|
return <ConfigSettingInternal<GerritConnectionConfig>
|
||||||
{...props}
|
{...props}
|
||||||
|
type="gerrit"
|
||||||
quickActions={gerritQuickActions}
|
quickActions={gerritQuickActions}
|
||||||
schema={gerritSchema}
|
schema={gerritSchema}
|
||||||
/>;
|
/>;
|
||||||
|
|
@ -76,16 +82,28 @@ function ConfigSettingInternal<T>({
|
||||||
config,
|
config,
|
||||||
quickActions,
|
quickActions,
|
||||||
schema,
|
schema,
|
||||||
|
type,
|
||||||
}: ConfigSettingProps & {
|
}: ConfigSettingProps & {
|
||||||
quickActions?: QuickAction<T>[],
|
quickActions?: QuickAction<T>[],
|
||||||
schema: Schema,
|
schema: Schema,
|
||||||
|
type: CodeHostType,
|
||||||
}) {
|
}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
|
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||||
|
const [isSecretsDisabled, setIsSecretsDisabled] = useState(false);
|
||||||
|
|
||||||
const formSchema = useMemo(() => {
|
const formSchema = useMemo(() => {
|
||||||
return z.object({
|
return z.object({
|
||||||
config: createZodConnectionConfigValidator(schema),
|
config: createZodConnectionConfigValidator(schema),
|
||||||
|
secretKey: z.string().optional().refine(async (secretKey) => {
|
||||||
|
if (!secretKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkIfSecretExists(secretKey, domain);
|
||||||
|
}, { message: "Secret not found" })
|
||||||
});
|
});
|
||||||
}, [schema]);
|
}, [schema]);
|
||||||
|
|
||||||
|
|
@ -118,25 +136,99 @@ function ConfigSettingInternal<T>({
|
||||||
})
|
})
|
||||||
}, [connectionId, domain, router, toast]);
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col w-full bg-background border rounded-lg p-6">
|
<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}
|
{...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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config"
|
name="config"
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value } }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-lg font-semibold">Configuration</FormLabel>
|
{isAuthSupportedForCodeHost(type) && (
|
||||||
{/* @todo : refactor this description into a shared file */}
|
<FormLabel>Configuration</FormLabel>
|
||||||
<FormDescription>Code hosts are configured via a....TODO</FormDescription>
|
)}
|
||||||
|
<FormDescription>{strings.connectionConfigDescription}</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<ConfigEditor<T>
|
<ConfigEditor<T>
|
||||||
|
ref={editorRef}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onConfigChange}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
actions={quickActions ?? []}
|
actions={quickActions ?? []}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,9 @@ export default function ConnectionManagementPage() {
|
||||||
currentTab={currentTab}
|
currentTab={currentTab}
|
||||||
/>
|
/>
|
||||||
</Header>
|
</Header>
|
||||||
<TabsContent value="overview">
|
<TabsContent
|
||||||
|
value="overview"
|
||||||
|
>
|
||||||
<h1 className="font-semibold text-lg">Overview</h1>
|
<h1 className="font-semibold text-lg">Overview</h1>
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|
@ -219,7 +221,15 @@ export default function ConnectionManagementPage() {
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</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} />
|
<DisplayNameSetting connectionId={connection.id} name={connection.name} />
|
||||||
<ConfigSetting
|
<ConfigSetting
|
||||||
connectionId={connection.id}
|
connectionId={connection.id}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ export default function NewConnectionPage({
|
||||||
params
|
params
|
||||||
}: { params: { type: string } }) {
|
}: { params: { type: string } }) {
|
||||||
const { type } = params;
|
const { type } = params;
|
||||||
const domain = useDomain();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const domain = useDomain();
|
||||||
|
|
||||||
const onCreated = useCallback(() => {
|
const onCreated = useCallback(() => {
|
||||||
router.push(`/${domain}/connections`);
|
router.push(`/${domain}/connections`);
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,28 @@ import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
|
||||||
import { QuickAction } from "../components/configEditor";
|
import { QuickAction } from "../components/configEditor";
|
||||||
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||||
import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.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>[] = [
|
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) => ({
|
fn: (previous: GithubConnectionConfig) => ({
|
||||||
...previous,
|
...previous,
|
||||||
|
|
@ -14,13 +34,21 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
name: "Add an organization",
|
name: "Add an organization",
|
||||||
},
|
description: (
|
||||||
{
|
<div className="flex flex-col">
|
||||||
fn: (previous: GithubConnectionConfig) => ({
|
<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>
|
||||||
...previous,
|
<span className="text-sm mt-2 mb-1">Examples:</span>
|
||||||
url: previous.url ?? "",
|
<div className="flex flex-row gap-1 items-center">
|
||||||
}),
|
{[
|
||||||
name: "Set a custom url",
|
"commaai",
|
||||||
|
"sourcebot",
|
||||||
|
"vercel"
|
||||||
|
].map((org) => (
|
||||||
|
<Code key={org}>{org}</Code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fn: (previous: GithubConnectionConfig) => ({
|
fn: (previous: GithubConnectionConfig) => ({
|
||||||
|
|
@ -31,16 +59,33 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
name: "Add a repo",
|
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) => ({
|
fn: (previous: GithubConnectionConfig) => ({
|
||||||
...previous,
|
...previous,
|
||||||
token: previous.token ?? {
|
users: [
|
||||||
secret: "",
|
...(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>[] = [
|
export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
|
||||||
|
|
@ -156,4 +201,3 @@ export const gerritQuickActions: QuickAction<GerritConnectionConfig>[] = [
|
||||||
name: "Exclude a project",
|
name: "Exclude a project",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -117,4 +117,15 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -8,7 +8,6 @@ import { useForm } from "react-hook-form"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
|
||||||
import { isServiceError } from "@/lib/utils"
|
import { isServiceError } from "@/lib/utils"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
import { useToast } from "@/components/hooks/use-toast"
|
import { useToast } from "@/components/hooks/use-toast"
|
||||||
|
|
|
||||||
31
packages/web/src/components/ui/popover.tsx
Normal file
31
packages/web/src/components/ui/popover.tsx
Normal 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 }
|
||||||
7
packages/web/src/lib/strings.ts
Normal file
7
packages/web/src/lib/strings.ts
Normal 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;
|
||||||
|
|
@ -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 => {
|
export const isServiceError = (data: unknown): data is ServiceError => {
|
||||||
return typeof data === 'object' &&
|
return typeof data === 'object' &&
|
||||||
data !== null &&
|
data !== null &&
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[\\w.-]+$"
|
"pattern": "^[\\w.-]+$"
|
||||||
},
|
},
|
||||||
|
"default": [],
|
||||||
"examples": [
|
"examples": [
|
||||||
[
|
[
|
||||||
"torvalds",
|
"torvalds",
|
||||||
|
|
@ -47,6 +48,7 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[\\w.-]+$"
|
"pattern": "^[\\w.-]+$"
|
||||||
},
|
},
|
||||||
|
"default": [],
|
||||||
"examples": [
|
"examples": [
|
||||||
[
|
[
|
||||||
"my-org-name"
|
"my-org-name"
|
||||||
|
|
@ -64,6 +66,7 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[\\w.-]+\\/[\\w.-]+$"
|
"pattern": "^[\\w.-]+\\/[\\w.-]+$"
|
||||||
},
|
},
|
||||||
|
"default": [],
|
||||||
"description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'."
|
"description": "List of individual repositories to sync with. Expected to be formatted as '{orgName}/{repoName}' or '{userName}/{repoName}'."
|
||||||
},
|
},
|
||||||
"topics": {
|
"topics": {
|
||||||
|
|
@ -72,6 +75,7 @@
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"minItems": 1,
|
"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.",
|
"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": [
|
"examples": [
|
||||||
[
|
[
|
||||||
|
|
@ -106,6 +110,7 @@
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"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.",
|
"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": [
|
"examples": [
|
||||||
[
|
[
|
||||||
|
|
|
||||||
21
yarn.lock
21
yarn.lock
|
|
@ -2106,6 +2106,27 @@
|
||||||
"@radix-ui/react-use-previous" "1.1.0"
|
"@radix-ui/react-use-previous" "1.1.0"
|
||||||
"@radix-ui/react-visually-hidden" "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":
|
"@radix-ui/react-popper@1.2.0":
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz"
|
resolved "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue