WFDASH-WEBAPP/src/components/dev-identity-switcher.tsx
SJCoetz 05abe499b0 feat(sprint): add new sprint pricing workflow and supporting code
Add full sprint pricing workflow and supporting infrastructure:
- New sidebar navigation entry for the sprint dashboard
- PocketBase category collection schema and migration script
- Server API routes for sprint item claims, attachments, counts, and file proxying
- Dev identity utilities and UI switcher for multi-user testing
- Reusable FilterTabs component replacing ItemStatusTabs
- Updated TypeScript types for sprint-related entities

Also include:
- Fix PocketBase file URL method casing (getUrl → getURL) across codebase
- Add development utility, smoke test, and helper scripts
- Update package.json with new sprint migration command
2026-07-03 07:29:43 +02:00

127 lines
3.9 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { Pencil, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
DEFAULT_DEV_EMAIL,
getClientIdentity,
setClientIdentity,
} from "@/lib/dev-identity";
/**
* Tiny dev-only identity switcher. The project is in "no auth" mode
* (PB users collection is empty), so the claim endpoint reads
* caller identity from a cookie. This widget lets the user see and
* change the current value, which is also how to test the
* multi-user race scenarios in /sprint.
*
* Hidden in production builds via an env check — only renders when
* `NEXT_PUBLIC_DEV_IDENTITY_SWITCHER` is "true". The /sprint page
* sets it via its own dev flag, but we re-check here as a safety
* belt.
*/
export function DevIdentitySwitcher({
onChange,
}: {
onChange?: (email: string) => void;
}) {
const [email, setEmail] = useState<string>(DEFAULT_DEV_EMAIL);
const [draft, setDraft] = useState<string>("");
const [open, setOpen] = useState(false);
useEffect(() => {
// Sync the displayed identity from the cookie on mount. This is a
// legitimate "external source → state" sync (the cookie is the
// external source); we can't derive this during render because
// document.cookie is a side-effectful read.
const current = getClientIdentity();
// eslint-disable-next-line react-hooks/set-state-in-effect
setEmail(current);
setDraft(current);
}, []);
const handleOpenChange = (next: boolean) => {
if (next) {
// Reset draft to the current value when opening, so cancelling
// a half-typed edit doesn't leak into the field next time.
setDraft(email);
}
setOpen(next);
};
const handleSave = () => {
const next = draft.trim() || DEFAULT_DEV_EMAIL;
setClientIdentity(next);
setEmail(next);
setOpen(false);
onChange?.(next);
};
const displayName = shortName(email);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
aria-label="Switch dev identity"
className="inline-flex items-center gap-1.5 rounded-md border bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors"
>
<User className="h-3 w-3" />
<span className="font-mono">{displayName}</span>
<Pencil className="h-3 w-3 opacity-60" />
</PopoverTrigger>
<PopoverContent align="end" className="w-80 space-y-3">
<div>
<p className="text-sm font-medium">Dev identity</p>
<p className="text-xs text-muted-foreground">
Used for claim attribution and the &ldquo;claimed by me&rdquo;
badge. Switch to test multi-user race scenarios.
</p>
</div>
<div className="space-y-1.5">
<label
htmlFor="dev-identity-input"
className="text-xs font-medium text-muted-foreground"
>
Email
</label>
<Input
id="dev-identity-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={DEFAULT_DEV_EMAIL}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
}
}}
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button size="sm" onClick={handleSave}>
Save
</Button>
</div>
</PopoverContent>
</Popover>
);
}
function shortName(email: string): string {
const at = email.indexOf("@");
return at > 0 ? email.slice(0, at) : email;
}