Files
gridpilot.gg/docs/architecture/LOGIN_FLOW_STATE_MACHINE.md
2026-01-04 12:49:30 +01:00

3.9 KiB

Login Flow State Machine Architecture

Problem

The current login page has unpredictable behavior due to:

  • Multiple useEffect runs with different session states
  • Race conditions between session loading and redirect logic
  • Client-side redirects that interfere with test expectations

Solution: State Machine Pattern

State Definitions

enum LoginState {
  UNAUTHENTICATED = "UNAUTHENTICATED",
  AUTHENTICATED_WITH_PERMISSIONS = "AUTHENTICATED_WITH_PERMISSIONS", 
  AUTHENTICATED_WITHOUT_PERMISSIONS = "AUTHENTICATED_WITHOUT_PERMISSIONS",
  POST_AUTH_REDIRECT = "POST_AUTH_REDIRECT"
}

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

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, doesn't 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 };
    }
  }
  
  // Called after authentication
  transitionToPostAuth(): void {
    if (this.session) {
      this.state = LoginState.POST_AUTH_REDIRECT;
    }
  }
}

Benefits

  1. Predictable: Same inputs always produce same outputs
  2. Testable: Can test each state transition independently
  3. No Race Conditions: State determined once at construction
  4. Clear Intent: Each state has a single purpose
  5. Maintainable: Easy to add new states or modify transitions

Usage in Login Page

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 eliminates all the unpredictable behavior and makes the flow testable and maintainable.