4.0 KiB
4.0 KiB
Login Flow State Machine (Strict)
This document defines the canonical, deterministic login flow controller for the website.
Authoritative website contract:
1) Core rule
Login flow logic MUST be deterministic.
The same inputs MUST produce the same state and the same next action.
2) State machine definition (strict)
2.1 State definitions
enum LoginState {
UNAUTHENTICATED = "UNAUTHENTICATED",
AUTHENTICATED_WITH_PERMISSIONS = "AUTHENTICATED_WITH_PERMISSIONS",
AUTHENTICATED_WITHOUT_PERMISSIONS = "AUTHENTICATED_WITHOUT_PERMISSIONS",
POST_AUTH_REDIRECT = "POST_AUTH_REDIRECT"
}
2.2 State transition table
| Current State | Session | ReturnTo | Next State | Action |
|---|---|---|---|---|
| INITIAL | null | any | UNAUTHENTICATED | Show login form |
| INITIAL | exists | '/dashboard' | AUTHENTICATED_WITH_PERMISSIONS | Redirect to dashboard |
| INITIAL | exists | NOT '/dashboard' | AUTHENTICATED_WITHOUT_PERMISSIONS | Show permission error |
| UNAUTHENTICATED | exists | any | POST_AUTH_REDIRECT | Redirect to returnTo |
| AUTHENTICATED_WITHOUT_PERMISSIONS | exists | any | POST_AUTH_REDIRECT | Redirect to returnTo |
2.3 Class-based controller
class LoginFlowController {
// Immutable state
private readonly session: AuthSessionDTO | null;
private readonly returnTo: string;
// State machine
private state: LoginState;
constructor(session: AuthSessionDTO | null, returnTo: string) {
this.session = session;
this.returnTo = returnTo;
this.state = this.determineInitialState();
}
private determineInitialState(): LoginState {
if (!this.session) return LoginState.UNAUTHENTICATED;
if (this.returnTo === '/dashboard') return LoginState.AUTHENTICATED_WITH_PERMISSIONS;
return LoginState.AUTHENTICATED_WITHOUT_PERMISSIONS;
}
// Pure function - no side effects
getState(): LoginState {
return this.state;
}
// Pure function - returns action, does not execute
getNextAction(): LoginAction {
switch (this.state) {
case LoginState.UNAUTHENTICATED:
return { type: 'SHOW_LOGIN_FORM' };
case LoginState.AUTHENTICATED_WITH_PERMISSIONS:
return { type: 'REDIRECT', path: '/dashboard' };
case LoginState.AUTHENTICATED_WITHOUT_PERMISSIONS:
return { type: 'SHOW_PERMISSION_ERROR' };
case LoginState.POST_AUTH_REDIRECT:
return { type: 'REDIRECT', path: this.returnTo };
}
}
// Transition called after authentication
transitionToPostAuth(): void {
if (this.session) {
this.state = LoginState.POST_AUTH_REDIRECT;
}
}
}
3) Non-negotiable rules
- The controller MUST be constructed from explicit inputs only.
- The controller MUST NOT perform side effects.
- Side effects (routing) MUST be executed outside the controller.
- The controller MUST be unit-tested per transition.
4) Usage in login page (example)
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { session } = useAuth();
const returnTo = searchParams.get('returnTo') ?? '/dashboard';
// Create controller once
const controller = useMemo(() =>
new LoginFlowController(session, returnTo),
[session, returnTo]
);
// Get current state
const state = controller.getState();
const action = controller.getNextAction();
// Execute action (only once)
useEffect(() => {
if (action.type === 'REDIRECT') {
router.replace(action.path);
}
}, [action, router]);
// Render based on state
if (state === LoginState.UNAUTHENTICATED) {
return <LoginForm />;
}
if (state === LoginState.AUTHENTICATED_WITHOUT_PERMISSIONS) {
return <PermissionError returnTo={returnTo} />;
}
// Show loading while redirecting
return <LoadingSpinner />;
}
This pattern ensures deterministic behavior and makes the flow testable.