mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-03 21:02:05 +08:00
## Stack Auth → Hexclave rename — PR 5 (internal symbols, paths,
packages, brand strings)
PR 5 finishes the **internal / non-wire** half of the Stack→Hexclave
rename. It only touches things where nothing outside the repo depends on
the exact name: internal symbols, file/dir names, the
`@stackframe/template` package, and residual brand strings. Plan +
progress are in `HEXCLAVE-RENAME-PR5-PLAN.md`.
Every step was verified green (`pnpm typecheck` + `pnpm lint`, 28/28)
and committed as its own checkpoint, then a fan-out of review agents
audited all commits and the findings were fixed.
### What changed
- **Internal symbols** (`@hexclave/shared`, `packages/template`, apps):
`stack*`/`Stack*` → `hexclave*`/`Hexclave*` — incl.
`stackGlobalsSymbol`, the `_Stack*AppImpl` classes,
`stackAppInternalsSymbol`, `StackContext`, `getStackStripe`, etc. The
`stack*App` local-variable convention
(`stackServerApp`/`stackClientApp`/…) was renamed across 175
source/example/doc files.
- **File renames**: `hexclave-handler/provider/context.tsx`,
`backend/hexclave.tsx`, `internal-tool/hexclave.ts`,
`hexclave-app-internals.ts`.
- **Directory renames**: `lib/hexclave-app`, `hexclave-companion`,
`[...hexclave]` route segment, `skills/hexclave`,
`dashboard/src/hexclave`, and the package dirs
**`packages/{next,shared,ui,sc,cli}`** (dropping the `stack-` prefix to
match the `@hexclave/*` npm names).
- **Packages**: `@stackframe/template` → `@hexclave/template`; **deleted
`packages/init-stack`** (onboarding lives in `@hexclave/cli init`; the
published npm package is untouched).
- **Brand strings**: reworded `Stack Auth`/`Stack dashboard` prose in
code + docs-mintlify, renamed `hexclave-app.mdx`/`use-hexclave-app.mdx`
with redirects, regenerated OpenAPI, updated coupled e2e assertions;
`doctor`/`init` now prefer `hexclave.config.ts`.
### Intentionally kept (verified, not oversights)
Wire/compat identifiers (`x-stack-*` headers, `stack-*` cookies,
`STACK_*` env names, `*.stack-auth.com`, `stackauth_`, `ask_stack_auth`,
query params), public `Stack*` SDK aliases, crypto/JWT/vault
domain-separation tags, `*-brand-sentinel`s, the
`Symbol.for("StackAuth--…")` string, `_stack_sync_metadata`, Postgres
`stackframe` / docker image names, the `stack-auth-logo*.svg` (used by
the rebrand modal), and `migration.mdx` / "formerly known as Stack Auth"
notes. False positives (Phosphor `StackIcon`/`StackSimple`, `TanStack`,
`OrbStack`, `stackable`/`Stacked` charts) left alone.
### Review pass
Six review agents audited all commits. Found + fixed one real bug — a
build script (`bundle-type-definitions.ts`) hardcoded the old
`lib/stack-app` glob path (not an import, so typecheck/lint were blind),
silently emptying the dashboard AI type bundle — plus stale comments, a
dead CI env var, and stale `.gitignore`/`.dockerignore` entries.
Cross-cutting audit confirmed **zero wire-compat identifiers were
accidentally renamed**.
### ⚠️ Verification note
`typecheck` + `lint` are fully green locally. The **e2e suite was not
run** (needs a live backend+DB), so the brand-string assertion +
OpenAPI-regen changes are verified by grep/codegen only — please let CI
exercise e2e to confirm.
### Base-branch note
This branch was forked from the local-only `cl/friendly-lewin-72293f`
(not on origin, no separate PR), so this PR against `dev` also carries
that branch's ~11 preceding Hexclave-rename commits (config-file rename,
env-var dual-read, AI setup-prompt rebrand). If those should land
separately, re-parent before merge.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Finishes the internal Stack Auth → Hexclave rename and cleans up
remaining stragglers, including dev-tool and prompt copy. All changes
are internal-only; public/wire APIs remain unchanged. Re-merged `dev`
and resolved the payments create-purchase-url conflict.
- **Refactors**
- Internal symbols: stack*/Stack* → hexclave*/Hexclave* (e.g.,
`getHexclaveServerApp` via `@/hexclave`, `getHexclaveStripe`,
`hexclaveAppInternalsSymbol`, `hexclaveSchemaInfo`, Prisma
`__hexclave_*`, `data-hexclave-handler-page`, Stripe mock
`hexclavePortPrefix`).
- Files/dirs: moved to `lib/hexclave-app`; handler route
`[...hexclave]`; backend entry `src/hexclave.tsx`; dashboard internals
`hexclave-app-internals`; companion `hexclave-companion`; dropped
`stack-` prefix across package dirs
(`packages/{shared,ui,sc,cli,next}`); workflows/emulator paths now
`packages/cli`; Quetzal codegen env at `packages/next/.env.local`.
- Packages/docs: `@stackframe/template` → `@hexclave/template`; removed
`packages/init-stack`; regenerated OpenAPI and updated docs
slugs/redirects for hexclave-app/use-hexclave-app.
- Brand strings/prompts: reworded remaining “Stack” dashboard strings to
Hexclave; updated dev-tool copy and prompts; `doctor/init` now prefer
`hexclave.config.ts`. Kept all wire-compat identifiers and public
aliases (`x-stack-*`, `stack-*` cookies, `STACK_*` env,
`*.stack-auth.com`, `Stack*` SDK names).
- Rebased/merged onto latest `dev`: retained `@hexclave/template`, kept
`src` in published files, refreshed setup-prompt imports and docs JSON,
adopted 1.0.5 version bumps, and re-merged `dev` again (resolved
`create-purchase-url` with `getHexclaveStripe`).
- **Bug Fixes**
- Restored dashboard AI type bundle by pointing the glob to
`packages/template/src/lib/hexclave-app`.
- Addressed rename leftovers: updated lingering `@/stack` imports and
CSS selector, fixed schema/meta and port-prefix expansions, and aligned
emulator commands to `packages/cli`.
- CI/build: removed a dead env var and stale ignore entries; fixed
Docker by renaming `STACK_SKIP_TEMPLATE_GENERATION` →
`HEXCLAVE_SKIP_TEMPLATE_GENERATION`.
<sup>Written for commit 3c1af3bff3.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1547?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
|
|
import { DownloadSimpleIcon } from "@phosphor-icons/react";
|
|
import type { ServerUser } from "@hexclave/next";
|
|
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
|
import {
|
|
Button,
|
|
Checkbox,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
Label,
|
|
RadioGroup,
|
|
RadioGroupItem,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
toast,
|
|
} from "@/components/ui";
|
|
import { download, generateCsv, mkConfig } from "export-to-csv";
|
|
import { useState } from "react";
|
|
|
|
type ExportFormat = "csv" | "json";
|
|
type ExportScope = "all" | "filtered";
|
|
|
|
type ExportField = {
|
|
key: string,
|
|
label: string,
|
|
enabled: boolean,
|
|
};
|
|
|
|
type ExportOptions = {
|
|
search?: string,
|
|
includeAnonymous: boolean,
|
|
onlyAnonymous?: boolean,
|
|
};
|
|
|
|
const DEFAULT_FIELDS: ExportField[] = [
|
|
{ key: "id", label: "User ID", enabled: true },
|
|
{ key: "displayName", label: "Display Name", enabled: true },
|
|
{ key: "primaryEmail", label: "Email", enabled: true },
|
|
{ key: "primaryEmailVerified", label: "Email Verified", enabled: true },
|
|
{ key: "signedUpAt", label: "Signed Up At", enabled: true },
|
|
{ key: "lastActiveAt", label: "Last Active At", enabled: true },
|
|
{ key: "isAnonymous", label: "Is Anonymous", enabled: false },
|
|
{ key: "hasPassword", label: "Has Password", enabled: false },
|
|
{ key: "otpAuthEnabled", label: "OTP Auth Enabled", enabled: false },
|
|
{ key: "passkeyAuthEnabled", label: "Passkey Auth Enabled", enabled: false },
|
|
{ key: "isMultiFactorRequired", label: "Multi-Factor Required", enabled: false },
|
|
{ key: "oauthProviders", label: "OAuth Providers", enabled: false },
|
|
{ key: "profileImageUrl", label: "Profile Image URL", enabled: false },
|
|
{ key: "clientMetadata", label: "Client Metadata", enabled: false },
|
|
{ key: "clientReadOnlyMetadata", label: "Client Read-Only Metadata", enabled: false },
|
|
{ key: "serverMetadata", label: "Server Metadata", enabled: false },
|
|
];
|
|
|
|
export function ExportUsersDialog(props: {
|
|
trigger: React.ReactNode,
|
|
exportOptions?: ExportOptions,
|
|
}) {
|
|
const { trigger, exportOptions } = props;
|
|
const hexclaveAdminApp = useAdminApp();
|
|
const [open, setOpen] = useState(false);
|
|
const [format, setFormat] = useState<ExportFormat>("csv");
|
|
const [scope, setScope] = useState<ExportScope>("all");
|
|
const [fields, setFields] = useState<ExportField[]>(DEFAULT_FIELDS);
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
|
|
const toggleField = (key: string) => {
|
|
setFields((prev) =>
|
|
prev.map((field) =>
|
|
field.key === key ? { ...field, enabled: !field.enabled } : field
|
|
)
|
|
);
|
|
};
|
|
|
|
const selectAllFields = () => {
|
|
setFields((prev) => prev.map((field) => ({ ...field, enabled: true })));
|
|
};
|
|
|
|
const deselectAllFields = () => {
|
|
setFields((prev) => prev.map((field) => ({ ...field, enabled: false })));
|
|
};
|
|
|
|
const handleExport = async () => {
|
|
const enabledFields = fields.filter((f) => f.enabled);
|
|
if (enabledFields.length === 0) {
|
|
toast({
|
|
title: "No fields selected",
|
|
description: "Please select at least one field to export",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setIsExporting(true);
|
|
try {
|
|
// Fetch all users
|
|
const allUsers = await fetchAllUsers(
|
|
hexclaveAdminApp,
|
|
scope === "filtered" ? exportOptions : undefined
|
|
);
|
|
|
|
if (allUsers.length === 0) {
|
|
toast({
|
|
title: "No users to export",
|
|
description: "There are no users matching the current filters",
|
|
variant: "destructive",
|
|
});
|
|
setIsExporting(false);
|
|
return;
|
|
}
|
|
|
|
// Transform user data based on selected fields
|
|
const transformedData = allUsers.map((user) =>
|
|
transformUserData(user, enabledFields)
|
|
);
|
|
|
|
// Export based on format
|
|
if (format === "csv") {
|
|
exportToCsv(transformedData);
|
|
} else {
|
|
exportToJson(transformedData);
|
|
}
|
|
|
|
toast({
|
|
title: "Export successful",
|
|
description: `Exported ${allUsers.length} user${allUsers.length === 1 ? "" : "s"}`,
|
|
variant: "success",
|
|
});
|
|
|
|
setOpen(false);
|
|
} catch (error) {
|
|
console.error("Export failed:", error);
|
|
toast({
|
|
title: "Export failed",
|
|
description: error instanceof Error ? error.message : "An unknown error occurred",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div onClick={() => setOpen(true)}>
|
|
{trigger}
|
|
</div>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Export Users</DialogTitle>
|
|
<DialogDescription>
|
|
Configure and download user data from your project
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-6 py-4">
|
|
{/* Export Format */}
|
|
<div className="space-y-3">
|
|
<Label className="text-sm font-medium">Export Format</Label>
|
|
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="csv">CSV (Comma-separated values)</SelectItem>
|
|
<SelectItem value="json">JSON (JavaScript Object Notation)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Export Scope */}
|
|
<div className="space-y-3">
|
|
<Label className="text-sm font-medium">Export Scope</Label>
|
|
<RadioGroup value={scope} onValueChange={(v) => setScope(v as ExportScope)}>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="all" id="scope-all" />
|
|
<Label htmlFor="scope-all" className="font-normal cursor-pointer">
|
|
Export all users in the project
|
|
</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<RadioGroupItem value="filtered" id="scope-filtered" />
|
|
<Label htmlFor="scope-filtered" className="font-normal cursor-pointer">
|
|
Export only filtered/searched users
|
|
{exportOptions?.search && (
|
|
<span className="text-muted-foreground ml-1">
|
|
(search: "{exportOptions.search}")
|
|
</span>
|
|
)}
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
{/* Field Selection */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-sm font-medium">Fields to Export</Label>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={selectAllFields}
|
|
className="h-7 text-xs"
|
|
>
|
|
Select All
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={deselectAllFields}
|
|
className="h-7 text-xs"
|
|
>
|
|
Deselect All
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3 max-h-[300px] overflow-y-auto border border-border rounded-lg p-4">
|
|
{fields.map((field) => (
|
|
<div key={field.key} className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={`field-${field.key}`}
|
|
checked={field.enabled}
|
|
onCheckedChange={() => toggleField(field.key)}
|
|
/>
|
|
<Label
|
|
htmlFor={`field-${field.key}`}
|
|
className="text-sm font-normal cursor-pointer"
|
|
>
|
|
{field.label}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Export Button */}
|
|
<div className="flex justify-end gap-3 pt-4">
|
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={isExporting}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={() => runAsynchronouslyWithAlert(handleExport)} disabled={isExporting}>
|
|
<DownloadSimpleIcon className="mr-2 h-4 w-4" />
|
|
{isExporting ? "Exporting..." : "Export Users"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
async function fetchAllUsers(
|
|
hexclaveAdminApp: ReturnType<typeof useAdminApp>,
|
|
options?: ExportOptions
|
|
): Promise<ServerUser[]> {
|
|
const allUsers: ServerUser[] = [];
|
|
let cursor: string | undefined = undefined;
|
|
const limit = 100; // Fetch in batches of 100
|
|
|
|
do {
|
|
const listUsersOptions: Parameters<typeof hexclaveAdminApp.listUsers>[0] = {
|
|
limit,
|
|
cursor,
|
|
query: options?.search,
|
|
includeAnonymous: options?.onlyAnonymous ? true : (options?.includeAnonymous ?? true),
|
|
orderBy: "signedUpAt",
|
|
desc: true,
|
|
};
|
|
if (options?.onlyAnonymous) {
|
|
Object.assign(listUsersOptions, { onlyAnonymous: true });
|
|
}
|
|
const batch = await hexclaveAdminApp.listUsers(listUsersOptions);
|
|
|
|
allUsers.push(...batch);
|
|
cursor = batch.nextCursor ?? undefined;
|
|
} while (cursor);
|
|
|
|
return allUsers;
|
|
}
|
|
|
|
function transformUserData(
|
|
user: ServerUser,
|
|
enabledFields: ExportField[]
|
|
): Record<string, unknown> {
|
|
const data: Record<string, unknown> = {};
|
|
|
|
for (const field of enabledFields) {
|
|
switch (field.key) {
|
|
case "id": {
|
|
data["User ID"] = user.id;
|
|
break;
|
|
}
|
|
case "displayName": {
|
|
data["Display Name"] = user.displayName ?? "";
|
|
break;
|
|
}
|
|
case "primaryEmail": {
|
|
data["Email"] = user.primaryEmail ?? "";
|
|
break;
|
|
}
|
|
case "primaryEmailVerified": {
|
|
data["Email Verified"] = user.primaryEmailVerified ? "Yes" : "No";
|
|
break;
|
|
}
|
|
case "signedUpAt": {
|
|
data["Signed Up At"] = new Date(user.signedUpAt).toISOString();
|
|
break;
|
|
}
|
|
case "lastActiveAt": {
|
|
data["Last Active At"] = new Date(user.lastActiveAt).toISOString();
|
|
break;
|
|
}
|
|
case "isAnonymous": {
|
|
data["Is Anonymous"] = user.isAnonymous ? "Yes" : "No";
|
|
break;
|
|
}
|
|
case "hasPassword": {
|
|
data["Has Password"] = user.hasPassword ? "Yes" : "No";
|
|
break;
|
|
}
|
|
case "otpAuthEnabled": {
|
|
data["OTP Auth Enabled"] = user.otpAuthEnabled ? "Yes" : "No";
|
|
break;
|
|
}
|
|
case "passkeyAuthEnabled": {
|
|
data["Passkey Auth Enabled"] = user.passkeyAuthEnabled ? "Yes" : "No";
|
|
break;
|
|
}
|
|
case "isMultiFactorRequired": {
|
|
data["Multi-Factor Required"] = user.isMultiFactorRequired ? "Yes" : "No";
|
|
break;
|
|
}
|
|
case "oauthProviders": {
|
|
data["OAuth Providers"] = user.oauthProviders.map((p) => p.id).join(", ");
|
|
break;
|
|
}
|
|
case "profileImageUrl": {
|
|
data["Profile Image URL"] = user.profileImageUrl ?? "";
|
|
break;
|
|
}
|
|
case "clientMetadata": {
|
|
data["Client Metadata"] = JSON.stringify(user.clientMetadata ?? {});
|
|
break;
|
|
}
|
|
case "clientReadOnlyMetadata": {
|
|
data["Client Read-Only Metadata"] = JSON.stringify(user.clientReadOnlyMetadata ?? {});
|
|
break;
|
|
}
|
|
case "serverMetadata": {
|
|
data["Server Metadata"] = JSON.stringify(user.serverMetadata ?? {});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
function exportToCsv(data: Record<string, unknown>[]) {
|
|
const csvConfig = mkConfig({
|
|
fieldSeparator: ",",
|
|
filename: `stack-users-export-${new Date().toISOString().split("T")[0]}`,
|
|
decimalSeparator: ".",
|
|
useKeysAsHeaders: true,
|
|
});
|
|
|
|
const csv = generateCsv(csvConfig)(data as any);
|
|
download(csvConfig)(csv);
|
|
}
|
|
|
|
function exportToJson(data: Record<string, unknown>[]) {
|
|
const jsonString = JSON.stringify(data, null, 2);
|
|
const blob = new Blob([jsonString], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `stack-users-export-${new Date().toISOString().split("T")[0]}.json`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
}
|