feat(dashboard): one-time Stack Auth → Hexclave rebrand modal (#1493)

## Summary

Adds a one-time informational modal that announces the **Stack Auth →
Hexclave** rebrand to existing logged-in dashboard users. Brand-new
users signing up after the cutoff already land on a fully
Hexclave-branded experience and don't see the modal — they have no
"Stack Auth" mental model to update.

Stacked on top of **PR #1481** (the visible-rebrand flip).

![Hexclave rebrand
modal](https://github.com/hexclave/stack-auth/raw/cl/hexclave-rebrand-modal/.github/assets/hexclave-rebrand-modal.png)

## What's in here

- **Component**:
[`apps/dashboard/src/components/hexclave-rebrand-modal.tsx`](https://github.com/hexclave/stack-auth/blob/cl/hexclave-rebrand-modal/apps/dashboard/src/components/hexclave-rebrand-modal.tsx)
— the modal itself plus the static `RebrandIllustration` (Stack Auth
mark → arrow → Hexclave benzene mark).
- **Mount**:
[`apps/dashboard/src/app/(main)/(protected)/layout-client.tsx`](https://github.com/hexclave/stack-auth/blob/cl/hexclave-rebrand-modal/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx)
— single `<HexclaveRebrandModal />` inside `ConfigUpdateDialogProvider`
so it covers every authenticated dashboard route.
- **Asset**: `apps/dashboard/public/hexclave-icon.svg` — the
benzene-ring mark pulled from `https://hexclave.com/icon.svg` (the
canonical Hexclave brand mark). Stack Auth side uses the existing
`/logo.svg` + `/logo-bright.svg` (dark mode variant).

## Targeting

Three independent conditions must all hold for the modal to render:

1. **Logged-in user** — `useUser({ or: "return-null" })` opts out of the
sign-in redirect so guests are silently skipped.
2. **`user.signedUpAt < REBRAND_CUTOFF`** where `REBRAND_CUTOFF =
2026-05-27T00:00:00Z`. This is the cleanest "pre-rebrand user" signal —
more accurate than cookie inspection (the existing `stack-is-https` hint
cookie gets dual-written for new and returning users alike since
[`packages/template/src/lib/cookie.ts:201`](https://github.com/hexclave/stack-auth/blob/cl/hexclave-rebrand-modal/packages/template/src/lib/cookie.ts#L201),
so it can't distinguish), and it follows the same user across
browsers/devices.
3. **No `hexclave-rebrand-modal-dismissed=true` in localStorage**. Any
dismissal path (Got it button, X, overlay click, Escape) routes through
`onOpenChange` and writes this flag, so the modal never re-opens for
that browser.

## Behavior verification

End-to-end checked against a live dev dashboard:

-  Modal opens once on first authenticated dashboard view for a
pre-cutoff user.
-  `localStorage["hexclave-rebrand-modal-dismissed"]` flips to `"true"`
on any dismissal path.
-  Reload after dismissal: zero `[role=dialog]` elements rendered,
localStorage flag persists.
-  Not rendered for guests (no useUser → early `return null`, no
auth-redirect).
-  Not rendered for users with `signedUpAt >= REBRAND_CUTOFF`.
-  SSR-safe: hooks into `useEffect` to read storage post-hydration;
`try/catch` around storage access so private-mode / sandboxed iframes
degrade silently.
-  `pnpm --filter @stackframe/dashboard lint` clean.

## Notes

- **`pnpm typecheck`** was not run as part of this change because the
dashboard typecheck depends on `codegen-prisma` against a live DB
(pre-existing infra debt noted in PR #1481). Lint covers the type-shape
of the touched files via the project's strict TS-aware ESLint rules.
- **Migration link** points to
[`docs.hexclave.com/migration`](https://docs.hexclave.com/migration),
the same URL referenced from
[`packages/template/src/internal/deprecation-warning.ts:36`](https://github.com/hexclave/stack-auth/blob/cl/hexclave-rebrand-modal/packages/template/src/internal/deprecation-warning.ts#L36)
— single source of truth for the migration story.
- **No animation** by design — the modal is static; entry/exit uses the
existing Radix Dialog fade.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds a one-time modal in the dashboard to announce the Stack Auth →
Hexclave rebrand for pre-rebrand users. It shows once per account per
browser, inlines the import-rename step, links the migration guide, and
doesn’t show for new signups or in dev/preview environments.

- **New Features**
- Shows only for logged-in users with `signedUpAt <
'2026-05-27T00:00:00Z'`; guests, post-cutoff signups, and local
emulator/remote dev/preview never see it.
- Dismissal persists via
`localStorage["hexclave-rebrand-modal-dismissed:<user.id>"]="true"`; all
close paths set the flag.
- Mounted in the protected dashboard layout so it covers all
authenticated routes once per account per browser.
- Includes illustration (`/logo.svg` → arrow → `/hexclave-icon.svg`) and
links to app.hexclave.com and the migration guide; copy tells users to
rename `@stackframe/*` to `@hexclave/*` (`@stackframe/stack` →
`@hexclave/next`).

<sup>Written for commit 92c07f2bd8.
Summary will update on new commits. <a
href="https://cubic.dev/pr/hexclave/stack-auth/pull/1493?utm_source=github">Review
in cubic</a></sup>

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
BilalG1 2026-05-26 18:16:25 -07:00 committed by GitHub
parent c8abb72286
commit 401191615f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 235 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@ -0,0 +1,26 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="neon-grad" x1="4" y1="4" x2="44" y2="44" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#00FFFF" />
<stop offset="50%" stop-color="#3B82F6" />
<stop offset="100%" stop-color="#8B5CF6" />
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1.5" result="blur1" />
<feGaussianBlur stdDeviation="3.5" result="blur2" />
<feMerge>
<feMergeNode in="blur2" />
<feMergeNode in="blur1" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<g id="benzene-mark">
<path d="M 24 4 L 41.32 14 L 41.32 34 L 24 44 L 6.68 34 L 6.68 14 Z" fill="none" stroke="url(#neon-grad)" stroke-width="3" stroke-linejoin="miter" />
<path d="M 11 16.87 L 14 15.13 L 14 32.87 L 11 31.13 Z" fill="url(#neon-grad)" />
<path d="M 11 16.87 L 14 15.13 L 14 32.87 L 11 31.13 Z" fill="url(#neon-grad)" transform="rotate(120 24 24)" />
<path d="M 11 16.87 L 14 15.13 L 14 32.87 L 11 31.13 Z" fill="url(#neon-grad)" transform="rotate(240 24 24)" />
</g>
</defs>
<use href="#benzene-mark" filter="url(#glow)" opacity="0.75" />
<use href="#benzene-mark" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -3,6 +3,7 @@
import Loading from "@/app/loading";
import { CursorBlastEffect } from "@stackframe/dashboard-ui-components";
import { ConfigUpdateDialogProvider } from "@/lib/config-update";
import { HexclaveRebrandModal } from "@/components/hexclave-rebrand-modal";
import { getPublicEnvVar } from '@/lib/env';
import { useStackApp, useUser } from "@stackframe/stack";
import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@stackframe/stack-shared/dist/local-emulator";
@ -60,6 +61,7 @@ export default function LayoutClient({ children }: { children: React.ReactNode }
return (
<ConfigUpdateDialogProvider>
<CursorBlastEffect />
<HexclaveRebrandModal />
{children}
</ConfigUpdateDialogProvider>
);

View File

@ -0,0 +1,207 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { getPublicEnvVar } from "@/lib/env";
import { useUser } from "@stackframe/stack";
import Image from "next/image";
import { useEffect, useState } from "react";
// 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
// rebrand and are the only ones who benefit from the announcement. Anyone
// signing up after this already lands on a Hexclave-branded experience and
// has no "Stack Auth" mental model to update — no point telling them.
const REBRAND_CUTOFF = new Date("2026-05-27T00:00:00.000Z");
/**
* One-time informational modal announcing the Stack Auth Hexclave rebrand.
*
* Skipped entirely in preview / local-emulator / remote-development environments
* those auto-create throwaway users or seed a fixture admin, so the rebrand
* notice would be friction for developers and meaningless for preview visitors
* who never used "Stack Auth" in the first place.
*
* 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_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
// gates on. Read at top so we can short-circuit before any hook runs the
// useEffect or computes the user-based gate.
const isDevEnvironment =
getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"
|| getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"
|| getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") === "true";
// `or: "return-null"` keeps this from triggering the sign-in redirect when
// it's rendered above the auth boundary — we simply opt out for guests.
const user = useUser({ or: "return-null" });
const isPreRebrandUser =
!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 this user hasn't dismissed it.
useEffect(() => {
if (!isPreRebrandUser || !storageKey) return;
try {
const dismissed = localStorage.getItem(storageKey);
if (dismissed !== "true") {
setOpen(true);
}
} catch {
// localStorage can throw in private-mode / sandboxed iframes; treat
// unavailable storage as "already dismissed" so we don't spam users
// who can't persist the dismissal anyway.
}
}, [isPreRebrandUser, storageKey]);
const dismiss = () => {
if (storageKey) {
try {
localStorage.setItem(storageKey, "true");
} catch {
// see above — best-effort write
}
}
setOpen(false);
};
if (!isPreRebrandUser) return null;
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) dismiss();
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<RebrandIllustration />
<DialogTitle className="text-center text-xl pt-2">
Stack Auth is now Hexclave
</DialogTitle>
<DialogDescription className="text-center">
We&apos;re rebranding! Same product, same team, new home at{" "}
<a
href="https://app.hexclave.com"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-2 hover:text-foreground"
>
app.hexclave.com
</a>
. To update your project, rename all{" "}
<code className="font-mono text-xs">@stackframe/*</code> imports to{" "}
<code className="font-mono text-xs">@hexclave/*</code> the only
exception is{" "}
<code className="font-mono text-xs">@stackframe/stack</code>, which
becomes <code className="font-mono text-xs">@hexclave/next</code>.
See the{" "}
<a
href={MIGRATION_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
migration guide
</a>{" "}
for full details.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-center pt-6">
<Button onClick={dismiss} className="min-w-32">
Got it
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Stack Auth mark (faded) arrow Hexclave benzene mark. Both logos are
* served from `/public` so they match the canonical brand assets.
*/
function RebrandIllustration() {
return (
<div
className="flex justify-center items-center gap-4 pb-2"
aria-hidden="true"
>
{/* Stack Auth: served light & dark variants depending on theme */}
<Image
src="/logo.svg"
alt=""
width={48}
height={48}
aria-hidden
className="h-12 w-auto opacity-50 block dark:hidden"
/>
<Image
src="/logo-bright.svg"
alt=""
width={48}
height={48}
aria-hidden
className="h-12 w-auto opacity-60 hidden dark:block"
/>
{/* Arrow — bridge between the two marks */}
<svg
width="40"
height="14"
viewBox="0 0 40 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-muted-foreground"
>
<path
d="M2 7 L34 7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<path
d="M28 1 L34 7 L28 13"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</svg>
{/* Hexclave benzene mark — gradient + glow filter, theme-agnostic */}
<Image
src="/hexclave-icon.svg"
alt=""
width={56}
height={56}
aria-hidden
className="h-14 w-14"
/>
</div>
);
}