diff --git a/src/App.tsx b/src/App.tsx index 424ec2e..e05d69f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { FluentProvider, makeStyles, + Toaster, tokens, webDarkTheme, webLightTheme, @@ -22,6 +23,7 @@ import { SecretsList } from './components/secrets/SecretsList'; import { SettingsDialog } from './components/settings/SettingsDialog'; import { VaultDashboard } from './components/vault/VaultDashboard'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; +import { APP_TOASTER_ID } from './lib/toast'; import { useAppStore } from './stores/appStore'; const queryClient = new QueryClient({ @@ -76,7 +78,7 @@ function MainContent() { } title="Select a Key Vault" - description="Choose a vault from the workspace switcher or sidebar to browse secrets, keys, and certificates." + description="Use the workspace switcher in the top bar — pick a tenant, then a subscription, then a vault — to browse its secrets, keys, and certificates. Press ⌘K / Ctrl+K to search anything." /> ); } @@ -147,6 +149,7 @@ function App() { {isSignedIn ? : } + ); diff --git a/src/components/auth/SignIn.tsx b/src/components/auth/SignIn.tsx index 16fac73..d1fdf65 100644 --- a/src/components/auth/SignIn.tsx +++ b/src/components/auth/SignIn.tsx @@ -38,6 +38,7 @@ const useStyles = makeStyles({ }, card: { width: '580px', + maxWidth: '90vw', padding: '28px 32px', }, headerIcon: { diff --git a/src/components/certificates/CertificateDetails.tsx b/src/components/certificates/CertificateDetails.tsx index 623010f..aa08d69 100644 --- a/src/components/certificates/CertificateDetails.tsx +++ b/src/components/certificates/CertificateDetails.tsx @@ -144,7 +144,7 @@ export function CertificateDetails({ item, onClose }: CertificateDetailsProps) {
{item.enabled ? 'Active' : 'Disabled'}
diff --git a/src/components/certificates/CertificatesList.tsx b/src/components/certificates/CertificatesList.tsx index 01b7526..e15c10e 100644 --- a/src/components/certificates/CertificatesList.tsx +++ b/src/components/certificates/CertificatesList.tsx @@ -71,8 +71,7 @@ const useStyles = makeStyles({ export function CertificatesList() { const classes = useStyles(); - const { selectedVaultUri, searchQuery, detailPanelOpen, splitRatio, setSplitRatio } = - useAppStore(); + const { selectedVaultUri, detailPanelOpen, splitRatio, setSplitRatio } = useAppStore(); const [visibleCount, setVisibleCount] = useState(50); const [selectedCert, setSelectedCert] = useState(null); const [localFilter, setLocalFilter] = useState(''); @@ -83,7 +82,7 @@ export function CertificatesList() { enabled: !!selectedVaultUri, }); - const filterText = localFilter || searchQuery; + const filterText = localFilter; const allCerts = certsQuery.data || []; const filteredCerts = allCerts.filter((c) => c.name.toLowerCase().includes(filterText.toLowerCase()), diff --git a/src/components/common/ItemTable.tsx b/src/components/common/ItemTable.tsx index d539b35..a38b9be 100644 --- a/src/components/common/ItemTable.tsx +++ b/src/components/common/ItemTable.tsx @@ -60,10 +60,10 @@ const useStyles = makeStyles({ fontSize: '11px', }, dimText: { - opacity: 0.5, + opacity: 0.65, }, dimmerText: { - opacity: 0.4, + opacity: 0.55, }, tagWrap: { display: 'flex', @@ -134,6 +134,15 @@ export function ItemTable({ onSelect?.(item)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect?.(item); + } + }} + tabIndex={onSelect ? 0 : undefined} + role="button" + aria-selected={selectedId === id} className={classes.row} style={{ background: selectedId === id ? tokens.colorBrandBackground2 : undefined, @@ -172,9 +181,11 @@ export function renderEnabled(enabled: boolean) { - {enabled ? 'Active' : 'Disabled'} + + {enabled ? 'Active' : 'Disabled'} + ); } @@ -183,7 +194,7 @@ export function renderEnabled(enabled: boolean) { export function renderDate(dateStr: string | null) { if (!dateStr) return ( - + ); diff --git a/src/components/keys/KeyDetails.tsx b/src/components/keys/KeyDetails.tsx index 8cd36eb..7133070 100644 --- a/src/components/keys/KeyDetails.tsx +++ b/src/components/keys/KeyDetails.tsx @@ -120,7 +120,7 @@ export function KeyDetails({ item, onClose }: KeyDetailsProps) {
{item.enabled ? 'Active' : 'Disabled'}
diff --git a/src/components/keys/KeysList.tsx b/src/components/keys/KeysList.tsx index d25e381..66feaf4 100644 --- a/src/components/keys/KeysList.tsx +++ b/src/components/keys/KeysList.tsx @@ -140,8 +140,7 @@ const columns: Column[] = [ export function KeysList() { const classes = useStyles(); - const { selectedVaultUri, searchQuery, detailPanelOpen, splitRatio, setSplitRatio } = - useAppStore(); + const { selectedVaultUri, detailPanelOpen, splitRatio, setSplitRatio } = useAppStore(); const [visibleCount, setVisibleCount] = useState(50); const [selectedKey, setSelectedKey] = useState(null); const [localFilter, setLocalFilter] = useState(''); @@ -152,7 +151,7 @@ export function KeysList() { enabled: !!selectedVaultUri, }); - const filterText = localFilter || searchQuery; + const filterText = localFilter; const allKeys = keysQuery.data || []; const filteredKeys = allKeys.filter((k) => k.name.toLowerCase().includes(filterText.toLowerCase()), diff --git a/src/components/layout/ContentTabs.tsx b/src/components/layout/ContentTabs.tsx index 58ad390..2d9c569 100644 --- a/src/components/layout/ContentTabs.tsx +++ b/src/components/layout/ContentTabs.tsx @@ -1,4 +1,4 @@ -import { makeStyles, Tab, TabList, tokens } from '@fluentui/react-components'; +import { Badge, makeStyles, Tab, TabList, tokens } from '@fluentui/react-components'; import { Certificate24Regular, ClipboardTextLtr24Regular, @@ -6,6 +6,8 @@ import { LockClosed24Regular, TextBulletListSquare24Regular, } from '@fluentui/react-icons'; +import { useQuery } from '@tanstack/react-query'; +import { listCertificates, listKeys, listSecrets } from '../../services/tauri'; import { useAppStore } from '../../stores/appStore'; import type { ItemTab } from '../../types'; @@ -19,14 +21,40 @@ const useStyles = makeStyles({ padding: '0 8px', gap: '6px', }, + count: { + marginLeft: '6px', + }, }); export function ContentTabs() { - const { activeTab, setActiveTab, selectedVaultName } = useAppStore(); + const { activeTab, setActiveTab, selectedVaultName, selectedVaultUri } = useAppStore(); const classes = useStyles(); + const secretsQuery = useQuery({ + queryKey: ['secrets', selectedVaultUri], + queryFn: () => listSecrets(selectedVaultUri!), + enabled: !!selectedVaultUri, + }); + const keysQuery = useQuery({ + queryKey: ['keys', selectedVaultUri], + queryFn: () => listKeys(selectedVaultUri!), + enabled: !!selectedVaultUri, + }); + const certsQuery = useQuery({ + queryKey: ['certificates', selectedVaultUri], + queryFn: () => listCertificates(selectedVaultUri!), + enabled: !!selectedVaultUri, + }); + if (!selectedVaultName) return null; + const renderCount = (n: number | undefined) => + n === undefined ? null : ( + + {n} + + ); + return (
}> Dashboard - }> + }> Secrets + {renderCount(secretsQuery.data?.length)} - }> + }> Keys + {renderCount(keysQuery.data?.length)} }> Certs + {renderCount(certsQuery.data?.length)} }> Audit Log diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 24f4cce..4fe79b6 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,6 +1,7 @@ import { Badge, Button, + Input, makeStyles, mergeClasses, Text, @@ -8,50 +9,18 @@ import { tokens, } from '@fluentui/react-components'; import { - Certificate24Regular, - ClipboardTextLtr24Regular, - Delete24Regular, - Key24Regular, - LockClosed24Regular, + Search24Regular, ShieldLock24Regular, Star24Filled, Star24Regular, - TextBulletListSquare24Regular, } from '@fluentui/react-icons'; -import { useQuery } from '@tanstack/react-query'; -import { listCertificates, listKeys, listSecrets } from '../../services/tauri'; +import { useMemo, useState } from 'react'; import { useAppStore } from '../../stores/appStore'; -import type { ItemTab } from '../../types'; - -interface NavItem { - id: ItemTab; - label: string; - icon: React.ReactElement; - countKey?: string; -} - -const navIconStyle = { fontSize: 16 } as const; - -const VAULT_NAV: NavItem[] = [ - { - id: 'dashboard', - label: 'Dashboard', - icon: , - }, - { id: 'secrets', label: 'Secrets', icon: }, - { id: 'keys', label: 'Keys', icon: }, - { - id: 'certificates', - label: 'Certificates', - icon: , - }, - { id: 'logs', label: 'Audit Log', icon: }, -]; const useStyles = makeStyles({ root: { - width: '220px', - minWidth: '220px', + width: '240px', + minWidth: '240px', height: '100%', display: 'flex', flexDirection: 'column', @@ -70,305 +39,258 @@ const useStyles = makeStyles({ width: '36px', height: '36px', }, - section: { + header: { + display: 'flex', + alignItems: 'center', + gap: '6px', padding: '8px 10px 4px', }, - sectionRecent: { - padding: '4px 10px', + headerTitle: { + flex: 1, + }, + filterWrap: { + padding: '0 10px 6px', + }, + filterInput: { + width: '100%', + }, + scroll: { + flex: 1, + overflowY: 'auto', + minHeight: 0, + padding: '0 10px 10px', + }, + section: { + marginTop: '6px', }, sectionLabel: { - marginBottom: '4px', - padding: '0 4px', + padding: '2px 4px', + marginBottom: '2px', }, - sectionHeader: { + vaultRow: { display: 'flex', alignItems: 'center', - justifyContent: 'space-between', - padding: '0 4px', - }, - clearBtn: { - width: '20px', - height: '20px', - minWidth: '20px', + gap: '2px', + marginBottom: '2px', }, - vaultItem: { - padding: '4px 8px', - borderRadius: '4px', - cursor: 'pointer', + vaultSelect: { + flex: 1, + minWidth: 0, display: 'flex', alignItems: 'center', gap: '6px', - marginBottom: '2px', + padding: '5px 8px', + borderRadius: '4px', + cursor: 'pointer', + width: '100%', + backgroundColor: 'transparent', + color: 'inherit', + fontFamily: 'inherit', + fontSize: 'inherit', + textAlign: 'left', }, - vaultItemSelected: { + vaultSelectSelected: { backgroundColor: tokens.colorBrandBackground2, }, - starIcon: { - fontSize: '12px', - color: tokens.colorPaletteYellowForeground1, + vaultIcon: { + fontSize: '14px', + opacity: 0.6, flexShrink: 0, }, - recentIcon: { - fontSize: '12px', - opacity: 0.5, + starIcon: { + fontSize: '14px', + color: tokens.colorPaletteYellowForeground1, flexShrink: 0, }, vaultName: { flex: 1, - }, - divider: { - borderTop: `1px solid ${tokens.colorNeutralStroke2}`, - margin: '4px 10px', - }, - navSection: { - padding: '4px 10px', - }, - navHeader: { - display: 'flex', - alignItems: 'center', - gap: '6px', - padding: '0 4px', - marginBottom: '4px', - }, - navTitle: { - flex: 1, + minWidth: 0, }, pinBtn: { width: '24px', height: '24px', minWidth: '24px', - }, - navItem: { - padding: '6px 8px', - borderRadius: '4px', - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - gap: '8px', - marginBottom: '2px', - }, - navItemActive: { - backgroundColor: tokens.colorBrandBackground2, - fontWeight: 600, - }, - navIcon: { - opacity: 0.6, - }, - navIconActive: { - opacity: 1, - }, - navLabel: { - flex: 1, + flexShrink: 0, }, emptyState: { - padding: '20px', + padding: '24px 16px', textAlign: 'center' as const, }, emptyIcon: { - fontSize: '32px', - opacity: 0.3, + fontSize: '30px', + opacity: 0.4, }, emptyText: { - color: tokens.colorNeutralForeground3, + color: tokens.colorNeutralForeground2, marginTop: '8px', + lineHeight: 1.5, }, }); export function Sidebar() { const { selectedVaultUri, - selectedVaultName, - activeTab, - setActiveTab, + keyvaults, pinnedVaults, - recentVaults, + selectedSubscriptionId, + selectedTenantId, selectVault, - unpinVault, pinVault, - clearRecentVaults, + unpinVault, sidebarCollapsed, - selectedTenantId, - selectedSubscriptionId, } = useAppStore(); const classes = useStyles(); + const [filter, setFilter] = useState(''); - const secretsQuery = useQuery({ - queryKey: ['secrets', selectedVaultUri], - queryFn: () => listSecrets(selectedVaultUri!), - enabled: !!selectedVaultUri, - }); - const keysQuery = useQuery({ - queryKey: ['keys', selectedVaultUri], - queryFn: () => listKeys(selectedVaultUri!), - enabled: !!selectedVaultUri, - }); - const certsQuery = useQuery({ - queryKey: ['certificates', selectedVaultUri], - queryFn: () => listCertificates(selectedVaultUri!), - enabled: !!selectedVaultUri, - }); + const isPinned = (uri: string) => pinnedVaults.some((v) => v.uri === uri); - const counts: Record = { - secrets: secretsQuery.data?.length, - keys: keysQuery.data?.length, - certificates: certsQuery.data?.length, + const togglePin = (name: string, uri: string) => { + if (isPinned(uri)) { + unpinVault(uri); + } else if (selectedTenantId && selectedSubscriptionId) { + pinVault({ name, uri, tenantId: selectedTenantId, subscriptionId: selectedSubscriptionId }); + } }; - const isPinned = pinnedVaults.some((v) => v.uri === selectedVaultUri); + const q = filter.trim().toLowerCase(); + const subVaults = useMemo( + () => keyvaults.filter((v) => !q || v.name.toLowerCase().includes(q)), + [keyvaults, q], + ); + const pinnedFiltered = useMemo( + () => pinnedVaults.filter((v) => !q || v.name.toLowerCase().includes(q)), + [pinnedVaults, q], + ); if (sidebarCollapsed) { return ( -
- {selectedVaultName && - VAULT_NAV.map((item) => ( - -
+ ); } - return ( -
- {pinnedVaults.length > 0 && ( -
- - Pinned + const renderVaultRow = (name: string, uri: string, key: string) => { + const pinned = isPinned(uri); + const selected = uri === selectedVaultUri; + return ( +
+ + +
+ ); + }; + + const hasAnything = pinnedVaults.length > 0 || keyvaults.length > 0; + + return ( + ); } diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index e36a9cf..964924d 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -62,11 +62,11 @@ export function StatusBar() { |
- tenant: + tenant:{' '} {currentTenant?.display_name || (selectedTenantId ? selectedTenantId.slice(0, 8) : '—')} - sub: + sub:{' '} {currentSub?.displayName || (selectedSubscriptionId ? selectedSubscriptionId.slice(0, 8) : '—')} @@ -74,13 +74,10 @@ export function StatusBar() {
- vault:{selectedVaultName || '—'} + vault:{' '} + {selectedVaultName || '—'} - + {themeMode}
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 875b113..5a6315e 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -132,7 +132,7 @@ export function TopBar() {
{mockMode && ( - + MOCK )} diff --git a/src/components/layout/WorkspaceSwitcher.tsx b/src/components/layout/WorkspaceSwitcher.tsx index 288aebb..b5d258a 100644 --- a/src/components/layout/WorkspaceSwitcher.tsx +++ b/src/components/layout/WorkspaceSwitcher.tsx @@ -6,6 +6,7 @@ import { MenuPopover, MenuTrigger, makeStyles, + Spinner, Text, tokens, } from '@fluentui/react-components'; @@ -34,13 +35,13 @@ const useStyles = makeStyles({ fontSize: '14px', }, tenantText: { - maxWidth: '100px', + maxWidth: '140px', }, subText: { - maxWidth: '120px', + maxWidth: '160px', }, vaultText: { - maxWidth: '130px', + maxWidth: '170px', }, separator: { opacity: 0.3, @@ -125,8 +126,15 @@ export function WorkspaceSwitcher() { - -
-
+
+ + {/* Danger zone — irreversible, visually separated */} +
+
+ + + Danger zone + +
+ + Purging destroys the secret and all its versions permanently — it cannot be undone, even + with soft-delete enabled. +
- {exportMessage && ( -
- - {exportMessage} - -
- )} - - {importMessage && ( -
- - {importMessage} - -
- )} - {/* Table */}
{secretsQuery.isLoading ? ( diff --git a/src/components/vault/VaultDashboard.tsx b/src/components/vault/VaultDashboard.tsx index 2555b78..ef92382 100644 --- a/src/components/vault/VaultDashboard.tsx +++ b/src/components/vault/VaultDashboard.tsx @@ -10,6 +10,7 @@ import { import { useQuery } from '@tanstack/react-query'; import { format } from 'date-fns'; import { useState } from 'react'; +import { useAppToast } from '../../lib/toast'; import { getAuditLog, listCertificates, listKeys, listSecrets } from '../../services/tauri'; import { useAppStore } from '../../stores/appStore'; @@ -132,6 +133,7 @@ export function VaultDashboard() { const classes = useStyles(); const { selectedVaultUri, selectedVaultName, keyvaults, setActiveTab } = useAppStore(); const [copiedUri, setCopiedUri] = useState(false); + const toast = useAppToast(); const currentVault = keyvaults.find((v) => v.vaultUri === selectedVaultUri); @@ -159,6 +161,7 @@ export function VaultDashboard() { if (selectedVaultUri) { navigator.clipboard.writeText(selectedVaultUri); setCopiedUri(true); + toast.success('Vault URI copied to clipboard'); setTimeout(() => setCopiedUri(false), 2000); } }; @@ -312,7 +315,10 @@ export function VaultDashboard() { .reverse() .slice(0, 5) .map((entry, i) => ( -
+
{(() => { try { @@ -372,7 +378,19 @@ function CountCard({ const classes = useCountCardStyles(); return ( - + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + >
{icon} diff --git a/src/lib/toast.tsx b/src/lib/toast.tsx new file mode 100644 index 0000000..96148be --- /dev/null +++ b/src/lib/toast.tsx @@ -0,0 +1,35 @@ +import { + Toast, + ToastBody, + ToastTitle, + useToastController, +} from '@fluentui/react-components'; + +/** Shared toaster id consumed by the single mounted in App. */ +export const APP_TOASTER_ID = 'azv-toaster'; + +type Intent = 'success' | 'error' | 'warning' | 'info'; + +/** + * Unified transient-feedback hook. Use instead of ad-hoc inline banners so all + * non-blocking notifications share one consistent surface, position, and timing. + */ +export function useAppToast() { + const { dispatchToast } = useToastController(APP_TOASTER_ID); + + const show = (intent: Intent, title: string, body?: string) => + dispatchToast( + + {title} + {body ? {body} : null} + , + { intent }, + ); + + return { + success: (title: string, body?: string) => show('success', title, body), + error: (title: string, body?: string) => show('error', title, body), + warning: (title: string, body?: string) => show('warning', title, body), + info: (title: string, body?: string) => show('info', title, body), + }; +} diff --git a/src/styles/dev-ui.css b/src/styles/dev-ui.css index 6c6aff0..c3c46ff 100644 --- a/src/styles/dev-ui.css +++ b/src/styles/dev-ui.css @@ -45,9 +45,9 @@ pre, .azv-title { letter-spacing: 0.06em; text-transform: uppercase; - font-size: 10px; + font-size: 11px; font-weight: 600; - opacity: 0.6; + opacity: 0.8; } /* ── Status dot indicator (green/red/yellow) ── */ @@ -82,6 +82,22 @@ pre, border-color: var(--azv-accent-dim); } +/* ── Inline pin button: hidden until the vault row is hovered/focused ── */ +.azv-pin { + opacity: 0; + transition: opacity 0.12s ease; +} + +.azv-vault-row:hover .azv-pin, +.azv-vault-row:focus-within .azv-pin { + opacity: 0.8; +} + +/* Always show the pin control for the currently pinned vaults so they're discoverable. */ +.azv-pin[aria-label^="Unpin"] { + opacity: 0.7; +} + /* ── Inline cell helpers ── */ .azv-status-row { display: flex; @@ -97,21 +113,21 @@ pre, /* ── Keyboard shortcut badge ── */ .azv-kbd { - border: 1px solid rgba(130, 145, 170, 0.4); + border: 1px solid rgba(130, 145, 170, 0.5); border-bottom-width: 2px; border-radius: 3px; padding: 1px 5px; - font-size: 10px; + font-size: 11px; font-family: "IBM Plex Mono", ui-monospace, monospace; - opacity: 0.7; + opacity: 0.85; line-height: 1.4; } /* ── Tab route hint ── */ .azv-tab-hint { - font-size: 10px; + font-size: 11px; font-family: "IBM Plex Mono", ui-monospace, monospace; - opacity: 0.5; + opacity: 0.65; margin-left: 4px; }