diff --git a/packages/web/public/gitea_pat_creation.png b/packages/web/public/gitea_pat_creation.png new file mode 100644 index 00000000..00d6ab94 Binary files /dev/null and b/packages/web/public/gitea_pat_creation.png differ diff --git a/packages/web/public/gitlab_pat_creation.png b/packages/web/public/gitlab_pat_creation.png new file mode 100644 index 00000000..3279b2c4 Binary files /dev/null and b/packages/web/public/gitlab_pat_creation.png differ diff --git a/packages/web/src/app/[domain]/components/codeHostIconButton.tsx b/packages/web/src/app/[domain]/components/codeHostIconButton.tsx new file mode 100644 index 00000000..bee932fe --- /dev/null +++ b/packages/web/src/app/[domain]/components/codeHostIconButton.tsx @@ -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 ( + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx index 0ba03e16..50560f5d 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/secretCombobox.tsx @@ -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>({ - resolver: zodResolver(formSchema), - defaultValues: { - key: "", - value: "", - }, - }); - const { isSubmitting } = form.formState; - - const onSubmit = useCallback(async (data: z.infer) => { - const response = await createSecret(data.key, data.value, domain); - if (isServiceError(response)) { - toast({ - description: `❌ Failed to create secret` - }); - 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 ; - case 'gitlab': - return ; - case 'gitea': - return ; - case 'gerrit': - return null; - } - }, [codeHostType]); - - - return ( - - - - Import a secret - - Secrets are used to authenticate with a code host. They are encrypted at rest using AES-256-CBC. - Checkout our security docs for more information. - - - -
- { - event.stopPropagation(); - form.handleSubmit(onSubmit)(event); - }} - > - {codeHostSpecificStep} - - - ( - - Value - -
- - -
-
- - The secret value to store securely. - - -
- )} - /> -
- - - ( - - Key - - - - - A unique name to identify this secret. - - - - )} - /> - - -
- -
-
- -
-
- ) -} - -const GitHubPATCreationStep = ({ step }: { step: number }) => { - return ( - Navigate to here on github.com (or your enterprise instance) and create a new personal access token. Sourcebot needs the repo scope in order to access private repositories: - > - Create a personal access token - - ) -} - -const GitLabPATCreationStep = ({ step }: { step: number }) => { - return ( - -

todo

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

todo

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

- {title} -

-

- {description} -

- {children} -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/importSecretDialog.tsx b/packages/web/src/app/[domain]/components/importSecretDialog.tsx new file mode 100644 index 00000000..d2d65a09 --- /dev/null +++ b/packages/web/src/app/[domain]/components/importSecretDialog.tsx @@ -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>({ + resolver: zodResolver(formSchema), + defaultValues: { + key: "", + value: "", + }, + }); + const { isSubmitting } = form.formState; + + const onSubmit = useCallback(async (data: z.infer) => { + const response = await createSecret(data.key, data.value, domain); + if (isServiceError(response)) { + toast({ + description: `❌ Failed to create secret` + }); + 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 ; + case 'gitlab': + return ; + case 'gitea': + return ; + case 'gerrit': + return null; + } + }, [codeHostType]); + + + return ( + + + + Import a secret + + Secrets are used to authenticate with a code host. They are encrypted at rest using AES-256-CBC. + Checkout our security docs for more information. + + + +
+ { + event.stopPropagation(); + form.handleSubmit(onSubmit)(event); + }} + > + {codeHostSpecificStep} + + + ( + + Value + +
+ + +
+
+ + The secret value to store securely. + + +
+ )} + /> +
+ + + ( + + Key + + + + + A unique name to identify this secret. + + + + )} + /> + + +
+ +
+
+ +
+
+ ) +} + +const GitHubPATCreationStep = ({ step }: { step: number }) => { + return ( + Navigate to here on github.com (or your enterprise instance) and create a new personal access token. Sourcebot needs the repo scope in order to access private repositories: + > + Create a personal access token + + ) +} + +const GitLabPATCreationStep = ({ step }: { step: number }) => { + return ( + Navigate to here on gitlab.com (or your self-hosted instance) and create a new personal access token. Sourcebot needs the read_api scope in order to access private projects: + > + Create a personal access token + + ) +} + +const GiteaPATCreationStep = ({ step }: { step: number }) => { + return ( + Navigate to here on gitea.com (or your self-hosted instance) and create a new access token. Sourcebot needs the read:repository, read:user, and read:organization scopes: + > + Create a personal access token + + ) +} + +interface SecretCreationStepProps { + step: number; + title: string; + description: string | React.ReactNode; + children: React.ReactNode; +} + +const SecretCreationStep = ({ step, title, description, children }: SecretCreationStepProps) => { + return ( +
+
+ {step} + +
+

+ {title} +

+

+ {description} +

+ {children} +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/components/navigationMenu.tsx b/packages/web/src/app/[domain]/components/navigationMenu.tsx index 3fd9a5e0..00344d35 100644 --- a/packages/web/src/app/[domain]/components/navigationMenu.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu.tsx @@ -60,13 +60,6 @@ export const NavigationMenu = async ({ - - - - Secrets - - - diff --git a/packages/web/src/app/[domain]/connections/[id]/components/overview.tsx b/packages/web/src/app/[domain]/connections/[id]/components/overview.tsx index 5811649f..8836baed 100644 --- a/packages/web/src/app/[domain]/connections/[id]/components/overview.tsx +++ b/packages/web/src/app/[domain]/connections/[id]/components/overview.tsx @@ -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) { diff --git a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx index 9a402fee..e03745ff 100644 --- a/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx +++ b/packages/web/src/app/[domain]/onboard/components/connectCodeHost.tsx @@ -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 (
- onSelect("github")} /> - onSelect("gitlab")} /> - onSelect("gitea")} /> - onSelect("gerrit")} @@ -107,32 +105,3 @@ const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => {
) } - -interface CodeHostButtonProps { - name: string; - logo: { src: string, className?: string }; - onClick: () => void; -} - -const CodeHostButton = ({ - name, - logo, - onClick, -}: CodeHostButtonProps) => { - const captureEvent = useCaptureEvent(); - return ( - - ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/secrets/columns.tsx b/packages/web/src/app/[domain]/secrets/columns.tsx deleted file mode 100644 index 53e23e31..00000000 --- a/packages/web/src/app/[domain]/secrets/columns.tsx +++ /dev/null @@ -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[] => { - return [ - { - accessorKey: "key", - cell: ({ row }) => { - const secret = row.original; - return
{secret.key}
; - } - }, - { - accessorKey: "createdAt", - header: ({ column }) => createSortHeader("Created At", column), - cell: ({ row }) => { - const secret = row.original; - return
{secret.createdAt}
; - } - }, - { - accessorKey: "delete", - cell: ({ row }) => { - const secret = row.original; - return ( - - ) - } - } - ] - -} - -const createSortHeader = (name: string, column: Column) => { - return ( - - ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/secrets/page.tsx b/packages/web/src/app/[domain]/secrets/page.tsx deleted file mode 100644 index c3d6d45d..00000000 --- a/packages/web/src/app/[domain]/secrets/page.tsx +++ /dev/null @@ -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 ( -
- - { !isServiceError(secrets) && ( -
- -
- )} -
- ) -} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/secrets/secretsTable.tsx b/packages/web/src/app/[domain]/secrets/secretsTable.tsx deleted file mode 100644 index ff6fd3cf..00000000 --- a/packages/web/src/app/[domain]/secrets/secretsTable.tsx +++ /dev/null @@ -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>({ - 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 ( -
-
- - ( - - Key - - - - - - )} - /> - ( - - Value - - - - - - )} - /> - - - - -
- ); -}; \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index 4c1720d1..f4a9f756 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -26,6 +26,10 @@ export default function SettingsLayout({ { title: "Members", href: `/${domain}/settings/members`, + }, + { + title: "Secrets", + href: `/${domain}/settings/secrets`, } ] diff --git a/packages/web/src/app/[domain]/settings/secrets/components/importSecretCard.tsx b/packages/web/src/app/[domain]/settings/secrets/components/importSecretCard.tsx new file mode 100644 index 00000000..0972e861 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/secrets/components/importSecretCard.tsx @@ -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(null); + const [isImportSecretDialogOpen, setIsImportSecretDialogOpen] = useState(false); + const router = useRouter(); + + return ( + <> + + + Import a new secret + Import a secret from a code host to allow Sourcebot to sync private repositories. + + + { + setSelectedCodeHost("github"); + setIsImportSecretDialogOpen(true); + }} + /> + { + setSelectedCodeHost("gitlab"); + setIsImportSecretDialogOpen(true); + }} + /> + { + setSelectedCodeHost("gitea"); + setIsImportSecretDialogOpen(true); + }} + /> + + + {selectedCodeHost && ( + { + router.refresh(); + }} + codeHostType={selectedCodeHost ?? "github"} + /> + )} + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx b/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx new file mode 100644 index 00000000..e8a3d603 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/secrets/components/secretsList.tsx @@ -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(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 ( +
+
+
+ + setSearchQuery(e.target.value)} + /> +
+ + +
+ +
+
+ {secrets.length === 0 || (filteredSecrets.length === 0 && searchQuery.length > 0) ? ( +
+

No Secrets Found

+

+ {filteredSecrets.length === 0 && searchQuery.length > 0 ? "No secrets found matching your filters." : "Use the form above to create a new secret."} +

+
+ ) : ( + filteredSecrets.map((secret) => ( +
+
+ +

{secret.key}

+
+
+

+ Created {getDisplayTime(secret.createdAt)} +

+ + + + + + { + setSecretToDelete(secret); + setIsDeleteDialogOpen(true); + }} + > + + Delete secret + + + +
+
+ )) + )} +
+
+ + + + Delete Secret + + Are you sure you want to delete the secret {secretToDelete?.key}? Any connections that use this secret will fail to sync. + + + + Cancel + + Delete + + + + +
+ ) +} + +const Code = ({ children, className, title }: { children: React.ReactNode, className?: string, title?: string }) => { + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/secrets/page.tsx b/packages/web/src/app/[domain]/settings/secrets/page.tsx new file mode 100644 index 00000000..86525df8 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/secrets/page.tsx @@ -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 ( +
+
+

Manage Secrets

+

These secrets grant Sourcebot access to private code.

+
+ + + +
+ ) +} \ No newline at end of file