feat(gating): multi-profils (Base+) non destructif #306
6 changed files with 283 additions and 28 deletions
|
|
@ -1,7 +1,10 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { X, Trash2, Lock, LockOpen, Plus } from "lucide-react";
|
import { X, Trash2, Lock, LockOpen, Plus, ShoppingCart } from "lucide-react";
|
||||||
import { useProfile } from "../../contexts/ProfileContext";
|
import { useProfile } from "../../contexts/ProfileContext";
|
||||||
|
import { useEntitlement } from "../../hooks/useEntitlement";
|
||||||
|
import { requiredTierFor } from "../../shared/entitlements";
|
||||||
|
import { isProfileCreationLocked } from "../../shared/profileGate";
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
"#4A90A4", "#22c55e", "#ef4444", "#f59e0b", "#8b5cf6",
|
"#4A90A4", "#22c55e", "#ef4444", "#f59e0b", "#8b5cf6",
|
||||||
|
|
@ -16,12 +19,20 @@ interface Props {
|
||||||
export default function ProfileFormModal({ onClose, editProfileId }: Props) {
|
export default function ProfileFormModal({ onClose, editProfileId }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { profiles, createProfile, updateProfile, deleteProfile, setPin } = useProfile();
|
const { profiles, createProfile, updateProfile, deleteProfile, setPin } = useProfile();
|
||||||
|
// Multi-profile gate (Base+, #300) — this modal is the SINGLE creation
|
||||||
|
// point (`createProfile`), reached from ProfileSwitcher AND
|
||||||
|
// ProfileSelectionPage, so gating here covers both entries. Managing
|
||||||
|
// existing profiles (rename/PIN/delete) stays available: the gate is
|
||||||
|
// non-destructive and only blocks creating a profile beyond the first.
|
||||||
|
const gate = useEntitlement("multi-profile");
|
||||||
|
const creationLocked = isProfileCreationLocked(profiles.length, gate);
|
||||||
|
const upsellTier = t(`license.editions.${requiredTierFor("multi-profile")}`);
|
||||||
|
|
||||||
const editProfile = editProfileId
|
const editProfile = editProfileId
|
||||||
? profiles.find((p) => p.id === editProfileId)
|
? profiles.find((p) => p.id === editProfileId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const [mode, setMode] = useState<"list" | "create" | "edit">(
|
const [mode, setMode] = useState<"list" | "create" | "edit" | "upsell">(
|
||||||
editProfileId ? "edit" : "list"
|
editProfileId ? "edit" : "list"
|
||||||
);
|
);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(editProfileId ?? null);
|
const [selectedId, setSelectedId] = useState<string | null>(editProfileId ?? null);
|
||||||
|
|
@ -31,6 +42,10 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
|
if (creationLocked) {
|
||||||
|
setMode("upsell");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setMode("create");
|
setMode("create");
|
||||||
setName("");
|
setName("");
|
||||||
setColor(PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)]);
|
setColor(PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)]);
|
||||||
|
|
@ -50,6 +65,13 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!name.trim()) return;
|
if (!name.trim()) return;
|
||||||
|
// Race guard: the create form is reachable while the license is still
|
||||||
|
// loading (`!ready` shows no lock, anti-flash). If the gate became active
|
||||||
|
// in the meantime, never call createProfile — show the upsell instead.
|
||||||
|
if (mode === "create" && creationLocked) {
|
||||||
|
setMode("upsell");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
|
|
@ -89,7 +111,7 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
|
||||||
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-md border border-[var(--border)]">
|
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-md border border-[var(--border)]">
|
||||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border)]">
|
<div className="flex items-center justify-between p-4 border-b border-[var(--border)]">
|
||||||
<h2 className="font-semibold text-[var(--foreground)]">
|
<h2 className="font-semibold text-[var(--foreground)]">
|
||||||
{mode === "create"
|
{mode === "create" || mode === "upsell"
|
||||||
? t("profile.create")
|
? t("profile.create")
|
||||||
: mode === "edit"
|
: mode === "edit"
|
||||||
? t("profile.edit")
|
? t("profile.edit")
|
||||||
|
|
@ -142,12 +164,54 @@ export default function ProfileFormModal({ onClose, editProfileId }: Props) {
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
className="flex items-center gap-2 w-full p-3 rounded-lg border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] text-[var(--muted-foreground)] text-sm transition-colors"
|
title={creationLocked ? t("nav.locked") : undefined}
|
||||||
|
className={`flex items-center gap-2 w-full p-3 rounded-lg border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] text-[var(--muted-foreground)] text-sm transition-colors ${
|
||||||
|
creationLocked ? "opacity-60" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
{/* Locked-not-hidden: the entry stays visible with a lock; the
|
||||||
|
click leads to the upsell panel (spec decision). */}
|
||||||
|
{creationLocked ? <Lock size={16} /> : <Plus size={16} />}
|
||||||
{t("profile.create")}
|
{t("profile.create")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : mode === "upsell" ? (
|
||||||
|
/* Compact upsell panel — NOT the shared <UpsellGate/>: this modal
|
||||||
|
also opens from ProfileSelectionPage, which renders OUTSIDE
|
||||||
|
BrowserRouter (App.tsx mounts the router only once a profile is
|
||||||
|
active), so UpsellGate's unconditional useNavigate would throw.
|
||||||
|
Same i18n keys; the "I already have a key" CTA is omitted here —
|
||||||
|
the ProfileSwitcher upsell dialog (always in-router) exposes it. */
|
||||||
|
<div className="space-y-4 py-2 text-center">
|
||||||
|
<Lock className="mx-auto h-12 w-12 text-[var(--muted-foreground)]" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold text-[var(--foreground)]">
|
||||||
|
{t("upsell.title", { tier: upsellTier })}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("upsell.features.multi-profile")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] opacity-50 cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ShoppingCart className="h-4 w-4" />
|
||||||
|
{t("upsell.ctaGet", { tier: upsellTier })}
|
||||||
|
</button>
|
||||||
|
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("upsell.ctaGetSoon")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode("list")}
|
||||||
|
className="w-full px-4 py-2 rounded-lg border border-[var(--border)] text-sm text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, Lock, Settings } from "lucide-react";
|
import { ChevronDown, Lock, Settings, X } from "lucide-react";
|
||||||
import { useProfile } from "../../contexts/ProfileContext";
|
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 PinDialog from "./PinDialog";
|
||||||
import ProfileFormModal from "./ProfileFormModal";
|
import ProfileFormModal from "./ProfileFormModal";
|
||||||
import type { Profile } from "../../services/profileService";
|
import type { Profile } from "../../services/profileService";
|
||||||
|
|
@ -9,9 +13,14 @@ import type { Profile } from "../../services/profileService";
|
||||||
export default function ProfileSwitcher() {
|
export default function ProfileSwitcher() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { profiles, activeProfile, switchProfile, updateProfile } = useProfile();
|
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 [open, setOpen] = useState(false);
|
||||||
const [pinProfile, setPinProfile] = useState<Profile | null>(null);
|
const [pinProfile, setPinProfile] = useState<Profile | null>(null);
|
||||||
const [showManage, setShowManage] = useState(false);
|
const [showManage, setShowManage] = useState(false);
|
||||||
|
const [showUpsell, setShowUpsell] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Close on outside click
|
// Close on outside click
|
||||||
|
|
@ -25,10 +34,18 @@ export default function ProfileSwitcher() {
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
const isLocked = (profile: Profile) =>
|
||||||
|
isProfileSwitchLocked(profile.id, activeProfile?.id ?? null, gate);
|
||||||
|
|
||||||
const handleSelect = (profile: Profile) => {
|
const handleSelect = (profile: Profile) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
if (profile.id === activeProfile?.id) return;
|
if (profile.id === activeProfile?.id) return;
|
||||||
|
|
||||||
|
if (isLocked(profile)) {
|
||||||
|
setShowUpsell(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (profile.pin_hash) {
|
if (profile.pin_hash) {
|
||||||
setPinProfile(profile);
|
setPinProfile(profile);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -67,24 +84,34 @@ export default function ProfileSwitcher() {
|
||||||
|
|
||||||
{open && (
|
{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">
|
<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) => (
|
{profiles.map((profile) => {
|
||||||
|
const locked = isLocked(profile);
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={profile.id}
|
key={profile.id}
|
||||||
onClick={() => handleSelect(profile)}
|
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 ${
|
className={`flex items-center gap-2 w-full px-3 py-2 text-sm transition-colors ${
|
||||||
profile.id === activeProfile?.id
|
profile.id === activeProfile?.id
|
||||||
? "bg-[var(--sidebar-active)] text-white"
|
? "bg-[var(--sidebar-active)] text-white"
|
||||||
: "hover:bg-[var(--sidebar-hover)] text-[var(--sidebar-fg)]"
|
: "hover:bg-[var(--sidebar-hover)] text-[var(--sidebar-fg)]"
|
||||||
}`}
|
} ${locked ? "opacity-60" : ""}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
style={{ backgroundColor: profile.color }}
|
style={{ backgroundColor: profile.color }}
|
||||||
/>
|
/>
|
||||||
<span className="truncate flex-1 text-left">{profile.name}</span>
|
<span className="truncate flex-1 text-left">{profile.name}</span>
|
||||||
{profile.pin_hash && <Lock size={12} className="opacity-50" />}
|
{/* 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>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
@ -111,6 +138,28 @@ export default function ProfileSwitcher() {
|
||||||
{showManage && (
|
{showManage && (
|
||||||
<ProfileFormModal onClose={() => setShowManage(false)} />
|
<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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,13 @@ import type { FeatureKey } from "../../shared/entitlements";
|
||||||
interface UpsellGateProps {
|
interface UpsellGateProps {
|
||||||
feature: FeatureKey;
|
feature: FeatureKey;
|
||||||
requiredTier: Edition;
|
requiredTier: Edition;
|
||||||
|
/**
|
||||||
|
* Called right after a CTA navigates away. Lets modal hosts (e.g. the
|
||||||
|
* ProfileSwitcher upsell dialog) close themselves — the Sidebar stays
|
||||||
|
* mounted across navigations, so an uncontrolled modal would linger on top
|
||||||
|
* of the destination page. Optional: route-level usage ignores it.
|
||||||
|
*/
|
||||||
|
onNavigate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -21,7 +28,7 @@ interface UpsellGateProps {
|
||||||
* The tier label reuses the existing `license.editions.*` keys; the feature
|
* The tier label reuses the existing `license.editions.*` keys; the feature
|
||||||
* description comes from `upsell.features.<FeatureKey>` (FR/EN).
|
* description comes from `upsell.features.<FeatureKey>` (FR/EN).
|
||||||
*/
|
*/
|
||||||
export default function UpsellGate({ feature, requiredTier }: UpsellGateProps) {
|
export default function UpsellGate({ feature, requiredTier, onNavigate }: UpsellGateProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const tier = t(`license.editions.${requiredTier}`);
|
const tier = t(`license.editions.${requiredTier}`);
|
||||||
|
|
@ -57,7 +64,10 @@ export default function UpsellGate({ feature, requiredTier }: UpsellGateProps) {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate("/settings/users")}
|
onClick={() => {
|
||||||
|
navigate("/settings/users");
|
||||||
|
onNavigate?.();
|
||||||
|
}}
|
||||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||||
>
|
>
|
||||||
<KeyRound className="h-4 w-4" />
|
<KeyRound className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Lock, Plus } from "lucide-react";
|
import { Lock, Plus } from "lucide-react";
|
||||||
import { useProfile } from "../contexts/ProfileContext";
|
import { useProfile } from "../contexts/ProfileContext";
|
||||||
|
import { useEntitlement } from "../hooks/useEntitlement";
|
||||||
|
import { isProfileCreationLocked } from "../shared/profileGate";
|
||||||
import { APP_NAME } from "../shared/constants";
|
import { APP_NAME } from "../shared/constants";
|
||||||
import PinDialog from "../components/profile/PinDialog";
|
import PinDialog from "../components/profile/PinDialog";
|
||||||
import ProfileFormModal from "../components/profile/ProfileFormModal";
|
import ProfileFormModal from "../components/profile/ProfileFormModal";
|
||||||
|
|
@ -9,6 +11,13 @@ import ProfileFormModal from "../components/profile/ProfileFormModal";
|
||||||
export default function ProfileSelectionPage() {
|
export default function ProfileSelectionPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { profiles, switchProfile, updateProfile } = useProfile();
|
const { profiles, switchProfile, updateProfile } = useProfile();
|
||||||
|
// Multi-profile gate (Base+, #300). This page renders only when no active
|
||||||
|
// profile resolves, so profile SELECTION stays free (never lock a user out
|
||||||
|
// of all of their profiles — see decisions log); only the creation entry is
|
||||||
|
// marked. The gate itself lives at the single creation point,
|
||||||
|
// ProfileFormModal, which this page opens.
|
||||||
|
const gate = useEntitlement("multi-profile");
|
||||||
|
const creationLocked = isProfileCreationLocked(profiles.length, gate);
|
||||||
const [pinProfileId, setPinProfileId] = useState<string | null>(null);
|
const [pinProfileId, setPinProfileId] = useState<string | null>(null);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
|
@ -66,10 +75,19 @@ export default function ProfileSelectionPage() {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => setShowCreate(true)}
|
||||||
className="flex flex-col items-center justify-center gap-3 p-6 rounded-xl border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] transition-colors cursor-pointer"
|
title={creationLocked ? t("nav.locked") : undefined}
|
||||||
|
className={`flex flex-col items-center justify-center gap-3 p-6 rounded-xl border-2 border-dashed border-[var(--border)] hover:border-[var(--primary)] transition-colors cursor-pointer ${
|
||||||
|
creationLocked ? "opacity-60" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="w-14 h-14 rounded-full flex items-center justify-center bg-[var(--muted)]">
|
<div className="w-14 h-14 rounded-full flex items-center justify-center bg-[var(--muted)]">
|
||||||
|
{/* Locked-not-hidden: the entry stays visible with a lock; the
|
||||||
|
modal's single creation gate shows the upsell on click. */}
|
||||||
|
{creationLocked ? (
|
||||||
|
<Lock size={24} className="text-[var(--muted-foreground)]" />
|
||||||
|
) : (
|
||||||
<Plus size={24} className="text-[var(--muted-foreground)]" />
|
<Plus size={24} className="text-[var(--muted-foreground)]" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-[var(--muted-foreground)]">
|
<span className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||||
{t("profile.create")}
|
{t("profile.create")}
|
||||||
|
|
|
||||||
59
src/shared/profileGate.test.ts
Normal file
59
src/shared/profileGate.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
isProfileSwitchLocked,
|
||||||
|
isProfileCreationLocked,
|
||||||
|
type EntitlementGate,
|
||||||
|
} from "./profileGate";
|
||||||
|
|
||||||
|
const LOCKED_GATE: EntitlementGate = { allowed: false, ready: true };
|
||||||
|
const ALLOWED_GATE: EntitlementGate = { allowed: true, ready: true };
|
||||||
|
const BOOTING_GATE: EntitlementGate = { allowed: false, ready: false };
|
||||||
|
|
||||||
|
describe("isProfileSwitchLocked", () => {
|
||||||
|
it("locks profiles beyond the active one when gate is active (Free, ready)", () => {
|
||||||
|
expect(isProfileSwitchLocked("p2", "p1", LOCKED_GATE)).toBe(true);
|
||||||
|
expect(isProfileSwitchLocked("p3", "p1", LOCKED_GATE)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the active profile accessible for a Free user", () => {
|
||||||
|
expect(isProfileSwitchLocked("p1", "p1", LOCKED_GATE)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks nothing while the license is not ready (anti-flash at boot)", () => {
|
||||||
|
expect(isProfileSwitchLocked("p2", "p1", BOOTING_GATE)).toBe(false);
|
||||||
|
expect(isProfileSwitchLocked("p1", "p1", BOOTING_GATE)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks nothing when multi-profile is allowed (Base/Premium)", () => {
|
||||||
|
expect(isProfileSwitchLocked("p2", "p1", ALLOWED_GATE)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks nothing when no active profile resolves (never lock out of ALL profiles)", () => {
|
||||||
|
expect(isProfileSwitchLocked("p1", null, LOCKED_GATE)).toBe(false);
|
||||||
|
expect(isProfileSwitchLocked("p2", null, LOCKED_GATE)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays unlocked when allowed even if not ready (allowed short-circuits)", () => {
|
||||||
|
expect(isProfileSwitchLocked("p2", "p1", { allowed: true, ready: false })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isProfileCreationLocked", () => {
|
||||||
|
it("locks creation for a Free user who already has at least one profile", () => {
|
||||||
|
expect(isProfileCreationLocked(1, LOCKED_GATE)).toBe(true);
|
||||||
|
expect(isProfileCreationLocked(5, LOCKED_GATE)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never blocks creating the FIRST profile (empty config)", () => {
|
||||||
|
expect(isProfileCreationLocked(0, LOCKED_GATE)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks nothing while the license is not ready (anti-flash at boot)", () => {
|
||||||
|
expect(isProfileCreationLocked(3, BOOTING_GATE)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks nothing when multi-profile is allowed (Base/Premium)", () => {
|
||||||
|
expect(isProfileCreationLocked(3, ALLOWED_GATE)).toBe(false);
|
||||||
|
expect(isProfileCreationLocked(0, ALLOWED_GATE)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/shared/profileGate.ts
Normal file
55
src/shared/profileGate.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* Pure predicates for the multi-profile gate (Base+), issue #300.
|
||||||
|
*
|
||||||
|
* NON-destructive by construction: these functions only decide whether a UI
|
||||||
|
* surface is locked — nothing is ever removed from profiles.json. An upgrade
|
||||||
|
* (edition becomes base/premium) flips `allowed` and every profile reappears
|
||||||
|
* untouched, without any migration.
|
||||||
|
*
|
||||||
|
* Anti-flash rule: the gate is active only when the license is `ready`
|
||||||
|
* (`status === "ready"` in LicenseContext). While loading or errored, nothing
|
||||||
|
* is locked — a paying user must never see a "locked" flash at boot (the same
|
||||||
|
* rule RequireFeature/Sidebar follow).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Shape returned by `useEntitlement("multi-profile")`. */
|
||||||
|
export interface EntitlementGate {
|
||||||
|
allowed: boolean;
|
||||||
|
ready: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is switching to `profileId` locked?
|
||||||
|
*
|
||||||
|
* Locked iff the gate is active (ready && !allowed) AND the profile is beyond
|
||||||
|
* the active one. A Free user keeps full access to their active profile.
|
||||||
|
*
|
||||||
|
* `activeProfileId === null` (degenerate state: no active profile resolves)
|
||||||
|
* locks NOTHING — we never lock a user out of all of their profiles; the only
|
||||||
|
* surface without an active profile is ProfileSelectionPage, where selection
|
||||||
|
* stays free by design (see decisions log #300).
|
||||||
|
*/
|
||||||
|
export function isProfileSwitchLocked(
|
||||||
|
profileId: string,
|
||||||
|
activeProfileId: string | null,
|
||||||
|
gate: EntitlementGate,
|
||||||
|
): boolean {
|
||||||
|
if (!gate.ready || gate.allowed) return false;
|
||||||
|
if (activeProfileId === null) return false;
|
||||||
|
return profileId !== activeProfileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is creating a NEW profile locked?
|
||||||
|
*
|
||||||
|
* Locked iff the gate is active AND at least one profile already exists — a
|
||||||
|
* Free user is entitled to one profile, so creation from an empty config
|
||||||
|
* (fresh/repaired install) is never blocked.
|
||||||
|
*/
|
||||||
|
export function isProfileCreationLocked(
|
||||||
|
profileCount: number,
|
||||||
|
gate: EntitlementGate,
|
||||||
|
): boolean {
|
||||||
|
if (!gate.ready || gate.allowed) return false;
|
||||||
|
return profileCount >= 1;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue