ubiquitous-invention/apps/web/components/layout/top-header.tsx
Randall Stillwell 336a5890a8 feat(audit): append-only audit_log, workspace archive cascade + restore, audit view
Soft-delete cascade was the missing half of archive: stamping
workspaces.archived_at alone left objects visible to anyone with a
direct id. The cascade runs in one transaction so the partial state
isn't reachable, and restore inverts it for any archived row in the
workspace — provenance-blind on purpose until we have a use case
that needs to distinguish per-workspace from per-object archives.

audit_log keeps the keyset index on (workspace_id, created_at) and
the actor_user_id FK with onDelete set null. recordAudit() refuses
to write a null actor without a metadata.system_actor label so the
audit view always has something to render. workspaces and invites
mutations call recordAudit on success; objects-router instrumentation
and the markdown importer's system-actor flow are filed as P2
follow-ups because each needs a thoughtful "what's audit-worthy?"
pass, not mechanical wiring.

Settings → Audit log lives at /<slug>/settings/audit, owner-gated,
keyset-paginated. ACTION_LABELS is small on purpose; new actions
fall back to their raw key so missing a label degrades gracefully.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 15:35:16 -05:00

270 lines
9.3 KiB
TypeScript

"use client";
import { usePathname, useRouter } from "next/navigation";
import { signOut } from "next-auth/react";
import {
AppWindow,
Calendar,
CheckSquare,
ChevronDown,
FileText,
LayoutGrid,
LayoutTemplate,
LogOut,
Pencil,
Plus,
Presentation,
ScrollText,
Search,
Settings,
User,
Users,
Zap,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
export interface TopHeaderProps {
onOpenSearch?: () => void;
onQuickAction?: (type: string) => void;
}
export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
const pathname = usePathname();
const router = useRouter();
const workspace = useWorkspaceStore((s) => s.currentWorkspace);
const workspaceName = workspace?.name ?? "Workspace";
const workspaceId = workspace?.id;
const initials = workspaceName.trim().slice(0, 2).toUpperCase() || "WS";
return (
<header
className={cn(
"sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-4 border-b border-border bg-background px-4",
)}
>
{/* Workspace dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex shrink-0 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary/15 text-[10px] font-bold text-primary">
{initials}
</div>
<span className="max-w-[180px] truncate">{workspaceName}</span>
<ChevronDown className="size-3.5 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 p-0">
{/* Header */}
<div className="flex items-start gap-3 px-4 py-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/15 text-sm font-bold text-primary">
{initials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{workspaceName}</p>
<p className="text-xs text-muted-foreground">1 member</p>
</div>
</div>
{/* Settings + People row */}
<div className="flex gap-2 px-4 pb-3">
<Button
variant="outline"
size="sm"
className="flex-1 gap-1.5 text-xs"
onClick={() => workspaceId && router.push(`/${workspaceId}/settings`)}
>
<Settings className="size-3.5" />
Settings
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 gap-1.5 text-xs"
>
<Users className="size-3.5" />
People
</Button>
</div>
<DropdownMenuSeparator className="my-0" />
{/* Manage section */}
<DropdownMenuLabel className="px-4 pb-1 pt-3 text-xs font-medium text-muted-foreground">
Manage
</DropdownMenuLabel>
<DropdownMenuItem className="gap-2.5 px-4">
<AppWindow className="size-4 text-muted-foreground" />
Apps
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>
workspaceId && router.push(`/${workspaceId}/settings/templates`)
}
>
<LayoutTemplate className="size-4 text-muted-foreground" />
Templates
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>
workspaceId && router.push(`/${workspaceId}/settings/types`)
}
>
<Pencil className="size-4 text-muted-foreground" />
Custom Fields
</DropdownMenuItem>
<DropdownMenuItem className="gap-2.5 px-4">
<Zap className="size-4 text-muted-foreground" />
Automations
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2.5 px-4"
onSelect={() =>
workspaceId && router.push(`/${workspaceId}/settings/audit`)
}
>
<ScrollText className="size-4 text-muted-foreground" />
Audit log
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* Create Workspace */}
<div className="px-3 py-2">
<Button
variant="outline"
size="sm"
className="w-full gap-1.5 text-xs"
onClick={() => window.alert("Create workspace (placeholder)")}
>
<Plus className="size-3.5" />
Create Workspace
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Center search bar */}
<div className="flex flex-1 justify-center px-2">
<div
role="button"
tabIndex={0}
className={cn(
"flex h-9 w-full max-w-[400px] cursor-pointer items-center gap-2 rounded-lg border border-border bg-muted/50 px-3 text-sm text-muted-foreground transition-colors hover:bg-muted/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
onClick={() => onOpenSearch?.()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenSearch?.();
}
}}
>
<Search className="size-4 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 flex-1 truncate text-left">Search...</span>
<kbd className="pointer-events-none shrink-0 rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] font-medium">
Cmd+/
</kbd>
</div>
</div>
{/* Right actions */}
<div className="flex shrink-0 items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Calendar"
>
<Calendar className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Calendar</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8"
aria-label="Quick actions"
>
<Plus className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={() => onQuickAction?.("task")}>
<CheckSquare className="size-4" />
New Task
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQuickAction?.("doc")}>
<FileText className="size-4" />
New Doc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQuickAction?.("whiteboard")}>
<Presentation className="size-4" />
New Whiteboard
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQuickAction?.("space")}>
<LayoutGrid className="size-4" />
New Space
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="relative h-8 w-8 rounded-full p-0"
aria-label="Account menu"
>
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">U</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onSelect={() => {}}>
<User className="size-4" />
Account
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
workspaceId && router.push(`/${workspaceId}/settings`)
}
>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => signOut({ callbackUrl: "/sign-in" })}
>
<LogOut className="size-4" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}