feat(dashboard): scope rebrand modal dismissal by user.id

Switches the localStorage key from the global
"hexclave-rebrand-modal-dismissed" to a per-user
"hexclave-rebrand-modal-dismissed:<user.id>".

Rationale: a shared browser (e.g. a work machine where two teammates
each log into their own dashboard account) was previously hiding the
announcement from teammate B as soon as teammate A dismissed it. Now
each account tracks its own dismissal independently. The trade-off —
the same human switching between their personal and work accounts will
see the modal once per account — is acceptable: each account is a
distinct identity, and a one-time brand notice per account is fine.

Storage key is computed as `${STORAGE_KEY_PREFIX}${user.id}` only when
`user` is non-null; both the useEffect read path and the dismiss write
path null-check `storageKey` defensively even though the existing gates
(`isPreRebrandUser`, `if (!isPreRebrandUser) return null`) already
ensure neither runs without a user.

No migration shim — the old global key was only ever written in local
dev/test before this PR ships, so no production data is being orphaned.
This commit is contained in:
Bilal Godil 2026-05-26 16:05:47 -07:00
parent 1c4d3a6bd7
commit 0902a7e579

View File

@ -14,7 +14,11 @@ import { useUser } from "@stackframe/stack";
import Image from "next/image";
import { useEffect, useState } from "react";
const STORAGE_KEY = "hexclave-rebrand-modal-dismissed";
// Per-user dismissal flag. Keyed by user.id so a shared browser (e.g. a
// machine where two teammates each log into their own accounts) tracks the
// dismissal separately for each account — otherwise one teammate dismissing
// would silently hide the announcement from the other.
const STORAGE_KEY_PREFIX = "hexclave-rebrand-modal-dismissed:";
const MIGRATION_DOCS_URL = "https://docs.hexclave.com/migration";
// Users who signed up before this instant predate the Stack Auth → Hexclave
@ -33,8 +37,8 @@ const REBRAND_CUTOFF = new Date("2026-05-27T00:00:00.000Z");
*
* For real customers: only renders for a logged-in user who signed up before
* {@link REBRAND_CUTOFF}. On any dismissal (confirm button, close button,
* overlay click, or Escape) writes `STORAGE_KEY` to localStorage so the modal
* never re-appears for that browser.
* overlay click, or Escape) writes `${STORAGE_KEY_PREFIX}${user.id}` to
* localStorage so the modal never re-appears for that account on that browser.
*/
export function HexclaveRebrandModal() {
// Skip in dev/preview environments — same flags the protected layout already
@ -52,12 +56,16 @@ export function HexclaveRebrandModal() {
!isDevEnvironment && user != null && user.signedUpAt < REBRAND_CUTOFF;
const [open, setOpen] = useState(false);
// Per-user storage key. `null` when there's no user; the gates below
// ensure we never try to read/write it in that case.
const storageKey = user ? `${STORAGE_KEY_PREFIX}${user.id}` : null;
// Read localStorage after hydration to avoid SSR mismatch — render closed
// on the server and only open if we know the user hasn't dismissed it.
// on the server and only open if we know this user hasn't dismissed it.
useEffect(() => {
if (!isPreRebrandUser) return;
if (!isPreRebrandUser || !storageKey) return;
try {
const dismissed = localStorage.getItem(STORAGE_KEY);
const dismissed = localStorage.getItem(storageKey);
if (dismissed !== "true") {
setOpen(true);
}
@ -66,13 +74,15 @@ export function HexclaveRebrandModal() {
// unavailable storage as "already dismissed" so we don't spam users
// who can't persist the dismissal anyway.
}
}, [isPreRebrandUser]);
}, [isPreRebrandUser, storageKey]);
const dismiss = () => {
try {
localStorage.setItem(STORAGE_KEY, "true");
} catch {
// see above — best-effort write
if (storageKey) {
try {
localStorage.setItem(storageKey, "true");
} catch {
// see above — best-effort write
}
}
setOpen(false);
};