mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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).

## 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:
parent
c8abb72286
commit
401191615f
BIN
.github/assets/hexclave-rebrand-modal.png
vendored
Normal file
BIN
.github/assets/hexclave-rebrand-modal.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
26
apps/dashboard/public/hexclave-icon.svg
Normal file
26
apps/dashboard/public/hexclave-icon.svg
Normal 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 |
@ -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>
|
||||
);
|
||||
|
||||
207
apps/dashboard/src/components/hexclave-rebrand-modal.tsx
Normal file
207
apps/dashboard/src/components/hexclave-rebrand-modal.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user