mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 20:35:24 +00:00
/settings/secrets page (#217)
This commit is contained in:
parent
5006a932ea
commit
83ab0a0bd3
15 changed files with 607 additions and 577 deletions
BIN
packages/web/public/gitea_pat_creation.png
Normal file
BIN
packages/web/public/gitea_pat_creation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
BIN
packages/web/public/gitlab_pat_creation.png
Normal file
BIN
packages/web/public/gitlab_pat_creation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 343 KiB |
|
|
@ -0,0 +1,35 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
interface CodeHostIconButton {
|
||||||
|
name: string;
|
||||||
|
logo: { src: string, className?: string };
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CodeHostIconButton = ({
|
||||||
|
name,
|
||||||
|
logo,
|
||||||
|
onClick,
|
||||||
|
}: CodeHostIconButton) => {
|
||||||
|
const captureEvent = useCaptureEvent();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="flex flex-col items-center justify-center p-4 w-24 h-24 cursor-pointer gap-2"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
captureEvent('wa_connect_code_host_button_pressed', {
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Image src={logo.src} alt={name} className={cn("w-8 h-8", logo.className)} />
|
||||||
|
<p className="text-sm font-medium">{name}</p>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { getSecrets } from "@/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
|
|
@ -8,29 +9,18 @@ import {
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from "@/components/ui/command"
|
} from "@/components/ui/command";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { cn, isServiceError, unwrapServiceError } 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 { 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { isDefined } from '@/lib/utils'
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { cn, CodeHostType, isDefined, isServiceError, unwrapServiceError } from "@/lib/utils";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Check, ChevronsUpDown, Loader2, PlusCircleIcon, TriangleAlert } from "lucide-react";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { ImportSecretDialog } from "../importSecretDialog";
|
||||||
|
|
||||||
interface SecretComboBoxProps {
|
interface SecretComboBoxProps {
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
codeHostType: CodeHostType;
|
codeHostType: CodeHostType;
|
||||||
|
|
@ -171,256 +161,3 @@ export const SecretCombobox = ({
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 captureEvent = useCaptureEvent();
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
key: z.string().min(1).refine(async (key) => {
|
|
||||||
const doesSecretExist = await checkIfSecretExists(key, domain);
|
|
||||||
if(!isServiceError(doesSecretExist)) {
|
|
||||||
captureEvent('wa_secret_combobox_import_secret_fail', {
|
|
||||||
type: codeHostType,
|
|
||||||
error: "A secret with this key already exists.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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`
|
|
||||||
});
|
|
||||||
captureEvent('wa_secret_combobox_import_secret_fail', {
|
|
||||||
type: codeHostType,
|
|
||||||
error: response.message,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
description: `✅ Secret created successfully!`
|
|
||||||
});
|
|
||||||
captureEvent('wa_secret_combobox_import_secret_success', {
|
|
||||||
type: codeHostType,
|
|
||||||
});
|
|
||||||
form.reset();
|
|
||||||
onOpenChange(false);
|
|
||||||
onSecretCreated(data.key);
|
|
||||||
}
|
|
||||||
}, [domain, toast, onOpenChange, onSecretCreated, form, codeHostType, captureEvent]);
|
|
||||||
|
|
||||||
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-[90vw] sm:max-w-2xl max-h-[80vh] overflow-scroll rounded-lg"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
288
packages/web/src/app/[domain]/components/importSecretDialog.tsx
Normal file
288
packages/web/src/app/[domain]/components/importSecretDialog.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { checkIfSecretExists, createSecret } from "@/actions";
|
||||||
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { CodeHostType, isServiceError } from "@/lib/utils";
|
||||||
|
import githubPatCreation from "@/public/github_pat_creation.png";
|
||||||
|
import gitlabPatCreation from "@/public/gitlab_pat_creation.png";
|
||||||
|
import giteaPatCreation from "@/public/gitea_pat_creation.png";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
|
||||||
|
interface ImportSecretDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSecretCreated: (key: string) => void;
|
||||||
|
codeHostType: CodeHostType;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHostType }: ImportSecretDialogProps) => {
|
||||||
|
const [showValue, setShowValue] = useState(false);
|
||||||
|
const domain = useDomain();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const captureEvent = useCaptureEvent();
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
key: z.string().min(1).refine(async (key) => {
|
||||||
|
const doesSecretExist = await checkIfSecretExists(key, domain);
|
||||||
|
if(!isServiceError(doesSecretExist)) {
|
||||||
|
captureEvent('wa_secret_combobox_import_secret_fail', {
|
||||||
|
type: codeHostType,
|
||||||
|
error: "A secret with this key already exists.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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`
|
||||||
|
});
|
||||||
|
captureEvent('wa_secret_combobox_import_secret_fail', {
|
||||||
|
type: codeHostType,
|
||||||
|
error: response.message,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
description: `✅ Secret created successfully!`
|
||||||
|
});
|
||||||
|
captureEvent('wa_secret_combobox_import_secret_success', {
|
||||||
|
type: codeHostType,
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
onOpenChange(false);
|
||||||
|
onSecretCreated(data.key);
|
||||||
|
}
|
||||||
|
}, [domain, toast, onOpenChange, onSecretCreated, form, codeHostType, captureEvent]);
|
||||||
|
|
||||||
|
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-[90vw] sm:max-w-2xl max-h-[80vh] overflow-scroll rounded-lg"
|
||||||
|
>
|
||||||
|
<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 rounded-sm"
|
||||||
|
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=<span>Navigate to <Link href="https://gitlab.com/-/user_settings/personal_access_tokens" target="_blank" className="underline">here on gitlab.com</Link> (or your self-hosted instance) and create a new personal access token. Sourcebot needs the <strong>read_api</strong> scope in order to access private projects:</span>
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
className="mx-auto rounded-sm"
|
||||||
|
src={gitlabPatCreation}
|
||||||
|
alt="Create a personal access token"
|
||||||
|
width={600}
|
||||||
|
height={600}
|
||||||
|
/>
|
||||||
|
</SecretCreationStep>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const GiteaPATCreationStep = ({ step }: { step: number }) => {
|
||||||
|
return (
|
||||||
|
<SecretCreationStep
|
||||||
|
step={step}
|
||||||
|
title="Create a Personal Access Token"
|
||||||
|
description=<span>Navigate to <Link href="https://gitea.com/user/settings/applications" target="_blank" className="underline">here on gitea.com</Link> (or your self-hosted instance) and create a new access token. Sourcebot needs the <strong>read:repository</strong>, <strong>read:user</strong>, and <strong>read:organization</strong> scopes:</span>
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
className="mx-auto rounded-sm"
|
||||||
|
src={giteaPatCreation}
|
||||||
|
alt="Create a personal access token"
|
||||||
|
width={600}
|
||||||
|
height={600}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -60,13 +60,6 @@ export const NavigationMenu = async ({
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
<NavigationMenuItem>
|
|
||||||
<Link href={`/${domain}/secrets`} legacyBehavior passHref>
|
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
|
||||||
Secrets
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</Link>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<Link href={`/${domain}/connections`} legacyBehavior passHref>
|
<Link href={`/${domain}/connections`} legacyBehavior passHref>
|
||||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export const Overview = ({ connectionId }: OverviewProps) => {
|
||||||
captureEvent('wa_connection_retry_sync_success', {});
|
captureEvent('wa_connection_retry_sync_success', {});
|
||||||
refetch();
|
refetch();
|
||||||
}
|
}
|
||||||
}, [connectionId, domain, toast, captureEvent, refetch]);
|
}, [connectionId, domain, captureEvent, refetch]);
|
||||||
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn, CodeHostType } from "@/lib/utils";
|
import { CodeHostType } from "@/lib/utils";
|
||||||
import { getCodeHostIcon } from "@/lib/utils";
|
import { getCodeHostIcon } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
GitHubConnectionCreationForm,
|
GitHubConnectionCreationForm,
|
||||||
|
|
@ -13,9 +12,8 @@ import {
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { OnboardingSteps } from "@/lib/constants";
|
import { OnboardingSteps } from "@/lib/constants";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
import { BackButton } from "./onboardBackButton";
|
import { BackButton } from "./onboardBackButton";
|
||||||
|
import { CodeHostIconButton } from "../../components/codeHostIconButton";
|
||||||
|
|
||||||
interface ConnectCodeHostProps {
|
interface ConnectCodeHostProps {
|
||||||
nextStep: OnboardingSteps;
|
nextStep: OnboardingSteps;
|
||||||
|
|
@ -84,22 +82,22 @@ interface CodeHostSelectionProps {
|
||||||
const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => {
|
const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
<CodeHostButton
|
<CodeHostIconButton
|
||||||
name="GitHub"
|
name="GitHub"
|
||||||
logo={getCodeHostIcon("github")!}
|
logo={getCodeHostIcon("github")!}
|
||||||
onClick={() => onSelect("github")}
|
onClick={() => onSelect("github")}
|
||||||
/>
|
/>
|
||||||
<CodeHostButton
|
<CodeHostIconButton
|
||||||
name="GitLab"
|
name="GitLab"
|
||||||
logo={getCodeHostIcon("gitlab")!}
|
logo={getCodeHostIcon("gitlab")!}
|
||||||
onClick={() => onSelect("gitlab")}
|
onClick={() => onSelect("gitlab")}
|
||||||
/>
|
/>
|
||||||
<CodeHostButton
|
<CodeHostIconButton
|
||||||
name="Gitea"
|
name="Gitea"
|
||||||
logo={getCodeHostIcon("gitea")!}
|
logo={getCodeHostIcon("gitea")!}
|
||||||
onClick={() => onSelect("gitea")}
|
onClick={() => onSelect("gitea")}
|
||||||
/>
|
/>
|
||||||
<CodeHostButton
|
<CodeHostIconButton
|
||||||
name="Gerrit"
|
name="Gerrit"
|
||||||
logo={getCodeHostIcon("gerrit")!}
|
logo={getCodeHostIcon("gerrit")!}
|
||||||
onClick={() => onSelect("gerrit")}
|
onClick={() => onSelect("gerrit")}
|
||||||
|
|
@ -107,32 +105,3 @@ const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CodeHostButtonProps {
|
|
||||||
name: string;
|
|
||||||
logo: { src: string, className?: string };
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CodeHostButton = ({
|
|
||||||
name,
|
|
||||||
logo,
|
|
||||||
onClick,
|
|
||||||
}: CodeHostButtonProps) => {
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className="flex flex-col items-center justify-center p-4 w-24 h-24 cursor-pointer gap-2"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
captureEvent('wa_connect_code_host_button_pressed', {
|
|
||||||
name,
|
|
||||||
})
|
|
||||||
onClick();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Image src={logo.src} alt={name} className={cn("w-8 h-8", logo.className)} />
|
|
||||||
<p className="text-sm font-medium">{name}</p>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Column, ColumnDef } from "@tanstack/react-table"
|
|
||||||
import { ArrowUpDown } from "lucide-react"
|
|
||||||
|
|
||||||
export type SecretColumnInfo = {
|
|
||||||
key: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const columns = (handleDelete: (key: string) => void): ColumnDef<SecretColumnInfo>[] => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
accessorKey: "key",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const secret = row.original;
|
|
||||||
return <div>{secret.key}</div>;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "createdAt",
|
|
||||||
header: ({ column }) => createSortHeader("Created At", column),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const secret = row.original;
|
|
||||||
return <div>{secret.createdAt}</div>;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "delete",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const secret = row.original;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => {
|
|
||||||
handleDelete(secret.key);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const createSortHeader = (name: string, column: Column<SecretColumnInfo, unknown>) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import { NavigationMenu } from "../components/navigationMenu";
|
|
||||||
import { SecretsTable } from "./secretsTable";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
|
||||||
import { getSecrets } from "@/actions";
|
|
||||||
|
|
||||||
export default async function SecretsPage({ params: { domain } }: { params: { domain: string } }) {
|
|
||||||
const secrets = await getSecrets(domain);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex flex-col items-center">
|
|
||||||
<NavigationMenu domain={domain} />
|
|
||||||
{ !isServiceError(secrets) && (
|
|
||||||
<div className="max-w-[90%]">
|
|
||||||
<SecretsTable
|
|
||||||
initialSecrets={secrets}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { getSecrets, createSecret } from "../../../actions"
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { columns, SecretColumnInfo } from "./columns";
|
|
||||||
import { DataTable } from "@/components/ui/data-table";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
|
||||||
import { deleteSecret } from "../../../actions"
|
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
|
||||||
const formSchema = z.object({
|
|
||||||
key: z.string().min(2).max(40),
|
|
||||||
value: z.string().min(2),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface SecretsTableProps {
|
|
||||||
initialSecrets: { createdAt: Date; key: string; }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
|
|
||||||
const [secrets, setSecrets] = useState<{ createdAt: Date; key: string; }[]>(initialSecrets);
|
|
||||||
const { toast } = useToast();
|
|
||||||
const domain = useDomain();
|
|
||||||
const captureEvent = useCaptureEvent();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSecretKeys = async () => {
|
|
||||||
const keys = await getSecrets(domain);
|
|
||||||
if (isServiceError(keys)) {
|
|
||||||
console.error("Failed to fetch secrets:", keys);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSecrets(keys);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSecretKeys();
|
|
||||||
}, [domain]);
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: {
|
|
||||||
key: "",
|
|
||||||
value: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCreateSecret = async (values: { key: string, value: string }) => {
|
|
||||||
const res = await createSecret(values.key, values.value, domain);
|
|
||||||
if (isServiceError(res)) {
|
|
||||||
if (res.errorCode === ErrorCode.SECRET_ALREADY_EXISTS) {
|
|
||||||
toast({
|
|
||||||
description: `❌ Secret with key ${values.key} already exists`
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
description: `❌ Failed to create secret`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
captureEvent('wa_secret_created_fail', {
|
|
||||||
key: values.key,
|
|
||||||
error: res.errorCode,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
description: `✅ Secret created successfully!`
|
|
||||||
});
|
|
||||||
captureEvent('wa_secret_created_success', {
|
|
||||||
key: values.key,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = await getSecrets(domain);
|
|
||||||
if (isServiceError(keys)) {
|
|
||||||
console.error("Failed to fetch secrets");
|
|
||||||
captureEvent('wa_secret_fetch_fail', {
|
|
||||||
error: keys.errorCode,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setSecrets(keys);
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
form.resetField("key");
|
|
||||||
form.resetField("value");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (key: string) => {
|
|
||||||
const res = await deleteSecret(key, domain);
|
|
||||||
if (isServiceError(res)) {
|
|
||||||
toast({
|
|
||||||
description: `❌ Failed to delete secret`
|
|
||||||
});
|
|
||||||
captureEvent('wa_secret_deleted_fail', {
|
|
||||||
key: key,
|
|
||||||
error: res.errorCode,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
description: `✅ Secret deleted successfully!`
|
|
||||||
});
|
|
||||||
captureEvent('wa_secret_deleted_success', {
|
|
||||||
key: key,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = await getSecrets(domain);
|
|
||||||
if ('keys' in keys) {
|
|
||||||
setSecrets(keys);
|
|
||||||
} else {
|
|
||||||
console.error(keys);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const keys = useMemo(() => {
|
|
||||||
return secrets.map((secret): SecretColumnInfo => {
|
|
||||||
return {
|
|
||||||
key: secret.key,
|
|
||||||
createdAt: secret.createdAt.toISOString(),
|
|
||||||
}
|
|
||||||
}).sort((a, b) => {
|
|
||||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
||||||
});
|
|
||||||
}, [secrets]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(handleCreateSecret)}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="key"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Key</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="value"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Value</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button className="mt-5" type="submit">Submit</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
<DataTable
|
|
||||||
columns={columns(handleDelete)}
|
|
||||||
data={keys}
|
|
||||||
searchKey="key"
|
|
||||||
searchPlaceholder="Search secrets..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -26,6 +26,10 @@ export default function SettingsLayout({
|
||||||
{
|
{
|
||||||
title: "Members",
|
title: "Members",
|
||||||
href: `/${domain}/settings/members`,
|
href: `/${domain}/settings/members`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Secrets",
|
||||||
|
href: `/${domain}/settings/secrets`,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { CodeHostIconButton } from "@/app/[domain]/components/codeHostIconButton";
|
||||||
|
import { Card, CardTitle, CardDescription, CardHeader, CardContent } from "@/components/ui/card";
|
||||||
|
import { getCodeHostIcon } from "@/lib/utils";
|
||||||
|
import { cn, CodeHostType } from "@/lib/utils";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ImportSecretDialog } from "@/app/[domain]/components/importSecretDialog";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface ImportSecretCardProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImportSecretCard = ({ className }: ImportSecretCardProps) => {
|
||||||
|
const [selectedCodeHost, setSelectedCodeHost] = useState<CodeHostType | null>(null);
|
||||||
|
const [isImportSecretDialogOpen, setIsImportSecretDialogOpen] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className={cn(className)}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Import a new secret</CardTitle>
|
||||||
|
<CardDescription>Import a secret from a code host to allow Sourcebot to sync private repositories.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-row gap-4 w-full justify-center">
|
||||||
|
<CodeHostIconButton
|
||||||
|
name="GitHub"
|
||||||
|
logo={getCodeHostIcon("github")!}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCodeHost("github");
|
||||||
|
setIsImportSecretDialogOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CodeHostIconButton
|
||||||
|
name="GitLab"
|
||||||
|
logo={getCodeHostIcon("gitlab")!}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCodeHost("gitlab");
|
||||||
|
setIsImportSecretDialogOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CodeHostIconButton
|
||||||
|
name="Gitea"
|
||||||
|
logo={getCodeHostIcon("gitea")!}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCodeHost("gitea");
|
||||||
|
setIsImportSecretDialogOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{selectedCodeHost && (
|
||||||
|
<ImportSecretDialog
|
||||||
|
open={isImportSecretDialogOpen}
|
||||||
|
onOpenChange={setIsImportSecretDialogOpen}
|
||||||
|
onSecretCreated={() => {
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
codeHostType={selectedCodeHost ?? "github"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { LucideKeyRound, MoreVertical, Search, LucideTrash } from "lucide-react";
|
||||||
|
import { useState, useMemo, useCallback } from "react";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { cn, getDisplayTime, isServiceError } from "@/lib/utils";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
|
import { deleteSecret } from "@/actions";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface Secret {
|
||||||
|
key: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretsListProps {
|
||||||
|
secrets: Secret[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SecretsList = ({ secrets }: SecretsListProps) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest");
|
||||||
|
const [secretToDelete, setSecretToDelete] = useState<Secret | null>(null);
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const domain = useDomain();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const filteredSecrets = useMemo(() => {
|
||||||
|
return secrets
|
||||||
|
.filter((secret) => {
|
||||||
|
const searchLower = searchQuery.toLowerCase();
|
||||||
|
const matchesSearch = secret.key.toLowerCase().includes(searchLower);
|
||||||
|
return matchesSearch;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
return dateSort === "newest"
|
||||||
|
? b.createdAt.getTime() - a.createdAt.getTime()
|
||||||
|
: a.createdAt.getTime() - b.createdAt.getTime()
|
||||||
|
});
|
||||||
|
}, [secrets, searchQuery, dateSort]);
|
||||||
|
|
||||||
|
const onDeleteSecret = useCallback(() => {
|
||||||
|
deleteSecret(secretToDelete?.key ?? "", domain)
|
||||||
|
.then((response) => {
|
||||||
|
if (isServiceError(response)) {
|
||||||
|
toast({
|
||||||
|
description: `❌ Failed to delete secret. Reason: ${response.message}`
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
description: `✅ Secret deleted successfully.`
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [domain, secretToDelete?.key, toast, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full mx-auto space-y-6">
|
||||||
|
<div className="flex gap-4 flex-col sm:flex-row">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Filter by secret name..."
|
||||||
|
className="pl-9"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select value={dateSort} onValueChange={(value) => setDateSort(value as "newest" | "oldest")}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Date" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="newest">Newest</SelectItem>
|
||||||
|
<SelectItem value="oldest">Oldest</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<div className="max-h-[600px] overflow-y-auto divide-y">
|
||||||
|
{secrets.length === 0 || (filteredSecrets.length === 0 && searchQuery.length > 0) ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-96 p-4">
|
||||||
|
<p className="font-medium text-sm">No Secrets Found</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
{filteredSecrets.length === 0 && searchQuery.length > 0 ? "No secrets found matching your filters." : "Use the form above to create a new secret."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredSecrets.map((secret) => (
|
||||||
|
<div key={secret.key} className="p-4 flex items-center justify-between bg-background">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<LucideKeyRound className="w-4 h-4 mr-2" />
|
||||||
|
<p className="font-mono">{secret.key}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Created {getDisplayTime(secret.createdAt)}
|
||||||
|
</p>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setSecretToDelete(secret);
|
||||||
|
setIsDeleteDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LucideTrash className="w-4 h-4 mr-2" />
|
||||||
|
Delete secret
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AlertDialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={setIsDeleteDialogOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Secret</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete the secret <Code>{secretToDelete?.key}</Code>? Any connections that use this secret will <strong>fail to sync.</strong>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={onDeleteSecret}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
packages/web/src/app/[domain]/settings/secrets/page.tsx
Normal file
28
packages/web/src/app/[domain]/settings/secrets/page.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { getSecrets } from "@/actions";
|
||||||
|
import { SecretsList } from "./components/secretsList";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { ImportSecretCard } from "./components/importSecretCard";
|
||||||
|
interface SecretsPageProps {
|
||||||
|
params: {
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SecretsPage({ params: { domain } }: SecretsPageProps) {
|
||||||
|
const secrets = await getSecrets(domain);
|
||||||
|
if (isServiceError(secrets)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">Manage Secrets</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">These secrets grant Sourcebot access to private code.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SecretsList secrets={secrets} />
|
||||||
|
<ImportSecretCard className="mt-4" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue