/settings/secrets page (#217)

This commit is contained in:
Brendan Kellam 2025-02-27 22:40:46 -08:00 committed by GitHub
parent 5006a932ea
commit 83ab0a0bd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 607 additions and 577 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

View file

@ -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>
)
}

View file

@ -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>
)
}

View 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>
)
}

View file

@ -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()}>

View file

@ -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) {

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
);
};

View file

@ -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`,
} }
] ]

View file

@ -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"}
/>
)}
</>
)
}

View file

@ -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>
)
}

View 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>
)
}