Files
gridpilot.gg/docs/architecture/website/LOGIN_FLOW_STATE_MACHINE.md
2026-01-11 14:42:54 +01:00

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

  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)

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.