From e38c755c643a0eb68a015d326b8276671146742c Mon Sep 17 00:00:00 2001 From: Krishna Date: Tue, 3 Mar 2026 20:00:16 -0800 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20feat(frontend):=20group=20for?= =?UTF-8?q?ked=20config=20secrets=20by=20override=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split forked config secret lists into collapsible Overrides and Inherited sections, with Overrides expanded by default and inherited rows visually de-emphasized. Add fork summary counts to config header and preserve query invalidation compatibility via a dedicated secrets view query key. --- .../src/components/secrets/SecretsTable.tsx | 453 +++++++++++++----- frontend/src/lib/api/queryKeys.ts | 2 + frontend/src/pages/SecretsPage.tsx | 22 +- 3 files changed, 358 insertions(+), 119 deletions(-) diff --git a/frontend/src/components/secrets/SecretsTable.tsx b/frontend/src/components/secrets/SecretsTable.tsx index a712d68..7acf0df 100644 --- a/frontend/src/components/secrets/SecretsTable.tsx +++ b/frontend/src/components/secrets/SecretsTable.tsx @@ -1,15 +1,18 @@ -import { useMemo, useRef, useState, type ChangeEventHandler } from 'react'; +import { useEffect, useMemo, useRef, useState, type ChangeEventHandler } from 'react'; import { useReactTable, getCoreRowModel, getFilteredRowModel, flexRender, - type ColumnDef + type ColumnDef, + type Row } from '@tanstack/react-table'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useNavigate } from 'react-router-dom'; import { + ChevronDownIcon, + ChevronRightIcon, EyeIcon, EyeOffIcon, GitCompareArrowsIcon, @@ -66,9 +69,30 @@ function hasOwnKey(record: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(record, key); } +type ForkBucket = 'override' | 'inherited'; + +interface SecretRow extends Secret { + forkBucket: ForkBucket; + isDirectInConfig: boolean; +} + +export interface ForkSecretsSummary { + isFork: boolean; + overrides: number; + inherited: number; + parentComparisonDegraded: boolean; +} + +interface SecretsQueryData { + rows: SecretRow[]; + summary: ForkSecretsSummary; +} + interface SecretsTableProps { projectSlug: string; configSlug: string; + parentSlug?: string; + onForkSummaryChange?: (summary: ForkSecretsSummary) => void; } function columnClass(columnId: string, header = false): string { @@ -90,7 +114,35 @@ function columnClass(columnId: string, header = false): string { return header ? '' : 'align-top'; } -export function SecretsTable({ projectSlug, configSlug }: SecretsTableProps) { +function summarizeForkRows( + rows: SecretRow[], + isFork: boolean, + parentComparisonDegraded = false +): ForkSecretsSummary { + if (!isFork) { + return { + isFork: false, + overrides: rows.length, + inherited: 0, + parentComparisonDegraded: false + }; + } + + const overrides = rows.filter((row) => row.forkBucket === 'override').length; + return { + isFork: true, + overrides, + inherited: rows.length - overrides, + parentComparisonDegraded + }; +} + +export function SecretsTable({ + projectSlug, + configSlug, + parentSlug, + onForkSummaryChange +}: SecretsTableProps) { const queryClient = useQueryClient(); const navigate = useNavigate(); const fileInputRef = useRef(null); @@ -102,13 +154,95 @@ export function SecretsTable({ projectSlug, configSlug }: SecretsTableProps) { const [globalFilter, setGlobalFilter] = useState(''); const [importOpen, setImportOpen] = useState(false); const [importPreview, setImportPreview] = useState(null); + const [overridesOpen, setOverridesOpen] = useState(true); + const [inheritedOpen, setInheritedOpen] = useState(false); + + const isFork = Boolean(parentSlug); + + useEffect(() => { + setOverridesOpen(true); + setInheritedOpen(false); + }, [projectSlug, configSlug, parentSlug]); + + const { data: secretsData, isLoading } = useQuery({ + queryKey: queryKeys.secretsView(projectSlug, configSlug, parentSlug), + queryFn: async () => { + const effectiveSecrets = await getSecrets(projectSlug, configSlug, { + includeParent: true, + includeMeta: true, + resolveReferences: false, + raw: false + }); + + if (!parentSlug) { + const rows: SecretRow[] = effectiveSecrets.map((secret) => ({ + ...secret, + forkBucket: 'override', + isDirectInConfig: true + })); + return { + rows, + summary: summarizeForkRows(rows, false) + }; + } + + const [directSecrets, parentSecretsResult] = await Promise.all([ + getSecretsKeyMap(projectSlug, configSlug, false, { + includeParent: false, + includeMeta: false, + resolveReferences: false, + raw: false + }), + getSecretsKeyMap(projectSlug, parentSlug, true, { + includeParent: true, + includeMeta: false, + resolveReferences: false, + raw: false + }) + .then((data) => ({ data, failed: false })) + .catch(() => ({ data: {} as Record, failed: true })) + ]); + + const parentSecrets = parentSecretsResult.data; + const parentComparisonDegraded = parentSecretsResult.failed; + + const rows: SecretRow[] = effectiveSecrets.map((secret) => { + const isDirectInConfig = hasOwnKey(directSecrets, secret.key); + + let forkBucket: ForkBucket = 'inherited'; + if (isDirectInConfig) { + if (parentComparisonDegraded) { + forkBucket = 'override'; + } else if (!hasOwnKey(parentSecrets, secret.key)) { + forkBucket = 'override'; + } else { + forkBucket = directSecrets[secret.key] === parentSecrets[secret.key] ? 'inherited' : 'override'; + } + } - const { data: secrets = [], isLoading } = useQuery({ - queryKey: queryKeys.secrets(projectSlug, configSlug), - queryFn: () => getSecrets(projectSlug, configSlug), + return { + ...secret, + forkBucket, + isDirectInConfig + }; + }); + + return { + rows, + summary: summarizeForkRows(rows, true, parentComparisonDegraded) + }; + }, enabled: !!projectSlug && !!configSlug }); + useEffect(() => { + if (!onForkSummaryChange || !secretsData) return; + onForkSummaryChange(secretsData.summary); + }, [onForkSummaryChange, secretsData]); + + const secrets = useMemo(() => secretsData?.rows ?? [], [secretsData?.rows]); + const summary = secretsData?.summary; + const deleteMutation = useMutation({ mutationFn: (key: string) => deleteSecret(projectSlug, configSlug, key), onSuccess: () => { @@ -248,7 +382,7 @@ export function SecretsTable({ projectSlug, configSlug }: SecretsTableProps) { } }; - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { id: 'icon', @@ -262,22 +396,34 @@ export function SecretsTable({ projectSlug, configSlug }: SecretsTableProps) { { accessorKey: 'key', header: 'KEY', - cell: ({ row }) => ( - - {row.original.key} - - ) + cell: ({ row }) => { + const inherited = row.original.forkBucket === 'inherited'; + return ( + + {row.original.key} + + ); + } }, { accessorKey: 'value', header: 'VALUE', cell: ({ row }) => { const revealed = revealedKeys.has(row.original.key); - return ( - revealed ? -
- -
: + const inherited = row.original.forkBucket === 'inherited'; + return revealed ? ( +
+ +
+ ) : ( •••••••••••• ); } @@ -292,53 +438,55 @@ export function SecretsTable({ projectSlug, configSlug }: SecretsTableProps) { { id: 'actions', header: '', - cell: ({ row }) => ( -
- - - - -
- ) + cell: ({ row }) => { + const isInherited = row.original.forkBucket === 'inherited'; + const editLabel = isInherited ? 'Override inherited secret' : 'Edit secret'; + return ( +
+ + + + +
+ ); + } } ], [navigate, projectSlug, revealedKeys] @@ -357,6 +505,31 @@ export function SecretsTable({ projectSlug, configSlug }: SecretsTableProps) { row.original.key.toLowerCase().includes(filterValue.toLowerCase()) }); + const filteredRows = table.getRowModel().rows; + const overrideRows = isFork ? filteredRows.filter((row) => row.original.forkBucket === 'override') : filteredRows; + const inheritedRows = isFork ? filteredRows.filter((row) => row.original.forkBucket === 'inherited') : []; + + const renderDataRow = (row: Row) => { + const inherited = row.original.forkBucket === 'inherited'; + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); + }; + return (
@@ -389,6 +562,12 @@ export function SecretsTable({ projectSlug, configSlug }: SecretsTableProps) {
+ {isFork && summary?.parentComparisonDegraded && ( +

+ Parent diff comparison is partially unavailable; direct keys are treated as overrides. +

+ )} + - {isLoading - ? Array.from({ length: 5 }).map((_, index) => ( - - - - - - - - - - - - - - - - - - )) - : table.getRowModel().rows.length === 0 - ? ( - - - setAddOpen(true)}> - - Add Secret - - ) : undefined - } - /> + {isLoading ? ( + Array.from({ length: 5 }).map((_, index) => ( + + + + + + + + + + + + + + + + + + )) + ) : filteredRows.length === 0 ? ( + + + setAddOpen(true)}> + + Add Secret + + ) : undefined + } + /> + + + ) : isFork ? ( + <> + + + + + + {overridesOpen && + (overrideRows.length > 0 ? ( + overrideRows.map((row) => renderDataRow(row)) + ) : ( + + + No overrides in this view. - ) - : table.getRowModel().rows.map((row) => ( - + + + + + {inheritedOpen && + (inheritedRows.length > 0 ? ( + inheritedRows.map((row) => renderDataRow(row)) + ) : ( + + + No inherited secrets in this view. + ))} + + ) : ( + filteredRows.map((row) => renderDataRow(row)) + )} diff --git a/frontend/src/lib/api/queryKeys.ts b/frontend/src/lib/api/queryKeys.ts index 6e115bb..a3b2fd8 100644 --- a/frontend/src/lib/api/queryKeys.ts +++ b/frontend/src/lib/api/queryKeys.ts @@ -7,6 +7,8 @@ export const queryKeys = { configs: (projectSlug: string) => ['configs', projectSlug] as const, secrets: (projectSlug: string, configSlug: string) => ['secrets', projectSlug, configSlug] as const, + secretsView: (projectSlug: string, configSlug: string, parentSlug?: string) => + ['secrets', projectSlug, configSlug, 'view', parentSlug ?? null] as const, tokens: () => ['tokens'] as const, audit: (filters?: { projectSlug?: string; diff --git a/frontend/src/pages/SecretsPage.tsx b/frontend/src/pages/SecretsPage.tsx index 5ddf1e9..b613ba8 100644 --- a/frontend/src/pages/SecretsPage.tsx +++ b/frontend/src/pages/SecretsPage.tsx @@ -1,9 +1,10 @@ +import { useEffect, useState } from 'react'; import { GitBranchIcon } from 'lucide-react'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { SecretsTable } from '../components/secrets/SecretsTable'; +import { SecretsTable, type ForkSecretsSummary } from '../components/secrets/SecretsTable'; import { EmptyState } from '../components/common/EmptyState'; import { getConfigs } from '../lib/api/configs'; import { getProjects } from '../lib/api/projects'; @@ -15,6 +16,7 @@ export function SecretsPage() { projectSlug: string; configSlug: string; }>(); + const [forkSummary, setForkSummary] = useState(null); const { data: projects = [] } = useQuery({ queryKey: queryKeys.projects(), queryFn: getProjects @@ -26,8 +28,14 @@ export function SecretsPage() { }); const currentProject = projects.find((p) => p.slug === projectSlug); const configs = configsQuery.data ?? []; + const currentConfig = configs.find((config) => config.slug === configSlug); const hasConfig = configs.some((config) => config.slug === configSlug); const missingConfig = configsQuery.isSuccess && !hasConfig; + const isForkConfig = Boolean(currentConfig?.parentSlug); + + useEffect(() => { + setForkSummary(null); + }, [projectSlug, configSlug, currentConfig?.parentSlug]); if (configsQuery.isLoading) { return ( @@ -73,6 +81,11 @@ export function SecretsPage() { {configSlug} + {isForkConfig && ( + + {forkSummary ? `${forkSummary.overrides} overrides / ${forkSummary.inherited} inherited` : '...'} + + )}

Manage secrets for this environment @@ -80,7 +93,12 @@ export function SecretsPage() { - + ); } From 6b5eb1578587b5acc1117ce95ef7be345e8513fa Mon Sep 17 00:00:00 2001 From: Krishna Date: Tue, 3 Mar 2026 23:38:37 -0800 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=A8=20feat(frontend):=20add=20toggl?= =?UTF-8?q?eable=20sidebar=20drawer=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a Dialog-based drawer for the left navigation on all screens, controlled from TopBar. Persist open/closed state in localStorage and expose accessibility state via aria-expanded and aria-hidden. --- frontend/src/components/layout/AppShell.tsx | 21 ++-- frontend/src/components/layout/Sidebar.tsx | 113 +++++++++++------- .../src/components/layout/SidebarDrawer.tsx | 22 ++++ frontend/src/components/layout/TopBar.tsx | 21 +++- .../src/components/layout/useSidebarDrawer.ts | 32 +++++ 5 files changed, 157 insertions(+), 52 deletions(-) create mode 100644 frontend/src/components/layout/SidebarDrawer.tsx create mode 100644 frontend/src/components/layout/useSidebarDrawer.ts diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index 397b1d1..58dbebb 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -1,16 +1,21 @@ import { Outlet } from 'react-router-dom'; -import { Sidebar } from './Sidebar'; +import { SidebarDrawer } from './SidebarDrawer'; import { TopBar } from './TopBar'; + +import { useSidebarDrawer } from './useSidebarDrawer'; + export function AppShell() { + const { isOpen, setIsOpen, toggle } = useSidebarDrawer(); + return ( -

- -
- +
+ +
+
-
); - -} \ No newline at end of file +
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index cc64a6b..2b476b5 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -8,30 +8,54 @@ import { UsersIcon, GroupIcon, LogOutIcon, - FolderIcon } from -'lucide-react'; + FolderIcon +} from 'lucide-react'; + import { Skeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + import { getProjects } from '../../lib/api/projects'; import { queryKeys } from '../../lib/api/queryKeys'; import { useAuth } from '../../lib/auth'; -function ProjectNavItem({ slug, name }: {slug: string;name: string;}) { + +interface SidebarProps { + ariaHidden?: boolean; + className?: string; + onNavigate?: () => void; +} + +function navItemClassName(isActive: boolean) { + return `flex items-center gap-2 px-2.5 py-1.5 rounded-md text-sm transition-colors ${isActive ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'}`; +} + +function ProjectNavItem({ + slug, + name, + onNavigate +}: { + slug: string; + name: string; + onNavigate?: () => void; +}) { const to = `/projects/${slug}/settings`; + return ( - `flex items-center gap-2 px-2.5 py-1.5 rounded-md text-sm transition-colors truncate ${isActive ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'}` - }> - + className={({ isActive }) => `${navItemClassName(isActive)} truncate`} + onClick={onNavigate} + > {name} - ); - + + ); } -export function Sidebar() { + +export function Sidebar({ ariaHidden, className, onNavigate }: SidebarProps) { const { logout } = useAuth(); const navigate = useNavigate(); + const { data: projects = [], isLoading } = useQuery({ queryKey: queryKeys.projects(), queryFn: getProjects @@ -40,8 +64,15 @@ export function Sidebar() { logout(); navigate('/login'); }; + return ( - + ); } diff --git a/frontend/src/components/layout/SidebarDrawer.tsx b/frontend/src/components/layout/SidebarDrawer.tsx new file mode 100644 index 0000000..947a62f --- /dev/null +++ b/frontend/src/components/layout/SidebarDrawer.tsx @@ -0,0 +1,22 @@ +import { Dialog, DialogContent } from '@/components/ui/dialog'; + +import { Sidebar } from './Sidebar'; + +interface SidebarDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function SidebarDrawer({ open, onOpenChange }: SidebarDrawerProps) { + return ( + + + + + + ); +} diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index f52fea5..be312ea 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -5,6 +5,7 @@ import { DownloadIcon, GithubIcon, LogOutIcon, + PanelLeftIcon, MoonIcon, SettingsIcon, SunIcon, @@ -39,6 +40,11 @@ import { notifyApiError } from '../../lib/api/errorToast'; const REPOSITORY_URL = 'https://github.com/bearlike/Simple-Secrets-Manager'; +interface TopBarProps { + isSidebarOpen: boolean; + onMenuToggle: () => void; +} + function downloadFile(content: string, filename: string, mimeType: string) { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); @@ -49,7 +55,7 @@ function downloadFile(content: string, filename: string, mimeType: string) { URL.revokeObjectURL(url); } -export function TopBar() { +export function TopBar({ isSidebarOpen, onMenuToggle }: TopBarProps) { const { projectSlug, configSlug } = useParams<{ projectSlug?: string; configSlug?: string; @@ -114,6 +120,19 @@ export function TopBar() { return (
+ +