A Free user keeps full access to their active profile; profiles beyond it show a lock in ProfileSwitcher and open an upsell dialog instead of switching. Creating a profile beyond the first is locked at the single creation point, ProfileFormModal (reached from both ProfileSwitcher and ProfileSelectionPage), with a race guard in handleSave covering the license boot window. Both creation entries stay visible with a lock (locked-not-hidden). Nothing is ever removed from profiles.json — an upgrade to Base/Premium makes every profile reappear untouched. - New pure predicates in src/shared/profileGate.ts (isProfileSwitchLocked, isProfileCreationLocked) + 10 vitest - ProfileFormModal upsell panel reuses upsell.* keys WITHOUT UpsellGate: the modal also opens from ProfileSelectionPage, which renders outside BrowserRouter, where UpsellGate's useNavigate would throw - UpsellGate gains an optional onNavigate callback so the ProfileSwitcher upsell dialog can close itself after navigation - No lock while the license is loading (anti-flash, ready guard); zero new i18n keys; no DB migration Resolves #300 Generated autonomously by /autopilot run of 2026-07-20 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
165 lines
6.1 KiB
TypeScript
165 lines
6.1 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ChevronDown, Lock, Settings, X } from "lucide-react";
|
|
import { useProfile } from "../../contexts/ProfileContext";
|
|
import { useEntitlement } from "../../hooks/useEntitlement";
|
|
import { requiredTierFor } from "../../shared/entitlements";
|
|
import { isProfileSwitchLocked } from "../../shared/profileGate";
|
|
import UpsellGate from "../shared/UpsellGate";
|
|
import PinDialog from "./PinDialog";
|
|
import ProfileFormModal from "./ProfileFormModal";
|
|
import type { Profile } from "../../services/profileService";
|
|
|
|
export default function ProfileSwitcher() {
|
|
const { t } = useTranslation();
|
|
const { profiles, activeProfile, switchProfile, updateProfile } = useProfile();
|
|
// Multi-profile gate (Base+, #300): profiles beyond the active one are
|
|
// LOCKED for a Free user — non-destructive, switching is blocked but nothing
|
|
// is removed from profiles.json. No lock while `!ready` (anti-flash).
|
|
const gate = useEntitlement("multi-profile");
|
|
const [open, setOpen] = useState(false);
|
|
const [pinProfile, setPinProfile] = useState<Profile | null>(null);
|
|
const [showManage, setShowManage] = useState(false);
|
|
const [showUpsell, setShowUpsell] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
// Close on outside click
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
if (open) document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [open]);
|
|
|
|
const isLocked = (profile: Profile) =>
|
|
isProfileSwitchLocked(profile.id, activeProfile?.id ?? null, gate);
|
|
|
|
const handleSelect = (profile: Profile) => {
|
|
setOpen(false);
|
|
if (profile.id === activeProfile?.id) return;
|
|
|
|
if (isLocked(profile)) {
|
|
setShowUpsell(true);
|
|
return;
|
|
}
|
|
|
|
if (profile.pin_hash) {
|
|
setPinProfile(profile);
|
|
} else {
|
|
switchProfile(profile.id);
|
|
}
|
|
};
|
|
|
|
const handlePinSuccess = async (rehashed?: string | null) => {
|
|
if (pinProfile) {
|
|
if (rehashed) {
|
|
try {
|
|
await updateProfile(pinProfile.id, { pin_hash: rehashed });
|
|
} catch {
|
|
// Best-effort rehash: don't block profile switch if persistence fails
|
|
}
|
|
}
|
|
switchProfile(pinProfile.id);
|
|
setPinProfile(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div ref={ref} className="relative px-3 pb-2">
|
|
<button
|
|
onClick={() => setOpen(!open)}
|
|
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm hover:bg-[var(--sidebar-hover)] transition-colors"
|
|
>
|
|
<span
|
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: activeProfile?.color }}
|
|
/>
|
|
<span className="truncate flex-1 text-left">{activeProfile?.name}</span>
|
|
<ChevronDown size={14} className={`transition-transform ${open ? "rotate-180" : ""}`} />
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute left-3 right-3 top-full mt-1 z-50 rounded-lg bg-[var(--sidebar-bg)] border border-white/10 shadow-lg overflow-hidden">
|
|
{profiles.map((profile) => {
|
|
const locked = isLocked(profile);
|
|
return (
|
|
<button
|
|
key={profile.id}
|
|
onClick={() => handleSelect(profile)}
|
|
title={locked ? t("nav.locked") : undefined}
|
|
className={`flex items-center gap-2 w-full px-3 py-2 text-sm transition-colors ${
|
|
profile.id === activeProfile?.id
|
|
? "bg-[var(--sidebar-active)] text-white"
|
|
: "hover:bg-[var(--sidebar-hover)] text-[var(--sidebar-fg)]"
|
|
} ${locked ? "opacity-60" : ""}`}
|
|
>
|
|
<span
|
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: profile.color }}
|
|
/>
|
|
<span className="truncate flex-1 text-left">{profile.name}</span>
|
|
{/* One lock only: the gating lock replaces the PIN lock on
|
|
locked rows (the PIN dialog is unreachable there anyway). */}
|
|
{locked ? (
|
|
<Lock size={12} />
|
|
) : (
|
|
profile.pin_hash && <Lock size={12} className="opacity-50" />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
<button
|
|
onClick={() => {
|
|
setOpen(false);
|
|
setShowManage(true);
|
|
}}
|
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm border-t border-white/10 hover:bg-[var(--sidebar-hover)] text-[var(--sidebar-fg)]"
|
|
>
|
|
<Settings size={14} />
|
|
<span>{t("profile.manageProfiles")}</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{pinProfile && (
|
|
<PinDialog
|
|
profileName={pinProfile.name}
|
|
storedHash={pinProfile.pin_hash!}
|
|
onSuccess={handlePinSuccess}
|
|
onCancel={() => setPinProfile(null)}
|
|
/>
|
|
)}
|
|
|
|
{showManage && (
|
|
<ProfileFormModal onClose={() => setShowManage(false)} />
|
|
)}
|
|
|
|
{showUpsell && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-md border border-[var(--border)]">
|
|
<div className="flex justify-end p-3 pb-0">
|
|
<button
|
|
onClick={() => setShowUpsell(false)}
|
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
<div className="px-4 pb-8">
|
|
<UpsellGate
|
|
feature="multi-profile"
|
|
requiredTier={requiredTierFor("multi-profile")}
|
|
onNavigate={() => setShowUpsell(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|