From 982b8fb2d9fdb39dbce537949c8ddc859c4bb5ae Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Fri, 24 Apr 2026 11:35:47 -0700 Subject: [PATCH] Simplify sign-up rules tester dialog (#1369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The sign-up rules tester dialog was dense and hard to parse: a two-column layout crammed 8 input fields against 4 stacked result panels (Outcome, Triggered rules, Evaluation trace, Normalized context), and used technical jargon ("Turnstile override", "Normalized context", "Evaluation trace") without much hierarchy. This PR reworks it around the user's actual question — *"will this sign-up be allowed?"* — and moves the entrypoint somewhere more discoverable. ## What changed ### 1. Dialog UI — essentials-first layout - Only **Email** and **Sign-up method** are shown upfront. - Everything else (OAuth provider, Country, Bot / free-trial-abuse scores, Turnstile) is hidden behind a single **Advanced options** collapsible panel. The label previews what's inside, so users know when they need to expand it. - Results are outcome-first: a large green/red hero card with a check/X icon and a plain-English decision ("Sign-up would be allowed"). Matched rules and resolved context are tucked into `
` sections below. - Removed the "Fill out the form above…" placeholder — it added clutter without adding info. ### 2. Loading → result transition - The outcome card now mounts **immediately** when Run test is clicked. While the request is in flight it shows a neutral gray card with a spinning `CircleNotchIcon` and "Running test…". - When the result arrives, the card's border/background transitions over 500ms to green or red, the spinner fades out, and the check/X fades in. Matched rules and resolved context slide down underneath via a `grid-rows-[0fr→1fr]` animation. ### 3. Entry-point moved to the page header - "Open tester" now sits **next to Add rule** in the header (secondary variant, same size). - Removed the dedicated "Test rules" card at the bottom of the page — it was using real estate for something a button can do. ### 4. Code cleanup - Dropped three exploratory variants (wizard, inspector, the original complex card) that were temporarily in the file during design exploration. - Extracted `useTestRulesState()` to encapsulate state + API call, so the card is purely presentational. ## Why The tester is an admin-only debugging tool, so it lives or dies by how fast someone can glance at it and answer *"would this sign-up go through?"*. The old dialog asked readers to visually parse two columns and seven fields just to find the outcome. The new layout answers that question in the first card. ## Walkthrough ![walkthrough](https://gist.githubusercontent.com/BilalG1/67639d1590ac172880dc705a027560d3/raw/tester-flow.gif) 21s demo (2x speed): page → open tester → type email → Run test → loading spinner transitions into the green decision card. [Download MP4](https://gist.githubusercontent.com/BilalG1/67639d1590ac172880dc705a027560d3/raw/tester-flow.mp4) · [Gist with all media](https://gist.github.com/BilalG1/67639d1590ac172880dc705a027560d3) ## Before / After ### Original tester ![before](https://gist.githubusercontent.com/BilalG1/67639d1590ac172880dc705a027560d3/raw/before-original.png) ### New header layout "Open tester" next to "Add rule"; no more bottom card. ![after header](https://gist.githubusercontent.com/BilalG1/67639d1590ac172880dc705a027560d3/raw/after-header-buttons.png) ### New tester dialog — initial Just Email + Sign-up method. Advanced options collapsed. ![after initial](https://gist.githubusercontent.com/BilalG1/67639d1590ac172880dc705a027560d3/raw/after-dialog-initial.png) ### New tester dialog — mid-run (loading) Outcome card mounts with a spinner while the request is in-flight. ![after loading](https://gist.githubusercontent.com/BilalG1/67639d1590ac172880dc705a027560d3/raw/after-dialog-loading.png) ### New tester dialog — result Outcome hero transitions to green; matched rules + resolved context collapsibles underneath. ![after results](https://gist.githubusercontent.com/BilalG1/67639d1590ac172880dc705a027560d3/raw/after-dialog-results.png) ## Test plan - [x] `pnpm typecheck` (dashboard) passes - [x] `pnpm lint` (dashboard) passes - [x] Manually exercised the tester against a configured rule (`emailDomain.endsWith("tempmail.com")`) with Advanced options both open and closed - [x] Verified the loading → green/red transition under artificial latency (1.2s) - [x] Verified the "Open tester" button sits next to "Add rule" and the bottom card is gone ## Scope notes - No backend, schema, or API changes. Only touches `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sign-up-rules/page-client.tsx`. - The existing analytics / trigger-history / rule-editor code is untouched. ## Summary by CodeRabbit ## Release Notes * **New Features** * Advanced testing options now available in a collapsible panel * Enhanced test results visualization with detailed rule evaluation display * **UI/UX Improvements** * Test trigger button relocated to main action area * Larger, repositioned "Run test" button * Reorganized results display with collapsible sections for rules and context details --------- Co-authored-by: Bilal Godil --- .../[projectId]/sign-up-rules/page-client.tsx | 594 ++++++++---------- 1 file changed, 278 insertions(+), 316 deletions(-) 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 */}