diff --git a/.github/workflows/docker-server-build-run.yaml b/.github/workflows/docker-server-build-run.yaml index a14aa2817e..611be5a8c5 100644 --- a/.github/workflows/docker-server-build-run.yaml +++ b/.github/workflows/docker-server-build-run.yaml @@ -36,7 +36,7 @@ jobs: - name: Run Docker container and check logs run: | docker run --add-host=host.docker.internal:host-gateway --env-file docker/server/.env.example -p 8101:8101 -p 8102:8102 -d --name stackframe-server server - sleep 60 + sleep 120 docker logs -t stackframe-server - name: Check server health diff --git a/.vscode/settings.json b/.vscode/settings.json index 8db8815fec..1fda6ee8b4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,8 @@ "typescript.tsdk": "node_modules/typescript/lib", "editor.tabSize": 2, "cSpell.words": [ + "sparkline", + "Clickhouse", "pushable", "autoupdate", "backlinks", diff --git a/AGENTS.md b/AGENTS.md index fd74263505..8fe66efc9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,10 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub - If there is an external browser tool connected, use it to test changes you make to the frontend when possible. - Whenever you update an SDK implementation in `sdks/implementations`, make sure to update the specs accordingly in `sdks/specs` such that if you reimplemented the entire SDK from the specs again, you would get the same implementation. (For example, if the specs are not precise enough to describe a change you made, make the specs more precise.) - When building internal tools for Stack Auth developers (eg. internal interfaces like the WAL info log etc.): Make the interfaces look very concise, assume the user is a pro-user. This only applies to internal tools that are used primarily by Stack Auth developers. +- When building frontend or React code for the dashboard, refer to DESIGN-GUIDE.md. +- NEVER implement a hacky solution without EXPLICIT approval from the user. Always go the extra mile to make sure the solution is clean, maintainable, and robust. +- Fail early, fail loud. Fail fast with an error instead of silently continuing. +- Do NOT use `as`/`any`/type casts or anything else like that to bypass the type system unless you specifically asked the user about it. Most of the time a place where you would use type casts is not one where you actually need them. Avoid wherever possible. ### Code-related - Use ES6 maps instead of records wherever you can. diff --git a/apps/backend/package.json b/apps/backend/package.json index da95683e28..824b5b1e3e 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -82,6 +82,7 @@ "@vercel/sandbox": "^1.2.0", "ai": "^4.3.17", "bcrypt": "^5.1.1", + "cel-js": "^0.8.2", "chokidar-cli": "^3.0.0", "dotenv": "^16.4.5", "dotenv-cli": "^7.3.0", diff --git a/apps/backend/prisma/migrations/20260201400000_add_restricted_by_admin_fields/migration.sql b/apps/backend/prisma/migrations/20260201400000_add_restricted_by_admin_fields/migration.sql new file mode 100644 index 0000000000..b5820def29 --- /dev/null +++ b/apps/backend/prisma/migrations/20260201400000_add_restricted_by_admin_fields/migration.sql @@ -0,0 +1,3 @@ +-- Add restricted by admin fields to ProjectUser +ALTER TABLE "ProjectUser" ADD COLUMN "restrictedByAdmin" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "ProjectUser" ADD COLUMN "restrictedByAdminReason" TEXT; diff --git a/apps/backend/prisma/migrations/20260201400001_add_restricted_by_admin_constraint/migration.sql b/apps/backend/prisma/migrations/20260201400001_add_restricted_by_admin_constraint/migration.sql new file mode 100644 index 0000000000..9f1fd5f19d --- /dev/null +++ b/apps/backend/prisma/migrations/20260201400001_add_restricted_by_admin_constraint/migration.sql @@ -0,0 +1,10 @@ +-- Add restrictedByAdminPrivateDetails column +ALTER TABLE "ProjectUser" ADD COLUMN "restrictedByAdminPrivateDetails" TEXT; + +-- Add constraint: When restrictedByAdmin is false, both reason and private details must be null +-- When restrictedByAdmin is true, reason and private details are optional +ALTER TABLE "ProjectUser" ADD CONSTRAINT "ProjectUser_restricted_by_admin_consistency" + CHECK ( + ("restrictedByAdmin" = true) OR + ("restrictedByAdmin" = false AND "restrictedByAdminReason" IS NULL AND "restrictedByAdminPrivateDetails" IS NULL) + ); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index e5d4f31073..ed724cc0e3 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -199,6 +199,11 @@ model ProjectUser { totpSecret Bytes? isAnonymous Boolean @default(false) + // Admin restriction fields - can be set by signup rules or manually by admins + restrictedByAdmin Boolean @default(false) + restrictedByAdminReason String? // Publicly viewable reason (shown to user) + restrictedByAdminPrivateDetails String? // Private details (server access only) + projectUserOAuthAccounts ProjectUserOAuthAccount[] teamMembers TeamMember[] contactChannels ContactChannel[] diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index 12596984e7..a202fa63bd 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -327,6 +327,12 @@ const handler = createSmartRouteHandler({ currentUser, displayName: userInfo.displayName ?? undefined, profileImageUrl: userInfo.profileImageUrl ?? undefined, + signUpRuleOptions: { + authMethod: 'oauth', + oauthProvider: provider.id, + // Note: Request context not easily available in OAuth callback + // TODO: Pass IP and user agent from stored OAuth state if needed + }, } ); diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx index 673501f1a9..a4d1071349 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/apple/native/route.tsx @@ -125,6 +125,11 @@ export const POST = createSmartRouteHandler({ email: appleUser.email, emailVerified: appleUser.emailVerified, primaryEmailAuthEnabled, + signUpRuleOptions: { + authMethod: 'oauth', + oauthProvider: 'apple', + // Note: Request context not easily available in native OAuth callback + }, }); projectUserId = result.projectUserId; isNewUser = true; diff --git a/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx b/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx index 8d7953d283..f16ada136c 100644 --- a/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/auth/otp/sign-in/verification-code-handler.tsx @@ -2,7 +2,7 @@ import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-chann import { sendEmailFromDefaultTemplate } from "@/lib/emails"; import { getSoleTenancyFromProjectBranch, Tenancy } from "@/lib/tenancies"; import { createAuthTokens } from "@/lib/tokens"; -import { createOrUpgradeAnonymousUser } from "@/lib/users"; +import { createOrUpgradeAnonymousUserWithRules } from "@/lib/users"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; import { VerificationCodeType } from "@/generated/prisma/client"; @@ -105,7 +105,9 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({ let isNewUser = false; if (!user) { - user = await createOrUpgradeAnonymousUser( + // Note: Request context (IP, user agent) is not available in verification code handler + // The rule evaluation will proceed with limited context + user = await createOrUpgradeAnonymousUserWithRules( tenancy, currentUser ?? null, { @@ -114,7 +116,11 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({ primary_email_auth_enabled: true, otp_auth_enabled: true, }, - [] + [], + { + authMethod: 'otp', + // TODO: Pass request context when available in verification code handler + } ); isNewUser = true; } diff --git a/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx b/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx index f8689d2c6a..a97a128527 100644 --- a/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx +++ b/apps/backend/src/app/api/latest/auth/password/sign-up/route.tsx @@ -1,6 +1,6 @@ import { validateRedirectUrl } from "@/lib/redirect-urls"; import { createAuthTokens } from "@/lib/tokens"; -import { createOrUpgradeAnonymousUser } from "@/lib/users"; +import { createOrUpgradeAnonymousUserWithRules } from "@/lib/users"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; import { KnownErrors } from "@stackframe/stack-shared"; @@ -54,7 +54,7 @@ export const POST = createSmartRouteHandler({ throw passwordError; } - const createdUser = await createOrUpgradeAnonymousUser( + const createdUser = await createOrUpgradeAnonymousUserWithRules( tenancy, currentUser ?? null, { @@ -63,7 +63,10 @@ export const POST = createSmartRouteHandler({ primary_email_auth_enabled: true, password, }, - [KnownErrors.UserWithEmailAlreadyExists] + [KnownErrors.UserWithEmailAlreadyExists], + { + authMethod: 'password', + } ); if (verificationCallbackUrl) { diff --git a/apps/backend/src/app/api/latest/internal/analytics/query/route.ts b/apps/backend/src/app/api/latest/internal/analytics/query/route.ts index 197478483d..4e9a4ed484 100644 --- a/apps/backend/src/app/api/latest/internal/analytics/query/route.ts +++ b/apps/backend/src/app/api/latest/internal/analytics/query/route.ts @@ -1,4 +1,4 @@ -import { getClickhouseExternalClient, isClickhouseConfigured } from "@/lib/clickhouse"; +import { getClickhouseExternalClient } from "@/lib/clickhouse"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, adminAuthTypeSchema, jsonSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields"; @@ -36,9 +36,6 @@ export const POST = createSmartRouteHandler({ if (body.include_all_branches) { throw new StackAssertionError("include_all_branches is not supported yet"); } - if (!isClickhouseConfigured()) { - throw new StackAssertionError("ClickHouse is not configured"); - } const client = getClickhouseExternalClient(); const queryId = `${auth.tenancy.project.id}:${auth.tenancy.branchId}:${randomUUID()}`; const resultSet = await Result.fromPromise(client.query({ diff --git a/apps/backend/src/app/api/latest/internal/analytics/query/timing/route.ts b/apps/backend/src/app/api/latest/internal/analytics/query/timing/route.ts index 04bb395653..927aa32955 100644 --- a/apps/backend/src/app/api/latest/internal/analytics/query/timing/route.ts +++ b/apps/backend/src/app/api/latest/internal/analytics/query/timing/route.ts @@ -1,8 +1,7 @@ -import { getClickhouseExternalClient, getQueryTimingStatsForProject, isClickhouseConfigured } from "@/lib/clickhouse"; +import { getClickhouseExternalClient, getQueryTimingStatsForProject } from "@/lib/clickhouse"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; export const POST = createSmartRouteHandler({ metadata: { hidden: true }, @@ -26,10 +25,6 @@ export const POST = createSmartRouteHandler({ }).defined(), }), async handler({ body, auth }) { - if (!isClickhouseConfigured()) { - throw new StackAssertionError("ClickHouse is not configured"); - } - const expectedPrefix = `${auth.tenancy.project.id}:${auth.tenancy.branchId}:`; if (!body.query_id.startsWith(expectedPrefix)) { throw new KnownErrors.ItemNotFound(body.query_id); diff --git a/apps/backend/src/app/api/latest/internal/clickhouse/migrate-events/route.tsx b/apps/backend/src/app/api/latest/internal/clickhouse/migrate-events/route.tsx index 2ec70d1765..bc6caf484b 100644 --- a/apps/backend/src/app/api/latest/internal/clickhouse/migrate-events/route.tsx +++ b/apps/backend/src/app/api/latest/internal/clickhouse/migrate-events/route.tsx @@ -1,5 +1,5 @@ import type { Prisma } from "@/generated/prisma/client"; -import { getClickhouseAdminClient, isClickhouseConfigured } from "@/lib/clickhouse"; +import { getClickhouseAdminClient } from "@/lib/clickhouse"; import { endUserIpInfoSchema, type EndUserIpInfo } from "@/lib/events"; import { DEFAULT_BRANCH_ID } from "@/lib/tenancies"; import { globalPrismaClient } from "@/prisma-client"; @@ -192,9 +192,6 @@ export const POST = createSmartRouteHandler({ let migratedEvents = 0; if (events.length) { - if (!isClickhouseConfigured()) { - throw new StatusError(StatusError.ServiceUnavailable, "ClickHouse is not configured"); - } const clickhouseClient = getClickhouseAdminClient(); const rowsToInsert = events.map(createClickhouseRow); migratedEvents = events.length; diff --git a/apps/backend/src/app/api/latest/internal/sign-up-rules-stats/route.tsx b/apps/backend/src/app/api/latest/internal/sign-up-rules-stats/route.tsx new file mode 100644 index 0000000000..ad541f3d8a --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/sign-up-rules-stats/route.tsx @@ -0,0 +1,136 @@ +import { getClickhouseAdminClient } from "@/lib/clickhouse"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +const ANALYTICS_HOURS = 48; + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + // Triggers per rule with hourly breakdown for sparklines + rule_triggers: yupArray(yupObject({ + rule_id: yupString().defined(), + total_count: yupNumber().integer().defined(), + hourly_counts: yupArray(yupObject({ + hour: yupString().defined(), + count: yupNumber().integer().defined(), + }).defined()).defined(), + }).defined()).defined(), + // Summary stats + total_triggers: yupNumber().integer().defined(), + triggers_by_action: yupObject({ + allow: yupNumber().integer().defined(), + reject: yupNumber().integer().defined(), + restrict: yupNumber().integer().defined(), + log: yupNumber().integer().defined(), + }).defined(), + }).defined(), + }), + handler: async (req) => { + const projectId = req.auth.tenancy.project.id; + const branchId = req.auth.tenancy.branchId; + + // Generate hour keys for the sparkline + const now = new Date(); + now.setUTCMinutes(0, 0, 0); + const since = new Date(now.getTime() - (ANALYTICS_HOURS - 1) * 60 * 60 * 1000); + const hourKeys = Array.from({ length: ANALYTICS_HOURS }, (_, index) => { + const hour = new Date(since.getTime() + index * 60 * 60 * 1000); + return hour.toISOString().slice(0, 13) + ':00:00.000Z'; + }); + + const client = getClickhouseAdminClient(); + + const result = await client.query({ + query: ` + SELECT + data.ruleId as rule_id, + data.action as action, + toStartOfHour(event_at) as hour + FROM analytics_internal.events + WHERE event_type = '$sign-up-rule-trigger' + AND project_id = {projectId:String} + AND branch_id = {branchId:String} + AND event_at >= {since:DateTime} + ORDER BY event_at ASC + `, + query_params: { + projectId, + branchId, + since: since.toISOString().slice(0, 19), + }, + format: "JSONEachRow", + }); + const rows: { + rule_id: string, + action: "allow" | "reject" | "restrict" | "log", + hour: string, + }[] = await result.json(); + + // Group by rule and hour for sparkline data + const ruleTriggersMap = new Map, + }>(); + + // Summary counts by action + const actionCounts = { + allow: 0, + reject: 0, + restrict: 0, + log: 0, + }; + + for (const row of rows) { + // Update action counts + const action = row.action; + if (action in actionCounts) { + actionCounts[action]++; + } + + // Update rule triggers + let ruleData = ruleTriggersMap.get(row.rule_id); + if (!ruleData) { + ruleData = { totalCount: 0, hourlyMap: new Map() }; + ruleTriggersMap.set(row.rule_id, ruleData); + } + ruleData.totalCount++; + + // Group by hour (normalize to ISO format) + // ClickHouse returns datetime without timezone, treat as UTC + const hourKey = new Date(row.hour + 'Z').toISOString().slice(0, 13) + ':00:00.000Z'; + ruleData.hourlyMap.set(hourKey, (ruleData.hourlyMap.get(hourKey) ?? 0) + 1); + } + + // Build hourly breakdown for each rule + const ruleTriggers = Array.from(ruleTriggersMap.entries()).map(([ruleId, data]) => ({ + rule_id: ruleId, + total_count: data.totalCount, + hourly_counts: hourKeys.map((hour) => ({ + hour, + count: data.hourlyMap.get(hour) ?? 0, + })), + })); + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: { + rule_triggers: ruleTriggers, + total_triggers: rows.length, + triggers_by_action: actionCounts, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index c05dde3e5e..49f4641b0f 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -92,7 +92,8 @@ type OnboardingConfig = { /** * Computes the restricted status and reason for a user based on their data and config. - * A user is "restricted" if they've signed up but haven't completed onboarding requirements. + * A user can be "restricted" for various reasons, for example if they've signed up but haven't completed onboarding + * requirements, or they've been restricted by an administrator via sign-up rules or manual admin action. * * The config parameter accepts any object with an optional `onboarding.requireEmailVerification` property. * This allows passing various config types (EnvironmentRenderedConfig, CompleteConfig, etc.) without type errors. @@ -101,7 +102,8 @@ export function computeRestrictedStatus( isAnonymous: boolean, primaryEmailVerified: boolean, config: T, -): { isRestricted: false, restrictedReason: null } | { isRestricted: true, restrictedReason: { type: "anonymous" | "email_not_verified" } } { + restrictedByAdmin?: boolean, +): { isRestricted: false, restrictedReason: null } | { isRestricted: true, restrictedReason: { type: "anonymous" | "email_not_verified" | "restricted_by_administrator" } } { // note: when you implement this function, make sure to also update the filter in the list users endpoint // Anonymous users are always restricted (they need to sign up first) @@ -110,10 +112,16 @@ export function computeRestrictedStatus( } // Check email verification requirement (default to false if not configured) + // This takes precedence over admin restriction because it's user-actionable if (config.onboarding.requireEmailVerification && !primaryEmailVerified) { return { isRestricted: true, restrictedReason: { type: "email_not_verified" } }; } + // Check if user was restricted by administrator (e.g., via sign-up rules or manual admin action) + if (restrictedByAdmin) { + return { isRestricted: true, restrictedReason: { type: "restricted_by_administrator" } }; + } + // EXTENSIBILITY: Add more conditions here in the future // e.g., phone verification, manual approval, etc. @@ -141,6 +149,7 @@ export const userPrismaToCrud = ( prisma.isAnonymous, primaryEmailVerified, config, + prisma.restrictedByAdmin, ); const result = { @@ -170,6 +179,9 @@ export const userPrismaToCrud = ( is_anonymous: prisma.isAnonymous, is_restricted: isRestricted, restricted_reason: restrictedReason, + restricted_by_admin: prisma.restrictedByAdmin, + restricted_by_admin_reason: prisma.restrictedByAdminReason, + restricted_by_admin_private_details: prisma.restrictedByAdminPrivateDetails, }; return result; }; @@ -347,6 +359,7 @@ export function getUserQuery(projectId: string, branchId: string, userId: string row.isAnonymous, primaryEmailContactChannel?.isVerified || false, config, + row.restrictedByAdmin, ); return { @@ -384,6 +397,9 @@ export function getUserQuery(projectId: string, branchId: string, userId: string is_anonymous: row.isAnonymous, is_restricted: restrictedStatus.isRestricted, restricted_reason: restrictedStatus.restrictedReason, + restricted_by_admin: row.restrictedByAdmin, + restricted_by_admin_reason: row.restrictedByAdminReason, + restricted_by_admin_private_details: row.restrictedByAdminPrivateDetails, }; }, }; @@ -417,6 +433,7 @@ export function getUserIfOnGlobalPrismaClientQuery( user.is_anonymous, user.primary_email_verified, config, + user.restricted_by_admin, ); return { ...user, @@ -481,9 +498,9 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC const includeAnonymous = query.include_anonymous === "true"; const includeRestricted = query.include_restricted === "true" || includeAnonymous; // include_anonymous also includes restricted - // Compute whether we need to filter out restricted users based on email verification // TODO: Instead of hardcoding this, we should use computeRestrictedStatus const shouldFilterRestrictedByEmail = !includeRestricted && auth.tenancy.config.onboarding.requireEmailVerification; + const shouldFilterRestrictedByAdmin = !includeRestricted; const where = { tenancyId: auth.tenancy.id, @@ -509,6 +526,9 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC }, }, } : {}, + ...shouldFilterRestrictedByAdmin ? { + restrictedByAdmin: false, + } : {}, ...query.query ? { OR: [ ...isUuid(queryWithoutSpecialChars!) ? [{ @@ -587,6 +607,23 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC const config = auth.tenancy.config; + // Validate restricted_by_admin fields consistency + const restrictedByAdmin = data.restricted_by_admin ?? false; + let restrictedByAdminReason = data.restricted_by_admin_reason === undefined + ? undefined + : (data.restricted_by_admin_reason || null); + let restrictedByAdminPrivateDetails = data.restricted_by_admin_private_details === undefined + ? undefined + : (data.restricted_by_admin_private_details || null); + + if (!restrictedByAdmin) { + if (restrictedByAdminReason != null) { + throw new StatusError(StatusError.BadRequest, "restricted_by_admin_reason requires restricted_by_admin=true"); + } + if (restrictedByAdminPrivateDetails != null) { + throw new StatusError(StatusError.BadRequest, "restricted_by_admin_private_details requires restricted_by_admin=true"); + } + } const newUser = await tx.projectUser.create({ data: { @@ -599,7 +636,10 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC serverMetadata: data.server_metadata === null ? Prisma.JsonNull : data.server_metadata, totpSecret: data.totp_secret_base64 == null ? data.totp_secret_base64 : Buffer.from(decodeBase64(data.totp_secret_base64)), isAnonymous: data.is_anonymous ?? false, - profileImageUrl: await uploadAndGetUrl(data.profile_image_url, "user-profile-images") + profileImageUrl: await uploadAndGetUrl(data.profile_image_url, "user-profile-images"), + restrictedByAdmin, + restrictedByAdminReason, + restrictedByAdminPrivateDetails, }, include: userFullInclude, }); @@ -1059,6 +1099,30 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC }); } + let restrictedByAdminReason = data.restricted_by_admin_reason === undefined + ? undefined + : (data.restricted_by_admin_reason || null); + + let restrictedByAdminPrivateDetails = data.restricted_by_admin_private_details === undefined + ? undefined + : (data.restricted_by_admin_private_details || null); + + // Compute effective restricted flag considering existing value for PATCH updates + const effectiveRestrictedByAdmin = data.restricted_by_admin ?? oldUser.restrictedByAdmin; + + if (!effectiveRestrictedByAdmin) { + // User is not (or will not be) restricted - reason/details must not be provided + if (restrictedByAdminReason != null) { + throw new StatusError(StatusError.BadRequest, "restricted_by_admin_reason requires restricted_by_admin=true"); + } + if (restrictedByAdminPrivateDetails != null) { + throw new StatusError(StatusError.BadRequest, "restricted_by_admin_private_details requires restricted_by_admin=true"); + } + // Clear reason and details when unrestricting + restrictedByAdminReason = null; + restrictedByAdminPrivateDetails = null; + } + const db = await tx.projectUser.update({ where: { tenancyId_projectUserId: { @@ -1074,7 +1138,10 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC requiresTotpMfa: data.totp_secret_base64 === undefined ? undefined : (data.totp_secret_base64 !== null), totpSecret: data.totp_secret_base64 == null ? data.totp_secret_base64 : Buffer.from(decodeBase64(data.totp_secret_base64)), isAnonymous: data.is_anonymous ?? undefined, - profileImageUrl: await uploadAndGetUrl(data.profile_image_url, "user-profile-images") + profileImageUrl: await uploadAndGetUrl(data.profile_image_url, "user-profile-images"), + restrictedByAdmin: data.restricted_by_admin ?? undefined, + restrictedByAdminReason: restrictedByAdminReason, + restrictedByAdminPrivateDetails: restrictedByAdminPrivateDetails, }, include: userFullInclude, }); diff --git a/apps/backend/src/lib/cel-evaluator.ts b/apps/backend/src/lib/cel-evaluator.ts new file mode 100644 index 0000000000..33900b1fb1 --- /dev/null +++ b/apps/backend/src/lib/cel-evaluator.ts @@ -0,0 +1,253 @@ +import { evaluate } from "cel-js"; +import { normalizeEmail } from "./emails"; + +/** + * Custom error class for CEL evaluation failures. + * Used to distinguish CEL-specific errors from other unexpected errors. + */ +export class CelEvaluationError extends Error { + public readonly customCaptureExtraArgs: unknown[]; + + constructor( + message: string, + public readonly expression: string, + public readonly cause?: unknown + ) { + super(message); + this.name = 'CelEvaluationError'; + // Extra context for structured logging via captureError + this.customCaptureExtraArgs = [{ expression, cause }]; + } +} + +/** + * Context variables available for sign-up rule CEL expressions. + */ +export type SignUpRuleContext = { + /** User's email address */ + email: string, + /** Domain part of email (after @) */ + emailDomain: string, + /** Authentication method: "password", "otp", "oauth", "passkey" */ + authMethod: 'password' | 'otp' | 'oauth' | 'passkey', + /** OAuth provider ID if authMethod is "oauth", empty string otherwise */ + oauthProvider: string, +}; + +/** + * Pre-processes a CEL expression to transform method calls into function calls + * that cel-js can evaluate. + * + * Transforms: + * - `str.contains("x")` → `_method_0` (pre-computed in context) + * - `str.startsWith("x")` → `_method_1` (pre-computed in context) + * - etc. + * + * Since cel-js doesn't support method calls, we pre-compute these values + * and add them to the context with unique keys to avoid collisions. + */ +function unescapeCelString(escaped: string): string { + return escaped.replace(/\\\\/g, '\\').replace(/\\"/g, '"'); +} + +function preprocessExpression( + expression: string, + context: SignUpRuleContext +): { expression: string, context: Record } { + const extendedContext: Record = { ...context }; + + // Pattern to match method calls: identifier.method("literal with optional escaped quotes") + // We handle: contains, startsWith, endsWith, matches + const methodPattern = /(\w+)\.(contains|startsWith|endsWith|matches)\s*\(\s*"((?:\\.|[^"\\])*)"\s*\)/g; + + let counter = 0; + + // Use replace with a callback to handle each match uniquely + // This ensures each occurrence gets a unique key, even if the same expression appears multiple times + const transformedExpr = expression.replace(methodPattern, (fullMatch, varName, method, arg) => { + // Get the variable value from context + const varValue = context[varName as keyof SignUpRuleContext]; + if (typeof varValue !== 'string') { + // Return unchanged if variable is not a string + return fullMatch; + } + + const unescapedArg = unescapeCelString(arg); + + // Use a counter-based key to avoid collisions between different arguments + // that would otherwise sanitize to the same key (e.g., "test+1" and "test-1") + const resultKey = `_method_${counter++}`; + let result: boolean; + + switch (method) { + case 'contains': { + result = varValue.includes(unescapedArg); + break; + } + case 'startsWith': { + result = varValue.startsWith(unescapedArg); + break; + } + case 'endsWith': { + result = varValue.endsWith(unescapedArg); + break; + } + case 'matches': { + try { + const regex = new RegExp(unescapedArg); + result = regex.test(varValue); + } catch { + // Invalid regex pattern - treat as non-match + result = false; + } + break; + } + default: { + return fullMatch; + } + } + + extendedContext[resultKey] = result; + return resultKey; + }); + + return { expression: transformedExpr, context: extendedContext }; +} + +/** + * Evaluates a CEL expression against a signup context. + * Returns true if the expression matches, false otherwise. + * + * Supports standard CEL operators plus string methods: + * - contains("substring") + * - startsWith("prefix") + * - endsWith("suffix") + * - matches("regex") + * + * @param expression - The CEL expression string to evaluate + * @param context - The signup context with variables like email, authMethod, etc. + * @returns boolean result of the expression evaluation + */ +export function evaluateCelExpression( + expression: string, + context: SignUpRuleContext +): boolean { + try { + // Pre-process to handle method calls + const { expression: transformedExpr, context: extendedContext } = preprocessExpression(expression, context); + + const result = evaluate(transformedExpr, extendedContext); + return Boolean(result); + } catch (e) { + // Wrap CEL evaluation errors with context and rethrow + // Callers should catch CelEvaluationError specifically + throw new CelEvaluationError( + `Failed to evaluate CEL expression: ${expression}`, + expression, + e + ); + } +} + +/** + * Creates a SignUpRuleContext from raw request data. + * This helper extracts and derives the context variables needed for rule evaluation. + * + * @param params - Raw parameters from the signup request + * @returns SignUpRuleContext ready for CEL evaluation + */ +export function createSignUpRuleContext(params: { + email?: string, + authMethod: 'password' | 'otp' | 'oauth' | 'passkey', + oauthProvider?: string, +}): SignUpRuleContext { + // Handle missing email (e.g., OAuth providers that don't return email) + // Use empty string so email-based rules don't match + let email = ''; + let emailDomain = ''; + + if (params.email) { + // Normalize email to match how it's stored in the database + email = normalizeEmail(params.email); + // Extract domain from normalized email + emailDomain = email.includes('@') ? (email.split('@').pop() ?? '') : ''; + } + + return { + email, + emailDomain, + authMethod: params.authMethod, + oauthProvider: params.oauthProvider ?? '', + }; +} + +// Unit tests +import.meta.vitest?.test('createSignUpRuleContext(...)', async ({ expect }) => { + // Should normalize email + expect(createSignUpRuleContext({ + email: 'Test.User@Example.COM', + authMethod: 'password', + })).toEqual({ + email: 'test.user@example.com', + emailDomain: 'example.com', + authMethod: 'password', + oauthProvider: '', + }); + + // Should handle missing email (OAuth providers without email) + expect(createSignUpRuleContext({ + email: undefined, + authMethod: 'oauth', + oauthProvider: 'discord', + })).toEqual({ + email: '', + emailDomain: '', + authMethod: 'oauth', + oauthProvider: 'discord', + }); + + // Should handle empty string email + expect(createSignUpRuleContext({ + email: '', + authMethod: 'oauth', + oauthProvider: 'twitter', + })).toEqual({ + email: '', + emailDomain: '', + authMethod: 'oauth', + oauthProvider: 'twitter', + }); + + // Should handle OAuth with email + expect(createSignUpRuleContext({ + email: 'oauth.user@gmail.com', + authMethod: 'oauth', + oauthProvider: 'google', + })).toEqual({ + email: 'oauth.user@gmail.com', + emailDomain: 'gmail.com', + authMethod: 'oauth', + oauthProvider: 'google', + }); +}); + +import.meta.vitest?.test('evaluateCelExpression with missing email', async ({ expect }) => { + // When email is empty, email-based conditions should not match + const context = createSignUpRuleContext({ + email: undefined, + authMethod: 'oauth', + oauthProvider: 'discord', + }); + + // Email-based conditions should fail when email is empty + expect(evaluateCelExpression('email == "test@example.com"', context)).toBe(false); + expect(evaluateCelExpression('email.contains("@")', context)).toBe(false); + expect(evaluateCelExpression('emailDomain == "example.com"', context)).toBe(false); + + // But authMethod-based conditions should still work + expect(evaluateCelExpression('authMethod == "oauth"', context)).toBe(true); + expect(evaluateCelExpression('oauthProvider == "discord"', context)).toBe(true); + + // Empty email should match empty string + expect(evaluateCelExpression('email == ""', context)).toBe(true); +}); diff --git a/apps/backend/src/lib/clickhouse.tsx b/apps/backend/src/lib/clickhouse.tsx index 5b8e4f181c..4d088478ec 100644 --- a/apps/backend/src/lib/clickhouse.tsx +++ b/apps/backend/src/lib/clickhouse.tsx @@ -2,40 +2,30 @@ import { createClient, type ClickHouseClient } from "@clickhouse/client"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; -const clickhouseUrl = getEnvVariable("STACK_CLICKHOUSE_URL", ""); -const clickhouseAdminUser = getEnvVariable("STACK_CLICKHOUSE_ADMIN_USER", "stackframe"); -const clickhouseExternalUser = "limited_user"; -const clickhouseAdminPassword = getEnvVariable("STACK_CLICKHOUSE_ADMIN_PASSWORD", ""); -const clickhouseExternalPassword = getEnvVariable("STACK_CLICKHOUSE_EXTERNAL_PASSWORD", ""); -const clickhouseDefaultDatabase = getEnvVariable("STACK_CLICKHOUSE_DATABASE", "default"); -const HAS_CLICKHOUSE = !!clickhouseUrl && !!clickhouseAdminPassword && !!clickhouseExternalPassword; - -if (!HAS_CLICKHOUSE) { - console.warn("ClickHouse is not configured. Analytics features will not be available."); -} - -export function isClickhouseConfigured() { - return HAS_CLICKHOUSE; +function getAdminAuth() { + return { + username: getEnvVariable("STACK_CLICKHOUSE_ADMIN_USER", "stackframe"), + password: getEnvVariable("STACK_CLICKHOUSE_ADMIN_PASSWORD"), + }; } export function createClickhouseClient(authType: "admin" | "external", database?: string) { - if (!HAS_CLICKHOUSE) { - throw new StackAssertionError("ClickHouse is not configured"); - } return createClient({ - url: clickhouseUrl, - username: authType === "admin" ? clickhouseAdminUser : clickhouseExternalUser, - password: authType === "admin" ? clickhouseAdminPassword : clickhouseExternalPassword, + url: getEnvVariable("STACK_CLICKHOUSE_URL"), + ...authType === "admin" ? getAdminAuth() : { + username: "limited_user", + password: getEnvVariable("STACK_CLICKHOUSE_EXTERNAL_PASSWORD"), + }, database, }); } export function getClickhouseAdminClient() { - return createClickhouseClient("admin", clickhouseDefaultDatabase); + return createClickhouseClient("admin", getEnvVariable("STACK_CLICKHOUSE_DATABASE", "default")); } export function getClickhouseExternalClient() { - return createClickhouseClient("external", clickhouseDefaultDatabase); + return createClickhouseClient("external", getEnvVariable("STACK_CLICKHOUSE_DATABASE", "default")); } export const getQueryTimingStats = async (client: ClickHouseClient, queryId: string) => { @@ -43,10 +33,7 @@ export const getQueryTimingStats = async (client: ClickHouseClient, queryId: str // Todo: for performance we should instead poll for this row to become available asynchronously after returning result. Flushed every 7.5 seconds by default await client.exec({ query: "SYSTEM FLUSH LOGS", - auth: { - username: clickhouseAdminUser, - password: clickhouseAdminPassword, - }, + auth: getAdminAuth(), }); const queryProfile = async () => { const profile = await client.query({ @@ -60,10 +47,7 @@ export const getQueryTimingStats = async (client: ClickHouseClient, queryId: str LIMIT 1 `, query_params: { query_id: queryId }, - auth: { - username: clickhouseAdminUser, - password: clickhouseAdminPassword, - }, + auth: getAdminAuth(), format: "JSON", }); @@ -109,10 +93,7 @@ export const getQueryTimingStatsForProject = async ( query_params: { query_id: queryId, }, - auth: { - username: clickhouseAdminUser, - password: clickhouseAdminPassword, - }, + auth: getAdminAuth(), format: "JSON", }); diff --git a/apps/backend/src/lib/events.tsx b/apps/backend/src/lib/events.tsx index fe705ee0f2..01c881f744 100644 --- a/apps/backend/src/lib/events.tsx +++ b/apps/backend/src/lib/events.tsx @@ -9,7 +9,7 @@ import { filterUndefined, typedKeys } from "@stackframe/stack-shared/dist/utils/ import { UnionToIntersection } from "@stackframe/stack-shared/dist/utils/types"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import * as yup from "yup"; -import { getClickhouseAdminClient, isClickhouseConfigured } from "./clickhouse"; +import { getClickhouseAdminClient } from "./clickhouse"; import { getEndUserInfo } from "./end-users"; import { DEFAULT_BRANCH_ID } from "./tenancies"; @@ -129,6 +129,20 @@ const ApiRequestEventType = { ], } as const satisfies SystemEventTypeBase; +const SignUpRuleTriggerEventType = { + id: "$sign-up-rule-trigger", + dataSchema: yupObject({ + projectId: yupString().defined(), + branchId: yupString().defined(), + ruleId: yupString().defined(), + action: yupString().oneOf(['allow', 'reject', 'restrict', 'log']).defined(), + email: yupString().nullable().defined(), + authMethod: yupString().oneOf(['password', 'otp', 'oauth', 'passkey']).nullable().defined(), + oauthProvider: yupString().nullable().defined(), + }), + inherits: [], +} as const satisfies SystemEventTypeBase; + export const SystemEventTypes = stripEventTypeSuffixFromKeys({ ProjectEventType, ProjectActivityEventType, @@ -137,6 +151,7 @@ export const SystemEventTypes = stripEventTypeSuffixFromKeys({ TokenRefreshEventType, ApiRequestEventType, LegacyApiEventType, + SignUpRuleTriggerEventType, } as const); const systemEventTypesById = new Map(Object.values(SystemEventTypes).map(eventType => [eventType.id, eventType])); @@ -240,19 +255,21 @@ export async function logEvent( }, }); - // Only log TokenRefresh events to ClickHouse - if (isClickhouseConfigured() && eventTypesArray.some(e => e.id === '$token-refresh')) { + // Log specific events to ClickHouse + const clickhouseEventTypes = ['$token-refresh', '$sign-up-rule-trigger']; + const matchingEventType = eventTypesArray.find(e => clickhouseEventTypes.includes(e.id)); + if (matchingEventType) { const clickhouseClient = getClickhouseAdminClient(); await clickhouseClient.insert({ table: "analytics_internal.events", values: [{ - event_type: '$token-refresh', + event_type: matchingEventType.id, event_at: timeRange.end, data: clickhouseEventData, project_id: projectId, branch_id: branchId, user_id: userId || null, - team_id: null, // Token refresh events don't have team context + team_id: null, }], format: "JSONEachRow", clickhouse_settings: { diff --git a/apps/backend/src/lib/oauth.tsx b/apps/backend/src/lib/oauth.tsx index 3caad36570..98cf315af2 100644 --- a/apps/backend/src/lib/oauth.tsx +++ b/apps/backend/src/lib/oauth.tsx @@ -1,10 +1,10 @@ import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-channel"; import { Tenancy } from "@/lib/tenancies"; -import { createOrUpgradeAnonymousUser } from "@/lib/users"; +import { createOrUpgradeAnonymousUserWithRules, SignUpRuleOptions } from "@/lib/users"; import { PrismaClientTransaction } from "@/prisma-client"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; -import { StackAssertionError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; /** * Find an existing OAuth account for sign-in. @@ -176,7 +176,7 @@ export async function linkOAuthAccountToUser( * This is used when a new OAuth sign-up should create a new user account. * * Creates: - * - User record (via createOrUpgradeAnonymousUser) + * - User record (via createOrUpgradeAnonymousUserWithRules) * - Auth method record * - OAuth account record with nested oauthAuthMethod * @@ -195,6 +195,7 @@ export async function createOAuthUserAndAccount( currentUser?: UsersCrud["Admin"]["Read"] | null, displayName?: string, profileImageUrl?: string, + signUpRuleOptions: SignUpRuleOptions, } ): Promise<{ projectUserId: string, oauthAccountId: string }> { // Check if sign up is allowed @@ -202,8 +203,8 @@ export async function createOAuthUserAndAccount( throw new KnownErrors.SignUpNotEnabled(); } - // Create new user (or upgrade anonymous user) - const newUser = await createOrUpgradeAnonymousUser( + // Create new user (or upgrade anonymous user) with sign-up rule evaluation + const newUser = await createOrUpgradeAnonymousUserWithRules( tenancy, params.currentUser ?? null, { @@ -214,6 +215,7 @@ export async function createOAuthUserAndAccount( primary_email_auth_enabled: params.primaryEmailAuthEnabled, }, [], + params.signUpRuleOptions, ); // Create auth method diff --git a/apps/backend/src/lib/sign-up-rules.ts b/apps/backend/src/lib/sign-up-rules.ts new file mode 100644 index 0000000000..c686df06bb --- /dev/null +++ b/apps/backend/src/lib/sign-up-rules.ts @@ -0,0 +1,107 @@ +import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; +import type { SignUpRuleAction } from "@stackframe/stack-shared/dist/interface/crud/sign-up-rules"; +import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import { CelEvaluationError, evaluateCelExpression, SignUpRuleContext } from "./cel-evaluator"; +import { logEvent, SystemEventTypes } from "./events"; +import { Tenancy } from "./tenancies"; + +/** + * Logs a sign-up rule trigger as a ClickHouse event for analytics. + * This runs asynchronously and doesn't block the signup flow. + */ +async function logRuleTrigger( + tenancy: Tenancy, + ruleId: string, + context: SignUpRuleContext, + action: SignUpRuleAction, +): Promise { + try { + await logEvent([SystemEventTypes.SignUpRuleTrigger], { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + ruleId, + action: action.type, + email: context.email, + authMethod: context.authMethod, + oauthProvider: context.oauthProvider, + }); + } catch (e) { + // Don't fail the signup if logging fails + captureError(`sign-up-rule-trigger-log-error`, new StackAssertionError(`Failed to log sign-up rule trigger for rule ${ruleId}`, { cause: e })); + } +} + +/** + * Evaluates all sign-up rules for a tenancy against the given context. + * Rules are evaluated in order of priority (highest first), then alphabetically by ID. + * Returns the first matching rule's action, or the default action if no rules match. + * + * This function should be called from all signup paths: + * - Password signup + * - OTP signup + * - OAuth signup + * - Passkey signup + * - Anonymous user conversion (when anonymous user adds email/auth method) + * + * This function should NOT be called when creating anonymous users. + * + * @param tenancy - The tenancy to evaluate rules for + * @param context - The signup context with email, authMethod, etc. + * @returns The rule result with action to take + */ +export async function evaluateSignUpRules( + tenancy: Tenancy, + context: SignUpRuleContext +) { + const config = tenancy.config; + + let restrictedBecauseOfSignUpRuleId: string | null = null; + for (const [ruleId, rule] of typedEntries(config.auth.signUpRules)) { + if (!rule.enabled || !rule.condition) continue; + + let matches = false; + try { + matches = evaluateCelExpression(rule.condition, context); + } catch (e) { + if (e instanceof CelEvaluationError) { + // technically a custom config could cause this, but the dashboard shouldn't allow creating faulty configs + // so for now, let's capture an error so we know that something is probably wrong on the DB + captureError(`cel-evaluation-error:${ruleId}`, new StackAssertionError(`CEL evaluation error for rule ${ruleId}`, { cause: e })); + } else { + throw e; + } + } + + if (matches) { + const actionConfig = rule.action; + const actionType = actionConfig.type; + const action: SignUpRuleAction = { + type: actionType, + message: actionConfig.message, + }; + + // log asynchronously + runAsynchronouslyAndWaitUntil(logRuleTrigger(tenancy, ruleId, context, action)); + + // apply the action + if (actionType === 'restrict') { + // Only record the first restrict rule (highest priority) + if (restrictedBecauseOfSignUpRuleId === null) { + restrictedBecauseOfSignUpRuleId = ruleId; + } + } + if (actionType === 'allow' || actionType === 'reject') { + return { + restrictedBecauseOfSignUpRuleId, + shouldAllow: actionType === 'allow', + }; + } + } + } + + return { + restrictedBecauseOfSignUpRuleId, + shouldAllow: config.auth.signUpRulesDefaultAction !== 'reject', + }; +} diff --git a/apps/backend/src/lib/users.tsx b/apps/backend/src/lib/users.tsx index 25fea48a2c..e4734c62b0 100644 --- a/apps/backend/src/lib/users.tsx +++ b/apps/backend/src/lib/users.tsx @@ -1,9 +1,92 @@ import { usersCrudHandlers } from "@/app/api/latest/users/crud"; +import { KnownErrors } from "@stackframe/stack-shared"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import { KeyIntersect } from "@stackframe/stack-shared/dist/utils/types"; +import { createSignUpRuleContext } from "./cel-evaluator"; +import { evaluateSignUpRules } from "./sign-up-rules"; import { Tenancy } from "./tenancies"; -export async function createOrUpgradeAnonymousUser( +/** + * Options for sign-up rule evaluation context. + */ +export type SignUpRuleOptions = { + authMethod: 'password' | 'otp' | 'oauth' | 'passkey', + oauthProvider?: string, +}; + +/** + * Creates or upgrades an anonymous user with sign-up rule evaluation. + * + * This function evaluates sign-up rules before creating/upgrading the user. + * Use this for all signup paths: + * - Password signup + * - OTP signup + * - OAuth signup + * - Passkey signup + * - Anonymous user conversion + * + * Do NOT use this for creating anonymous users (use createOrUpgradeAnonymousUserWithoutRules directly). + * + * @param tenancy - The tenancy context + * @param currentUser - Current user (if any, for anonymous upgrade) + * @param createOrUpdate - User creation/update data + * @param allowedErrorTypes - Error types to allow + * @param signUpRuleOptions - Options for sign-up rule evaluation + * @returns Created or updated user + * @throws KnownErrors.SignUpRejected if a sign-up rule rejects the signup + */ +export async function createOrUpgradeAnonymousUserWithRules( + tenancy: Tenancy, + currentUser: UsersCrud["Admin"]["Read"] | null, + createOrUpdate: KeyIntersect, + allowedErrorTypes: (new (...args: any) => any)[], + signUpRuleOptions: SignUpRuleOptions, +): Promise { + const email = createOrUpdate.primary_email ?? currentUser?.primary_email ?? undefined; + const ruleResult = await evaluateSignUpRules(tenancy, createSignUpRuleContext({ + email, + authMethod: signUpRuleOptions.authMethod, + oauthProvider: signUpRuleOptions.oauthProvider, + })); + + if (!ruleResult.shouldAllow) { + throw new KnownErrors.SignUpRejected(); + } + + const existingRestrictionPrivateDetails = createOrUpdate.restricted_by_admin_private_details ?? currentUser?.restricted_by_admin_private_details; + const restrictionPrivateDetails = ruleResult.restrictedBecauseOfSignUpRuleId + ? `Restricted by sign-up rule: ${ruleResult.restrictedBecauseOfSignUpRuleId}` + : undefined; + + const enrichedCreateOrUpdate = { + ...createOrUpdate, + ...!!ruleResult.restrictedBecauseOfSignUpRuleId ? { + restricted_by_admin: true, + restricted_by_admin_private_details: existingRestrictionPrivateDetails ? `${existingRestrictionPrivateDetails}\n\n${restrictionPrivateDetails}` : restrictionPrivateDetails, + } : {}, + }; + + // Proceed with user creation/upgrade + const user = await createOrUpgradeAnonymousUserWithoutRules( + tenancy, + currentUser, + enrichedCreateOrUpdate as KeyIntersect, + allowedErrorTypes, + ); + + return user; +} + +/** + * Creates or upgrades an anonymous user WITHOUT sign-up rule evaluation. + * + * Use this only for: + * - Creating anonymous users (no rules apply) + * - Internal operations where rules should be bypassed + * + * For all signup paths, use createOrUpgradeAnonymousUserWithRules instead. + */ +export async function createOrUpgradeAnonymousUserWithoutRules( tenancy: Tenancy, currentUser: UsersCrud["Admin"]["Read"] | null, createOrUpdate: KeyIntersect, diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 58b7cbe9ac..83362fa5f9 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -23,6 +23,7 @@ "@assistant-ui/react-markdown": "^0.10.5", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.3.4", "@monaco-editor/react": "4.7.0", "@phosphor-icons/react": "^2.1.10", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/charts-section-with-data.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/charts-section-with-data.tsx index 7440e43ef7..ff0e78c699 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/charts-section-with-data.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/charts-section-with-data.tsx @@ -11,11 +11,10 @@ import { CardTitle, Typography, } from "@/components/ui"; +import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; import { useAdminApp, useProjectId } from '../use-admin-app'; import { DonutChartDisplay, LineChartDisplay, LineChartDisplayConfig } from './line-chart'; -const stackAppInternalsSymbol = Symbol.for("StackAuth--DO-NOT-USE-OR-YOU-WILL-BE-FIRED--StackAppInternals"); - const dailySignUpsConfig = { name: 'Daily Sign-ups', description: 'User registration over the last 30 days', diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe-section-with-data.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe-section-with-data.tsx index 353d165d53..a7b867b29d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe-section-with-data.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe-section-with-data.tsx @@ -1,11 +1,10 @@ 'use client'; import { ErrorBoundary } from '@sentry/nextjs'; +import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; import { useAdminApp } from '../use-admin-app'; import { GlobeSection } from './globe'; -const stackAppInternalsSymbol = Symbol.for("StackAuth--DO-NOT-USE-OR-YOU-WILL-BE-FIRED--StackAppInternals"); - export function GlobeSectionWithData({ includeAnonymous }: { includeAnonymous: boolean }) { const adminApp = useAdminApp(); const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(includeAnonymous); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx index 947829bea1..e4918d8e68 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/metrics-page.tsx @@ -5,6 +5,7 @@ import { Link } from "@/components/link"; import { useRouter } from "@/components/router"; import { cn, Typography } from '@/components/ui'; import { ALL_APPS_FRONTEND, getAppPath } from "@/lib/apps-frontend"; +import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; import { CaretUpIcon, CompassIcon, DotsThreeIcon, GlobeIcon, SquaresFourIcon } from "@phosphor-icons/react"; import useResizeObserver from '@react-hook/resize-observer'; import { useUser } from '@stackframe/stack'; @@ -113,8 +114,6 @@ const dauConfig = { } } satisfies LineChartDisplayConfig; -const stackAppInternalsSymbol = Symbol.for("StackAuth--DO-NOT-USE-OR-YOU-WILL-BE-FIRED--StackAppInternals"); - function TotalUsersDisplay({ includeAnonymous, minimal = false }: { includeAnonymous: boolean, minimal?: boolean }) { const adminApp = useAdminApp(); const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(includeAnonymous); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx new file mode 100644 index 0000000000..1df4043b8e --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx @@ -0,0 +1,863 @@ +"use client"; + +import { ConditionBuilder, isConditionTreeValid } from "@/components/rule-builder"; +import { + ActionDialog, + Alert, + Button, + cn, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, + Typography, +} from "@/components/ui"; +import { + createEmptyCondition, + createEmptyGroup, + parseCelToVisualTree, + visualTreeToCel, + type RuleNode, +} from "@/lib/cel-visual-parser"; +import { useUpdateConfig } from "@/lib/config-update"; +import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; +import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { ArrowsDownUpIcon, CheckIcon, PencilSimpleIcon, PlusIcon, TrashIcon, XIcon } from "@phosphor-icons/react"; +import type { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; +import { useAsyncCallback } from "@stackframe/stack-shared/dist/hooks/use-async-callback"; +import type { SignUpRule, SignUpRuleAction } from "@stackframe/stack-shared/dist/interface/crud/sign-up-rules"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; +import React, { useMemo, useState } from "react"; +import { Area, AreaChart, ResponsiveContainer, YAxis } from "recharts"; +import { AppEnabledGuard } from "../app-enabled-guard"; +import { PageLayout } from "../page-layout"; +import { useAdminApp } from "../use-admin-app"; + +// Analytics types +type RuleAnalytics = { + ruleId: string, + totalCount: number, + hourlyCounts: { hour: string, count: number }[], +}; + +type SignUpRuleEntry = { + id: string, + rule: SignUpRule, +}; + +// Get sorted rules from config +// Type assertion needed because schema changes take effect at build time +type ConfigWithSignUpRules = CompleteConfig & { + auth: { + signUpRules?: Record, + signUpRulesDefaultAction?: 'allow' | 'reject', + }, +}; + +// Compact sparkline component for rule analytics (inline next to buttons) +function RuleSparkline({ + data, + totalCount, + isLoading, +}: { + data: { hour: string, count: number }[], + totalCount: number, + isLoading: boolean, +}) { + // Show skeleton while loading + if (isLoading) { + return ( +
+
+
+
+ ); + } + + // Ensure we have at least 2 data points for the chart to render a line + const chartData = data.length >= 2 ? data : [{ hour: '0', count: 0 }, { hour: '1', count: 0 }]; + // Calculate max for Y domain - use at least 1 to avoid divide-by-zero + const maxCount = Math.max(1, ...chartData.map(d => d.count)); + + return ( +
+ + + + + + + {totalCount} +
+ ); +} + +// Base card style for rules (without transition - added conditionally per component) +const ruleCardClassName = cn( + "rounded-xl", + "bg-background/60 backdrop-blur-xl ring-1 ring-foreground/[0.06]", +); + +// Inline rule editor component +function RuleEditor({ + rule, + ruleId, + isNew, + onSave, + onCancel, +}: { + rule?: SignUpRule, + ruleId: string, + isNew: boolean, + onSave: (ruleId: string, rule: SignUpRule) => Promise, + onCancel: () => void, +}) { + const ruleAction = rule?.action; + const [displayName, setDisplayName] = useState(rule?.displayName ?? ''); + const [actionType, setActionType] = useState(ruleAction?.type ?? 'allow'); + const [actionMessage, setActionMessage] = useState(ruleAction?.message ?? ''); + const [enabled, setEnabled] = useState(rule?.enabled ?? true); + const [isSaving, setIsSaving] = useState(false); + + // Parse existing condition or create empty group + const initialConditionTree = useMemo((): RuleNode => { + if (rule?.condition) { + const parsed = parseCelToVisualTree(rule.condition); + if (parsed) return parsed; + } + const group = createEmptyGroup('and'); + group.children = [createEmptyCondition()]; + return group; + }, [rule?.condition]); + + const [conditionTree, setConditionTree] = useState(initialConditionTree); + + // Validate the condition tree + const isTreeValid = isConditionTreeValid(conditionTree); + + const handleSave = async () => { + if (!displayName.trim() || !isTreeValid) return; + + setIsSaving(true); + try { + const normalizedConditionTree = conditionTree.type === 'group' && conditionTree.children.length === 0 + ? { ...conditionTree, children: [createEmptyCondition()] } + : conditionTree; + const celCondition = visualTreeToCel(normalizedConditionTree); + + const newRule: SignUpRule = { + displayName: displayName.trim(), + condition: celCondition, + priority: rule?.priority ?? 0, + enabled, + action: { + type: actionType, + message: actionType === 'reject' ? actionMessage || undefined : undefined, + }, + }; + await onSave(ruleId, newRule); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+ {/* Enabled toggle on the left */} +
+ +
+ + {/* Main content */} +
+ {/* Name input */} + setDisplayName(e.target.value)} + placeholder="Rule name (e.g., Block disposable emails)" + autoFocus + /> + + {/* Conditions */} +
+ +
+ +
+
+ + {/* Action */} +
+
+ + +
+ + {actionType === 'reject' && ( + setActionMessage(e.target.value)} + placeholder="Internal rejection reason (not shown to user)" + className="flex-1" + /> + )} +
+ + {/* Save/Cancel buttons */} +
+ + +
+
+
+
+ ); +} + +// Sortable rule row component (view mode) +function SortableRuleRow({ + entry, + analytics, + isAnalyticsLoading, + isEditing, + onEdit, + onDelete, + onToggleEnabled, + onSave, + onCancelEdit, +}: { + entry: SignUpRuleEntry, + analytics?: RuleAnalytics, + isAnalyticsLoading: boolean, + isEditing: boolean, + onEdit: () => void, + onDelete: () => void, + onToggleEnabled: (enabled: boolean) => void, + onSave: (ruleId: string, rule: SignUpRule) => Promise, + onCancelEdit: () => void, +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: entry.id, disabled: isEditing }); + + const style = { + transform: CSS.Transform.toString(transform), + // Only apply transition when not actively dragging to avoid lag + transition: isDragging ? undefined : transition, + }; + + const actionType = entry.rule.action.type; + const actionLabels: Record = { + 'allow': 'Allow', + 'reject': 'Reject', + 'restrict': 'Restrict', + 'log': 'Log', + }; + const actionLabel = actionLabels[actionType] ?? actionType; + + const conditionSummary = entry.rule.condition || '(no condition)'; + const isEnabled = entry.rule.enabled !== false; + + // If editing, show the editor + if (isEditing) { + return ( +
+ +
+ ); + } + + // View mode - entire card is draggable + return ( +
+ {/* Enable/disable switch - on the left */} +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + +
+ + {/* Rule info */} +
+
+ + {entry.rule.displayName || 'Unnamed rule'} + + + {actionLabel} + +
+ + {conditionSummary} + +
+ + {/* Actions - sparkline, edit, and delete */} +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + {/* Sparkline and trigger count */} +
+ +
+ + +
+
+ ); +} + +// Default action card - looks like a rule but without controls +function DefaultActionCard({ + value, + onChange, +}: { + value: 'allow' | 'reject', + onChange: (value: 'allow' | 'reject') => void, +}) { + return ( +
+ {/* Spacer to align with switch position in rule cards */} +
+ + {/* Info */} +
+
+ + Default action + +
+ + When no rules match + +
+ + {/* Action dropdown */} + +
+ ); +} + +// Delete confirmation dialog +function DeleteRuleDialog({ + open, + onOpenChange, + ruleName, + onConfirm, +}: { + open: boolean, + onOpenChange: (open: boolean) => void, + ruleName: string, + onConfirm: () => Promise, +}) { + return ( + + + Are you sure you want to delete the rule {ruleName}? This action cannot be undone. + + + ); +} + +// Custom hook to fetch sign-up rules analytics +function useSignUpRulesAnalytics() { + const stackAdminApp = useAdminApp(); + const [analytics, setAnalytics] = useState>(new Map()); + const [isLoading, setIsLoading] = useState(true); + + React.useEffect(() => { + let cancelled = false; + setIsLoading(true); + + const fetchAnalytics = async () => { + const response = await (stackAdminApp as any)[stackAppInternalsSymbol].sendRequest( + '/internal/sign-up-rules-stats', + { method: 'GET' }, + 'admin' // Required for internal endpoints + ); + if (cancelled) return; + + if (!response.ok) { + throw new StackAssertionError(`Failed to fetch sign-up rules stats: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + const analyticsMap = new Map(); + for (const trigger of data.rule_triggers ?? []) { + analyticsMap.set(trigger.rule_id, { + ruleId: trigger.rule_id, + totalCount: trigger.total_count, + hourlyCounts: trigger.hourly_counts ?? [], + }); + } + + setAnalytics(analyticsMap); + setIsLoading(false); + }; + + runAsynchronouslyWithAlert(fetchAnalytics); + + return () => { + cancelled = true; + }; + }, [stackAdminApp]); + + return { analytics, isLoading }; +} + +export default function PageClient() { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const config = project.useConfig(); + const updateConfig = useUpdateConfig(); + + const [editingRuleId, setEditingRuleId] = useState(null); + const [isCreatingNew, setIsCreatingNew] = useState(false); + const [newRuleId, setNewRuleId] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [ruleToDelete, setRuleToDelete] = useState(null); + + // Fetch analytics data + const { analytics: ruleAnalytics, isLoading: isAnalyticsLoading } = useSignUpRulesAnalytics(); + + // Type assertion needed because schema changes take effect at build time + const configWithRules = config as ConfigWithSignUpRules; + + // Server state (source of truth) + const serverRules = useMemo(() => + typedEntries(configWithRules.auth.signUpRules).map(([id, rule]) => ({ id, rule })), + [configWithRules.auth.signUpRules] + ); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- TypeScript may not see these as optional due to type assertion + const defaultAction = configWithRules.auth.signUpRulesDefaultAction ?? 'allow'; + + // ===== LOCAL STATE FOR REORDERING ONLY ===== + // When user drags to reorder, we store the new order locally until they save + const [pendingOrder, setPendingOrder] = useState(null); + + // Compute the displayed rules: if we have a pending order, reorder server rules accordingly + const signUpRules: SignUpRuleEntry[] = useMemo(() => { + if (pendingOrder === null) return serverRules; + // Reorder server rules based on pending order + const ruleMap = new Map(serverRules.map(r => [r.id, r])); + const result: SignUpRuleEntry[] = []; + for (const id of pendingOrder) { + const rule = ruleMap.get(id); + if (rule) result.push(rule); + } + return result; + }, [serverRules, pendingOrder]); + + const hasOrderChanges = pendingOrder !== null; + + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const currentOrder = pendingOrder ?? serverRules.map(r => r.id); + const oldIndex = currentOrder.indexOf(active.id as string); + const newIndex = currentOrder.indexOf(over.id as string); + const newOrder = arrayMove(currentOrder, oldIndex, newIndex); + setPendingOrder(newOrder); + } + }; + + const handleAddRule = () => { + const id = generateUuid(); + setNewRuleId(id); + setIsCreatingNew(true); + setEditingRuleId(null); + }; + + // Save rule immediately to config + const handleSaveRule = async (ruleId: string, rule: SignUpRule) => { + // For new rules, set priority to be at the top (don't mutate the input) + const ruleToSave: SignUpRule = isCreatingNew + ? { ...rule, priority: serverRules.length + 1 } + : rule; + + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { + [`auth.signUpRules.${ruleId}`]: ruleToSave, + }, + pushable: true, + }); + setEditingRuleId(null); + setIsCreatingNew(false); + setNewRuleId(null); + }; + + const handleCancelEdit = () => { + setEditingRuleId(null); + setIsCreatingNew(false); + setNewRuleId(null); + }; + + // Delete rule immediately + const handleDeleteRule = async () => { + if (!ruleToDelete) return; + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { + [`auth.signUpRules.${ruleToDelete.id}`]: null, + }, + pushable: true, + }); + // Clear from pending order if present + if (pendingOrder) { + setPendingOrder(pendingOrder.filter(id => id !== ruleToDelete.id)); + } + setDeleteDialogOpen(false); + setRuleToDelete(null); + }; + + // Toggle enabled immediately + const handleToggleEnabled = async (ruleId: string, enabled: boolean) => { + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { + [`auth.signUpRules.${ruleId}.enabled`]: enabled, + }, + pushable: true, + }); + }; + + // Change default action immediately + const handleDefaultActionChange = async (value: 'allow' | 'reject') => { + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { + 'auth.signUpRulesDefaultAction': value, + }, + pushable: true, + }); + }; + + // Save reorder changes + const handleSaveOrder = async () => { + if (!pendingOrder) return; + + const configUpdate: Record = {}; + pendingOrder.forEach((ruleId, index) => { + configUpdate[`auth.signUpRules.${ruleId}.priority`] = pendingOrder.length - index; + }); + + await updateConfig({ + adminApp: stackAdminApp, + configUpdate, + pushable: true, + }); + + setPendingOrder(null); + }; + + const handleDiscardOrder = () => { + setPendingOrder(null); + }; + + const [handleSaveOrderAsync, isSavingOrder] = useAsyncCallback(handleSaveOrder, [handleSaveOrder]); + + const isAnyEditing = editingRuleId !== null || isCreatingNew; + + return ( + + + + Add rule + + } + > + {/* Rules list and default action */} +
+ {/* New rule editor (at the top when creating) */} + {isCreatingNew && newRuleId && ( + + )} + + {/* Pending order banner */} + {hasOrderChanges && ( +
+
+
+ + Rule order has been changed +
+
+ + +
+
+ + {/* Rules list inside the banner */} +
+ + r.id)} + strategy={verticalListSortingStrategy} + > + {signUpRules.map((entry) => ( + { + setEditingRuleId(entry.id); + setIsCreatingNew(false); + }} + onDelete={() => { + setRuleToDelete(entry); + setDeleteDialogOpen(true); + }} + onToggleEnabled={(enabled) => runAsynchronouslyWithAlert(handleToggleEnabled(entry.id, enabled))} + onSave={handleSaveRule} + onCancelEdit={handleCancelEdit} + /> + ))} + + +
+
+ )} + + {/* Normal rules list (when no pending order) */} + {!hasOrderChanges && signUpRules.length > 0 && ( + + r.id)} + strategy={verticalListSortingStrategy} + > + {signUpRules.map((entry) => ( + { + setEditingRuleId(entry.id); + setIsCreatingNew(false); + }} + onDelete={() => { + setRuleToDelete(entry); + setDeleteDialogOpen(true); + }} + onToggleEnabled={(enabled) => runAsynchronouslyWithAlert(handleToggleEnabled(entry.id, enabled))} + onSave={handleSaveRule} + onCancelEdit={handleCancelEdit} + /> + ))} + + + )} + + {/* Empty state */} + {!hasOrderChanges && signUpRules.length === 0 && !isCreatingNew && ( + + No sign-up rules configured. Click "Add rule" to create your first rule. + + )} + + {/* Default action card - always at the bottom */} + runAsynchronouslyWithAlert(handleDefaultActionChange(v))} + /> +
+ + {/* Delete confirmation dialog */} + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page.tsx new file mode 100644 index 0000000000..355a7084a8 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { default } from "./page-client"; diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index a0a9bd21da..95565c82ba 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -11,15 +11,26 @@ import { AccordionItem, AccordionTrigger, ActionCell, + Alert, + AlertDescription, + AlertTitle, Avatar, AvatarFallback, AvatarImage, Button, + cn, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, + Input, Separator, SimpleTooltip, Table, @@ -28,18 +39,18 @@ import { TableHead, TableHeader, TableRow, + Textarea, Typography, - cn, useToast } from "@/components/ui"; import { DeleteUserDialog, ImpersonateUserDialog } from "@/components/user-dialogs"; import { useThemeWatcher } from '@/lib/theme'; import MonacoEditor from '@monaco-editor/react'; -import { AtIcon, CalendarIcon, CheckIcon, DotsThreeIcon, EnvelopeIcon, HashIcon, ShieldIcon, SquareIcon, XIcon } from "@phosphor-icons/react"; +import { AtIcon, CalendarIcon, CheckIcon, DotsThreeIcon, EnvelopeIcon, HashIcon, ProhibitIcon, ShieldIcon, SquareIcon, XIcon } from "@phosphor-icons/react"; import { ServerContactChannel, ServerOAuthProvider, ServerUser } from "@stackframe/stack"; import { KnownErrors } from "@stackframe/stack-shared"; import { fromNow } from "@stackframe/stack-shared/dist/utils/dates"; -import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; +import { captureError, StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; import { isJsonSerializable } from "@stackframe/stack-shared/dist/utils/json"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import { useEffect, useMemo, useState } from "react"; @@ -247,6 +258,224 @@ function UserHeader({ user }: UserHeaderProps) { ); } +// Get the human-readable restriction reason +function getRestrictionReasonText(user: ServerUser): string { + const restrictedReason = user.restrictedReason; + if (!restrictedReason) return ''; + + switch (restrictedReason.type) { + case 'anonymous': { + return 'Anonymous user'; + } + case 'email_not_verified': { + return 'Unverified email'; + } + case 'restricted_by_administrator': { + return 'Manually restricted'; + } + default: { + return 'Restricted'; + } + } +} + +// Restriction dialog for editing restriction details +function RestrictionDialog({ + user, + open, + onOpenChange, +}: { + user: ServerUser, + open: boolean, + onOpenChange: (open: boolean) => void, +}) { + const restrictedByAdmin = (user as any).restrictedByAdmin ?? false; + const restrictedByAdminReason = (user as any).restrictedByAdminReason ?? null; + const restrictedByAdminPrivateDetails = (user as any).restrictedByAdminPrivateDetails ?? null; + + const [publicReason, setPublicReason] = useState(restrictedByAdminReason ?? ''); + const [privateDetails, setPrivateDetails] = useState(restrictedByAdminPrivateDetails ?? ''); + const [isSaving, setIsSaving] = useState(false); + + // Reset form when dialog opens + const handleOpenChange = (newOpen: boolean) => { + if (newOpen) { + setPublicReason(restrictedByAdminReason ?? ''); + setPrivateDetails(restrictedByAdminPrivateDetails ?? ''); + } + onOpenChange(newOpen); + }; + + const handleSaveAndRestrict = async () => { + if (!privateDetails.trim()) { + alert('Please enter the private details for the restriction.'); + return; + } + + setIsSaving(true); + try { + await user.update({ restrictedByAdmin: true, restrictedByAdminReason: publicReason.trim() || null, restrictedByAdminPrivateDetails: privateDetails.trim() || null } as any); + onOpenChange(false); + } catch (error) { + captureError(`user-restriction-save-and-restrict-error`, new StackAssertionError(`Failed to save and restrict user ${user.id}`, { cause: error })); + } finally { + setIsSaving(false); + } + }; + + const handleRemoveRestriction = async () => { + setIsSaving(true); + try { + await user.update({ + restrictedByAdmin: false, + restrictedByAdminReason: null, + restrictedByAdminPrivateDetails: null, + } as any); + onOpenChange(false); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + User Restriction + + Restricted users cannot access your app by default. You can optionally provide a public reason (shown to the user) and private details (for internal notes). + + +
+
+ + setPublicReason(e.target.value)} + placeholder="Optional message visible to the user" + disabled={isSaving} + /> +
+
+ +