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';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { getSecrets } from "@/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
|
|
@ -8,29 +9,18 @@ import {
|
|||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, isServiceError, unwrapServiceError } from "@/lib/utils";
|
||||
import { ChevronsUpDown, Check, PlusCircleIcon, Loader2, Eye, EyeOff, TriangleAlert } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
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'
|
||||
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 {
|
||||
isDisabled: boolean;
|
||||
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>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href={`/${domain}/secrets`} legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
Secrets
|
||||
</NavigationMenuLink>
|
||||
</Link>
|
||||
</NavigationMenuItem>
|
||||
<NavigationMenuItem>
|
||||
<Link href={`/${domain}/connections`} legacyBehavior passHref>
|
||||
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export const Overview = ({ connectionId }: OverviewProps) => {
|
|||
captureEvent('wa_connection_retry_sync_success', {});
|
||||
refetch();
|
||||
}
|
||||
}, [connectionId, domain, toast, captureEvent, refetch]);
|
||||
}, [connectionId, domain, captureEvent, refetch]);
|
||||
|
||||
|
||||
if (error) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { cn, CodeHostType } from "@/lib/utils";
|
||||
import { CodeHostType } from "@/lib/utils";
|
||||
import { getCodeHostIcon } from "@/lib/utils";
|
||||
import {
|
||||
GitHubConnectionCreationForm,
|
||||
|
|
@ -13,9 +12,8 @@ import {
|
|||
import { useRouter } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { OnboardingSteps } from "@/lib/constants";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { BackButton } from "./onboardBackButton";
|
||||
import { CodeHostIconButton } from "../../components/codeHostIconButton";
|
||||
|
||||
interface ConnectCodeHostProps {
|
||||
nextStep: OnboardingSteps;
|
||||
|
|
@ -84,22 +82,22 @@ interface CodeHostSelectionProps {
|
|||
const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<CodeHostButton
|
||||
<CodeHostIconButton
|
||||
name="GitHub"
|
||||
logo={getCodeHostIcon("github")!}
|
||||
onClick={() => onSelect("github")}
|
||||
/>
|
||||
<CodeHostButton
|
||||
<CodeHostIconButton
|
||||
name="GitLab"
|
||||
logo={getCodeHostIcon("gitlab")!}
|
||||
onClick={() => onSelect("gitlab")}
|
||||
/>
|
||||
<CodeHostButton
|
||||
<CodeHostIconButton
|
||||
name="Gitea"
|
||||
logo={getCodeHostIcon("gitea")!}
|
||||
onClick={() => onSelect("gitea")}
|
||||
/>
|
||||
<CodeHostButton
|
||||
<CodeHostIconButton
|
||||
name="Gerrit"
|
||||
logo={getCodeHostIcon("gerrit")!}
|
||||
onClick={() => onSelect("gerrit")}
|
||||
|
|
@ -107,32 +105,3 @@ const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => {
|
|||
</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",
|
||||
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