Merge branch 'dev' into promptless/update-analytics-tables-documentation

This commit is contained in:
promptless[bot] 2026-04-20 05:59:02 +00:00
commit f8c94acb0b
18 changed files with 2140 additions and 310 deletions

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
import { validateImageAttachments } from "@stackframe/stack-shared/dist/ai/image-limits";
import { yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { Json } from "@stackframe/stack-shared/dist/utils/json";
import { generateText, ModelMessage, stepCountIs, streamText } from "ai";
@ -55,7 +56,10 @@ export const POST = createSmartRouteHandler({
throw new StatusError(StatusError.BadRequest, imageValidationResult.reason);
}
const model = selectModel(quality, speed, isAuthenticated);
const authenticatedApiKey = isAuthenticated
? getEnvVariable("STACK_OPENROUTER_AUTHENTICATED_API_KEY", "")
: "";
const model = selectModel(quality, speed, isAuthenticated, authenticatedApiKey || undefined);
const isDocsOrSearch = systemPromptId === "docs-ask-ai" || systemPromptId === "command-center-ask-ai";
let systemPrompt = getFullSystemPrompt(systemPromptId);
if (isDocsOrSearch) {

View File

@ -1,4 +1,5 @@
import { ALLOWED_MODEL_IDS } from "@/lib/ai/models";
import { preprocessProxyBody } from "@/private";
import { handleApiRequest } from "@/route-handlers/smart-route-handler";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
@ -30,6 +31,10 @@ function sanitizeBody(raw: ArrayBuffer): Uint8Array {
parsed.metadata.user_id = parsed.metadata.user_id.slice(0, 128);
}
parsed = preprocessProxyBody({
parsedBody: parsed,
});
return new TextEncoder().encode(JSON.stringify(parsed));
}

View File

@ -2,7 +2,7 @@ import { Prisma } from "@/generated/prisma/client";
import { EmailOutboxSimpleStatus } from "@/generated/prisma/enums";
import { getClickhouseAdminClient } from "@/lib/clickhouse";
import { ClickHouseError } from "@clickhouse/client";
import { ActivitySplit, buildSplitFromDailyEntitySets } from "@/lib/metrics-activity-split";
import { ActivitySplit } from "@/lib/metrics-activity-split";
import { Tenancy } from "@/lib/tenancies";
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
@ -19,7 +19,6 @@ import {
MetricsRecentUserSchema,
} from "@stackframe/stack-shared/dist/interface/admin-metrics";
import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { userFullInclude, userPrismaToCrud, usersCrudHandlers } from "../../users/crud";
@ -58,11 +57,6 @@ function formatClickhouseDateTimeParam(date: Date): string {
return date.toISOString().slice(0, 19);
}
function normalizeUuidFromEvent(value: string): string | null {
const normalized = value.trim().toLowerCase();
return isUuid(normalized) ? normalized : null;
}
async function loadUsersByCountry(tenancy: Tenancy, includeAnonymous: boolean = false): Promise<Record<string, number>> {
const clickhouseClient = getClickhouseAdminClient();
const res = await clickhouseClient.query({
@ -163,7 +157,7 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymou
AND user_id IS NOT NULL
AND event_at >= {since:DateTime}
AND event_at < {untilExclusive:DateTime}
AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0)
AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0)
GROUP BY day
ORDER BY day ASC
`,
@ -197,29 +191,71 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymou
return out;
}
async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAnonymous: boolean): Promise<ActivitySplit> {
async function loadDailyActiveSplitFromClickhouse(options: {
tenancy: Tenancy,
now: Date,
entity: "user" | "team",
includeAnonymous: boolean,
}): Promise<ActivitySplit> {
const { tenancy, now, entity, includeAnonymous } = options;
const todayUtc = new Date(now);
todayUtc.setUTCHours(0, 0, 0, 0);
const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS);
const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS);
const clickhouseClient = getClickhouseAdminClient();
const schema = await getPrismaSchemaForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
const userRows = await clickhouseClient.query({
const idCol = entity === "user" ? "user_id" : "team_id";
// Teams don't have an is_anonymous concept, so that filter is users-only.
const anonFilter = entity === "user"
? "AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0)"
: "";
const clickhouseClient = getClickhouseAdminClient();
// Note: the inner `assumeNotNull(${idCol}) AS entity_id` must not reuse the
// column name, or ClickHouse re-resolves `WHERE ${idCol} IS NOT NULL`
// against the alias (assumeNotNull returns '' for NULLs, which passes the
// not-null test) and phantom rows slip through.
const result = await clickhouseClient.query({
query: `
SELECT
toDate(event_at) AS day,
assumeNotNull(user_id) AS user_id
FROM analytics_internal.events
WHERE event_type = '$token-refresh'
AND project_id = {projectId:String}
AND branch_id = {branchId:String}
AND user_id IS NOT NULL
AND event_at >= {since:DateTime}
AND event_at < {untilExclusive:DateTime}
AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0)
GROUP BY day, user_id
toString(w.day) AS day,
count() AS total_count,
countIf(f.first_date = w.day) AS new_count,
countIf(f.first_date < w.day AND w.prev_day = addDays(w.day, -1)) AS retained_count,
countIf(f.first_date < w.day AND (isNull(w.prev_day) OR w.prev_day < addDays(w.day, -1))) AS reactivated_count
FROM (
SELECT
day,
entity_id,
lagInFrame(day, 1) OVER (PARTITION BY entity_id ORDER BY day) AS prev_day
FROM (
SELECT DISTINCT
toDate(event_at) AS day,
assumeNotNull(${idCol}) AS entity_id
FROM analytics_internal.events
WHERE event_type = '$token-refresh'
AND project_id = {projectId:String}
AND branch_id = {branchId:String}
AND ${idCol} IS NOT NULL
AND event_at >= {since:DateTime}
AND event_at < {untilExclusive:DateTime}
${anonFilter}
)
) AS w
LEFT JOIN (
SELECT
assumeNotNull(${idCol}) AS entity_id,
toDate(min(event_at)) AS first_date
FROM analytics_internal.events
WHERE event_type = '$token-refresh'
AND project_id = {projectId:String}
AND branch_id = {branchId:String}
AND ${idCol} IS NOT NULL
AND event_at < {untilExclusive:DateTime}
${anonFilter}
GROUP BY entity_id
) AS f USING (entity_id)
GROUP BY w.day
ORDER BY w.day ASC
`,
query_params: {
projectId: tenancy.project.id,
@ -229,129 +265,35 @@ async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAno
includeAnonymous: includeAnonymous ? 1 : 0,
},
format: "JSONEachRow",
}).then((result) => result.json() as Promise<{ day: string, user_id: string }[]>);
const sanitizedUserRows = userRows.flatMap((row) => {
const userId = normalizeUuidFromEvent(row.user_id);
if (userId == null) {
return [];
}
return [{ ...row, user_id: userId }];
});
const rows = (await result.json()) as {
day: string,
total_count: string,
new_count: string,
retained_count: string,
reactivated_count: string,
}[];
const activeUserIds = [...new Set(sanitizedUserRows.map((row) => row.user_id))];
const users: { projectUserId: string, signedUpAtOrCreatedAt: Date }[] = activeUserIds.length === 0
? []
: await prisma.$replica().$queryRaw<{ projectUserId: string, signedUpAtOrCreatedAt: Date }[]>`
SELECT
"projectUserId"::text AS "projectUserId",
COALESCE("signedUpAt", "createdAt") AS "signedUpAtOrCreatedAt"
FROM ${sqlQuoteIdent(schema)}."ProjectUser"
WHERE "tenancyId" = ${tenancy.id}::UUID
AND "projectUserId" IN (${Prisma.join(activeUserIds.map((id) => Prisma.sql`${id}::UUID`))})
${includeAnonymous ? Prisma.empty : Prisma.sql`AND "isAnonymous" = false`}
`;
const byDay = new Map(rows.map((r) => [r.day.split('T')[0], r]));
const orderedDays: string[] = [];
const idsByDay = new Map<string, Set<string>>();
for (let i = 0; i <= METRICS_WINDOW_DAYS; i += 1) {
const date = new Date(since.getTime() + i * ONE_DAY_MS).toISOString().split('T')[0];
orderedDays.push(date);
idsByDay.set(date, new Set<string>());
}
for (const row of sanitizedUserRows) {
const day = row.day.split('T')[0];
const daySet = idsByDay.get(day);
if (daySet) {
daySet.add(row.user_id);
}
orderedDays.push(new Date(since.getTime() + i * ONE_DAY_MS).toISOString().split('T')[0]);
}
const split: ActivitySplit = {
total: orderedDays.map((date) => ({ date, activity: Number(byDay.get(date)?.total_count ?? 0) })),
new: orderedDays.map((date) => ({ date, activity: Number(byDay.get(date)?.new_count ?? 0) })),
retained: orderedDays.map((date) => ({ date, activity: Number(byDay.get(date)?.retained_count ?? 0) })),
reactivated: orderedDays.map((date) => ({ date, activity: Number(byDay.get(date)?.reactivated_count ?? 0) })),
};
return split;
}
const createdDayByUserId = new Map<string, string>(
users.map((user) => [user.projectUserId, user.signedUpAtOrCreatedAt.toISOString().split('T')[0]])
);
return buildSplitFromDailyEntitySets({
orderedDays,
entityIdsByDay: idsByDay,
createdDayByEntityId: createdDayByUserId,
});
async function loadDailyActiveUsersSplit(tenancy: Tenancy, now: Date, includeAnonymous: boolean): Promise<ActivitySplit> {
return await loadDailyActiveSplitFromClickhouse({ tenancy, now, entity: "user", includeAnonymous });
}
async function loadDailyActiveTeamsSplit(tenancy: Tenancy, now: Date): Promise<ActivitySplit> {
const todayUtc = new Date(now);
todayUtc.setUTCHours(0, 0, 0, 0);
const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS);
const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS);
const clickhouseClient = getClickhouseAdminClient();
const schema = await getPrismaSchemaForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
const teamRows = await clickhouseClient.query({
query: `
SELECT
toDate(event_at) AS day,
assumeNotNull(team_id) AS team_id
FROM analytics_internal.events
WHERE event_type = '$token-refresh'
AND project_id = {projectId:String}
AND branch_id = {branchId:String}
AND team_id IS NOT NULL
AND event_at >= {since:DateTime}
AND event_at < {untilExclusive:DateTime}
GROUP BY day, team_id
`,
query_params: {
projectId: tenancy.project.id,
branchId: tenancy.branchId,
since: formatClickhouseDateTimeParam(since),
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
},
format: "JSONEachRow",
}).then((result) => result.json() as Promise<{ day: string, team_id: string }[]>);
const sanitizedTeamRows = teamRows.flatMap((row) => {
const teamId = normalizeUuidFromEvent(row.team_id);
if (teamId == null) {
return [];
}
return [{ ...row, team_id: teamId }];
});
const activeTeamIds = [...new Set(sanitizedTeamRows.map((row) => row.team_id))];
const teams: { teamId: string, createdAt: Date }[] = activeTeamIds.length === 0
? []
: await prisma.$replica().$queryRaw<{ teamId: string, createdAt: Date }[]>`
SELECT "teamId"::text AS "teamId", "createdAt"
FROM ${sqlQuoteIdent(schema)}."Team"
WHERE "tenancyId" = ${tenancy.id}::UUID
AND "teamId" IN (${Prisma.join(activeTeamIds.map((id) => Prisma.sql`${id}::UUID`))})
`;
const orderedDays: string[] = [];
const idsByDay = new Map<string, Set<string>>();
for (let i = 0; i <= METRICS_WINDOW_DAYS; i += 1) {
const date = new Date(since.getTime() + i * ONE_DAY_MS).toISOString().split('T')[0];
orderedDays.push(date);
idsByDay.set(date, new Set<string>());
}
for (const row of sanitizedTeamRows) {
const day = row.day.split('T')[0];
const daySet = idsByDay.get(day);
if (daySet) {
daySet.add(row.team_id);
}
}
const createdDayByTeamId = new Map<string, string>(
teams.map((team) => [team.teamId, team.createdAt.toISOString().split('T')[0]])
);
return buildSplitFromDailyEntitySets({
orderedDays,
entityIdsByDay: idsByDay,
createdDayByEntityId: createdDayByTeamId,
});
return await loadDailyActiveSplitFromClickhouse({ tenancy, now, entity: "team", includeAnonymous: false });
}
async function loadLoginMethods(tenancy: Tenancy): Promise<{ method: string, count: number }[]> {
@ -397,6 +339,9 @@ async function loadRecentlyActiveUsers(tenancy: Tenancy, includeAnonymous: boole
return dbUsers.map((user) => userPrismaToCrud(user, tenancy.config));
}
// UUID v4 regex identical to isUuid() in stack-shared, ported to ClickHouse re2 syntax.
const MAU_UUID_V4_REGEX = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$";
async function loadMonthlyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false): Promise<number> {
const { since, untilExclusive } = getMetricsWindowBounds(now);
@ -404,17 +349,19 @@ async function loadMonthlyActiveUsers(tenancy: Tenancy, now: Date, includeAnonym
try {
const result = await clickhouseClient.query({
query: `
SELECT
assumeNotNull(user_id) AS user_id
FROM analytics_internal.events
WHERE event_type = '$token-refresh'
AND project_id = {projectId:String}
AND branch_id = {branchId:String}
AND user_id IS NOT NULL
AND event_at >= {since:DateTime}
AND event_at < {untilExclusive:DateTime}
AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0)
GROUP BY user_id
SELECT uniqExact(sipHash64(normalized_user_id)) AS mau
FROM (
SELECT lower(trim(assumeNotNull(user_id))) AS normalized_user_id
FROM analytics_internal.events
WHERE event_type = '$token-refresh'
AND project_id = {projectId:String}
AND branch_id = {branchId:String}
AND user_id IS NOT NULL
AND event_at >= {since:DateTime}
AND event_at < {untilExclusive:DateTime}
AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0)
)
WHERE match(normalized_user_id, {uuidRe:String})
`,
query_params: {
projectId: tenancy.project.id,
@ -422,18 +369,12 @@ async function loadMonthlyActiveUsers(tenancy: Tenancy, now: Date, includeAnonym
since: formatClickhouseDateTimeParam(since),
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
includeAnonymous: includeAnonymous ? 1 : 0,
uuidRe: MAU_UUID_V4_REGEX,
},
format: "JSONEachRow",
});
const rows: { user_id: string }[] = await result.json();
const uniqueUserIds = new Set<string>();
for (const row of rows) {
const normalizedUserId = normalizeUuidFromEvent(row.user_id);
if (normalizedUserId != null) {
uniqueUserIds.add(normalizedUserId);
}
}
return uniqueUserIds.size;
const rows: { mau: string | number }[] = await result.json();
return Number(rows[0]?.mau ?? 0);
} catch (error) {
// Only swallow real ClickHouse errors (e.g. project hasn't enabled
// analytics yet, transient query failure). Anything else is a programming
@ -835,7 +776,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
LEFT JOIN (
SELECT
user_id,
argMax(JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8'), event_at) AS latest_is_anonymous
argMax(coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0), event_at) AS latest_is_anonymous
FROM analytics_internal.events
WHERE event_type = '$token-refresh'
AND project_id = {projectId:String}
@ -846,7 +787,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
) AS token_refresh_users
ON e.user_id = token_refresh_users.user_id
`;
const nonAnonymousAnalyticsUserFilter = "({includeAnonymous:UInt8} = 1 OR coalesce(JSONExtract(toJSONString(e.data), 'is_anonymous', 'Nullable(UInt8)'), token_refresh_users.latest_is_anonymous, 0) = 0)";
const nonAnonymousAnalyticsUserFilter = "({includeAnonymous:UInt8} = 1 OR coalesce(CAST(e.data.is_anonymous, 'Nullable(UInt8)'), token_refresh_users.latest_is_anonymous, 0) = 0)";
const [dailyEventResult, totalVisitorResult, referrerResult, topRegionResult, onlineResult] = await Promise.all([
// Combined daily aggregates: page-view count, click count, and unique
// visitors per day — one scan over the page-view/click event types.
@ -953,7 +894,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
uniqExactIf(
assumeNotNull(user_id),
user_id IS NOT NULL
AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0)
AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0)
) AS visitors
FROM analytics_internal.events
WHERE event_type = '$token-refresh'
@ -987,7 +928,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
AND user_id IS NOT NULL
AND event_at >= {onlineSince:DateTime}
AND event_at < {untilExclusive:DateTime}
AND ({includeAnonymous:UInt8} = 1 OR JSONExtract(toJSONString(data), 'is_anonymous', 'UInt8') = 0)
AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0)
`,
query_params: {
onlineSince: formatClickhouseDateTimeParam(new Date(now.getTime() - 5 * 60 * 1000)),

View File

@ -69,15 +69,22 @@ export function createOpenRouterProvider() {
});
}
export function createDirectOpenRouterProvider(apiKey: string) {
return createOpenRouter({ apiKey });
}
export function selectModel(
quality: ModelQuality,
speed: ModelSpeed,
isAuthenticated: boolean
isAuthenticated: boolean,
directApiKey?: string,
) {
const config =
MODEL_SELECTION_MATRIX[quality][speed][isAuthenticated ? "authenticated" : "unauthenticated"];
const openrouter = createOpenRouterProvider();
const openrouter = directApiKey
? createDirectOpenRouterProvider(directApiKey)
: createOpenRouterProvider();
const model = openrouter(config.modelId);
return model;
}

View File

@ -0,0 +1,8 @@
/**
* Opaque preprocessing step applied to every parsed request body that
* flows through the AI proxy. The concrete behavior lives in the
* private implementation; the fallback is an identity function.
*/
export type AiProxyBodyProcessor = (input: {
parsedBody: Record<string, unknown>,
}) => Record<string, unknown>;

@ -1 +1 @@
Subproject commit 576f383b69a9593a9cff8d755c64c810aeeae239
Subproject commit 73d5adbbc1843fae71483b4143d44e97f00fee1b

View File

@ -1,3 +1,4 @@
import { AiProxyBodyProcessor } from "@/lib/ai/proxy-preprocessing";
import { SignUpRiskEngine } from "@/lib/risk-scores";
import { createNeutralSignUpHeuristicFacts } from "@/lib/sign-up-heuristics";
@ -9,3 +10,5 @@ export const signUpRiskEngine: SignUpRiskEngine = {
};
},
};
export const preprocessProxyBody: AiProxyBodyProcessor = ({ parsedBody }) => parsedBody;

View File

@ -1 +1 @@
export { signUpRiskEngine } from "./implementation.generated";
export { signUpRiskEngine, preprocessProxyBody } from "./implementation.generated";

View File

@ -55,7 +55,7 @@ export function DraftProgressBar({ steps, currentStep, onStepClick, disableNavig
</button>
{!isLast && (
<div className="w-20 h-1 bg-muted-foreground/15 overflow-hidden">
<div className="w-8 sm:w-20 h-1 bg-muted-foreground/15 overflow-hidden">
<div
className={cn(
"h-full bg-primary transition-all duration-300",
@ -86,7 +86,7 @@ export function DraftProgressBar({ steps, currentStep, onStepClick, disableNavig
>
{step.label}
</span>
{!isLast && <div className="w-20" />}
{!isLast && <div className="w-8 sm:w-20" />}
</div>
);
})}

View File

@ -247,10 +247,20 @@ export function DomainReputationCard() {
const capacityLabel = isBoostActive ? (
<span>
{hourlyUsed} of{" "}
<span className="text-red-500 line-through">{Math.round(baseHourlyCapacity)}</span>
{" "}
<span className="text-blue-500 font-medium">{Math.round(hourlyCapacity)}</span>
/h max
<span
className="text-red-500 line-through"
title="Base hourly capacity (replaced by active boost)"
>
{Math.round(baseHourlyCapacity)}
</span>
{" \u2192 "}
<span
className="text-blue-500 font-medium"
title="Boosted hourly capacity"
>
{Math.round(hourlyCapacity)}
</span>
/h max <span className="text-blue-500 font-medium">(boosted)</span>
</span>
) : (
`${hourlyUsed} of ${Math.round(hourlyCapacity)}/h max`
@ -260,7 +270,7 @@ export function DomainReputationCard() {
<DesignCard
gradient="default"
glassmorphic
className="w-72"
className="w-full lg:w-72"
>
<div className="flex items-center gap-2 mb-3">
<div className="p-1 rounded-md bg-foreground/[0.06] dark:bg-foreground/[0.04]">

View File

@ -134,7 +134,7 @@ export default function PageClient() {
title="Sent"
description="View email logs and domain reputation"
>
<div data-walkthrough="emails-sent" className="flex gap-6">
<div data-walkthrough="emails-sent" className="flex flex-col lg:flex-row gap-6">
{/* Left side: Email Log with toggle inside card */}
<div className="flex-1 flex flex-col gap-4">
<DesignCard

View File

@ -40,6 +40,14 @@ const SERVER_TYPE_LABELS: Record<ServerType, string> = {
standard: "Custom SMTP",
};
const MANAGED_DOMAIN_STATUS_LABELS: Record<ManagedDomainStatus, string> = {
pending_dns: "Pending DNS records",
pending_verification: "Pending verification",
verified: "Verified",
applied: "Applied",
failed: "Failed",
};
const VISIBLE_FIELDS: Record<ServerType, ServerFieldConfig[]> = {
shared: [],
managed: [],
@ -317,7 +325,7 @@ function ManagedEmailSetupDialog(props: { trigger: React.ReactNode }) {
<Alert key={domain.domainId} className="bg-slate-500/5 border-slate-500/20">
<AlertTitle className="font-mono text-xs">{domain.senderLocalPart}@{domain.subdomain}</AlertTitle>
<AlertDescription className="mt-1 flex items-center justify-between gap-2">
<span className="text-xs">Status: {domain.status}</span>
<span className="text-xs">Status: {(MANAGED_DOMAIN_STATUS_LABELS as Record<string, string>)[domain.status] ?? domain.status}</span>
<DesignButton
size="sm"
variant="secondary"

View File

@ -63,17 +63,17 @@ export default function PageClient() {
gradient="default"
contentClassName="p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2.5 rounded-xl bg-foreground/[0.04] ring-1 ring-foreground/[0.06]">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-4 min-w-0 flex-1">
<div className="p-2.5 rounded-xl bg-foreground/[0.04] ring-1 ring-foreground/[0.06] shrink-0">
<EnvelopeSimpleIcon className="h-5 w-5 text-muted-foreground" />
</div>
<Typography className="font-semibold text-foreground">
<Typography className="font-semibold text-foreground truncate">
{template.displayName}
</Typography>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="sm"

View File

@ -193,7 +193,7 @@ export default function PageClient() {
onClick: handleSaveTheme
}}
>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{themes.map((theme) => (
<ThemeOption
key={theme.id}

View File

@ -3481,7 +3481,7 @@ NiceResponse {
"date": <stripped field 'date'>,
},
{
"activity": 9,
"activity": 10,
"date": <stripped field 'date'>,
},
],
@ -3607,7 +3607,7 @@ NiceResponse {
"date": <stripped field 'date'>,
},
{
"activity": 1,
"activity": 0,
"date": <stripped field 'date'>,
},
],

View File

@ -1,126 +1 @@
// TODO: Use configPath in the prompt once local emulator is set up:
// Add "npx @stackframe/stack-cli emulator run --config-file ${configPath}" to project dev command
export const createInitPrompt = (web: boolean, configPath?: string) => `=============================
STACK AUTH SETUP INSTRUCTIONS
=============================
These instructions describe how to set up Stack Auth.
${web ? `
First of all, if you have access to a terminal, it is better to use the \`npx @stackframe/stack-cli init\`` : ""}
Install mcp server from https://mcp.stack-auth.com if not already installed
For reference, questions, or information on Stack Auth, you can query the docs on https://docs.stack-auth.com via curl or any tools that you have
## Setup
### 1) Install the package
Run the install command using whatever package manager the project uses (npm, yarn, pnpm, bun):
| Framework | Package |
|-----------|---------|
| Next.js | \`@stackframe/stack\` |
| React | \`@stackframe/react\` |
| Vanilla JS | \`@stackframe/js\` |
### 2) Create the Stack apps
Depending on whether you're on a client or a server, you will want to create stackClientApp or stackServerApp. Some environments, like Next.js, have both, so create both files.
The stack client app has client-level permissions. It contains most of the useful methods and hooks for your client-side code.
The stack server app has full read and write access to all users. It requires STACK_SECRET_SERVER_KEY env variable and should only be used in secure context
In Next.js, env vars are auto-detected (NEXT_PUBLIC_STACK_PROJECT_ID etc.), so the constructor needs no explicit config. For other frameworks, you must pass projectId explicitly using the framework's env var access method. Pass publishableClientKey only if your project is configured to require publishable client keys.
The tokenStore should be "nextjs-cookie" for Next.js, or "cookie" for all other frameworks.
Make sure to set redirectMethod on non next.js frameworks. For example for tanstack router import like so:
import { useNavigate } from '@tanstack/react-router'
\`\`\`ts
// src/stack/client.ts
import { StackClientApp } from "@stackframe/stack"; // or "@stackframe/react" or "@stackframe/js"
export const stackClientApp = new StackClientApp({
// Next.js: omit projectId/publishableClientKey (auto-detected from NEXT_PUBLIC_ env vars)
// Other frameworks: pass projectId explicitly, and publishableClientKey only if required by your project. For Vite:
// projectId: import.meta.env.VITE_STACK_PROJECT_ID,
// publishableClientKey: import.meta.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY,
tokenStore: "nextjs-cookie", // or "cookie" for non-Next.js,
// redirectMethod: { useNavigate } // or "window"
});
\`\`\`
If the framework has server-side support (e.g. Next.js), also create a server app:
\`\`\`ts
// src/stack/server.ts
import "server-only";
import { StackServerApp } from "@stackframe/stack";
import { stackClientApp } from "./client";
export const stackServerApp = new StackServerApp({
inheritsFrom: stackClientApp,
});
\`\`\`
### 3) Create the Stack handler (if available in framework)
This sets up pages for sign in, sign up, password reset, etc.
\`\`\`tsx
import { StackHandler } from "@stackframe/stack"; // Next.js
// import { StackHandler } from "@stackframe/react"; // React
export default function Handler() {
return <StackHandler fullPage />;
}
\`\`\`
### 4) Create a Suspense boundary
Suspense is necessary for many stack auth hooks such as useUser to function. Add a loading component with a custom loading indicator for the current project. Don't add if one already exists
For example:
\`\`\`tsx
//src/loading.tsx
export default function Loading() {
return <p>Loading...</p>
}
\`\`\`
### 5) Link environment variables
This is only necessary if not using local emulator. Ensure these are ignored by git.
Rename the env var keys in .env to match the framework's convention for client-exposed variables. For example, Vite requires VITE_ prefix, Next.js uses NEXT_PUBLIC_, etc. The values should stay the same only rename the keys.
The required variables are:
- Project ID (e.g. NEXT_PUBLIC_STACK_PROJECT_ID, VITE_STACK_PROJECT_ID, etc.)
- Secret server key: STACK_SECRET_SERVER_KEY (only for frameworks with server-side support, no prefix needed)
The publishable client key (e.g. NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, VITE_STACK_PUBLISHABLE_CLIENT_KEY, etc.) is only required if your project has publishable client keys enabled as a requirement.
### 6) React only: Wrap the entire page in a Stack provider
This is used for the useUser and useStackApp hooks.
\`\`\`tsx
import { StackProvider, StackTheme } from "@stackframe/stack";
import { stackClientApp } from "../stack/client"; // adjust relative path
\`\`\`
Then wrap the body content:
\`\`\`tsx
return (
<body>
<StackProvider app={stackClientApp}>
<StackTheme>{children}</StackTheme>
</StackProvider>
</body>
);
\`\`\`
`;
export { createInitPrompt } from "@stackframe/stack-shared/dist/helpers/init-prompt";

View File

@ -0,0 +1,126 @@
// TODO: Use configPath in the prompt once local emulator is set up:
// Add "npx @stackframe/stack-cli emulator run --config-file ${configPath}" to project dev command
export const createInitPrompt = (web: boolean, configPath?: string) => `=============================
STACK AUTH SETUP INSTRUCTIONS
=============================
These instructions describe how to set up Stack Auth.
${web ? `
First of all, if you have access to a terminal, it is better to use the \`npx @stackframe/stack-cli init\`` : ""}
Install mcp server from https://mcp.stack-auth.com if not already installed
For reference, questions, or information on Stack Auth, you can query the docs on https://docs.stack-auth.com via curl or any tools that you have
## Setup
### 1) Install the package
Run the install command using whatever package manager the project uses (npm, yarn, pnpm, bun):
| Framework | Package |
|-----------|---------|
| Next.js | \`@stackframe/stack\` |
| React | \`@stackframe/react\` |
| Vanilla JS | \`@stackframe/js\` |
### 2) Create the Stack apps
Depending on whether you're on a client or a server, you will want to create stackClientApp or stackServerApp. Some environments, like Next.js, have both, so create both files.
The stack client app has client-level permissions. It contains most of the useful methods and hooks for your client-side code.
The stack server app has full read and write access to all users. It requires STACK_SECRET_SERVER_KEY env variable and should only be used in secure context
In Next.js, env vars are auto-detected (NEXT_PUBLIC_STACK_PROJECT_ID etc.), so the constructor needs no explicit config. For other frameworks, you must pass projectId explicitly using the framework's env var access method. Pass publishableClientKey only if your project is configured to require publishable client keys.
The tokenStore should be "nextjs-cookie" for Next.js, or "cookie" for all other frameworks.
Make sure to set redirectMethod on non next.js frameworks. For example for tanstack router import like so:
import { useNavigate } from '@tanstack/react-router'
\`\`\`ts
// src/stack/client.ts
import { StackClientApp } from "@stackframe/stack"; // or "@stackframe/react" or "@stackframe/js"
export const stackClientApp = new StackClientApp({
// Next.js: omit projectId/publishableClientKey (auto-detected from NEXT_PUBLIC_ env vars)
// Other frameworks: pass projectId explicitly, and publishableClientKey only if required by your project. For Vite:
// projectId: import.meta.env.VITE_STACK_PROJECT_ID,
// publishableClientKey: import.meta.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY,
tokenStore: "nextjs-cookie", // or "cookie" for non-Next.js,
// redirectMethod: { useNavigate } // or "window"
});
\`\`\`
If the framework has server-side support (e.g. Next.js), also create a server app:
\`\`\`ts
// src/stack/server.ts
import "server-only";
import { StackServerApp } from "@stackframe/stack";
import { stackClientApp } from "./client";
export const stackServerApp = new StackServerApp({
inheritsFrom: stackClientApp,
});
\`\`\`
### 3) Create the Stack handler (if available in framework)
This sets up pages for sign in, sign up, password reset, etc.
\`\`\`tsx
import { StackHandler } from "@stackframe/stack"; // Next.js
// import { StackHandler } from "@stackframe/react"; // React
export default function Handler() {
return <StackHandler fullPage />;
}
\`\`\`
### 4) Create a Suspense boundary
Suspense is necessary for many stack auth hooks such as useUser to function. Add a loading component with a custom loading indicator for the current project. Don't add if one already exists
For example:
\`\`\`tsx
//src/loading.tsx
export default function Loading() {
return <p>Loading...</p>
}
\`\`\`
### 5) Link environment variables
This is only necessary if not using local emulator. Ensure these are ignored by git.
Rename the env var keys in .env to match the framework's convention for client-exposed variables. For example, Vite requires VITE_ prefix, Next.js uses NEXT_PUBLIC_, etc. The values should stay the same only rename the keys.
The required variables are:
- Project ID (e.g. NEXT_PUBLIC_STACK_PROJECT_ID, VITE_STACK_PROJECT_ID, etc.)
- Secret server key: STACK_SECRET_SERVER_KEY (only for frameworks with server-side support, no prefix needed)
The publishable client key (e.g. NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, VITE_STACK_PUBLISHABLE_CLIENT_KEY, etc.) is only required if your project has publishable client keys enabled as a requirement.
### 6) React only: Wrap the entire page in a Stack provider
This is used for the useUser and useStackApp hooks.
\`\`\`tsx
import { StackProvider, StackTheme } from "@stackframe/stack";
import { stackClientApp } from "../stack/client"; // adjust relative path
\`\`\`
Then wrap the body content:
\`\`\`tsx
return (
<body>
<StackProvider app={stackClientApp}>
<StackTheme>{children}</StackTheme>
</StackProvider>
</body>
);
\`\`\`
`;