diff --git a/.github/assets/hexclave-rebrand-modal.png b/.github/assets/hexclave-rebrand-modal.png new file mode 100644 index 0000000000..4079615689 Binary files /dev/null and b/.github/assets/hexclave-rebrand-modal.png differ diff --git a/apps/dashboard/public/hexclave-icon.svg b/apps/dashboard/public/hexclave-icon.svg new file mode 100644 index 0000000000..6954a8f3fe --- /dev/null +++ b/apps/dashboard/public/hexclave-icon.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx b/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx index de0da7ce84..19c1b51f0e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx @@ -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 ( + {children} ); diff --git a/apps/dashboard/src/components/hexclave-rebrand-modal.tsx b/apps/dashboard/src/components/hexclave-rebrand-modal.tsx new file mode 100644 index 0000000000..b96ff01bc5 --- /dev/null +++ b/apps/dashboard/src/components/hexclave-rebrand-modal.tsx @@ -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 ( + { + if (!next) dismiss(); + }} + > + + + + + Stack Auth is now Hexclave + + + We're rebranding! Same product, same team, new home at{" "} + + app.hexclave.com + + . To update your project, rename all{" "} + @stackframe/* imports to{" "} + @hexclave/* — the only + exception is{" "} + @stackframe/stack, which + becomes @hexclave/next. + See the{" "} + + migration guide + {" "} + for full details. + + + + + + + + ); +} + +/** + * Stack Auth mark (faded) → arrow → Hexclave benzene mark. Both logos are + * served from `/public` so they match the canonical brand assets. + */ +function RebrandIllustration() { + return ( + + ); +}