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 index eefe671f6..fe05db84a 100644 --- 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 @@ -38,7 +38,7 @@ 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 { ArrowsDownUpIcon, CaretDownIcon, CaretRightIcon, CheckIcon, CheckCircleIcon, CircleNotchIcon, PencilSimpleIcon, PlusIcon, SlidersIcon, TrashIcon, XCircleIcon, 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"; @@ -764,11 +764,10 @@ function DefaultActionCard({ const DEFAULT_TURNSTILE_OVERRIDE = "__default__"; -function TestRulesCard({ - stackAdminApp, -}: { - stackAdminApp: ReturnType, -}) { + +// Shared hook used by every TestRulesCard variant - encapsulates all the state +// and the API call so the variants can focus purely on the UI. +function useTestRulesState(stackAdminApp: ReturnType) { const [email, setEmail] = useState(''); const [authMethod, setAuthMethod] = useState('password'); const [oauthProvider, setOauthProvider] = useState(''); @@ -836,83 +835,88 @@ function TestRulesCard({ setResult(data); }, [authMethod, botRiskScoreOverride, countryCodeOverride, email, freeTrialAbuseRiskScoreOverride, oauthProvider, stackAdminApp, turnstileResultOverride]); - const handleAuthMethodChange = (value: string) => { - if (value === 'password' || value === 'otp' || value === 'oauth' || value === 'passkey') { - setAuthMethod(value); - if (value !== 'oauth') { - setOauthProvider(''); - } - } + return { + email, setEmail, + authMethod, setAuthMethod, + oauthProvider, setOauthProvider, + countryCodeOverride, setCountryCodeOverride, + turnstileResultOverride, setTurnstileResultOverride, + botRiskScoreOverride, setBotRiskScoreOverride, + freeTrialAbuseRiskScoreOverride, setFreeTrialAbuseRiskScoreOverride, + result, + runTest, + isRunning, }; +} - const evaluations = result?.evaluations ?? []; - const matchedEvaluations = evaluations.filter((evaluation) => evaluation.status === 'matched'); - const decisionRule = result?.outcome.decision_rule_id - ? evaluations.find((evaluation) => evaluation.rule_id === result.outcome.decision_rule_id) - : undefined; - const restrictedRule = result?.outcome.restricted_because_of_rule_id - ? evaluations.find((evaluation) => evaluation.rule_id === result.outcome.restricted_because_of_rule_id) - : undefined; +type TestRulesState = ReturnType; - const outcomeLabel = result?.outcome.should_allow ? 'Allow' : 'Reject'; - const outcomeTone = result?.outcome.should_allow - ? "bg-green-500/10 text-green-700 dark:text-green-400 border-green-500/20" - : "bg-red-500/10 text-red-700 dark:text-red-400 border-red-500/20"; - - const actionBadgeClassName = (type: SignUpRulesTestEvaluation['action']['type']) => cn( +function actionBadgeClassNameFor(type: SignUpRulesTestEvaluation['action']['type']) { + return cn( "text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded", type === 'allow' && "bg-green-500/10 text-green-600 dark:text-green-400", type === 'reject' && "bg-red-500/10 text-red-600 dark:text-red-400", type === 'restrict' && "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", type === 'log' && "bg-blue-500/10 text-blue-600 dark:text-blue-400", ); +} - const statusBadgeClassName = (status: SignUpRulesTestEvaluationStatus) => cn( - "text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded", - status === 'matched' && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", - status === 'not_matched' && "bg-muted/60 text-muted-foreground", - status === 'disabled' && "bg-muted/60 text-muted-foreground", - status === 'missing_condition' && "bg-amber-500/10 text-amber-600 dark:text-amber-400", - status === 'error' && "bg-red-500/10 text-red-600 dark:text-red-400", - ); +const DECISION_LABEL: Record = { + allow: 'Allowed by rule', + reject: 'Rejected by rule', + 'default-allow': 'Allowed by default', + 'default-reject': 'Rejected by default', +}; - const statusLabel: Record = { - matched: 'Matched', - not_matched: 'No match', - disabled: 'Disabled', - missing_condition: 'No condition', - error: 'Error', - }; +const STATUS_LABEL: Record = { + matched: 'Matched', + not_matched: 'No match', + disabled: 'Disabled', + missing_condition: 'No condition', + error: 'Error', +}; - const decisionLabel: Record = { - allow: 'Allowed by rule', - reject: 'Rejected by rule', - 'default-allow': 'Allowed by default', - 'default-reject': 'Rejected by default', - }; +// Essentials-first test rules card with a collapsible "Advanced" panel +// and an outcome-forward results view. The outcome box mounts as soon as +// a run kicks off so users see a loading indicator before it resolves. +function TestRulesCard({ state }: { state: TestRulesState }) { + const [showAdvanced, setShowAdvanced] = useState(false); + const { result, isRunning } = state; + + // Keep the results region mounted across loading -> result transitions + // so we can animate the color/icon change smoothly. + const hasRun = isRunning || result !== null; + + const matchedCount = result?.evaluations.filter((e) => e.status === 'matched').length ?? 0; + const decisionRule = result?.outcome.decision_rule_id + ? result.evaluations.find((e) => e.rule_id === result.outcome.decision_rule_id) + : undefined; + const restrictedRule = result?.outcome.restricted_because_of_rule_id + && result.outcome.restricted_because_of_rule_id !== result.outcome.decision_rule_id + ? result.evaluations.find((e) => e.rule_id === result.outcome.restricted_because_of_rule_id) + : undefined; return ( -
-
-
+
+
+
- - Email - + setEmail(e.target.value)} + value={state.email} + onChange={(e) => state.setEmail(e.target.value)} placeholder="user@company.com" />
- - Auth method - - { + if (v === 'password' || v === 'otp' || v === 'oauth' || v === 'passkey') { + state.setAuthMethod(v); + if (v !== 'oauth') state.setOauthProvider(''); + } + }}> + Password OTP @@ -923,247 +927,213 @@ function TestRulesCard({
-
- - OAuth provider - - setOauthProvider(e.target.value)} - placeholder={authMethod === 'oauth' ? "google" : "Only used for OAuth"} - disabled={authMethod !== 'oauth'} - list="sign-up-rule-test-oauth-providers" - /> - - {OAUTH_PROVIDER_OPTIONS.map((provider) => ( - -
+ -
-
- - Country code override - - setCountryCodeOverride(val ?? "")} - /> + {showAdvanced && ( +
+ {state.authMethod === 'oauth' && ( +
+ + state.setOauthProvider(e.target.value)} + placeholder="google" + list="sign-up-rule-test-oauth-providers-v1" + /> + + {OAUTH_PROVIDER_OPTIONS.map((p) => +
+ )} +
+
+ + state.setCountryCodeOverride(val ?? "")} + /> + Leave blank to use real geolocation. +
+
+ + +
+
+ + state.setBotRiskScoreOverride(e.target.value)} + placeholder="0-100 (blank = real)" + inputMode="numeric" + /> +
+
+ + state.setFreeTrialAbuseRiskScoreOverride(e.target.value)} + placeholder="0-100 (blank = real)" + inputMode="numeric" + /> +
+
-
- - Bot score override - - setBotRiskScoreOverride(e.target.value)} - placeholder="0-100" - inputMode="numeric" - /> -
-
- - Free trial abuse override - - setFreeTrialAbuseRiskScoreOverride(e.target.value)} - placeholder="0-100" - inputMode="numeric" - /> -
-
- - Turnstile override - - -
-
+ )} -
+
- - Simulate a sign-up request to preview which rules trigger. - - - Leave overrides blank to derive country code and risk scores on the server from request geolocation and signup context. -
-
- {!result ? ( - - Run a test to preview which rules trigger and what the sign-up outcome would be. - - ) : ( - <> -
-
- Outcome - {outcomeLabel} -
- - {decisionLabel[result.outcome.decision]} - - {decisionRule && ( - - Decision rule: {decisionRule.display_name || decisionRule.rule_id} - - )} - {decisionRule?.action.message && ( - - Rejection reason: {decisionRule.action.message} - - )} - {restrictedRule && ( - - Restricted by: {restrictedRule.display_name || restrictedRule.rule_id} - - )} -
- -
-
- - Triggered rules - - - {matchedEvaluations.length} matched - -
- {matchedEvaluations.length === 0 ? ( - - No rules matched. Default action applies. - - ) : ( -
- {matchedEvaluations.map((evaluation) => ( -
-
- - {evaluation.display_name || evaluation.rule_id} - - - {evaluation.condition || "No condition"} - -
-
- - {evaluation.action.type} - - {evaluation.rule_id === result.outcome.decision_rule_id && ( - - Decision - - )} - {evaluation.rule_id === result.outcome.restricted_because_of_rule_id && ( - - Restrict - - )} -
-
- ))} -
- )} -
- -
-
- - Evaluation trace - - - {evaluations.length} evaluated - -
- {evaluations.length === 0 ? ( - - No rules configured yet. - - ) : ( -
- {evaluations.map((evaluation) => ( -
-
- - {evaluation.display_name || evaluation.rule_id} - - - {evaluation.condition || "No condition"} - -
-
- - {evaluation.action.type} - - - {statusLabel[evaluation.status]} - -
-
- ))} -
- )} -
- -
- - Normalized context - - - Email: {result.context.email || "(empty)"} - - - Email domain: {result.context.email_domain || "(empty)"} - - - Country code: {result.context.country_code || "(empty)"} - - - OAuth provider: {result.context.oauth_provider || "(empty)"} - - - Turnstile result: {result.context.turnstile_result} - - - Risk score (bot): {result.context.risk_scores.bot} - - - Risk score (free trial abuse): {result.context.risk_scores.free_trial_abuse} - -
- +
+
+
+ {/* Outcome hero — mounts on run start with neutral/loading style, + then fades into green/red once the result arrives. */} +
+
+ + + +
+
+ + {!result && "Running test…"} + {result && `Sign-up would ${result.outcome.should_allow ? 'be allowed' : 'be rejected'}`} + + + {!result && "Evaluating configured rules."} + {result && ( + <> + {DECISION_LABEL[result.outcome.decision]} + {decisionRule && <> — {decisionRule.display_name || decisionRule.rule_id}} + + )} + + {restrictedRule && ( + + Restricted by: {restrictedRule.display_name || restrictedRule.rule_id} + + )} + {decisionRule?.action.message && ( + + Reason: {decisionRule.action.message} + + )} +
+
+ + {/* Matched rules + context only render once the result arrives; they + slide in underneath the outcome hero. */} +
+
+ {result && ( + <> +
+ + Rule evaluations + {matchedCount} matched of {result.evaluations.length} + +
+ {result.evaluations.map((e) => ( +
+ + {e.display_name || e.rule_id} + {STATUS_LABEL[e.status]} + {e.action.type} +
+ ))} +
+
+ +
+ Resolved context +
+
Email: {result.context.email || "(empty)"}
+
Domain: {result.context.email_domain || "(empty)"}
+
Country: {result.context.country_code || "(empty)"}
+
OAuth provider: {result.context.oauth_provider || "(empty)"}
+
Turnstile: {result.context.turnstile_result}
+
Bot score: {result.context.risk_scores.bot}
+
Free-trial abuse: {result.context.risk_scores.free_trial_abuse}
+
+
+ + )} +
+
+
+
); @@ -1174,14 +1144,16 @@ function TestRulesDialog({ }: { stackAdminApp: ReturnType, }) { + const state = useTestRulesState(stackAdminApp); + return ( - - + Test sign-up rules @@ -1189,7 +1161,7 @@ function TestRulesDialog({ - + @@ -1450,13 +1422,16 @@ export default function PageClient() { title="Sign-up Rules" description="Create rules to control who can sign up. Rules are evaluated in order from top to bottom." actions={ - +
+ + +
} > {/* Rules list and default action */} @@ -1586,19 +1561,6 @@ export default function PageClient() { value={defaultAction} onChange={(v) => runAsynchronouslyWithAlert(handleDefaultActionChange(v))} /> -
-
-
-
- Test rules - - Try sample sign-ups without touching the live flow. - -
- -
-
-
{/* Delete confirmation dialog */}