138 lines
4.0 KiB
Markdown
138 lines
4.0 KiB
Markdown
# Login Flow State Machine (Strict)
|
|
|
|
This document defines the canonical, deterministic login flow controller for the website.
|
|
|
|
Authoritative website contract:
|
|
|
|
- [`docs/architecture/website/WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:1)
|
|
|
|
## 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
1. The controller MUST be constructed from explicit inputs only.
|
|
2. The controller MUST NOT perform side effects.
|
|
3. Side effects (routing) MUST be executed outside the controller.
|
|
4. The controller MUST be unit-tested per transition.
|
|
|
|
## 4) Usage in login page (example)
|
|
|
|
```typescript
|
|
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.
|