From 316ef379c4580a1695ba12b9e7fd31010c6996da Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 27 Jan 2026 22:12:01 -0800 Subject: [PATCH 01/23] Sign-up Rules --- AGENTS.md | 2 + apps/backend/package.json | 1 + .../migration.sql | 18 + .../migration.sql | 3 + .../migration.sql | 10 + apps/backend/prisma/schema.prisma | 21 + .../oauth/callback/[provider_id]/route.tsx | 10 +- .../otp/sign-in/verification-code-handler.tsx | 12 +- .../latest/auth/password/sign-up/route.tsx | 9 +- .../latest/internal/signup-rules/route.tsx | 113 + .../backend/src/app/api/latest/users/crud.tsx | 28 +- apps/backend/src/lib/cel-evaluator.ts | 192 ++ apps/backend/src/lib/signup-rules.ts | 228 ++ apps/backend/src/lib/users.tsx | 137 ++ apps/dashboard/package.json | 7 +- .../[projectId]/signup-rules/page-client.tsx | 883 +++++++ .../[projectId]/signup-rules/page.tsx | 6 + .../users/[userId]/page-client.tsx | 228 +- .../rule-builder/condition-builder.tsx | 329 +++ .../src/components/rule-builder/index.tsx | 1 + apps/dashboard/src/lib/apps-frontend.tsx | 1 + apps/dashboard/src/lib/cel-visual-parser.ts | 417 ++++ .../api/v1/auth/signup-rules.test.ts | 2034 +++++++++++++++++ .../dev-postgres-with-extensions/Dockerfile | 1 + .../src/config/schema-fuzzer.test.ts | 19 + packages/stack-shared/src/config/schema.ts | 46 + .../stack-shared/src/interface/crud/users.ts | 44 +- packages/stack-shared/src/known-errors.tsx | 16 +- packages/stack-shared/src/schema-fields.ts | 2 +- .../src/components-page/onboarding.tsx | 2 +- .../apps/implementations/admin-app-impl.ts | 4 +- .../apps/implementations/client-app-impl.ts | 2 +- .../apps/implementations/server-app-impl.ts | 10 + .../template/src/lib/stack-app/users/index.ts | 24 +- pnpm-lock.yaml | 116 +- 35 files changed, 4902 insertions(+), 74 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260201300000_signup_rule_trigger/migration.sql create mode 100644 apps/backend/prisma/migrations/20260201400000_add_restricted_by_admin_fields/migration.sql create mode 100644 apps/backend/prisma/migrations/20260201400001_add_restricted_by_admin_constraint/migration.sql create mode 100644 apps/backend/src/app/api/latest/internal/signup-rules/route.tsx create mode 100644 apps/backend/src/lib/cel-evaluator.ts create mode 100644 apps/backend/src/lib/signup-rules.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/signup-rules/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/signup-rules/page.tsx create mode 100644 apps/dashboard/src/components/rule-builder/condition-builder.tsx create mode 100644 apps/dashboard/src/components/rule-builder/index.tsx create mode 100644 apps/dashboard/src/lib/cel-visual-parser.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/auth/signup-rules.test.ts diff --git a/AGENTS.md b/AGENTS.md index fd74263505..f479d5798d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,8 @@ 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. ### Code-related - Use ES6 maps instead of records wherever you can. diff --git a/apps/backend/package.json b/apps/backend/package.json index 185a332aa2..3d61385e95 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -80,6 +80,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/20260201300000_signup_rule_trigger/migration.sql b/apps/backend/prisma/migrations/20260201300000_signup_rule_trigger/migration.sql new file mode 100644 index 0000000000..803ab821f0 --- /dev/null +++ b/apps/backend/prisma/migrations/20260201300000_signup_rule_trigger/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "SignupRuleTrigger" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenancyId" UUID NOT NULL, + "ruleId" TEXT NOT NULL, + "userId" UUID, + "action" TEXT NOT NULL, + "metadata" JSONB, + "triggeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SignupRuleTrigger_pkey" PRIMARY KEY ("tenancyId","id") +); + +-- CreateIndex +CREATE INDEX "SignupRuleTrigger_tenancyId_ruleId_triggeredAt_idx" ON "SignupRuleTrigger"("tenancyId", "ruleId", "triggeredAt"); + +-- CreateIndex +CREATE INDEX "SignupRuleTrigger_tenancyId_triggeredAt_idx" ON "SignupRuleTrigger"("tenancyId", "triggeredAt"); 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 d054ca7e57..b74cc072dd 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[] @@ -1051,3 +1056,19 @@ model SubscriptionInvoice { @@id([tenancyId, id]) @@unique([tenancyId, stripeInvoiceId]) } + +// Signup Rules Analytics +model SignupRuleTrigger { + id String @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + ruleId String + userId String? @db.Uuid // User ID if created, null if rejected + action String // allow, reject, restrict, log, add_metadata + metadata Json? // matched conditions, IP, country, etc. + + triggeredAt DateTime @default(now()) + + @@id([tenancyId, id]) + @@index([tenancyId, ruleId, triggeredAt]) + @@index([tenancyId, triggeredAt]) +} 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 2d7cfb52d2..342ff3b5e2 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 @@ -3,7 +3,7 @@ import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-chann import { validateRedirectUrl } from "@/lib/redirect-urls"; import { Tenancy, getTenancy } from "@/lib/tenancies"; import { oauthCookieSchema } from "@/lib/tokens"; -import { createOrUpgradeAnonymousUser } from "@/lib/users"; +import { createOrUpgradeAnonymousUserWithRules } from "@/lib/users"; import { getProvider, oauthServer } from "@/oauth"; import { PrismaClientTransaction, getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -376,7 +376,7 @@ const handler = createSmartRouteHandler({ currentUser = null; } - const newAccountBeforeAuthMethod = await createOrUpgradeAnonymousUser( + const newAccountBeforeAuthMethod = await createOrUpgradeAnonymousUserWithRules( tenancy, currentUser, { @@ -387,6 +387,12 @@ const handler = createSmartRouteHandler({ primary_email_auth_enabled: primaryEmailAuthEnabled, }, [], + { + 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 + }, ); const authMethod = await prisma.authMethod.create({ data: { 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/signup-rules/route.tsx b/apps/backend/src/app/api/latest/internal/signup-rules/route.tsx new file mode 100644 index 0000000000..b855d62771 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/signup-rules/route.tsx @@ -0,0 +1,113 @@ +import { globalPrismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; + +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(), + add_metadata: yupNumber().integer().defined(), + }).defined(), + }).defined(), + }), + handler: async (req) => { + const tenancyId = req.auth.tenancy.id; + const since = new Date(Date.now() - ANALYTICS_HOURS * 60 * 60 * 1000); + + // Get all triggers for this tenancy in the last 48 hours + const triggers = await globalPrismaClient.signupRuleTrigger.findMany({ + where: { + tenancyId, + triggeredAt: { + gte: since, + }, + }, + orderBy: { + triggeredAt: 'asc', + }, + }); + + // 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, + add_metadata: 0, + }; + + for (const trigger of triggers) { + // Update action counts + const action = trigger.action as keyof typeof actionCounts; + if (action in actionCounts) { + actionCounts[action]++; + } + + // Update rule triggers + let ruleData = ruleTriggersMap.get(trigger.ruleId); + if (!ruleData) { + ruleData = { totalCount: 0, hourlyMap: new Map() }; + ruleTriggersMap.set(trigger.ruleId, ruleData); + } + ruleData.totalCount++; + + // Group by hour (ISO format truncated to hour) + const hourKey = trigger.triggeredAt.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: Array.from(data.hourlyMap.entries()) + .sort((a, b) => stringCompare(a[0], b[0])) + .map(([hour, count]) => ({ hour, count })), + })); + + return { + statusCode: 200, + bodyType: "json", + body: { + rule_triggers: ruleTriggers, + total_triggers: triggers.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..8200e86cc7 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -101,7 +101,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 +111,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 signup 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 +148,7 @@ export const userPrismaToCrud = ( prisma.isAnonymous, primaryEmailVerified, config, + prisma.restrictedByAdmin, ); const result = { @@ -170,6 +178,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 +358,7 @@ export function getUserQuery(projectId: string, branchId: string, userId: string row.isAnonymous, primaryEmailContactChannel?.isVerified || false, config, + row.restrictedByAdmin, ); return { @@ -384,6 +396,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 +432,7 @@ export function getUserIfOnGlobalPrismaClientQuery( user.is_anonymous, user.primary_email_verified, config, + user.restricted_by_admin, ); return { ...user, @@ -599,7 +615,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: data.restricted_by_admin ?? false, + restrictedByAdminReason: data.restricted_by_admin_reason === undefined ? undefined : (data.restricted_by_admin_reason || null), + restrictedByAdminPrivateDetails: data.restricted_by_admin_private_details === undefined ? undefined : (data.restricted_by_admin_private_details || null), }, include: userFullInclude, }); @@ -1074,7 +1093,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: data.restricted_by_admin_reason === undefined ? undefined : (data.restricted_by_admin_reason || null), + restrictedByAdminPrivateDetails: data.restricted_by_admin_private_details === undefined ? undefined : (data.restricted_by_admin_private_details || null), }, 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..498db30b34 --- /dev/null +++ b/apps/backend/src/lib/cel-evaluator.ts @@ -0,0 +1,192 @@ +import { evaluate, parse, CelEvaluationError, CelParseError, CelTypeError } from "cel-js"; + +/** + * Context variables available for signup 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, +}; + +// Extended context with helper functions for string operations +type ExtendedContext = SignupRuleContext & { + // Pre-computed helpers for common patterns + _email_lower: 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 preprocessExpression( + expression: string, + context: SignupRuleContext +): { expression: string, context: Record } { + const extendedContext: Record = { ...context }; + + // Pattern to match method calls: identifier.method("literal") + // We handle: contains, startsWith, endsWith, matches + const methodPattern = /(\w+)\.(contains|startsWith|endsWith|matches)\s*\(\s*"([^"]+)"\s*\)/g; + + let transformedExpr = expression; + let counter = 0; + + // Use replaceAll with a callback to handle each match uniquely + // This ensures each occurrence gets a unique key, even if the same expression appears multiple times + 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; + } + + // 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(arg); + break; + } + case 'startsWith': { + result = varValue.startsWith(arg); + break; + } + case 'endsWith': { + result = varValue.endsWith(arg); + break; + } + case 'matches': { + try { + result = new RegExp(arg).test(varValue); + } catch { + 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) { + // Log the error but return false for safety + console.error('CEL evaluation error:', e); + return false; + } +} + +/** + * Validates a CEL expression without evaluating it. + * This is useful for checking if an expression is syntactically correct + * before saving it. + * + * @param expression - The CEL expression string to validate + * @returns Object with valid: true if expression is valid, or valid: false with error message + */ +export function validateCelExpression(expression: string): { valid: true } | { valid: false, error: string } { + try { + // First, transform the expression as we would during evaluation + // Use dummy context for validation + const dummyContext: SignupRuleContext = { + email: 'test@example.com', + emailDomain: 'example.com', + authMethod: 'password', + oauthProvider: '', + }; + + const { expression: transformedExpr, context } = preprocessExpression(expression, dummyContext); + + // Try to parse the transformed expression + parse(transformedExpr); + + // Also try to evaluate it to catch type errors + evaluate(transformedExpr, context); + + return { valid: true }; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + // Provide a user-friendly error message + if (e instanceof CelParseError) { + return { valid: false, error: `Invalid expression syntax: ${message}` }; + } + if (e instanceof CelTypeError) { + return { valid: false, error: `Type error in expression: ${message}` }; + } + if (e instanceof CelEvaluationError) { + return { valid: false, error: `Expression evaluation error: ${message}` }; + } + return { valid: false, error: message }; + } +} + +/** + * 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 { + const email = params.email; + const emailDomain = email.includes('@') ? email.split('@').pop() ?? '' : ''; + + return { + email, + emailDomain, + authMethod: params.authMethod, + oauthProvider: params.oauthProvider ?? '', + }; +} diff --git a/apps/backend/src/lib/signup-rules.ts b/apps/backend/src/lib/signup-rules.ts new file mode 100644 index 0000000000..8713e73ab5 --- /dev/null +++ b/apps/backend/src/lib/signup-rules.ts @@ -0,0 +1,228 @@ +import { KnownErrors } from "@stackframe/stack-shared"; +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { evaluateCelExpression, SignupRuleContext } from "./cel-evaluator"; +import { Tenancy } from "./tenancies"; +import { globalPrismaClient } from "@/prisma-client"; + +/** + * Metadata entry with value and target for where it should be stored. + */ +export type SignupRuleMetadataEntry = { + value: string | number | boolean, + target: 'client' | 'client_read_only' | 'server', +}; + +/** + * The action to take when a signup rule matches. + */ +export type SignupRuleAction = { + type: 'allow' | 'reject' | 'restrict' | 'log' | 'add_metadata', + metadata?: Record, + message?: string, +}; + +/** + * A signup rule from the config. + * Type definition for the signupRules field in auth config. + */ +type SignupRuleConfig = { + enabled?: boolean, + displayName?: string, + priority?: number, + condition?: string, + action?: { + type?: 'allow' | 'reject' | 'restrict' | 'log' | 'add_metadata', + message?: string, + metadata?: Record, + }, +}; + +/** + * Extended auth config type that includes signup rules. + * Used for type assertions since the schema types may not be updated yet. + */ +type AuthConfigWithSignupRules = { + signupRules?: Record, + signupRulesDefaultAction?: 'allow' | 'reject', +}; + +/** + * Result of evaluating signup rules. + */ +export type SignupRuleResult = { + /** The rule ID that matched, or null if no rules matched (using default action) */ + ruleId: string | null, + /** The action to take */ + action: SignupRuleAction, +}; + +/** + * Logs a signup rule trigger to the database for analytics. + * This runs asynchronously and doesn't block the signup flow. + */ +async function logRuleTrigger( + tenancyId: string, + ruleId: string, + context: SignupRuleContext, + action: SignupRuleAction, + userId?: string +): Promise { + try { + await globalPrismaClient.signupRuleTrigger.create({ + data: { + tenancyId, + ruleId, + userId, + action: action.type, + metadata: { + email: context.email, + emailDomain: context.emailDomain, + authMethod: context.authMethod, + oauthProvider: context.oauthProvider, + }, + }, + }); + } catch (e) { + // Don't fail the signup if logging fails + console.error('Failed to log signup rule trigger:', e); + } +} + +/** + * Evaluates all signup rules for a tenancy against the given context. + * Rules are evaluated in order of priority (lowest 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 +): Promise { + const config = tenancy.config; + // Type assertion for signup rules fields that may not be in the generated types yet + const authConfig = config.auth as typeof config.auth & AuthConfigWithSignupRules; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- TypeScript may not see these as optional due to type assertion + const rules = authConfig.signupRules ?? {} as Record; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- TypeScript may not see these as optional due to type assertion + const defaultActionType = authConfig.signupRulesDefaultAction ?? 'allow'; + + // Get all enabled rules and sort by priority (ascending), then by ID (alphabetically) + const sortedRuleEntries = Object.entries(rules) + .filter(([, rule]) => rule.enabled) + .sort((a, b) => { + const priorityA = a[1].priority; + const priorityB = b[1].priority; + if (priorityA !== priorityB) return priorityA - priorityB; + return stringCompare(a[0], b[0]); + }); + + // Evaluate each rule in order + for (const [ruleId, rule] of sortedRuleEntries) { + if (!rule.condition) continue; + + try { + const matches = evaluateCelExpression(rule.condition, context); + if (matches) { + const action: SignupRuleAction = { + type: rule.action.type, + metadata: rule.action.metadata, + message: rule.action.message, + }; + + // Log rule trigger to database for analytics (async, don't await) + // The userId will be set later after user creation if successful + runAsynchronously(logRuleTrigger(tenancy.id, ruleId, context, action)); + + return { + ruleId, + action, + }; + } + } catch (e) { + // Log CEL evaluation error but continue to next rule + console.error(`CEL evaluation error for rule ${ruleId}:`, e); + } + } + + // No rules matched - return default action + return { + ruleId: null, + action: { type: defaultActionType }, + }; +} + +/** + * Applies the signup rule result action. + * This should be called after evaluateSignupRules to handle the action. + * + * @param result - The result from evaluateSignupRules + * @throws KnownErrors.SignUpRejected if action is 'reject' + */ +export function applySignupRuleAction(result: SignupRuleResult): { + shouldRestrict: boolean, + metadata?: Record, +} { + switch (result.action.type) { + case 'reject': { + // Throw an error to reject the signup + // Don't include the custom rule message to avoid helping users evade rules + throw new KnownErrors.SignUpRejected(); + } + case 'restrict': { + // Mark user as restricted (will need to be handled in user creation) + return { shouldRestrict: true }; + } + case 'add_metadata': { + // Return metadata to be added to the user + return { shouldRestrict: false, metadata: result.action.metadata }; + } + case 'log': { + // Just log, don't restrict or reject + // The logging is already done in evaluateSignupRules + return { shouldRestrict: false }; + } + case 'allow': + default: { + // Allow the signup to proceed normally + return { shouldRestrict: false }; + } + } +} + +/** + * Combined function to evaluate and apply signup rules. + * This is the main entry point for signup rule evaluation. + * + * @param tenancy - The tenancy to evaluate rules for + * @param context - The signup context with email, authMethod, etc. + * @returns Object with shouldRestrict, optional metadata, and ruleId that triggered + * @throws KnownErrors.SignUpRejected if a rule rejects the signup + */ +export async function evaluateAndApplySignupRules( + tenancy: Tenancy, + context: SignupRuleContext +): Promise<{ + shouldRestrict: boolean, + metadata?: Record, + ruleId: string | null, +}> { + const result = await evaluateSignupRules(tenancy, context); + const applied = applySignupRuleAction(result); + return { + ...applied, + ruleId: result.ruleId, + }; +} diff --git a/apps/backend/src/lib/users.tsx b/apps/backend/src/lib/users.tsx index 25fea48a2c..ad6099f39b 100644 --- a/apps/backend/src/lib/users.tsx +++ b/apps/backend/src/lib/users.tsx @@ -2,7 +2,144 @@ import { usersCrudHandlers } from "@/app/api/latest/users/crud"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import { KeyIntersect } from "@stackframe/stack-shared/dist/utils/types"; import { Tenancy } from "./tenancies"; +import { createSignupRuleContext } from "./cel-evaluator"; +import { evaluateAndApplySignupRules, SignupRuleMetadataEntry } from "./signup-rules"; +/** + * Options for signup rule evaluation context. + */ +export type SignupRuleOptions = { + authMethod: 'password' | 'otp' | 'oauth' | 'passkey', + oauthProvider?: string, +}; + +/** + * Creates or upgrades an anonymous user with signup rule evaluation. + * + * This function evaluates signup 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 createOrUpgradeAnonymousUser 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 signup rule evaluation + * @returns Created or updated user + * @throws KnownErrors.SignUpRejected if a signup 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 { + // Get email from create/update data + // TypeScript doesn't know this field exists due to KeyIntersect, but it's always passed for signup + const email = (createOrUpdate as { primary_email?: string }).primary_email ?? ''; + + // Create context for rule evaluation + const context = createSignupRuleContext({ + email, + authMethod: signupRuleOptions.authMethod, + oauthProvider: signupRuleOptions.oauthProvider, + }); + + // Evaluate and apply signup rules (may throw if rejected) + const ruleResult = await evaluateAndApplySignupRules(tenancy, context); + + // Build metadata objects for each target from signup rule metadata + let clientMetadata: Record | undefined; + let clientReadOnlyMetadata: Record | undefined; + let serverMetadata: Record | undefined; + + if (ruleResult.metadata) { + for (const [key, entry] of Object.entries(ruleResult.metadata)) { + switch (entry.target) { + case 'client': { + clientMetadata = { ...clientMetadata, [key]: entry.value }; + break; + } + case 'client_read_only': { + clientReadOnlyMetadata = { ...clientReadOnlyMetadata, [key]: entry.value }; + break; + } + case 'server': { + serverMetadata = { ...serverMetadata, [key]: entry.value }; + break; + } + } + } + } + + // Merge signup rule data into createOrUpdate + // Use type assertion as we know the structure from UsersCrud + const createOrUpdateWithMeta = createOrUpdate as Record; + + // Build the private restriction details if shouldRestrict is true + // The public reason is left empty - admins can set it manually if they want to show something to the user + const restrictionPrivateDetails = ruleResult.shouldRestrict && ruleResult.ruleId + ? `Restricted by signup rule: ${ruleResult.ruleId}` + : ruleResult.shouldRestrict + ? 'Restricted by signup rules' + : undefined; + + const enrichedCreateOrUpdate = { + ...createOrUpdate, + // Merge client_metadata (signup rule metadata overwrites existing keys) + ...(clientMetadata && { + client_metadata: { + ...(createOrUpdateWithMeta.client_metadata as Record | undefined), + ...clientMetadata, + }, + }), + // Merge client_read_only_metadata + ...(clientReadOnlyMetadata && { + client_read_only_metadata: { + ...(createOrUpdateWithMeta.client_read_only_metadata as Record | undefined), + ...clientReadOnlyMetadata, + }, + }), + // Merge server_metadata + ...(serverMetadata && { + server_metadata: { + ...(createOrUpdateWithMeta.server_metadata as Record | undefined), + ...serverMetadata, + }, + }), + // Handle shouldRestrict by setting restricted_by_admin fields + // Note: reason (public) is left null, private_details contains the rule info + ...(ruleResult.shouldRestrict && { + restricted_by_admin: true, + restricted_by_admin_private_details: restrictionPrivateDetails, + }), + }; + + // Proceed with user creation/upgrade + return await createOrUpgradeAnonymousUser( + tenancy, + currentUser, + enrichedCreateOrUpdate as KeyIntersect, + allowedErrorTypes, + ); +} + +/** + * Creates or upgrades an anonymous user WITHOUT signup 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 createOrUpgradeAnonymousUser( tenancy: Tenancy, currentUser: UsersCrud["Admin"]["Read"] | null, diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 7ef79efc09..4a76445c97 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -18,12 +18,12 @@ }, "dependencies": { "@ai-sdk/openai": "^1.3.23", - "ai": "^4.3.17", "@assistant-ui/react": "^0.10.24", "@assistant-ui/react-ai-sdk": "^0.10.14", "@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", @@ -67,6 +67,7 @@ "@tanstack/react-table": "^8.20.5", "@vercel/analytics": "^1.2.2", "@vercel/speed-insights": "^1.0.12", + "ai": "^4.3.17", "browser-image-compression": "^2.0.2", "canvas-confetti": "^1.9.2", "class-variance-authority": "^0.7.0", @@ -83,13 +84,13 @@ "next-themes": "^0.2.1", "posthog-js": "^1.235.0", "react": "19.2.3", + "react-day-picker": "^9.6.7", "react-dom": "19.2.3", "react-globe.gl": "^2.28.2", - "react-day-picker": "^9.6.7", "react-hook-form": "^7.53.1", "react-icons": "^5.0.1", - "react-resizable-panels": "^2.1.6", "react-markdown": "^9.0.1", + "react-resizable-panels": "^2.1.6", "react-syntax-highlighter": "^15.6.1", "recharts": "^2.14.1", "remark-gfm": "^4.0.1", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/signup-rules/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/signup-rules/page-client.tsx new file mode 100644 index 0000000000..6b66f0d79d --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/signup-rules/page-client.tsx @@ -0,0 +1,883 @@ +"use client"; + +import { ConditionBuilder } from "@/components/rule-builder"; +import { + ActionDialog, + Alert, + Button, + cn, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Spinner, + Switch, + Typography, +} from "@/components/ui"; +import { useUpdateConfig } from "@/lib/config-update"; +import { + createEmptyCondition, + createEmptyGroup, + parseCelToVisualTree, + visualTreeToCel, + type RuleNode, +} from "@/lib/cel-visual-parser"; +import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; +import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { CheckIcon, PencilSimpleIcon, PlusIcon, TrashIcon, XIcon } from "@phosphor-icons/react"; +import type { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; +import { runAsynchronously, runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; +import React, { useState, useMemo } from "react"; +import { Area, AreaChart, ResponsiveContainer } 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 }[], +}; + +// Types for signup rules from config +type SignupRuleMetadataEntry = { + value: string | number | boolean, + target: 'client' | 'client_read_only' | 'server', +}; + +type SignupRuleAction = { + type: 'allow' | 'reject' | 'restrict' | 'log' | 'add_metadata', + metadata?: Record, + message?: string, +}; + +type SignupRule = { + enabled: boolean, + displayName: string, + priority: number, + condition: string, + action: SignupRuleAction, +}; + +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', + }, +}; + +function getSortedRules(config: CompleteConfig): SignupRuleEntry[] { + const configWithRules = config as ConfigWithSignupRules; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- TypeScript may not see these as optional due to type assertion + const rules = configWithRules.auth.signupRules ?? {}; + return Object.entries(rules) + .map(([id, rule]) => ({ id, rule: rule as SignupRule })) + .sort((a, b) => { + const priorityA = a.rule.priority; + const priorityB = b.rule.priority; + if (priorityA !== priorityB) return priorityA - priorityB; + return stringCompare(a.id, b.id); + }); +} + +// Sparkline component for rule analytics +function RuleSparkline({ + data, + totalCount, +}: { + data: { hour: string, count: number }[], + totalCount: number, +}) { + if (data.length === 0) { + return ( +
+ No activity +
+ ); + } + + const avgPerHour = totalCount / Math.max(data.length, 1); + const rateLabel = avgPerHour < 1 + ? `${totalCount}/48h` + : `${avgPerHour.toFixed(1)}/h`; + + return ( +
+ + + + + + {rateLabel} +
+ ); +} + +// 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]", +); + +// Individual metadata entry for the editor +type MetadataEditorEntry = { + key: string, + value: string, + target: 'client' | 'client_read_only' | 'server', +}; + +// 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 [displayName, setDisplayName] = useState(rule?.displayName ?? ''); + const [actionType, setActionType] = useState(rule?.action.type ?? 'allow'); + const [actionMessage, setActionMessage] = useState(rule?.action.message ?? ''); + const [enabled, setEnabled] = useState(rule?.enabled ?? true); + const [isSaving, setIsSaving] = useState(false); + + // Metadata entries for add_metadata action + const initialMetadata = useMemo((): MetadataEditorEntry[] => { + if (!rule?.action.metadata) return [{ key: '', value: '', target: 'server' }]; + const entries: MetadataEditorEntry[] = Object.entries(rule.action.metadata).map(([key, entry]) => ({ + key, + value: String(entry.value), + target: entry.target, + })); + return entries.length > 0 ? entries : [{ key: '', value: '', target: 'server' }]; + }, [rule?.action.metadata]); + + const [metadataEntries, setMetadataEntries] = useState(initialMetadata); + + // 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); + + const handleSave = async () => { + if (!displayName.trim()) return; + + setIsSaving(true); + try { + const celCondition = visualTreeToCel(conditionTree); + + // Build metadata from entries + const metadata: Record | undefined = + actionType === 'add_metadata' + ? metadataEntries + .filter(e => e.key.trim()) + .reduce((acc, e) => { + acc[e.key.trim()] = { value: e.value, target: e.target }; + return acc; + }, {} as Record) + : undefined; + + const newRule: SignupRule = { + displayName: displayName.trim(), + condition: celCondition, + priority: rule?.priority ?? 0, + enabled, + action: { + type: actionType, + message: actionType === 'reject' ? actionMessage || undefined : undefined, + metadata: metadata && Object.keys(metadata).length > 0 ? metadata : undefined, + }, + }; + await onSave(ruleId, newRule); + } finally { + setIsSaving(false); + } + }; + + const addMetadataEntry = () => { + setMetadataEntries([...metadataEntries, { key: '', value: '', target: 'server' }]); + }; + + const removeMetadataEntry = (index: number) => { + if (metadataEntries.length > 1) { + setMetadataEntries(metadataEntries.filter((_, i) => i !== index)); + } + }; + + const updateMetadataEntry = (index: number, updates: Partial) => { + setMetadataEntries(metadataEntries.map((entry, i) => + i === index ? { ...entry, ...updates } : entry + )); + }; + + 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" + /> + )} +
+ + {/* Metadata entries for add_metadata action */} + {actionType === 'add_metadata' && ( +
+ +
+ {metadataEntries.map((entry, index) => ( +
+ updateMetadataEntry(index, { key: e.target.value })} + placeholder="Key" + className="flex-1" + /> + updateMetadataEntry(index, { value: e.target.value })} + placeholder="Value" + className="flex-1" + /> + + +
+ ))} + +
+
+ )} + + {/* Save/Cancel buttons */} +
+ + +
+
+
+
+ ); +} + +// Sortable rule row component (view mode) +function SortableRuleRow({ + entry, + analytics, + isEditing, + onEdit, + onDelete, + onToggleEnabled, + onSave, + onCancelEdit, +}: { + entry: SignupRuleEntry, + analytics?: RuleAnalytics, + 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 actionLabel = { + 'allow': 'Allow', + 'reject': 'Reject', + 'restrict': 'Restrict', + 'log': 'Log', + 'add_metadata': 'Add metadata', + }[entry.rule.action.type]; + + const conditionSummary = entry.rule.condition || '(no condition)'; + + // 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} + +
+ + {/* Sparkline chart for analytics */} + {analytics && ( +
+ +
+ )} + + {/* Actions - edit and delete */} +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + + +
+
+ ); +} + +// 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. + + + ); +} + +// Internal symbol for accessing SDK internals +const stackAppInternalsSymbol = Symbol.for("StackAuth--DO-NOT-USE-OR-YOU-WILL-BE-FIRED--StackAppInternals"); + +// Custom hook to fetch signup rules analytics +function useSignupRulesAnalytics() { + const stackAdminApp = useAdminApp(); + const [analytics, setAnalytics] = useState>(new Map()); + const [isLoading, setIsLoading] = useState(true); + + React.useEffect(() => { + let cancelled = false; + + const fetchAnalytics = async () => { + try { + const response = await (stackAdminApp as any)[stackAppInternalsSymbol].sendRequest('/internal/signup-rules', { + method: 'GET', + }); + if (cancelled) return; + + 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); + } catch (e) { + console.debug('Failed to fetch signup rules analytics:', e); + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + runAsynchronously(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); + + // Reordering loading state + const [isReordering, setIsReordering] = useState(false); + + // Fetch analytics data + const { analytics: ruleAnalytics } = useSignupRulesAnalytics(); + + // Type assertion needed because schema changes take effect at build time + const configWithRules = config as ConfigWithSignupRules; + // 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'; + + const sortedRules = useMemo(() => getSortedRules(config), [config]); + + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = sortedRules.findIndex((r) => r.id === active.id); + const newIndex = sortedRules.findIndex((r) => r.id === over.id); + const newOrder = arrayMove(sortedRules, oldIndex, newIndex); + + setIsReordering(true); + + const configUpdate: Record = {}; + newOrder.forEach((entry, index) => { + configUpdate[`auth.signupRules.${entry.id}.priority`] = index; + }); + + try { + await updateConfig({ + adminApp: stackAdminApp, + configUpdate, + pushable: true, + }); + } finally { + setIsReordering(false); + } + } + }; + + const handleAddRule = () => { + const id = generateUuid(); + setNewRuleId(id); + setIsCreatingNew(true); + setEditingRuleId(null); + }; + + const handleSaveRule = async (ruleId: string, rule: SignupRule) => { + // For new rules, set priority to be at the end + if (isCreatingNew) { + rule.priority = sortedRules.length; + } + + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { + [`auth.signupRules.${ruleId}`]: rule, + }, + pushable: true, + }); + setEditingRuleId(null); + setIsCreatingNew(false); + setNewRuleId(null); + }; + + const handleCancelEdit = () => { + setEditingRuleId(null); + setIsCreatingNew(false); + setNewRuleId(null); + }; + + const handleDeleteRule = async () => { + if (!ruleToDelete) return; + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { + [`auth.signupRules.${ruleToDelete.id}`]: null, + }, + pushable: true, + }); + setDeleteDialogOpen(false); + setRuleToDelete(null); + }; + + const handleToggleEnabled = async (ruleId: string, enabled: boolean) => { + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { + [`auth.signupRules.${ruleId}.enabled`]: enabled, + }, + pushable: true, + }); + }; + + const handleDefaultActionChange = async (value: 'allow' | 'reject') => { + await updateConfig({ + adminApp: stackAdminApp, + configUpdate: { + 'auth.signupRulesDefaultAction': value, + }, + pushable: true, + }); + }; + + const isAnyEditing = editingRuleId !== null || isCreatingNew; + + return ( + + + + Add rule + + } + > + {/* Rules list and default action */} +
+ {/* Blocking loading overlay */} + {isReordering && ( +
+
+ + Saving order... +
+
+ )} + + {/* New rule editor (at the top when creating) */} + {isCreatingNew && newRuleId && ( + + )} + + {sortedRules.length > 0 ? ( + runAsynchronouslyWithAlert(handleDragEnd(e))} + > + r.id)} + strategy={verticalListSortingStrategy} + > + {sortedRules.map((entry) => ( + { + setEditingRuleId(entry.id); + setIsCreatingNew(false); + }} + onDelete={() => { + setRuleToDelete(entry); + setDeleteDialogOpen(true); + }} + onToggleEnabled={(enabled) => runAsynchronouslyWithAlert(handleToggleEnabled(entry.id, enabled))} + onSave={handleSaveRule} + onCancelEdit={handleCancelEdit} + /> + ))} + + + ) : !isCreatingNew ? ( + + No sign-up rules configured. Click "Add rule" to create your first rule. + + ) : null} + + {/* Default action card - always at the bottom */} + runAsynchronouslyWithAlert(handleDefaultActionChange(v))} + /> +
+ + {/* Delete confirmation dialog */} + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/signup-rules/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/signup-rules/page.tsx new file mode 100644 index 0000000000..a97fa151ad --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/signup-rules/page.tsx @@ -0,0 +1,6 @@ +"use server"; +import PageClient from "./page-client"; + +export default async function Page() { + return ; +} 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..b9357eaf6a 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,25 @@ import { AccordionItem, AccordionTrigger, ActionCell, + Alert, + AlertDescription, + AlertTitle, Avatar, AvatarFallback, AvatarImage, Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, + Input, Separator, SimpleTooltip, Table, @@ -28,6 +38,7 @@ import { TableHead, TableHeader, TableRow, + Textarea, Typography, cn, useToast @@ -35,7 +46,7 @@ import { 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"; @@ -247,6 +258,219 @@ 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 () => { + setIsSaving(true); + try { + await user.update({ + restrictedByAdmin: true, + restrictedByAdminReason: publicReason.trim() || null, + restrictedByAdminPrivateDetails: privateDetails.trim() || null, + } as any); + onOpenChange(false); + } 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} + /> +
+
+ +