mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Merge remote-tracking branch 'origin/dev' into devin/1783369307-cli-auth-app
This commit is contained in:
commit
292859e921
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/backend",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -12,9 +12,15 @@ const backendDir = resolve(__dirname, '..');
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(resolve(backendDir, 'package.json'), 'utf-8'));
|
||||
|
||||
// Packages that must remain as runtime imports (can't be statically bundled)
|
||||
// Packages that must remain as runtime imports (can't be statically bundled).
|
||||
// @aws-sdk and @smithy use complex class hierarchies that rolldown mis-scopes,
|
||||
// causing "StructureSchema$2 is not defined" at runtime. They're unused by the
|
||||
// migration script anyway (pulled in transitively), and node_modules is present
|
||||
// in the Docker image, so keeping them external is safe.
|
||||
const externalPackages = [
|
||||
'@prisma/client',
|
||||
'@aws-sdk',
|
||||
'@smithy',
|
||||
];
|
||||
|
||||
const customNoExternal = new Set([
|
||||
|
||||
@ -95,18 +95,24 @@ export const POST = createSmartRouteHandler({
|
||||
customerId: req.params.customer_id,
|
||||
});
|
||||
|
||||
// Read the current quantity from bulldozer-js BEFORE starting the Prisma
|
||||
// transaction. getItemQuantityForCustomer reads via HTTP from the
|
||||
// bulldozer-js server and does not use the Prisma client; keeping the
|
||||
// call inside the transaction would hold the transaction open for the
|
||||
// full HTTP round-trip, exhausting the interactive-transaction pool
|
||||
// under load and causing "Unable to start a transaction" cascades.
|
||||
const totalQuantity = await getItemQuantityForCustomer({
|
||||
prisma,
|
||||
tenancyId: tenancy.id,
|
||||
itemId: req.params.item_id,
|
||||
customerId: req.params.customer_id,
|
||||
customerType: req.params.customer_type,
|
||||
});
|
||||
if (!allowNegative && (totalQuantity + req.body.delta < 0)) {
|
||||
throw new KnownErrors.ItemQuantityInsufficientAmount(req.params.item_id, req.params.customer_id, req.body.delta);
|
||||
}
|
||||
|
||||
const change = await retryTransaction(prisma, async (tx) => {
|
||||
const totalQuantity = await getItemQuantityForCustomer({
|
||||
prisma: tx,
|
||||
tenancyId: tenancy.id,
|
||||
itemId: req.params.item_id,
|
||||
customerId: req.params.customer_id,
|
||||
customerType: req.params.customer_type,
|
||||
});
|
||||
if (!allowNegative && (totalQuantity + req.body.delta < 0)) {
|
||||
throw new KnownErrors.ItemQuantityInsufficientAmount(req.params.item_id, req.params.customer_id, req.body.delta);
|
||||
}
|
||||
// dual write - prisma and bulldozer
|
||||
const change = await tx.itemQuantityChange.create({
|
||||
data: {
|
||||
tenancyId: tenancy.id,
|
||||
|
||||
@ -105,31 +105,35 @@ export const POST = createSmartRouteHandler({
|
||||
throw new KnownErrors.VerificationCodeNotFound();
|
||||
}
|
||||
|
||||
// Read the seat cap from bulldozer-js BEFORE starting the Prisma
|
||||
// transaction. getItemQuantityForCustomer reads via HTTP from the
|
||||
// bulldozer-js server; keeping the call inside the transaction would
|
||||
// hold it open for the full HTTP round-trip, risking transaction
|
||||
// timeouts and connection-pool exhaustion.
|
||||
let maxDashboardAdmins: number | undefined;
|
||||
if (auth.tenancy.project.id === "internal" && arePlanLimitsEnforced()) {
|
||||
try {
|
||||
maxDashboardAdmins = await getItemQuantityForCustomer({
|
||||
prisma,
|
||||
tenancyId: auth.tenancy.id,
|
||||
customerId: invitationData.team_id,
|
||||
itemId: "dashboard_admins",
|
||||
customerType: "team",
|
||||
});
|
||||
} catch (error) {
|
||||
captureError("team-invitations:accept:dashboard-admins-seat-check", error);
|
||||
maxDashboardAdmins = UNLIMITED_ITEM_CAPACITY;
|
||||
}
|
||||
}
|
||||
|
||||
await retryTransaction(prisma, async (tx) => {
|
||||
if (auth.tenancy.project.id === "internal" && arePlanLimitsEnforced()) {
|
||||
if (maxDashboardAdmins !== undefined) {
|
||||
const currentMemberCount = await tx.teamMember.count({
|
||||
where: {
|
||||
tenancyId: auth.tenancy.id,
|
||||
teamId: invitationData.team_id,
|
||||
},
|
||||
});
|
||||
// The seat cap lives in bulldozer, but accepting a team invite must not
|
||||
// hard-depend on it: if bulldozer is unreachable, report it and skip the
|
||||
// check this once (treat the cap as unlimited) rather than blocking the
|
||||
// invite. The cap is re-enforced on the next accept once bulldozer is back.
|
||||
let maxDashboardAdmins: number;
|
||||
try {
|
||||
maxDashboardAdmins = await getItemQuantityForCustomer({
|
||||
prisma: tx,
|
||||
tenancyId: auth.tenancy.id,
|
||||
customerId: invitationData.team_id,
|
||||
itemId: "dashboard_admins",
|
||||
customerType: "team",
|
||||
});
|
||||
} catch (error) {
|
||||
captureError("team-invitations:accept:dashboard-admins-seat-check", error);
|
||||
maxDashboardAdmins = UNLIMITED_ITEM_CAPACITY;
|
||||
}
|
||||
if (currentMemberCount + 1 > maxDashboardAdmins) {
|
||||
throw new KnownErrors.ItemQuantityInsufficientAmount("dashboard_admins", invitationData.team_id, -1);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { ITEM_IDS, UNLIMITED } from "@hexclave/shared/dist/plans";
|
||||
import type { SubscriptionRow } from "./payments/schema/types";
|
||||
import { buildUsageRow, getNextPlanId, getPlanUsagePeriod } from "./plan-usage";
|
||||
import { buildUsageRow, getNextPlanId, getPlanUsagePeriod, readBillingSubscriptionMapOrSkip } from "./plan-usage";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function createSubscriptionPeriod(startMillis: number, endMillis: number): SubscriptionRow {
|
||||
@ -134,6 +134,32 @@ describe("plan upgrade targets", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("readBillingSubscriptionMapOrSkip", () => {
|
||||
it("returns the subscription map when the bulldozer read succeeds", async () => {
|
||||
const start = Date.UTC(2026, 4, 15);
|
||||
const end = Date.UTC(2026, 5, 15);
|
||||
const subMap = { sub_1: createSubscriptionPeriod(start, end) };
|
||||
const result = await readBillingSubscriptionMapOrSkip(() => Promise.resolve(subMap));
|
||||
expect(result).toBe(subMap);
|
||||
});
|
||||
|
||||
it("falls back to an empty map (free plan) instead of throwing when bulldozer is down", async () => {
|
||||
// Regression: plan usage is read on the dashboard's own pages (overview
|
||||
// limit banners, usage page). A bulldozer outage here used to bubble a 500
|
||||
// and take the whole dashboard down. It must now degrade to "no
|
||||
// subscription" (free plan) rather than failing the page.
|
||||
const result = await readBillingSubscriptionMapOrSkip(() => {
|
||||
throw new Error("bulldozer unreachable");
|
||||
});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("also swallows async rejections from the bulldozer read", async () => {
|
||||
const result = await readBillingSubscriptionMapOrSkip(() => Promise.reject(new Error("bulldozer timed out")));
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("billing period selection", () => {
|
||||
it("uses the subscription period when available", () => {
|
||||
const start = Date.UTC(2026, 4, 15);
|
||||
|
||||
@ -12,7 +12,7 @@ import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, getTenancy, type Te
|
||||
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, globalPrismaClient, sqlQuoteIdent } from "@/prisma-client";
|
||||
import { BASE_PLAN_IDS_BY_TIER, ITEM_IDS, PLAN_LIMITS, UNLIMITED, type ItemId, type PlanId } from "@hexclave/shared/dist/plans";
|
||||
import type { PlanUsageResponse } from "@hexclave/shared/dist/interface/admin-interface";
|
||||
import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { captureError, HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { mapWithConcurrency } from "@hexclave/shared/dist/utils/promises";
|
||||
import type { SubscriptionRow } from "./payments/schema/types";
|
||||
|
||||
@ -136,6 +136,27 @@ function getUsageItemLabel(itemId: ItemId): string {
|
||||
return USAGE_ITEM_LABELS.get(itemId) ?? throwErr(`Missing usage item label for ${itemId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the billing team's subscription map, but never lets a bulldozer outage
|
||||
* break the caller. Plan usage is surfaced only on the dashboard's own
|
||||
* (internal project) surfaces — the overview/analytics limit banners and the
|
||||
* usage page — so a hard dependency on bulldozer here would take the whole
|
||||
* dashboard down whenever bulldozer is unreachable. On failure we report the
|
||||
* error and fall back to an empty subscription map, which resolves to the free
|
||||
* plan: we skip whatever we would normally bill/enforce rather than failing the
|
||||
* page. The real plan is re-resolved on the next request once bulldozer is back.
|
||||
*/
|
||||
export async function readBillingSubscriptionMapOrSkip(
|
||||
read: () => Promise<Record<string, SubscriptionRow>>,
|
||||
): Promise<Record<string, SubscriptionRow>> {
|
||||
try {
|
||||
return await read();
|
||||
} catch (error) {
|
||||
captureError("plan-usage:subscription-map-unavailable", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveActivePlanSubscription(subscriptions: Record<string, SubscriptionRow>): SubscriptionRow | null {
|
||||
const activeSubscriptions = Object.values(subscriptions).filter(isActiveSubscription);
|
||||
for (const planId of BASE_PLAN_IDS_BY_TIER) {
|
||||
@ -394,12 +415,12 @@ export async function getPlanUsageForProject(project: UsageSourceProject, now: D
|
||||
|
||||
const internalTenancy = await getInternalBillingTenancy();
|
||||
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
|
||||
const subscriptions = await getSubscriptionMapForCustomer({
|
||||
const subscriptions = await readBillingSubscriptionMapOrSkip(() => getSubscriptionMapForCustomer({
|
||||
prisma: internalPrisma,
|
||||
tenancyId: internalTenancy.id,
|
||||
customerType: "team",
|
||||
customerId: ownerTeamId,
|
||||
});
|
||||
}));
|
||||
const activePlanSubscription = resolveActivePlanSubscription(subscriptions);
|
||||
const planId = resolveActivePlanId(activePlanSubscription);
|
||||
const period = getPlanUsagePeriod(activePlanSubscription, now);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/bulldozer-js",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/dashboard",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useViewerLocation } from '@/hooks/use-viewer-location';
|
||||
import { hexclaveAppInternalsSymbol } from "@/lib/hexclave-app-internals";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { captureError } from "@hexclave/shared/dist/utils/errors";
|
||||
@ -33,6 +34,7 @@ function GlobeErrorComponent(props: { error: Error }) {
|
||||
function GlobeSectionWithMetrics({ includeAnonymous, interactive }: { includeAnonymous: boolean, interactive?: boolean }) {
|
||||
const adminApp = useAdminApp();
|
||||
const data = (adminApp as any)[hexclaveAppInternalsSymbol].useMetrics(includeAnonymous);
|
||||
const viewerLocation = useViewerLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -42,6 +44,7 @@ function GlobeSectionWithMetrics({ includeAnonymous, interactive }: { includeAno
|
||||
totalUsers={data.total_users}
|
||||
activeUsersByCountry={data.active_users_by_country ?? {}}
|
||||
interactive={interactive}
|
||||
initialPointOfView={viewerLocation}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { US_CENTER } from '@/hooks/use-viewer-location';
|
||||
import { useWaitForIdle } from '@/hooks/use-wait-for-idle';
|
||||
import { useDashboardUser } from '@/lib/dashboard-user';
|
||||
import { useThemeWatcher } from '@/lib/theme';
|
||||
@ -359,7 +360,7 @@ type SatelliteHandle = {
|
||||
lastCountryCheckAt: number,
|
||||
};
|
||||
|
||||
export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry?: Record<string, MetricsRecentUser[]>, satelliteCount?: number, interactive?: boolean, children?: React.ReactNode}) {
|
||||
export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, initialPointOfView, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry?: Record<string, MetricsRecentUser[]>, satelliteCount?: number, interactive?: boolean, initialPointOfView?: { lat: number, lng: number }, children?: React.ReactNode}) {
|
||||
const hasWaitedForIdle = useWaitForIdle(1000, 5000);
|
||||
if (!hasWaitedForIdle) {
|
||||
return <GlobeLoading devReason="waiting for cpu" />;
|
||||
@ -372,6 +373,7 @@ export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, sa
|
||||
activeUsersByCountry={activeUsersByCountry ?? {}}
|
||||
satelliteCount={satelliteCount ?? 2}
|
||||
interactive={interactive ?? false}
|
||||
initialPointOfView={initialPointOfView}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@ -466,7 +468,7 @@ function GlobeLoading(props: { devReason: string, className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry: Record<string, MetricsRecentUser[]>, satelliteCount: number, interactive: boolean, children?: React.ReactNode}) {
|
||||
function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, initialPointOfView, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry: Record<string, MetricsRecentUser[]>, satelliteCount: number, interactive: boolean, initialPointOfView?: { lat: number, lng: number }, children?: React.ReactNode}) {
|
||||
const countries = use(countriesPromise);
|
||||
const projectId = useProjectId();
|
||||
const globeRef = useRef<GlobeMethods | undefined>(undefined);
|
||||
@ -677,7 +679,6 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
resumeRender();
|
||||
}, [cameraDistance, shouldShowGlobe, squareSize, interactive]);
|
||||
|
||||
|
||||
const totalUsersInCountries = Object.values(countryData).reduce((acc, curr) => acc + curr, 0);
|
||||
const totalPopulationInCountries = countries.features.reduce((acc, curr) => acc + curr.properties.POP_EST, 0);
|
||||
const oneSided95PercentZScore = 1.645;
|
||||
@ -1156,8 +1157,7 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
controls.enableZoom = interactive;
|
||||
controls.enableRotate = true;
|
||||
current.camera().position.z = cameraDistance;
|
||||
// Little Saint James Island, U.S. Virgin Islands
|
||||
current.pointOfView({ lat: 18.3076, lng: -64.8267 }, 0);
|
||||
current.pointOfView({ lat: initialPointOfView?.lat ?? US_CENTER.lat, lng: initialPointOfView?.lng ?? US_CENTER.lng }, 0);
|
||||
|
||||
// Fix z-fighting: Enable proper depth testing
|
||||
const renderer = current.renderer();
|
||||
|
||||
@ -18,9 +18,11 @@ import { Alert, AlertDescription, Button } from "@/components/ui";
|
||||
import { Link } from "@/components/link";
|
||||
import { useDashboardInternalUser } from "@/lib/dashboard-user";
|
||||
import { PLAN_LIMITS, resolvePlanId } from "@hexclave/shared/dist/plans";
|
||||
import { captureError } from "@hexclave/shared/dist/utils/errors";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
|
||||
import { Suspense, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { useAdminApp } from "../use-admin-app";
|
||||
|
||||
// ============================================================================
|
||||
@ -322,11 +324,45 @@ export function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () =
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan-limit banners live on core dashboard pages (overview, analytics,
|
||||
* session replays) but read the internal project's billing state from
|
||||
* bulldozer (plan usage, owned products, item quantities). Billing is not
|
||||
* essential to those pages, so a bulldozer outage must not take them down:
|
||||
* on any read failure we report it and render nothing rather than letting the
|
||||
* error escape to the page. `Suspense` keeps the banner from blocking the page
|
||||
* while its data loads.
|
||||
*/
|
||||
function BillingBannerErrorFallback({ location, error }: { location: string, error: unknown }) {
|
||||
useEffect(() => {
|
||||
captureError(location, error);
|
||||
}, [location, error]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function ResilientBillingBanner(props: { children: ReactNode, location: string }) {
|
||||
return (
|
||||
<ErrorBoundary errorComponent={({ error }) => <BillingBannerErrorFallback location={props.location} error={error} />}>
|
||||
<Suspense fallback={null}>
|
||||
{props.children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a warning banner when analytics event usage is at 80%+ or 100%.
|
||||
* Fetches the billing team's analytics_events item and computes usage against the plan's total allocation.
|
||||
*/
|
||||
export function AnalyticsEventLimitBanner() {
|
||||
return (
|
||||
<ResilientBillingBanner location="analytics-limit-banner:billing-read-failed">
|
||||
<AnalyticsEventLimitBannerContent />
|
||||
</ResilientBillingBanner>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsEventLimitBannerContent() {
|
||||
const adminApp = useAdminApp();
|
||||
const project = adminApp.useProject();
|
||||
const planUsage = adminApp.usePlanUsage();
|
||||
@ -350,6 +386,14 @@ export function AnalyticsEventLimitBanner() {
|
||||
* Since the limit is the same across all plans, no upgrade button is shown.
|
||||
*/
|
||||
export function SessionReplayLimitBanner() {
|
||||
return (
|
||||
<ResilientBillingBanner location="session-replay-limit-banner:billing-read-failed">
|
||||
<SessionReplayLimitBannerContent />
|
||||
</ResilientBillingBanner>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionReplayLimitBannerContent() {
|
||||
const adminApp = useAdminApp();
|
||||
const project = adminApp.useProject();
|
||||
const planUsage = adminApp.usePlanUsage();
|
||||
|
||||
@ -37,12 +37,13 @@ import type { CompleteConfig } from "@hexclave/shared/dist/config/schema";
|
||||
import type { RestrictedReason } from "@hexclave/shared/dist/schema-fields";
|
||||
import { urlSchema, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { captureError, HexclaveAssertionError, throwErr } from "@hexclave/shared/dist/utils/errors";
|
||||
import { allProviders } from "@hexclave/shared/dist/utils/oauth";
|
||||
import { typedFromEntries, typedEntries } from "@hexclave/shared/dist/utils/objects";
|
||||
import { resolvePlanId } from "@hexclave/shared/dist/plans";
|
||||
import { generateUuid } from "@hexclave/shared/dist/utils/uuids";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
|
||||
import { Suspense, useEffect, useId, useMemo, useState } from "react";
|
||||
import { AppEnabledGuard } from "../app-enabled-guard";
|
||||
import { PageLayout } from "../page-layout";
|
||||
import { useAdminApp } from "../use-admin-app";
|
||||
@ -193,7 +194,24 @@ function AddCustomOidcButton({ onClick }: { onClick: () => void }) {
|
||||
return <AddCustomOidcButtonDisabled onClick={onClick} isTeamPlanOrAbove={false} />;
|
||||
}
|
||||
|
||||
return <AddCustomOidcButtonInner team={ownerTeam} onClick={onClick} />;
|
||||
// The plan check reads the internal project's owned products from bulldozer.
|
||||
// That gate must not break the auth-methods page if bulldozer is down: on a
|
||||
// read failure (or while it loads) we report it and fall back to the locked
|
||||
// (non-Team) state, which is the same safe default as having no owner team.
|
||||
return (
|
||||
<ErrorBoundary errorComponent={({ error }) => <AddCustomOidcButtonPlanReadFailed onClick={onClick} error={error} />}>
|
||||
<Suspense fallback={<AddCustomOidcButtonDisabled onClick={onClick} isTeamPlanOrAbove={false} />}>
|
||||
<AddCustomOidcButtonInner team={ownerTeam} onClick={onClick} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function AddCustomOidcButtonPlanReadFailed({ onClick, error }: { onClick: () => void, error: unknown }) {
|
||||
useEffect(() => {
|
||||
captureError("auth-methods:custom-oidc-plan-gate", error);
|
||||
}, [error]);
|
||||
return <AddCustomOidcButtonDisabled onClick={onClick} isTeamPlanOrAbove={false} />;
|
||||
}
|
||||
|
||||
function AddCustomOidcButtonInner({
|
||||
|
||||
33
apps/dashboard/src/app/api/viewer-location/route.ts
Normal file
33
apps/dashboard/src/app/api/viewer-location/route.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { headers } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* Returns the dashboard viewer's approximate latitude and longitude,
|
||||
* derived from IP-based geolocation headers injected by the CDN/proxy
|
||||
* (Vercel, Cloudflare). No browser permissions are required.
|
||||
*
|
||||
* Returns `{ lat, lng }` when geo headers are available, otherwise
|
||||
* `{ lat: null, lng: null }` so the caller can fall back.
|
||||
*/
|
||||
export async function GET() {
|
||||
const allHeaders = await headers();
|
||||
|
||||
// Vercel injects lat/lng headers on every edge/serverless request.
|
||||
const vercelLat = allHeaders.get("x-vercel-ip-latitude");
|
||||
const vercelLng = allHeaders.get("x-vercel-ip-longitude");
|
||||
|
||||
if (vercelLat != null && vercelLng != null) {
|
||||
const lat = parseFloat(vercelLat);
|
||||
const lng = parseFloat(vercelLng);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
return NextResponse.json({ lat, lng });
|
||||
}
|
||||
}
|
||||
|
||||
// Cloudflare provides a cf-ipcountry header but no lat/lng, so we can't
|
||||
// do much with it here. Fall back gracefully.
|
||||
|
||||
return NextResponse.json({ lat: null, lng: null });
|
||||
}
|
||||
67
apps/dashboard/src/hooks/use-viewer-location.tsx
Normal file
67
apps/dashboard/src/hooks/use-viewer-location.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { captureError } from "@hexclave/shared/dist/utils/errors";
|
||||
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type ViewerLocation = { lat: number, lng: number };
|
||||
|
||||
// US geographic center fallback
|
||||
export const US_CENTER: ViewerLocation = { lat: 39.5, lng: -98.35 };
|
||||
|
||||
// Cache the result so we only fetch once per page load, even if multiple
|
||||
// components mount/unmount the hook.
|
||||
let cachedLocation: ViewerLocation | null = null;
|
||||
let fetchPromise: Promise<ViewerLocation> | null = null;
|
||||
|
||||
function fetchViewerLocation(): Promise<ViewerLocation> {
|
||||
if (fetchPromise != null) return fetchPromise;
|
||||
fetchPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch("/api/viewer-location");
|
||||
if (!res.ok) {
|
||||
fetchPromise = null;
|
||||
return US_CENTER;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (typeof data.lat === "number" && typeof data.lng === "number") {
|
||||
return { lat: data.lat, lng: data.lng };
|
||||
}
|
||||
} catch (e) {
|
||||
captureError("viewer-location-fetch", e instanceof Error ? e : new Error(String(e)));
|
||||
fetchPromise = null;
|
||||
}
|
||||
// Header data was missing or malformed; allow retry on next mount.
|
||||
fetchPromise = null;
|
||||
return US_CENTER;
|
||||
})();
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dashboard viewer's approximate location based on IP
|
||||
* geolocation (via CDN headers), falling back to the US center.
|
||||
* The fetch runs once per page load and the result is cached.
|
||||
*/
|
||||
export function useViewerLocation(): ViewerLocation {
|
||||
const [location, setLocation] = useState<ViewerLocation>(() => cachedLocation ?? US_CENTER);
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedLocation != null) return;
|
||||
let cancelled = false;
|
||||
runAsynchronously(async () => {
|
||||
const loc = await fetchViewerLocation();
|
||||
// Only cache a successful (non-fallback) result so transient
|
||||
// failures can be retried on next mount.
|
||||
if (loc !== US_CENTER) {
|
||||
cachedLocation = loc;
|
||||
}
|
||||
if (!cancelled) setLocation(loc);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return location;
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/dev-launchpad",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/e2e-tests",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hexclave/hosted-components",
|
||||
"private": true,
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}09",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hexclave/internal-tool",
|
||||
"private": true,
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/pre-dev.mjs && next dev --turbopack --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}41",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/mcp",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/mock-oauth-server",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/skills",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/docs-mintlify",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "mint dev --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}04 --no-open",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/docs",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-cjs-test",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/convex-example",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-demo-app",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "",
|
||||
"private": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/docs-examples",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "",
|
||||
"private": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/e-commerce-demo",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/js-example",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"description": "",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hexclave/lovable-react-18-example",
|
||||
"private": true,
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-middleware-demo",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-supabase",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/example-tanstack-start-demo",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "TanStack Start demo app for Hexclave",
|
||||
"private": true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/cli",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"description": "The CLI for Hexclave. https://hexclave.com",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/dashboard-ui-components",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/js",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/next",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/react",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/sc",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"exports": {
|
||||
"./force-react-server": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/shared-backend",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/shared",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"scripts": {
|
||||
"build": "rimraf dist && tsdown",
|
||||
|
||||
@ -559,7 +559,7 @@ const appSetupPrompt: Record<PublicAppSetupPromptId, string> =
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
For large OAuth providers like Google and GitHub, Hexclave automatically provides a client ID and secret, so no extra provider setup is needed.
|
||||
For some OAuth providers like Google and GitHub, Hexclave automatically provides a client ID and secret, so no extra provider setup is needed.
|
||||
|
||||
Then wire the SDK setup above: create the Hexclave App object, wrap React apps in the provider, and add auth pages where your framework needs them. OAuth client IDs/secrets and trusted domains are environment-specific, so leave placeholders or ask the user for those instead of inventing them. See [Auth providers](https://docs.hexclave.com/guides/apps/authentication/auth-providers) and [hexclave.config.ts: Auth](https://docs.hexclave.com/guides/going-further/hexclave-config#auth).
|
||||
`,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/tanstack-start",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
"//": "NEXT_LINE_PLATFORM template",
|
||||
"private": true,
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
|
||||
"name": "@hexclave/template",
|
||||
"private": true,
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/ui",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"repository": "https://github.com/hexclave/hexclave",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/swift-sdk",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"private": true,
|
||||
"description": "Hexclave Swift SDK",
|
||||
"scripts": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hexclave/sdk-spec",
|
||||
"version": "1.0.51",
|
||||
"version": "1.0.52",
|
||||
"private": true,
|
||||
"description": "Hexclave SDK specification files",
|
||||
"scripts": {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user