mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Test sign-up rules widget
This commit is contained in:
parent
457ff2bdbe
commit
6fcf1a888f
@ -1017,6 +1017,48 @@ async function seedDummyProject(options: DummyProjectSeedOptions) {
|
||||
projectId: DUMMY_PROJECT_ID,
|
||||
branchId: DEFAULT_BRANCH_ID,
|
||||
branchConfigOverrideOverride: {
|
||||
auth: {
|
||||
signUpRulesDefaultAction: "allow",
|
||||
signUpRules: {
|
||||
"allow-dummy-domain": {
|
||||
enabled: true,
|
||||
displayName: "Allow @dummy.dev",
|
||||
priority: 4,
|
||||
condition: 'emailDomain == "dummy.dev"',
|
||||
action: {
|
||||
type: "allow",
|
||||
},
|
||||
},
|
||||
"block-disposable-emails": {
|
||||
enabled: true,
|
||||
displayName: "Block disposable emails",
|
||||
priority: 3,
|
||||
condition: 'emailDomain.matches("(?i)mailinator\\\\.com|tempmail\\\\.com")',
|
||||
action: {
|
||||
type: "reject",
|
||||
message: "Disposable emails are not allowed",
|
||||
},
|
||||
},
|
||||
"restrict-free-domains": {
|
||||
enabled: true,
|
||||
displayName: "Restrict free email domains",
|
||||
priority: 2,
|
||||
condition: 'emailDomain in ["gmail.com", "yahoo.com", "outlook.com"]',
|
||||
action: {
|
||||
type: "restrict",
|
||||
},
|
||||
},
|
||||
"log-test-prefix": {
|
||||
enabled: true,
|
||||
displayName: "Log test+ emails",
|
||||
priority: 1,
|
||||
condition: 'email.startsWith("test+")',
|
||||
action: {
|
||||
type: "log",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
payments: paymentsBranchOverride as any,
|
||||
apps: {
|
||||
installed: typedFromEntries(typedEntries(ALL_APPS).map(([key]) => [key, { enabled: true }])),
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
import { createSignUpRuleContext } from "@/lib/cel-evaluator";
|
||||
import { evaluateSignUpRulesWithTrace } from "@/lib/sign-up-rules";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { adaptSchema, adminAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
|
||||
const AUTH_METHODS = ['password', 'otp', 'oauth', 'passkey'] as const;
|
||||
const ACTION_TYPES = ['allow', 'reject', 'restrict', 'log'] as const;
|
||||
const DECISION_TYPES = ['allow', 'reject', 'default-allow', 'default-reject'] as const;
|
||||
const STATUS_TYPES = ['matched', 'not_matched', 'disabled', 'missing_condition', 'error'] as const;
|
||||
|
||||
export const POST = createSmartRouteHandler({
|
||||
metadata: {
|
||||
hidden: true,
|
||||
},
|
||||
request: yupObject({
|
||||
auth: yupObject({
|
||||
type: adminAuthTypeSchema.defined(),
|
||||
tenancy: adaptSchema.defined(),
|
||||
}),
|
||||
body: yupObject({
|
||||
email: yupString().optional(),
|
||||
auth_method: yupString().oneOf(AUTH_METHODS).defined(),
|
||||
oauth_provider: yupString().optional(),
|
||||
}).defined(),
|
||||
}),
|
||||
response: yupObject({
|
||||
statusCode: yupNumber().oneOf([200]).defined(),
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupObject({
|
||||
context: yupObject({
|
||||
email: yupString().defined(),
|
||||
email_domain: yupString().defined(),
|
||||
auth_method: yupString().oneOf(AUTH_METHODS).defined(),
|
||||
oauth_provider: yupString().defined(),
|
||||
}).defined(),
|
||||
evaluations: yupArray(yupObject({
|
||||
rule_id: yupString().defined(),
|
||||
display_name: yupString().defined(),
|
||||
enabled: yupBoolean().defined(),
|
||||
condition: yupString().defined(),
|
||||
status: yupString().oneOf(STATUS_TYPES).defined(),
|
||||
action: yupObject({
|
||||
type: yupString().oneOf(ACTION_TYPES).defined(),
|
||||
message: yupString().optional(),
|
||||
}).defined(),
|
||||
error: yupString().optional(),
|
||||
}).defined()).defined(),
|
||||
outcome: yupObject({
|
||||
should_allow: yupBoolean().defined(),
|
||||
decision: yupString().oneOf(DECISION_TYPES).defined(),
|
||||
decision_rule_id: yupString().nullable().defined(),
|
||||
restricted_because_of_rule_id: yupString().nullable().defined(),
|
||||
}).defined(),
|
||||
}).defined(),
|
||||
}),
|
||||
handler: async (req) => {
|
||||
const context = createSignUpRuleContext({
|
||||
email: req.body.email,
|
||||
authMethod: req.body.auth_method,
|
||||
oauthProvider: req.body.oauth_provider,
|
||||
});
|
||||
const trace = evaluateSignUpRulesWithTrace(req.auth.tenancy, context);
|
||||
|
||||
return {
|
||||
statusCode: 200 as const,
|
||||
bodyType: "json" as const,
|
||||
body: {
|
||||
context: {
|
||||
email: context.email,
|
||||
email_domain: context.emailDomain,
|
||||
auth_method: context.authMethod,
|
||||
oauth_provider: context.oauthProvider,
|
||||
},
|
||||
evaluations: trace.evaluations.map((evaluation) => ({
|
||||
rule_id: evaluation.ruleId,
|
||||
display_name: evaluation.rule.displayName ?? "",
|
||||
enabled: evaluation.rule.enabled !== false,
|
||||
condition: evaluation.rule.condition ?? "",
|
||||
status: evaluation.status,
|
||||
action: {
|
||||
type: evaluation.rule.action.type,
|
||||
message: evaluation.rule.action.message,
|
||||
},
|
||||
...(evaluation.error ? { error: evaluation.error } : {}),
|
||||
})),
|
||||
outcome: {
|
||||
should_allow: trace.outcome.shouldAllow,
|
||||
decision: trace.outcome.decision,
|
||||
decision_rule_id: trace.outcome.decisionRuleId,
|
||||
restricted_because_of_rule_id: trace.outcome.restrictedBecauseOfSignUpRuleId,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -1,5 +1,5 @@
|
||||
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
|
||||
import type { SignUpRuleAction } from "@stackframe/stack-shared/dist/interface/crud/sign-up-rules";
|
||||
import type { SignUpRule, 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";
|
||||
@ -54,17 +54,92 @@ export async function evaluateSignUpRules(
|
||||
tenancy: Tenancy,
|
||||
context: SignUpRuleContext
|
||||
) {
|
||||
const config = tenancy.config;
|
||||
const result = evaluateSignUpRulesInternal(tenancy, context, { includeEvaluations: false, logTriggers: true });
|
||||
return {
|
||||
restrictedBecauseOfSignUpRuleId: result.outcome.restrictedBecauseOfSignUpRuleId,
|
||||
shouldAllow: result.outcome.shouldAllow,
|
||||
};
|
||||
}
|
||||
|
||||
export type SignUpRuleEvaluationStatus =
|
||||
| 'matched'
|
||||
| 'not_matched'
|
||||
| 'disabled'
|
||||
| 'missing_condition'
|
||||
| 'error';
|
||||
|
||||
export type SignUpRuleEvaluation = {
|
||||
ruleId: string,
|
||||
rule: SignUpRule,
|
||||
status: SignUpRuleEvaluationStatus,
|
||||
error?: string,
|
||||
};
|
||||
|
||||
export type SignUpRulesTraceResult = {
|
||||
evaluations: SignUpRuleEvaluation[],
|
||||
outcome: {
|
||||
shouldAllow: boolean,
|
||||
decision: 'allow' | 'reject' | 'default-allow' | 'default-reject',
|
||||
decisionRuleId: string | null,
|
||||
restrictedBecauseOfSignUpRuleId: string | null,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates all sign-up rules and returns a trace of evaluations.
|
||||
* This is used for admin testing and does not log any analytics events.
|
||||
*/
|
||||
export function evaluateSignUpRulesWithTrace(
|
||||
tenancy: Tenancy,
|
||||
context: SignUpRuleContext
|
||||
): SignUpRulesTraceResult {
|
||||
return evaluateSignUpRulesInternal(tenancy, context, { includeEvaluations: true, logTriggers: false });
|
||||
}
|
||||
|
||||
function evaluateSignUpRulesInternal(
|
||||
tenancy: Tenancy,
|
||||
context: SignUpRuleContext,
|
||||
options: { includeEvaluations: boolean, logTriggers: boolean }
|
||||
): SignUpRulesTraceResult {
|
||||
const config = tenancy.config;
|
||||
const evaluations: SignUpRuleEvaluation[] = [];
|
||||
let restrictedBecauseOfSignUpRuleId: string | null = null;
|
||||
|
||||
const recordEvaluation = options.includeEvaluations
|
||||
? (evaluation: SignUpRuleEvaluation) => {
|
||||
evaluations.push(evaluation);
|
||||
}
|
||||
: () => {};
|
||||
|
||||
for (const [ruleId, rule] of typedEntries(config.auth.signUpRules)) {
|
||||
if (!rule.enabled || !rule.condition) continue;
|
||||
const isEnabled = rule.enabled === true;
|
||||
if (!isEnabled) {
|
||||
recordEvaluation({
|
||||
ruleId,
|
||||
rule,
|
||||
status: 'disabled',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!rule.condition) {
|
||||
recordEvaluation({
|
||||
ruleId,
|
||||
rule,
|
||||
status: 'missing_condition',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let matches = false;
|
||||
let status: SignUpRuleEvaluationStatus = 'not_matched';
|
||||
let error: string | undefined;
|
||||
try {
|
||||
matches = evaluateCelExpression(rule.condition, context);
|
||||
status = matches ? 'matched' : 'not_matched';
|
||||
} catch (e) {
|
||||
if (e instanceof CelEvaluationError) {
|
||||
status = 'error';
|
||||
error = e.message;
|
||||
// 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 }));
|
||||
@ -73,6 +148,13 @@ export async function evaluateSignUpRules(
|
||||
}
|
||||
}
|
||||
|
||||
recordEvaluation({
|
||||
ruleId,
|
||||
rule,
|
||||
status,
|
||||
...(error ? { error } : {}),
|
||||
});
|
||||
|
||||
if (matches) {
|
||||
const actionConfig = rule.action;
|
||||
const actionType = actionConfig.type;
|
||||
@ -81,8 +163,10 @@ export async function evaluateSignUpRules(
|
||||
message: actionConfig.message,
|
||||
};
|
||||
|
||||
// log asynchronously
|
||||
runAsynchronouslyAndWaitUntil(logRuleTrigger(tenancy, ruleId, context, action));
|
||||
if (options.logTriggers) {
|
||||
// log asynchronously
|
||||
runAsynchronouslyAndWaitUntil(logRuleTrigger(tenancy, ruleId, context, action));
|
||||
}
|
||||
|
||||
// apply the action
|
||||
if (actionType === 'restrict') {
|
||||
@ -93,15 +177,26 @@ export async function evaluateSignUpRules(
|
||||
}
|
||||
if (actionType === 'allow' || actionType === 'reject') {
|
||||
return {
|
||||
restrictedBecauseOfSignUpRuleId,
|
||||
shouldAllow: actionType === 'allow',
|
||||
evaluations,
|
||||
outcome: {
|
||||
restrictedBecauseOfSignUpRuleId,
|
||||
shouldAllow: actionType === 'allow',
|
||||
decision: actionType,
|
||||
decisionRuleId: ruleId,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shouldAllow = config.auth.signUpRulesDefaultAction !== 'reject';
|
||||
return {
|
||||
restrictedBecauseOfSignUpRuleId,
|
||||
shouldAllow: config.auth.signUpRulesDefaultAction !== 'reject',
|
||||
evaluations,
|
||||
outcome: {
|
||||
restrictedBecauseOfSignUpRuleId,
|
||||
shouldAllow,
|
||||
decision: shouldAllow ? 'default-allow' : 'default-reject',
|
||||
decisionRuleId: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -54,8 +54,12 @@ export async function createOrUpgradeAnonymousUserWithRules(
|
||||
}
|
||||
|
||||
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}`
|
||||
const restrictionRuleId = ruleResult.restrictedBecauseOfSignUpRuleId;
|
||||
const restrictionRuleDisplayName = restrictionRuleId
|
||||
? (tenancy.config.auth.signUpRules[restrictionRuleId].displayName ?? "")
|
||||
: "";
|
||||
const restrictionPrivateDetails = restrictionRuleId
|
||||
? `Restricted by sign-up rule: ${restrictionRuleId}${restrictionRuleDisplayName ? ` (${restrictionRuleDisplayName})` : ""}`
|
||||
: undefined;
|
||||
|
||||
const enrichedCreateOrUpdate = {
|
||||
|
||||
@ -6,6 +6,13 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
cn,
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -32,6 +39,7 @@ 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 { standardProviders } from "@stackframe/stack-shared/dist/utils/oauth";
|
||||
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";
|
||||
@ -53,6 +61,44 @@ type SignUpRuleEntry = {
|
||||
rule: SignUpRule,
|
||||
};
|
||||
|
||||
type SignUpRulesTestEvaluationStatus =
|
||||
| 'matched'
|
||||
| 'not_matched'
|
||||
| 'disabled'
|
||||
| 'missing_condition'
|
||||
| 'error';
|
||||
|
||||
type SignUpRulesTestEvaluation = {
|
||||
rule_id: string,
|
||||
display_name: string,
|
||||
enabled: boolean,
|
||||
condition: string,
|
||||
status: SignUpRulesTestEvaluationStatus,
|
||||
action: {
|
||||
type: 'allow' | 'reject' | 'restrict' | 'log',
|
||||
message?: string,
|
||||
},
|
||||
error?: string,
|
||||
};
|
||||
|
||||
type SignUpRulesTestResult = {
|
||||
context: {
|
||||
email: string,
|
||||
email_domain: string,
|
||||
auth_method: 'password' | 'otp' | 'oauth' | 'passkey',
|
||||
oauth_provider: string,
|
||||
},
|
||||
evaluations: SignUpRulesTestEvaluation[],
|
||||
outcome: {
|
||||
should_allow: boolean,
|
||||
decision: 'allow' | 'reject' | 'default-allow' | 'default-reject',
|
||||
decision_rule_id: string | null,
|
||||
restricted_because_of_rule_id: string | null,
|
||||
},
|
||||
};
|
||||
|
||||
const OAUTH_PROVIDER_OPTIONS = Array.from(standardProviders);
|
||||
|
||||
// Get sorted rules from config
|
||||
// Type assertion needed because schema changes take effect at build time
|
||||
type ConfigWithSignUpRules = CompleteConfig & {
|
||||
@ -467,6 +513,332 @@ function DefaultActionCard({
|
||||
);
|
||||
}
|
||||
|
||||
function TestRulesCard({
|
||||
stackAdminApp,
|
||||
}: {
|
||||
stackAdminApp: ReturnType<typeof useAdminApp>,
|
||||
}) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [authMethod, setAuthMethod] = useState<SignUpRulesTestResult['context']['auth_method']>('password');
|
||||
const [oauthProvider, setOauthProvider] = useState('');
|
||||
const [result, setResult] = useState<SignUpRulesTestResult | null>(null);
|
||||
|
||||
const [runTest, isRunning] = useAsyncCallback(async () => {
|
||||
const response = await (stackAdminApp as any)[stackAppInternalsSymbol].sendRequest(
|
||||
'/internal/sign-up-rules-test',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: email || undefined,
|
||||
auth_method: authMethod,
|
||||
oauth_provider: authMethod === 'oauth' ? (oauthProvider || undefined) : undefined,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
'admin'
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new StackAssertionError(`Failed to test sign-up rules: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setResult(data);
|
||||
}, [authMethod, email, oauthProvider, stackAdminApp]);
|
||||
|
||||
const handleAuthMethodChange = (value: string) => {
|
||||
if (value === 'password' || value === 'otp' || value === 'oauth' || value === 'passkey') {
|
||||
setAuthMethod(value);
|
||||
if (value !== 'oauth') {
|
||||
setOauthProvider('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
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(
|
||||
"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 statusLabel: Record<SignUpRulesTestEvaluationStatus, string> = {
|
||||
matched: 'Matched',
|
||||
not_matched: 'No match',
|
||||
disabled: 'Disabled',
|
||||
missing_condition: 'No condition',
|
||||
error: 'Error',
|
||||
};
|
||||
|
||||
const decisionLabel: Record<SignUpRulesTestResult['outcome']['decision'], string> = {
|
||||
allow: 'Allowed by rule',
|
||||
reject: 'Rejected by rule',
|
||||
'default-allow': 'Allowed by default',
|
||||
'default-reject': 'Rejected by default',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Typography variant="secondary" className="text-xs uppercase tracking-wide">
|
||||
Email
|
||||
</Typography>
|
||||
<Input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@company.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Typography variant="secondary" className="text-xs uppercase tracking-wide">
|
||||
Auth method
|
||||
</Typography>
|
||||
<Select value={authMethod} onValueChange={handleAuthMethodChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="password">Password</SelectItem>
|
||||
<SelectItem value="otp">OTP</SelectItem>
|
||||
<SelectItem value="oauth">OAuth</SelectItem>
|
||||
<SelectItem value="passkey">Passkey</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Typography variant="secondary" className="text-xs uppercase tracking-wide">
|
||||
OAuth provider
|
||||
</Typography>
|
||||
<Input
|
||||
value={oauthProvider}
|
||||
onChange={(e) => setOauthProvider(e.target.value)}
|
||||
placeholder={authMethod === 'oauth' ? "google" : "Only used for OAuth"}
|
||||
disabled={authMethod !== 'oauth'}
|
||||
list="sign-up-rule-test-oauth-providers"
|
||||
/>
|
||||
<datalist id="sign-up-rule-test-oauth-providers">
|
||||
{OAUTH_PROVIDER_OPTIONS.map((provider) => (
|
||||
<option key={provider} value={provider} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => runAsynchronouslyWithAlert(runTest)}
|
||||
loading={isRunning}
|
||||
>
|
||||
Run test
|
||||
</Button>
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
Simulate a sign-up request to preview which rules trigger.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{!result ? (
|
||||
<Alert>
|
||||
Run a test to preview which rules trigger and what the sign-up outcome would be.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className={cn("rounded-lg border p-3 space-y-1", outcomeTone)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography className="text-sm font-semibold">Outcome</Typography>
|
||||
<span className="text-xs font-semibold uppercase tracking-wide">{outcomeLabel}</span>
|
||||
</div>
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
{decisionLabel[result.outcome.decision]}
|
||||
</Typography>
|
||||
{decisionRule && (
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
Decision rule: {decisionRule.display_name || decisionRule.rule_id}
|
||||
</Typography>
|
||||
)}
|
||||
{decisionRule?.action.message && (
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
Rejection reason: {decisionRule.action.message}
|
||||
</Typography>
|
||||
)}
|
||||
{restrictedRule && (
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
Restricted by: {restrictedRule.display_name || restrictedRule.rule_id}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Triggered rules
|
||||
</Typography>
|
||||
<Typography variant="secondary" className="text-[11px]">
|
||||
{matchedEvaluations.length} matched
|
||||
</Typography>
|
||||
</div>
|
||||
{matchedEvaluations.length === 0 ? (
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
No rules matched. Default action applies.
|
||||
</Typography>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{matchedEvaluations.map((evaluation) => (
|
||||
<div
|
||||
key={evaluation.rule_id}
|
||||
className="flex items-center justify-between gap-2 rounded-md bg-background/60 px-2.5 py-2 ring-1 ring-foreground/[0.04]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Typography className="text-xs font-medium truncate">
|
||||
{evaluation.display_name || evaluation.rule_id}
|
||||
</Typography>
|
||||
<Typography variant="secondary" className="text-[10px] truncate">
|
||||
{evaluation.condition || "No condition"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<span className={actionBadgeClassName(evaluation.action.type)}>
|
||||
{evaluation.action.type}
|
||||
</span>
|
||||
{evaluation.rule_id === result.outcome.decision_rule_id && (
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded bg-foreground/5 text-foreground">
|
||||
Decision
|
||||
</span>
|
||||
)}
|
||||
{evaluation.rule_id === result.outcome.restricted_because_of_rule_id && (
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
|
||||
Restrict
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Evaluation trace
|
||||
</Typography>
|
||||
<Typography variant="secondary" className="text-[11px]">
|
||||
{evaluations.length} evaluated
|
||||
</Typography>
|
||||
</div>
|
||||
{evaluations.length === 0 ? (
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
No rules configured yet.
|
||||
</Typography>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-48 overflow-auto pr-1">
|
||||
{evaluations.map((evaluation) => (
|
||||
<div
|
||||
key={evaluation.rule_id}
|
||||
className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 hover:bg-muted/40 transition-colors hover:transition-none"
|
||||
title={evaluation.error ?? undefined}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Typography className="text-xs font-medium truncate">
|
||||
{evaluation.display_name || evaluation.rule_id}
|
||||
</Typography>
|
||||
<Typography variant="secondary" className="text-[10px] truncate">
|
||||
{evaluation.condition || "No condition"}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<span className={actionBadgeClassName(evaluation.action.type)}>
|
||||
{evaluation.action.type}
|
||||
</span>
|
||||
<span className={statusBadgeClassName(evaluation.status)}>
|
||||
{statusLabel[evaluation.status]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3 space-y-1">
|
||||
<Typography className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Normalized context
|
||||
</Typography>
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
Email: {result.context.email || "(empty)"}
|
||||
</Typography>
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
Email domain: {result.context.email_domain || "(empty)"}
|
||||
</Typography>
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
OAuth provider: {result.context.oauth_provider || "(empty)"}
|
||||
</Typography>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TestRulesDialog({
|
||||
stackAdminApp,
|
||||
}: {
|
||||
stackAdminApp: ReturnType<typeof useAdminApp>,
|
||||
}) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="secondary">
|
||||
Open tester
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Test sign-up rules</DialogTitle>
|
||||
<DialogDescription>
|
||||
Simulate a sign-up request to see which rules trigger and how the final decision is made.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<TestRulesCard stackAdminApp={stackAdminApp} />
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Delete confirmation dialog
|
||||
function DeleteRuleDialog({
|
||||
open,
|
||||
@ -848,6 +1220,19 @@ export default function PageClient() {
|
||||
value={defaultAction}
|
||||
onChange={(v) => runAsynchronouslyWithAlert(handleDefaultActionChange(v))}
|
||||
/>
|
||||
<div className="pt-10">
|
||||
<div className="relative rounded-xl border border-dashed border-border/70 bg-muted/10 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<Typography className="text-sm font-semibold">Test rules</Typography>
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
Try sample sign-ups without touching the live flow.
|
||||
</Typography>
|
||||
</div>
|
||||
<TestRulesDialog stackAdminApp={stackAdminApp} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../../../../../helpers";
|
||||
import { Project, niceBackendFetch } from "../../../../backend-helpers";
|
||||
|
||||
describe("with admin access", () => {
|
||||
it("uses default action when no rules match", async ({ expect }) => {
|
||||
await Project.createAndSwitch({ config: {} });
|
||||
const response = await niceBackendFetch("/api/v1/internal/sign-up-rules-test", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
email: "user@example.com",
|
||||
auth_method: "password",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
evaluations: [],
|
||||
outcome: {
|
||||
should_allow: true,
|
||||
decision: "default-allow",
|
||||
decision_rule_id: null,
|
||||
restricted_because_of_rule_id: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a decision rule when an allow/reject rule matches", async ({ expect }) => {
|
||||
await Project.createAndSwitch();
|
||||
await Project.updateConfig({
|
||||
"auth.signUpRules.log-first": {
|
||||
enabled: true,
|
||||
displayName: "Log first",
|
||||
priority: 2,
|
||||
condition: 'emailDomain == "example.com"',
|
||||
action: {
|
||||
type: "log",
|
||||
},
|
||||
},
|
||||
"auth.signUpRules.block-oauth": {
|
||||
enabled: true,
|
||||
displayName: "Block OAuth",
|
||||
priority: 1,
|
||||
condition: 'authMethod == "oauth"',
|
||||
action: {
|
||||
type: "reject",
|
||||
message: "OAuth blocked",
|
||||
},
|
||||
},
|
||||
"auth.signUpRulesDefaultAction": "allow",
|
||||
});
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/internal/sign-up-rules-test", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
body: {
|
||||
email: "test@example.com",
|
||||
auth_method: "oauth",
|
||||
oauth_provider: "google",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body?.outcome).toMatchObject({
|
||||
should_allow: false,
|
||||
decision: "reject",
|
||||
decision_rule_id: "block-oauth",
|
||||
restricted_because_of_rule_id: null,
|
||||
});
|
||||
expect(response.body?.evaluations.map((evaluation: { rule_id: string }) => evaluation.rule_id)).toEqual([
|
||||
"log-first",
|
||||
"block-oauth",
|
||||
]);
|
||||
expect(response.body?.evaluations[0]).toMatchObject({
|
||||
status: "matched",
|
||||
action: { type: "log" },
|
||||
});
|
||||
expect(response.body?.evaluations[1]).toMatchObject({
|
||||
status: "matched",
|
||||
action: { type: "reject" },
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user