diff --git a/apps/platform/package.json b/apps/platform/package.json index 26b57e49..ea6cc8eb 100644 --- a/apps/platform/package.json +++ b/apps/platform/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-context-menu": "^2.1.5", diff --git a/apps/platform/public/svg/shared/Error.svg b/apps/platform/public/svg/shared/Error.svg new file mode 100644 index 00000000..6b5ae5ea --- /dev/null +++ b/apps/platform/public/svg/shared/Error.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/platform/public/svg/shared/Vector.svg b/apps/platform/public/svg/shared/Vector.svg new file mode 100644 index 00000000..c7aad118 --- /dev/null +++ b/apps/platform/public/svg/shared/Vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/platform/public/svg/shared/index.ts b/apps/platform/public/svg/shared/index.ts index 07133a8c..0d058acc 100644 --- a/apps/platform/public/svg/shared/index.ts +++ b/apps/platform/public/svg/shared/index.ts @@ -7,6 +7,10 @@ import SettingsSVG from './settings.svg' import ThreeDotOptionSVG from './3dotOption.svg' import AddSVG from './add.svg' import LoadingSVG from './loading.svg' +import MessageSVG from './message.svg' +import VectorSVG from './vector.svg' +import ErrorSVG from './Error.svg' +import TrashSVG from './trash.svg' export { DropdownSVG, @@ -17,5 +21,9 @@ export { SettingsSVG, ThreeDotOptionSVG, AddSVG, - LoadingSVG + LoadingSVG, + MessageSVG, + VectorSVG, + ErrorSVG, + TrashSVG, } diff --git a/apps/platform/public/svg/shared/message.svg b/apps/platform/public/svg/shared/message.svg new file mode 100644 index 00000000..f7757498 --- /dev/null +++ b/apps/platform/public/svg/shared/message.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/platform/public/svg/shared/trash.svg b/apps/platform/public/svg/shared/trash.svg new file mode 100644 index 00000000..8c51e673 --- /dev/null +++ b/apps/platform/public/svg/shared/trash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/platform/src/app/(main)/page.tsx b/apps/platform/src/app/(main)/page.tsx index 9a9cb2ac..af66c101 100644 --- a/apps/platform/src/app/(main)/page.tsx +++ b/apps/platform/src/app/(main)/page.tsx @@ -38,6 +38,7 @@ import { DialogTrigger } from '@/components/ui/dialog' import ControllerInstance from '@/lib/controller-instance' +import { Textarea } from '@/components/ui/textarea' export default function Index(): JSX.Element { const [isSheetOpen, setIsSheetOpen] = useState(false) @@ -144,10 +145,12 @@ export default function Index(): JSX.Element { - + {isProjectEmpty ? null : ( + + )}
@@ -190,8 +193,8 @@ export default function Index(): JSX.Element { > Description - { setNewProjectData((prev) => ({ @@ -217,7 +220,10 @@ export default function Index(): JSX.Element { onChange={(e) => { setNewProjectData((prev) => ({ ...prev, - envName: e.target.value + environments: (prev.environments || []).map( + (env, index) => + index === 0 ? { ...env, name: e.target.value } : env + ) })) }} placeholder="Your project default environment name" @@ -232,13 +238,18 @@ export default function Index(): JSX.Element { > Env. Description - { setNewProjectData((prev) => ({ ...prev, - envDescription: e.target.value + environments: (prev.environments || []).map( + (env, index) => + index === 0 + ? { ...env, description: e.target.value } + : env + ) })) }} placeholder="Detailed description about your environment" @@ -265,7 +276,7 @@ export default function Index(): JSX.Element { })) }} > - + @@ -334,7 +345,9 @@ export default function Index(): JSX.Element {
Create a file and start setting up your environment and secret keys
- +
)} diff --git a/apps/platform/src/app/(main)/project/[project]/@variable/page.tsx b/apps/platform/src/app/(main)/project/[project]/@variable/page.tsx index a4362de4..23ade9a5 100644 --- a/apps/platform/src/app/(main)/project/[project]/@variable/page.tsx +++ b/apps/platform/src/app/(main)/project/[project]/@variable/page.tsx @@ -1,7 +1,247 @@ -import React from 'react' +'use client' -function VariablePage(): React.JSX.Element { - return
VariablePage
+import { useEffect, useState } from 'react' +import type { + ClientResponse, + GetAllVariablesOfProjectResponse, + Project +} from '@keyshade/schema' +import { FolderSVG } from '@public/svg/dashboard' +import { MessageSVG } from '@public/svg/shared' +import { ChevronDown } from 'lucide-react' +import dayjs from 'dayjs' +import { Button } from '@/components/ui/button' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from '@/components/ui/collapsible' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table' +import ControllerInstance from '@/lib/controller-instance' +import ConfirmDelete from '@/components/ui/confirm-delete' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger +} from '@/components/ui/context-menu' + +interface VariablePageProps { + currentProject: Project | undefined +} + +function VariablePage({ + currentProject +}: VariablePageProps): React.JSX.Element { + const [allVariables, setAllVariables] = useState< + GetAllVariablesOfProjectResponse['items'] + >([]) + // Holds the currently open section ID + const [openSections, setOpenSections] = useState>(new Set()) + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [selectedVariableSlug, setSelectedVariableSlug] = useState< + string | null + >(null) + + //Environments table toggle logic + const toggleSection = (id: string) => { + setOpenSections((prev) => { + const newSet = new Set(prev) + if (newSet.has(id)) { + newSet.delete(id) + } else { + newSet.add(id) + } + return newSet + }) + } + + const openDeleteDialog = (variableSlug: string) => { + setIsDeleteDialogOpen(true) + setSelectedVariableSlug(variableSlug) + } + + const closeDeleteDialog = () => { + setIsDeleteDialogOpen(false) + setSelectedVariableSlug(null) + } + + useEffect(() => { + const getAllVariables = async () => { + if (!currentProject) { + return + } + + const { + success, + error, + data + }: ClientResponse = + await ControllerInstance.getInstance().variableController.getAllVariablesOfProject( + { projectSlug: currentProject.slug }, + {} + ) + + if (success && data) { + setAllVariables(data.items) + } else { + // eslint-disable-next-line no-console -- we need to log the error + console.error(error) + } + } + + getAllVariables() + }, [currentProject, allVariables]) + + return ( +
+ {/* Showing this when there are no variables present */} + {allVariables.length === 0 ? ( +
+ + +
+

+ Declare your first variable +

+

+ Declare and store a variable against different environments +

+
+ + +
+ ) : ( + // Showing this when variables are present +
+ {allVariables.map((variable) => ( + + + toggleSection(variable.variable.id)} + open={openSections.has(variable.variable.id)} + > + +
+ + {variable.variable.name} + + +
+
+
+
+ {dayjs(variable.variable.createdAt).toNow(true)} ago + by +
+
+
+ {variable.variable.lastUpdatedBy.name.split(' ')[0]} +
+ + + + {variable.variable.lastUpdatedBy.name + .charAt(0) + .toUpperCase() + + variable.variable.lastUpdatedBy.name + .slice(1, 2) + .toLowerCase()} + + +
+
+ +
+
+ + {variable.values ? ( + + + + + Environment + + + Value + + + + + {variable.values.map((env) => ( + + + {env.environment.name} + + + {env.value} + + + ))} + +
+ ) : null} +
+
+
+ + console.log('Show version history')} + > + Show Version History + + console.log('Edit variable')} + > + Edit + + openDeleteDialog(variable.variable.slug)} + > + Delete + + +
+ ))} + + {/* Delete variable alert dialog */} + {isDeleteDialogOpen ? ( + + ) : null} +
+ )} +
+ ) } export default VariablePage diff --git a/apps/platform/src/app/(main)/project/[project]/layout.tsx b/apps/platform/src/app/(main)/project/[project]/layout.tsx index 9edfb0bb..36114df1 100644 --- a/apps/platform/src/app/(main)/project/[project]/layout.tsx +++ b/apps/platform/src/app/(main)/project/[project]/layout.tsx @@ -1,8 +1,16 @@ 'use client' +import type { MouseEvent, MouseEventHandler } from 'react' import { useEffect, useState } from 'react' import { useSearchParams } from 'next/navigation' import { AddSVG } from '@public/svg/shared' -import type { Project } from '@keyshade/schema' +import type { + ClientResponse, + CreateVariableRequest, + Environment, + GetAllEnvironmentsOfProjectResponse, + Project +} from '@keyshade/schema' +import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Dialog, @@ -15,6 +23,15 @@ import { import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import ControllerInstance from '@/lib/controller-instance' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import VariablePage from './@variable/page' +import { Toaster } from '@/components/ui/sonner' interface DetailedProjectPageProps { params: { project: string } @@ -31,12 +48,76 @@ function DetailedProjectPage({ const [key, setKey] = useState('') // eslint-disable-next-line @typescript-eslint/no-unused-vars -- will be used later const [value, setValue] = useState('') - const [currentProject, setCurrentProject] = useState() + const [isOpen, setIsOpen] = useState(false) + const [newVariableData, setNewVariableData] = useState({ + variableName: '', + note: '', + environmentName: '', + environmentValue: '' + }) + const [availableEnvironments, setAvailableEnvironments] = useState([]) const searchParams = useSearchParams() const tab = searchParams.get('tab') ?? 'rollup-details' + const addVariable = async (e: MouseEvent) => { + e.preventDefault() + + if (!currentProject) { + throw new Error("Current project doesn't exist") + } + + const request: CreateVariableRequest = { + name: newVariableData.variableName, + projectSlug: currentProject.slug, + entries: newVariableData.environmentValue + ? [ + { + value: newVariableData.environmentValue, + environmentSlug: newVariableData.environmentName + } + ] + : undefined, + note: newVariableData.note + } + + const { success, error } = + await ControllerInstance.getInstance().variableController.createVariable( + request, + {} + ) + + if (success) { + toast.success('Variable added successfully', { + // eslint-disable-next-line react/no-unstable-nested-components -- we need to nest the description + description: () => ( +

+ The variable has been added to the project +

+ ) + }) + } + + if (error) { + if (error.statusCode === 409) { + toast.error('Variable name already exists', { + // eslint-disable-next-line react/no-unstable-nested-components -- we need to nest the description + description: () => ( +

+ Variable name is already there, kindly use different one. +

+ ) + }) + } else { + // eslint-disable-next-line no-console -- we need to log the error that are not in the if condition + console.error(error) + } + } + + setIsOpen(false) + } + useEffect(() => { async function getProjectBySlug() { const { success, error, data } = @@ -56,67 +137,228 @@ function DetailedProjectPage({ getProjectBySlug() }, [params.project]) + useEffect(() => { + const getAllEnvironments = async () => { + if (!currentProject) { + return + } + + const { + success, + error, + data + }: ClientResponse = + await ControllerInstance.getInstance().environmentController.getAllEnvironmentsOfProject( + { projectSlug: currentProject.slug }, + {} + ) + + if (success && data) { + setAvailableEnvironments(data.items) + } else { + // eslint-disable-next-line no-console -- we need to log the error + console.error(error) + } + } + + getAllEnvironments() + }, [currentProject]) + return (
-
+
{currentProject?.name}
- - - - - - - Add a new secret - - Add a new secret to the project. This secret will be encrypted - and stored securely. - - -
-
-
- - { - setKey(e.target.value) - }} - placeholder="Enter the name of the secret" - /> + {tab === 'secret' && ( + + + + + + + Add a new secret + + Add a new secret to the project. This secret will be encrypted + and stored securely. + + +
+
+
+ + { + setKey(e.target.value) + }} + placeholder="Enter the name of the secret" + /> +
+
+ + { + setValue(e.target.value) + }} + placeholder="Enter the value of the secret" + /> +
-
- - { - setValue(e.target.value) - }} - placeholder="Enter the value of the secret" - /> +
+
-
- + +
+ )} + {tab === 'variable' && ( + + + + + + + + Add a new variable + + + Add a new variable to the project + + + +
+
+
+ + + setNewVariableData({ + ...newVariableData, + variableName: e.target.value + }) + } + placeholder="Enter the key of the variable" + value={newVariableData.variableName} + /> +
+ +
+ + + setNewVariableData({ + ...newVariableData, + note: e.target.value + }) + } + placeholder="Enter the note of the secret" + value={newVariableData.note} + /> +
+ +
+
+ + +
+ +
+ + + setNewVariableData({ + ...newVariableData, + environmentValue: e.target.value + }) + } + placeholder="Environment Value" + value={newVariableData.environmentValue} + /> +
+
+ +
+ +
+
-
- -
+ +
+ )} -
+ +
{tab === 'secret' && secret} - {tab === 'variable' && variable} + {tab === 'variable' && } + {/* {tab === 'variable' && variable} */}
+ ) } -export default DetailedProjectPage +export default DetailedProjectPage \ No newline at end of file diff --git a/apps/platform/src/components/dashboard/projectCard/index.tsx b/apps/platform/src/components/dashboard/projectCard/index.tsx index 4cd0ad52..1818c51f 100644 --- a/apps/platform/src/components/dashboard/projectCard/index.tsx +++ b/apps/platform/src/components/dashboard/projectCard/index.tsx @@ -25,6 +25,7 @@ function ProjectCard({ }: ProjectCardProps): JSX.Element { const { id, + slug, name, description, environmentCount, @@ -69,7 +70,7 @@ function ProjectCard({
diff --git a/apps/platform/src/components/ui/alert-dialog.tsx b/apps/platform/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..2ec17ee8 --- /dev/null +++ b/apps/platform/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/apps/platform/src/components/ui/collapsible.tsx b/apps/platform/src/components/ui/collapsible.tsx new file mode 100644 index 00000000..28dd1567 --- /dev/null +++ b/apps/platform/src/components/ui/collapsible.tsx @@ -0,0 +1,12 @@ +"use client" + +import * as React from "react" +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } \ No newline at end of file diff --git a/apps/platform/src/components/ui/confirm-delete.tsx b/apps/platform/src/components/ui/confirm-delete.tsx new file mode 100644 index 00000000..53ee0644 --- /dev/null +++ b/apps/platform/src/components/ui/confirm-delete.tsx @@ -0,0 +1,103 @@ +'use client' + +import React, { useCallback, useEffect } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { TrashSVG } from '@public/svg/shared' +import ControllerInstance from '@/lib/controller-instance' +import { toast } from 'sonner' + +function ConfirmDelete({ + isOpen, + onClose, + variableSlug +}: { + isOpen: boolean; + onClose: () => void; + variableSlug: string | null; +}) { + + const deleteVariable = async () => { + + if( variableSlug === null ){ + return + } + + const { success, error } = await ControllerInstance.getInstance().variableController.deleteVariable( + {variableSlug: variableSlug}, + {} + ) + + if (success) { + toast.success('Variable deleted successfully', { + // eslint-disable-next-line react/no-unstable-nested-components -- we need to nest the description + description: () => ( +

+ The variable has been deleted. +

+ ) + }) + } + if( error ){ + console.error(error) + } + + onClose() + + } + + //Cleaning the pointer events for the context menu after closing the alert dialog + const cleanup = useCallback(() => { + document.body.style.pointerEvents = '' + document.documentElement.style.pointerEvents = '' + }, []) + + useEffect(() => { + if (!open) { + cleanup() + } + return () => cleanup() + }, [open, cleanup]) + + return ( + + + +
+ + + Do you really want to delete this variable? + +
+ + This action cannot be undone. This will permanently delete your variable and remove your variable data from our servers. + +
+ + onClose()} + > + Cancel + + + Yes, delete the variable + + +
+
+ ) +} + +export default ConfirmDelete diff --git a/apps/platform/src/components/ui/textarea.tsx b/apps/platform/src/components/ui/textarea.tsx new file mode 100644 index 00000000..2a10e86f --- /dev/null +++ b/apps/platform/src/components/ui/textarea.tsx @@ -0,0 +1,22 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +const Textarea = React.forwardRef< + HTMLTextAreaElement, + React.ComponentProps<'textarea'> +>(({ className, ...props }, ref) => { + return ( +