Files
acc-server-manager/frontend.md
Fran Jurmanović 60175f8052
Some checks failed
Release and Deploy / build (push) Failing after 2m11s
Release and Deploy / deploy (push) Has been skipped
2fa for polling and security
2025-08-16 16:21:39 +02:00

52 KiB
Raw Blame History

Frontend Steam 2FA Implementation Guide

Overview

This document provides complete implementation details for the frontend Steam 2FA system. The backend Steam 2FA implementation is complete and waiting for frontend integration. This guide includes all necessary code, components, and integration instructions.

Problem Statement

The current Steam 2FA backend implementation (local/controller/steam_2fa.go, local/model/steam_2fa.go) provides REST API endpoints but has no frontend to:

  1. Poll for pending 2FA requests
  2. Display 2FA prompts to users
  3. Allow users to confirm/cancel 2FA operations

Without frontend implementation, Steam operations requiring 2FA will hang indefinitely until timeout (5 minutes).

Architecture Overview

Backend API Endpoints (Already Implemented)

  • GET /v1/steam2fa/pending - Returns array of pending 2FA requests
  • GET /v1/steam2fa/{id} - Gets specific 2FA request details
  • POST /v1/steam2fa/{id}/complete - Marks 2FA request as completed
  • POST /v1/steam2fa/{id}/cancel - Cancels 2FA request

Frontend Components (To Be Implemented)

src/
├── models/
│   └── steam2fa.ts              # TypeScript interfaces
├── stores/
│   └── steam2fa.ts              # Svelte store for state management
├── components/
│   └── Steam2FANotification.svelte  # Modal component
└── lib/
    └── api/
        └── steam2fa.ts          # API client functions

Implementation Details

1. TypeScript Interfaces (src/models/steam2fa.ts)

export type Steam2FAStatus = 'idle' | 'pending' | 'complete' | 'error';

export interface Steam2FARequest {
  id: string;
  status: Steam2FAStatus;
  message: string;
  requestTime: string; // ISO 8601 timestamp
  completedAt?: string; // ISO 8601 timestamp
  errorMsg?: string;
  serverId?: string; // UUID
}

export interface Steam2FAStore {
  requests: Steam2FARequest[];
  isPolling: boolean;
  error: string | null;
  lastChecked: Date | null;
}

2. API Client (src/lib/api/steam2fa.ts)

import type { Steam2FARequest } from '../models/steam2fa';

const API_BASE = '/v1/steam2fa';

export class Steam2FAApi {
  /**
   * Get all pending 2FA requests
   */
  static async getPendingRequests(): Promise<Steam2FARequest[]> {
    const response = await fetch(`${API_BASE}/pending`, {
      method: 'GET',
      credentials: 'include', // Include auth cookies
    });

    if (!response.ok) {
      throw new Error(`Failed to fetch pending requests: ${response.statusText}`);
    }

    return response.json();
  }

  /**
   * Get specific 2FA request by ID
   */
  static async getRequest(id: string): Promise<Steam2FARequest> {
    const response = await fetch(`${API_BASE}/${id}`, {
      method: 'GET',
      credentials: 'include',
    });

    if (!response.ok) {
      throw new Error(`Failed to fetch request ${id}: ${response.statusText}`);
    }

    return response.json();
  }

  /**
   * Mark 2FA request as completed
   */
  static async completeRequest(id: string): Promise<Steam2FARequest> {
    const response = await fetch(`${API_BASE}/${id}/complete`, {
      method: 'POST',
      credentials: 'include',
    });

    if (!response.ok) {
      throw new Error(`Failed to complete request ${id}: ${response.statusText}`);
    }

    return response.json();
  }

  /**
   * Cancel 2FA request
   */
  static async cancelRequest(id: string): Promise<Steam2FARequest> {
    const response = await fetch(`${API_BASE}/${id}/cancel`, {
      method: 'POST',
      credentials: 'include',
    });

    if (!response.ok) {
      throw new Error(`Failed to cancel request ${id}: ${response.statusText}`);
    }

    return response.json();
  }
}

3. Svelte Store (src/stores/steam2fa.ts)

import { writable, derived } from 'svelte/store';
import type { Steam2FARequest, Steam2FAStore } from '../models/steam2fa';
import { Steam2FAApi } from '../lib/api/steam2fa';

const POLLING_INTERVAL = 5000; // 5 seconds
const MAX_RETRIES = 3;

// Create the main store
function createSteam2FAStore() {
  const { subscribe, set, update } = writable<Steam2FAStore>({
    requests: [],
    isPolling: false,
    error: null,
    lastChecked: null,
  });

  let pollingInterval: NodeJS.Timeout | null = null;
  let retryCount = 0;

  const startPolling = () => {
    if (pollingInterval) return; // Already polling

    update(store => ({ ...store, isPolling: true, error: null }));

    const poll = async () => {
      try {
        const requests = await Steam2FAApi.getPendingRequests();
        
        update(store => ({
          ...store,
          requests,
          error: null,
          lastChecked: new Date(),
        }));
        
        retryCount = 0; // Reset retry count on success
      } catch (error) {
        console.error('Steam 2FA polling error:', error);
        retryCount++;
        
        if (retryCount >= MAX_RETRIES) {
          update(store => ({
            ...store,
            error: `Failed to check for 2FA requests: ${error.message}`,
            isPolling: false,
          }));
          stopPolling();
          return;
        }
        
        update(store => ({
          ...store,
          error: `Connection issue (retry ${retryCount}/${MAX_RETRIES})`,
        }));
      }
    };

    // Poll immediately, then set interval
    poll();
    pollingInterval = setInterval(poll, POLLING_INTERVAL);
  };

  const stopPolling = () => {
    if (pollingInterval) {
      clearInterval(pollingInterval);
      pollingInterval = null;
    }
    update(store => ({ ...store, isPolling: false }));
  };

  const completeRequest = async (id: string) => {
    try {
      await Steam2FAApi.completeRequest(id);
      
      // Remove the completed request from store
      update(store => ({
        ...store,
        requests: store.requests.filter(req => req.id !== id),
      }));
    } catch (error) {
      console.error('Failed to complete 2FA request:', error);
      update(store => ({
        ...store,
        error: `Failed to complete 2FA: ${error.message}`,
      }));
      throw error;
    }
  };

  const cancelRequest = async (id: string) => {
    try {
      await Steam2FAApi.cancelRequest(id);
      
      // Remove the cancelled request from store
      update(store => ({
        ...store,
        requests: store.requests.filter(req => req.id !== id),
      }));
    } catch (error) {
      console.error('Failed to cancel 2FA request:', error);
      update(store => ({
        ...store,
        error: `Failed to cancel 2FA: ${error.message}`,
      }));
      throw error;
    }
  };

  const clearError = () => {
    update(store => ({ ...store, error: null }));
  };

  return {
    subscribe,
    startPolling,
    stopPolling,
    completeRequest,
    cancelRequest,
    clearError,
  };
}

export const steam2fa = createSteam2FAStore();

// Derived store for pending requests
export const pendingRequests = derived(
  steam2fa,
  $steam2fa => $steam2fa.requests.filter(req => req.status === 'pending')
);

// Derived store to check if any requests are pending
export const hasPendingRequests = derived(
  pendingRequests,
  $pendingRequests => $pendingRequests.length > 0
);

4. Modal Component (src/components/Steam2FANotification.svelte)

<script lang="ts">
  import { steam2fa, pendingRequests } from '../stores/steam2fa';
  import type { Steam2FARequest } from '../models/steam2fa';

  let isVisible = false;
  let currentRequest: Steam2FARequest | null = null;
  let isProcessing = false;

  // Watch for pending requests
  $: if ($pendingRequests.length > 0 && !isVisible) {
    currentRequest = $pendingRequests[0]; // Show first pending request
    isVisible = true;
  } else if ($pendingRequests.length === 0 && isVisible) {
    isVisible = false;
    currentRequest = null;
  }

  const handleConfirm = async () => {
    if (!currentRequest || isProcessing) return;
    
    isProcessing = true;
    try {
      await steam2fa.completeRequest(currentRequest.id);
      // Store will automatically update and close modal
    } catch (error) {
      console.error('Failed to confirm 2FA:', error);
      // Error is already handled in store
    } finally {
      isProcessing = false;
    }
  };

  const handleCancel = async () => {
    if (!currentRequest || isProcessing) return;
    
    isProcessing = true;
    try {
      await steam2fa.cancelRequest(currentRequest.id);
      // Store will automatically update and close modal
    } catch (error) {
      console.error('Failed to cancel 2FA:', error);
      // Error is already handled in store
    } finally {
      isProcessing = false;
    }
  };

  const getServerContext = (request: Steam2FARequest) => {
    if (request.serverId) {
      return `for server ${request.serverId}`;
    }
    return '';
  };

  const formatTime = (isoString: string) => {
    return new Date(isoString).toLocaleTimeString();
  };
</script>

{#if isVisible && currentRequest}
  <!-- Modal Backdrop -->
  <div class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="steam-2fa-title">
    <div class="modal-content">
      <div class="modal-header">
        <h2 id="steam-2fa-title">Steam Authentication Required</h2>
        <div class="server-context">
          {getServerContext(currentRequest)}
        </div>
      </div>

      <div class="modal-body">
        <div class="auth-icon">
          🔐
        </div>
        
        <div class="message">
          <p><strong>Steam Guard Authentication Needed</strong></p>
          <p class="prompt-message">{currentRequest.message}</p>
        </div>

        <div class="instructions">
          <ol>
            <li>Check your <strong>Steam Mobile App</strong></li>
            <li>Approve the login request</li>
            <li>Click "I've Confirmed" below</li>
          </ol>
        </div>

        <div class="time-info">
          <small>Requested at: {formatTime(currentRequest.requestTime)}</small>
        </div>

        {#if $steam2fa.error}
          <div class="error-message">
            <strong>Error:</strong> {$steam2fa.error}
            <button 
              type="button" 
              class="error-dismiss"
              on:click={steam2fa.clearError}
              aria-label="Dismiss error"
            >
              ×
            </button>
          </div>
        {/if}
      </div>

      <div class="modal-footer">
        <button 
          type="button"
          class="btn btn-primary"
          on:click={handleConfirm}
          disabled={isProcessing}
        >
          {#if isProcessing}
            Processing...
          {:else}
            I've Confirmed ✓
          {/if}
        </button>
        
        <button 
          type="button"
          class="btn btn-secondary"
          on:click={handleCancel}
          disabled={isProcessing}
        >
          Cancel
        </button>
      </div>
    </div>
  </div>
{/if}

<style>
  .modal-backdrop {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.7);
    display: flex;
    justify-content: center;
    align-items: center;
    z-index: 9999;
    backdrop-filter: blur(2px);
  }

  .modal-content {
    background: white;
    border-radius: 12px;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
    max-width: 500px;
    width: 90%;
    max-height: 90vh;
    overflow-y: auto;
    animation: modalSlideIn 0.3s ease-out;
  }

  @keyframes modalSlideIn {
    from {
      opacity: 0;
      transform: translateY(-20px) scale(0.95);
    }
    to {
      opacity: 1;
      transform: translateY(0) scale(1);
    }
  }

  .modal-header {
    padding: 24px 24px 16px;
    border-bottom: 1px solid #e5e7eb;
  }

  .modal-header h2 {
    margin: 0;
    color: #1f2937;
    font-size: 1.5rem;
    font-weight: 600;
  }

  .server-context {
    color: #6b7280;
    font-size: 0.875rem;
    margin-top: 4px;
  }

  .modal-body {
    padding: 24px;
    text-align: center;
  }

  .auth-icon {
    font-size: 3rem;
    margin-bottom: 16px;
  }

  .message {
    margin-bottom: 24px;
  }

  .message p {
    margin: 0 0 8px 0;
    color: #1f2937;
  }

  .prompt-message {
    font-family: monospace;
    background: #f3f4f6;
    padding: 12px;
    border-radius: 6px;
    color: #374151;
    font-size: 0.875rem;
    margin-top: 12px !important;
  }

  .instructions {
    text-align: left;
    background: #fef3c7;
    border: 1px solid #fbbf24;
    border-radius: 8px;
    padding: 16px;
    margin-bottom: 16px;
  }

  .instructions ol {
    margin: 0;
    padding-left: 20px;
    color: #92400e;
  }

  .instructions li {
    margin-bottom: 4px;
  }

  .time-info {
    color: #6b7280;
    margin-bottom: 16px;
  }

  .error-message {
    background: #fef2f2;
    border: 1px solid #f87171;
    color: #dc2626;
    padding: 12px;
    border-radius: 6px;
    margin-bottom: 16px;
    position: relative;
    text-align: left;
  }

  .error-dismiss {
    position: absolute;
    top: 8px;
    right: 12px;
    background: none;
    border: none;
    font-size: 1.25rem;
    color: #dc2626;
    cursor: pointer;
    line-height: 1;
  }

  .modal-footer {
    padding: 16px 24px 24px;
    display: flex;
    gap: 12px;
    justify-content: center;
  }

  .btn {
    padding: 12px 24px;
    border: none;
    border-radius: 6px;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.2s;
    font-size: 1rem;
  }

  .btn:disabled {
    opacity: 0.6;
    cursor: not-allowed;
  }

  .btn-primary {
    background: #10b981;
    color: white;
  }

  .btn-primary:hover:not(:disabled) {
    background: #059669;
  }

  .btn-secondary {
    background: #f3f4f6;
    color: #374151;
    border: 1px solid #d1d5db;
  }

  .btn-secondary:hover:not(:disabled) {
    background: #e5e7eb;
  }

  /* Dark mode support */
  @media (prefers-color-scheme: dark) {
    .modal-content {
      background: #1f2937;
      color: #f9fafb;
    }

    .modal-header {
      border-bottom-color: #374151;
    }

    .modal-header h2 {
      color: #f9fafb;
    }

    .message p {
      color: #f9fafb;
    }

    .prompt-message {
      background: #374151;
      color: #d1d5db;
    }

    .btn-secondary {
      background: #374151;
      color: #d1d5db;
      border-color: #4b5563;
    }

    .btn-secondary:hover:not(:disabled) {
      background: #4b5563;
    }
  }
</style>

5. Main App Integration

Add to your main layout file (e.g., src/routes/+layout.svelte):

<script>
  import { onMount, onDestroy } from 'svelte';
  import { steam2fa } from '../stores/steam2fa';
  import Steam2FANotification from '../components/Steam2FANotification.svelte';

  onMount(() => {
    // Start polling when app loads
    steam2fa.startPolling();
  });

  onDestroy(() => {
    // Stop polling when app is destroyed
    steam2fa.stopPolling();
  });
</script>

<!-- Your existing layout content -->
<main>
  <!-- Your app content -->
  <slot />
</main>

<!-- Steam 2FA notification overlay -->
<Steam2FANotification />

Integration Steps

1. Install Dependencies

Ensure your frontend has these dependencies (likely already present):

{
  "devDependencies": {
    "@types/node": "^20.x.x"
  }
}

2. Create Files

Create all the files listed above in their respective directories in your frontend repository.

3. Update Main Layout

Add the Steam2FANotification component and polling initialization to your main layout.

4. Configure API Base URL

Ensure your API calls are pointing to the correct backend URL. Update the API_BASE constant in steam2fa.ts if needed:

const API_BASE = '/v1/steam2fa'; // Adjust if your backend uses different base URL

5. Test Authentication

Ensure your frontend sends authentication cookies/headers with API requests. The backend requires authentication for all 2FA endpoints.

Flow Diagram

SteamCMD Operation Started
        ↓
Backend detects 2FA prompt
        ↓
Steam2FARequest created with "pending" status
        ↓
Frontend polling detects pending request
        ↓
Modal appears automatically
        ↓
User checks Steam Mobile App
        ↓
User approves login in Steam app
        ↓
User clicks "I've Confirmed" button
        ↓
Frontend calls POST /v1/steam2fa/{id}/complete
        ↓
Backend marks request as complete
        ↓
SteamCMD operation continues
        ↓
Frontend polling finds no pending requests
        ↓
Modal closes automatically

Error Handling

Frontend Error Scenarios

  1. Network Errors: Displays retry count and stops after 3 failures
  2. API Errors: Shows specific error messages to user
  3. Timeout: Backend handles 5-minute timeout, frontend shows appropriate message
  4. Multiple Requests: Shows first pending request, handles queue automatically

User Experience

  • Immediate Feedback: Modal appears as soon as 2FA is required
  • Clear Instructions: Step-by-step guide for users
  • Error Recovery: Clear error messages with retry options
  • Non-Blocking: User can cancel if needed

Testing Instructions

Manual Testing

  1. Setup: Ensure Steam account has 2FA enabled
  2. Trigger: Create/update a server to trigger SteamCMD
  3. Verify Modal: Check that modal appears when 2FA prompt occurs
  4. Test Success: Approve in Steam app, click "I've Confirmed"
  5. Test Cancel: Try canceling a 2FA request
  6. Test Errors: Disconnect network during operation

Debugging

// Check store state in browser console
console.log($steam2fa);

// Manual API testing
Steam2FAApi.getPendingRequests().then(console.log);

Security Considerations

  1. Authentication: All API calls include credentials
  2. No Sensitive Data: No Steam credentials exposed
  3. Timeout Protection: 5-minute backend timeout prevents hanging
  4. Request Validation: Backend validates request ownership

Performance Considerations

  1. Polling Frequency: 5-second interval balances responsiveness with load
  2. Automatic Cleanup: Backend cleans up old requests after 30 minutes
  3. Efficient Updates: Reactive stores minimize re-renders
  4. Error Backoff: Stops polling after repeated failures

Future Enhancements

  1. WebSocket Support: Real-time updates instead of polling
  2. Browser Notifications: Alert users even when tab is not active
  3. Multiple Request Support: Handle multiple simultaneous 2FA requests
  4. Enhanced Prompt Detection: More sophisticated Steam output parsing

Troubleshooting

Common Issues

Modal Doesn't Appear

  • Check browser console for JavaScript errors
  • Verify API connectivity: fetch('/v1/steam2fa/pending')
  • Ensure user is authenticated
  • Check backend logs for 2FA request creation

API Errors

  • Verify authentication cookies are sent
  • Check CORS configuration
  • Ensure backend is running and accessible
  • Review backend error logs

SteamCMD Hangs

  • Check if 2FA request was created in backend logs
  • Verify Steam Mobile App connectivity
  • Look for timeout errors after 5 minutes

Debug Commands

# Check backend logs for 2FA events
grep -i "2fa" logs/app.log

# Monitor API requests
tail -f logs/app.log | grep "steam2fa"

Server Management Pages Implementation

Overview

In addition to the Steam 2FA system, the frontend needs complete server management pages for creating, viewing, updating, and deleting ACC servers. This section provides implementation details for all server management functionality.

Backend API Endpoints (Already Implemented)

Server CRUD Operations

  • GET /v1/server - List all servers with filtering
  • GET /v1/api/server - List servers in API format (simplified)
  • GET /v1/server/{id} - Get specific server details
  • POST /v1/server - Create new server
  • PUT /v1/server/{id} - Update existing server
  • DELETE /v1/server/{id} - Delete server

Service Control

  • GET /v1/server/{id}/service/{service} - Get service status
  • POST /v1/server/{id}/service/start - Start server service
  • POST /v1/server/{id}/service/stop - Stop server service
  • POST /v1/server/{id}/service/restart - Restart server service

Configuration Management

  • GET /v1/server/{id}/config - List server config files
  • GET /v1/server/{id}/config/{file} - Get specific config file
  • PUT /v1/server/{id}/config/{file} - Update config file

Authentication

  • POST /v1/auth/login - User login
  • POST /v1/auth/open-token - Generate open token (for service calls)
  • GET /v1/auth/me - Get current user info

Frontend Implementation

1. TypeScript Models (src/models/server.ts)

export type ServiceStatus = 'running' | 'stopped' | 'starting' | 'stopping' | 'unknown';

export interface ServerState {
  session: string;
  sessionStart: string; // ISO timestamp
  playerCount: number;
  track: string;
  maxConnections: number;
  sessionDurationMinutes: number;
}

export interface ServerAPI {
  name: string;
  status: ServiceStatus;
  state: ServerState | null;
  playerCount: number;
  track: string;
}

export interface Server {
  id: string; // UUID
  name: string;
  status: ServiceStatus;
  path: string;
  serviceName: string;
  state: ServerState | null;
  dateCreated: string; // ISO timestamp
}

export interface ServerFilter {
  name?: string;
  serviceName?: string;
  status?: string;
  serverID?: string;
  limit?: number;
  offset?: number;
}

export interface CreateServerRequest {
  name: string;
  // Additional server configuration fields as needed
}

export interface UpdateServerRequest {
  name: string;
  // Other updateable fields
}

2. API Client (src/lib/api/server.ts)

import type { Server, ServerAPI, ServerFilter, CreateServerRequest, UpdateServerRequest } from '../models/server';

const API_BASE = '/v1';

export class ServerApi {
  /**
   * Get all servers with optional filtering
   */
  static async getServers(filter?: ServerFilter): Promise<Server[]> {
    const params = new URLSearchParams();
    if (filter) {
      Object.entries(filter).forEach(([key, value]) => {
        if (value !== undefined && value !== null) {
          params.append(key, value.toString());
        }
      });
    }

    const url = `${API_BASE}/server${params.toString() ? `?${params.toString()}` : ''}`;
    const response = await fetch(url, {
      method: 'GET',
      credentials: 'include',
    });

    if (!response.ok) {
      throw new Error(`Failed to fetch servers: ${response.statusText}`);
    }

    return response.json();
  }

  /**
   * Get servers in API format (simplified)
   */
  static async getServersAPI(filter?: ServerFilter): Promise<ServerAPI[]> {
    const params = new URLSearchParams();
    if (filter) {
      Object.entries(filter).forEach(([key, value]) => {
        if (value !== undefined && value !== null) {
          params.append(key, value.toString());
        }
      });
    }

    const url = `${API_BASE}/api/server${params.toString() ? `?${params.toString()}` : ''}`;
    const response = await fetch(url, {
      method: 'GET',
      credentials: 'include',
    });

    if (!response.ok) {
      throw new Error(`Failed to fetch servers: ${response.statusText}`);
    }

    return response.json();
  }

  /**
   * Get specific server by ID
   */
  static async getServer(id: string): Promise<Server> {
    const response = await fetch(`${API_BASE}/server/${id}`, {
      method: 'GET',
      credentials: 'include',
    });

    if (!response.ok) {
      if (response.status === 404) {
        throw new Error('Server not found');
      }
      throw new Error(`Failed to fetch server: ${response.statusText}`);
    }

    return response.json();
  }

  /**
   * Create new server
   */
  static async createServer(serverData: CreateServerRequest): Promise<Server> {
    const response = await fetch(`${API_BASE}/server`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      credentials: 'include',
      body: JSON.stringify(serverData),
    });

    if (!response.ok) {
      const errorData = await response.json().catch(() => null);
      throw new Error(errorData?.message || `Failed to create server: ${response.statusText}`);
    }

    return response.json();
  }

  /**
   * Update existing server
   */
  static async updateServer(id: string, serverData: UpdateServerRequest): Promise<Server> {
    const response = await fetch(`${API_BASE}/server/${id}`, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
      },
      credentials: 'include',
      body: JSON.stringify(serverData),
    });

    if (!response.ok) {
      const errorData = await response.json().catch(() => null);
      throw new Error(errorData?.message || `Failed to update server: ${response.statusText}`);
    }

    return response.json();
  }

  /**
   * Delete server
   */
  static async deleteServer(id: string): Promise<void> {
    const response = await fetch(`${API_BASE}/server/${id}`, {
      method: 'DELETE',
      credentials: 'include',
    });

    if (!response.ok) {
      if (response.status === 404) {
        throw new Error('Server not found');
      }
      throw new Error(`Failed to delete server: ${response.statusText}`);
    }
  }

  /**
   * Get service status
   */
  static async getServiceStatus(serverId: string, serviceName: string): Promise<{ status: string; state: string }> {
    const response = await fetch(`${API_BASE}/server/${serverId}/service/${serviceName}`, {
      method: 'GET',
      credentials: 'include',
    });

    if (!response.ok) {
      throw new Error(`Failed to get service status: ${response.statusText}`);
    }

    return response.json();
  }

  /**
   * Start server service
   */
  static async startService(serverId: string): Promise<string> {
    const response = await fetch(`${API_BASE}/server/${serverId}/service/start`, {
      method: 'POST',
      credentials: 'include',
    });

    if (!response.ok) {
      throw new Error(`Failed to start service: ${response.statusText}`);
    }

    return response.text();
  }

  /**
   * Stop server service
   */
  static async stopService(serverId: string): Promise<string> {
    const response = await fetch(`${API_BASE}/server/${serverId}/service/stop`, {
      method: 'POST',
      credentials: 'include',
    });

    if (!response.ok) {
      throw new Error(`Failed to stop service: ${response.statusText}`);
    }

    return response.text();
  }

  /**
   * Restart server service
   */
  static async restartService(serverId: string): Promise<string> {
    const response = await fetch(`${API_BASE}/server/${serverId}/service/restart`, {
      method: 'POST',
      credentials: 'include',
    });

    if (!response.ok) {
      throw new Error(`Failed to restart service: ${response.statusText}`);
    }

    return response.text();
  }
}

3. Server Store (src/stores/server.ts)

import { writable, derived } from 'svelte/store';
import type { Server, ServerFilter } from '../models/server';
import { ServerApi } from '../lib/api/server';

interface ServerStore {
  servers: Server[];
  selectedServer: Server | null;
  isLoading: boolean;
  error: string | null;
  filter: ServerFilter;
}

function createServerStore() {
  const { subscribe, set, update } = writable<ServerStore>({
    servers: [],
    selectedServer: null,
    isLoading: false,
    error: null,
    filter: {},
  });

  const loadServers = async (filter?: ServerFilter) => {
    update(store => ({ ...store, isLoading: true, error: null }));

    try {
      const servers = await ServerApi.getServers(filter);
      update(store => ({
        ...store,
        servers,
        filter: filter || {},
        isLoading: false,
      }));
    } catch (error) {
      console.error('Failed to load servers:', error);
      update(store => ({
        ...store,
        error: error.message,
        isLoading: false,
      }));
    }
  };

  const loadServer = async (id: string) => {
    update(store => ({ ...store, isLoading: true, error: null }));

    try {
      const server = await ServerApi.getServer(id);
      update(store => ({
        ...store,
        selectedServer: server,
        isLoading: false,
      }));
      return server;
    } catch (error) {
      console.error('Failed to load server:', error);
      update(store => ({
        ...store,
        error: error.message,
        isLoading: false,
      }));
      throw error;
    }
  };

  const createServer = async (serverData: any) => {
    update(store => ({ ...store, isLoading: true, error: null }));

    try {
      const newServer = await ServerApi.createServer(serverData);
      update(store => ({
        ...store,
        servers: [...store.servers, newServer],
        isLoading: false,
      }));
      return newServer;
    } catch (error) {
      console.error('Failed to create server:', error);
      update(store => ({
        ...store,
        error: error.message,
        isLoading: false,
      }));
      throw error;
    }
  };

  const updateServer = async (id: string, serverData: any) => {
    update(store => ({ ...store, isLoading: true, error: null }));

    try {
      const updatedServer = await ServerApi.updateServer(id, serverData);
      update(store => ({
        ...store,
        servers: store.servers.map(s => s.id === id ? updatedServer : s),
        selectedServer: store.selectedServer?.id === id ? updatedServer : store.selectedServer,
        isLoading: false,
      }));
      return updatedServer;
    } catch (error) {
      console.error('Failed to update server:', error);
      update(store => ({
        ...store,
        error: error.message,
        isLoading: false,
      }));
      throw error;
    }
  };

  const deleteServer = async (id: string) => {
    update(store => ({ ...store, isLoading: true, error: null }));

    try {
      await ServerApi.deleteServer(id);
      update(store => ({
        ...store,
        servers: store.servers.filter(s => s.id !== id),
        selectedServer: store.selectedServer?.id === id ? null : store.selectedServer,
        isLoading: false,
      }));
    } catch (error) {
      console.error('Failed to delete server:', error);
      update(store => ({
        ...store,
        error: error.message,
        isLoading: false,
      }));
      throw error;
    }
  };

  const controlService = async (serverId: string, action: 'start' | 'stop' | 'restart') => {
    try {
      let result: string;
      switch (action) {
        case 'start':
          result = await ServerApi.startService(serverId);
          break;
        case 'stop':
          result = await ServerApi.stopService(serverId);
          break;
        case 'restart':
          result = await ServerApi.restartService(serverId);
          break;
      }
      
      // Refresh server data after service control
      await loadServer(serverId);
      return result;
    } catch (error) {
      console.error(`Failed to ${action} service:`, error);
      update(store => ({ ...store, error: error.message }));
      throw error;
    }
  };

  const clearError = () => {
    update(store => ({ ...store, error: null }));
  };

  const setFilter = (filter: ServerFilter) => {
    update(store => ({ ...store, filter }));
    loadServers(filter);
  };

  return {
    subscribe,
    loadServers,
    loadServer,
    createServer,
    updateServer,
    deleteServer,
    controlService,
    clearError,
    setFilter,
  };
}

export const serverStore = createServerStore();

// Derived stores
export const servers = derived(serverStore, $store => $store.servers);
export const selectedServer = derived(serverStore, $store => $store.selectedServer);
export const isLoading = derived(serverStore, $store => $store.isLoading);
export const serverError = derived(serverStore, $store => $store.error);

4. Server List Page (src/routes/servers/+page.svelte)

<script lang="ts">
  import { onMount } from 'svelte';
  import { goto } from '$app/navigation';
  import { serverStore, servers, isLoading, serverError } from '../../stores/server';
  import type { Server } from '../../models/server';

  let searchTerm = '';
  let statusFilter = '';

  onMount(() => {
    serverStore.loadServers();
  });

  $: filteredServers = $servers.filter(server => {
    const matchesSearch = !searchTerm || 
      server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
      server.serviceName.toLowerCase().includes(searchTerm.toLowerCase());
    
    const matchesStatus = !statusFilter || server.status === statusFilter;
    
    return matchesSearch && matchesStatus;
  });

  const handleCreateServer = () => {
    goto('/servers/create');
  };

  const handleViewServer = (server: Server) => {
    goto(`/servers/${server.id}`);
  };

  const handleEditServer = (server: Server) => {
    goto(`/servers/${server.id}/edit`);
  };

  const handleDeleteServer = async (server: Server) => {
    if (confirm(`Are you sure you want to delete server "${server.name}"?`)) {
      try {
        await serverStore.deleteServer(server.id);
      } catch (error) {
        alert(`Failed to delete server: ${error.message}`);
      }
    }
  };

  const getStatusColor = (status: string) => {
    switch (status) {
      case 'running': return 'text-green-600 bg-green-100';
      case 'stopped': return 'text-red-600 bg-red-100';
      case 'starting': return 'text-yellow-600 bg-yellow-100';
      case 'stopping': return 'text-orange-600 bg-orange-100';
      default: return 'text-gray-600 bg-gray-100';
    }
  };

  const formatDate = (dateString: string) => {
    return new Date(dateString).toLocaleDateString();
  };
</script>

<svelte:head>
  <title>ACC Servers</title>
</svelte:head>

<div class="container mx-auto px-4 py-8">
  <!-- Header -->
  <div class="flex justify-between items-center mb-8">
    <h1 class="text-3xl font-bold text-gray-900">ACC Servers</h1>
    <button 
      on:click={handleCreateServer}
      class="btn btn-primary"
    >
      Create New Server
    </button>
  </div>

  <!-- Filters -->
  <div class="mb-6 flex gap-4">
    <div class="flex-1">
      <input 
        type="text" 
        placeholder="Search servers..."
        bind:value={searchTerm}
        class="input input-bordered w-full"
      />
    </div>
    <div>
      <select bind:value={statusFilter} class="select select-bordered">
        <option value="">All Status</option>
        <option value="running">Running</option>
        <option value="stopped">Stopped</option>
        <option value="starting">Starting</option>
        <option value="stopping">Stopping</option>
      </select>
    </div>
  </div>

  <!-- Error Message -->
  {#if $serverError}
    <div class="alert alert-error mb-4">
      <span>{$serverError}</span>
      <button 
        on:click={serverStore.clearError}
        class="btn btn-sm btn-ghost"
      >
        ×
      </button>
    </div>
  {/if}

  <!-- Loading State -->
  {#if $isLoading}
    <div class="flex justify-center py-8">
      <div class="loading loading-spinner loading-lg"></div>
    </div>
  {:else if filteredServers.length === 0}
    <!-- Empty State -->
    <div class="text-center py-12">
      <div class="text-gray-500 mb-4">
        {$servers.length === 0 ? 'No servers found' : 'No servers match your filter'}
      </div>
      {#if $servers.length === 0}
        <button 
          on:click={handleCreateServer}
          class="btn btn-primary"
        >
          Create Your First Server
        </button>
      {/if}
    </div>
  {:else}
    <!-- Server Grid -->
    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      {#each filteredServers as server (server.id)}
        <div class="card bg-base-100 shadow-xl">
          <div class="card-body">
            <h2 class="card-title">{server.name}</h2>
            
            <!-- Status Badge -->
            <div class="badge {getStatusColor(server.status)} mb-2">
              {server.status}
            </div>

            <!-- Server Info -->
            <div class="text-sm text-gray-600 space-y-1">
              <div><strong>Service:</strong> {server.serviceName}</div>
              <div><strong>Created:</strong> {formatDate(server.dateCreated)}</div>
              {#if server.state}
                <div><strong>Players:</strong> {server.state.playerCount}/{server.state.maxConnections}</div>
                <div><strong>Track:</strong> {server.state.track || 'Unknown'}</div>
              {/if}
            </div>

            <!-- Actions -->
            <div class="card-actions justify-end mt-4">
              <button 
                on:click={() => handleViewServer(server)}
                class="btn btn-sm btn-primary"
              >
                View
              </button>
              <button 
                on:click={() => handleEditServer(server)}
                class="btn btn-sm btn-secondary"
              >
                Edit
              </button>
              <button 
                on:click={() => handleDeleteServer(server)}
                class="btn btn-sm btn-error"
              >
                Delete
              </button>
            </div>
          </div>
        </div>
      {/each}
    </div>
  {/if}
</div>

<style>
  .container {
    max-width: 1200px;
  }

  .btn {
    @apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors;
  }

  .btn-primary {
    @apply text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-500;
  }

  .btn-secondary {
    @apply text-gray-700 bg-gray-200 hover:bg-gray-300 focus:ring-gray-500;
  }

  .btn-error {
    @apply text-white bg-red-600 hover:bg-red-700 focus:ring-red-500;
  }

  .btn-sm {
    @apply px-2 py-1 text-xs;
  }

  .input {
    @apply block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500;
  }

  .select {
    @apply block px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500;
  }

  .card {
    @apply border border-gray-200 rounded-lg;
  }

  .card-body {
    @apply p-6;
  }

  .card-title {
    @apply text-lg font-semibold text-gray-900 mb-2;
  }

  .badge {
    @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
  }

  .alert {
    @apply p-4 rounded-md;
  }

  .alert-error {
    @apply bg-red-50 border border-red-200 text-red-800;
  }

  .loading {
    @apply animate-spin;
  }

  .loading-spinner {
    @apply h-8 w-8 border-4 border-gray-200 border-t-blue-600 rounded-full;
  }

  .loading-lg {
    @apply h-12 w-12;
  }
</style>

5. Create Server Page (src/routes/servers/create/+page.svelte)

<script lang="ts">
  import { goto } from '$app/navigation';
  import { serverStore } from '../../../stores/server';

  let formData = {
    name: '',
  };

  let isSubmitting = false;
  let errors: Record<string, string> = {};

  const validate = () => {
    errors = {};
    
    if (!formData.name.trim()) {
      errors.name = 'Server name is required';
    } else if (formData.name.length < 3) {
      errors.name = 'Server name must be at least 3 characters';
    } else if (formData.name.length > 64) {
      errors.name = 'Server name must not exceed 64 characters';
    }

    return Object.keys(errors).length === 0;
  };

  const handleSubmit = async () => {
    if (!validate()) return;

    isSubmitting = true;
    try {
      const newServer = await serverStore.createServer(formData);
      goto(`/servers/${newServer.id}`);
    } catch (error) {
      errors.general = error.message;
    } finally {
      isSubmitting = false;
    }
  };

  const handleCancel = () => {
    goto('/servers');
  };
</script>

<svelte:head>
  <title>Create Server - ACC Server Manager</title>
</svelte:head>

<div class="container mx-auto px-4 py-8 max-w-2xl">
  <h1 class="text-3xl font-bold text-gray-900 mb-8">Create New ACC Server</h1>

  <div class="card bg-base-100 shadow-xl">
    <div class="card-body">
      <form on:submit|preventDefault={handleSubmit}>
        <!-- General Error -->
        {#if errors.general}
          <div class="alert alert-error mb-4">
            <span>{errors.general}</span>
          </div>
        {/if}

        <!-- Server Name -->
        <div class="form-control mb-4">
          <label class="label" for="name">
            <span class="label-text">Server Name *</span>
          </label>
          <input 
            type="text" 
            id="name"
            bind:value={formData.name}
            class="input input-bordered w-full {errors.name ? 'input-error' : ''}"
            placeholder="Enter server name"
            required
          />
          {#if errors.name}
            <label class="label">
              <span class="label-text-alt text-error">{errors.name}</span>
            </label>
          {/if}
        </div>

        <!-- Info Box -->
        <div class="alert alert-info mb-6">
          <div>
            <h3 class="font-bold">What happens when you create a server?</h3>
            <div class="text-sm mt-2">
              <ul class="list-disc list-inside space-y-1">
                <li>ACC server files will be downloaded via SteamCMD</li>
                <li>A Windows service will be created</li>
                <li>Default configuration files will be generated</li>
                <li>You may need to confirm Steam 2FA if prompted</li>
              </ul>
            </div>
          </div>
        </div>

        <!-- Action Buttons -->
        <div class="card-actions justify-end">
          <button 
            type="button"
            on:click={handleCancel}
            class="btn btn-ghost"
            disabled={isSubmitting}
          >
            Cancel
          </button>
          <button 
            type="submit"
            class="btn btn-primary"
            disabled={isSubmitting}
          >
            {#if isSubmitting}
              <span class="loading loading-spinner loading-sm mr-2"></span>
              Creating Server...
            {:else}
              Create Server
            {/if}
          </button>
        </div>
      </form>
    </div>
  </div>

  <!-- Steam 2FA Notice -->
  <div class="mt-6 p-4 bg-yellow-50 border border-yellow-200 rounded-md">
    <div class="flex">
      <div class="flex-shrink-0">
        <svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
        </svg>
      </div>
      <div class="ml-3">
        <h3 class="text-sm font-medium text-yellow-800">Steam Authentication Required</h3>
        <div class="mt-2 text-sm text-yellow-700">
          <p>If your Steam account has 2FA enabled, you'll need to confirm the login request in your Steam Mobile App when prompted.</p>
        </div>
      </div>
    </div>
  </div>
</div>

6. Server Detail Page (src/routes/servers/[id]/+page.svelte)

<script lang="ts">
  import { page } from '$app/stores';
  import { onMount } from 'svelte';
  import { goto } from '$app/navigation';
  import { serverStore, selectedServer, isLoading, serverError } from '../../../stores/server';

  $: serverId = $page.params.id;

  let isControlling = false;
  let controlError = '';

  onMount(() => {
    if (serverId) {
      serverStore.loadServer(serverId);
    }
  });

  const handleEdit = () => {
    goto(`/servers/${serverId}/edit`);
  };

  const handleDelete = async () => {
    if (!$selectedServer) return;
    
    if (confirm(`Are you sure you want to delete server "${$selectedServer.name}"?`)) {
      try {
        await serverStore.deleteServer(serverId);
        goto('/servers');
      } catch (error) {
        alert(`Failed to delete server: ${error.message}`);
      }
    }
  };

  const handleServiceControl = async (action: 'start' | 'stop' | 'restart') => {
    if (!$selectedServer) return;

    isControlling = true;
    controlError = '';
    
    try {
      await serverStore.controlService(serverId, action);
    } catch (error) {
      controlError = error.message;
    } finally {
      isControlling = false;
    }
  };

  const getStatusColor = (status: string) => {
    switch (status) {
      case 'running': return 'badge-success';
      case 'stopped': return 'badge-error';
      case 'starting': return 'badge-warning';
      case 'stopping': return 'badge-info';
      default: return 'badge-ghost';
    }
  };

  const formatDate = (dateString: string) => {
    return new Date(dateString).toLocaleString();
  };
</script>

<svelte:head>
  <title>{$selectedServer?.name || 'Server'} - ACC Server Manager</title>
</svelte:head>

<div class="container mx-auto px-4 py-8">
  <!-- Loading State -->
  {#if $isLoading}
    <div class="flex justify-center py-12">
      <div class="loading loading-spinner loading-lg"></div>
    </div>
  {:else if $serverError}
    <!-- Error State -->
    <div class="alert alert-error">
      <span>{$serverError}</span>
      <button 
        on:click={() => goto('/servers')}
        class="btn btn-sm btn-ghost"
      >
        Back to Servers
      </button>
    </div>
  {:else if $selectedServer}
    <!-- Server Details -->
    <div class="flex justify-between items-start mb-8">
      <div>
        <h1 class="text-3xl font-bold text-gray-900">{$selectedServer.name}</h1>
        <div class="flex items-center gap-2 mt-2">
          <div class="badge {getStatusColor($selectedServer.status)}">
            {$selectedServer.status}
          </div>
          <span class="text-gray-600">Service: {$selectedServer.serviceName}</span>
        </div>
      </div>
      
      <div class="flex gap-2">
        <button 
          on:click={handleEdit}
          class="btn btn-secondary"
        >
          Edit
        </button>
        <button 
          on:click={handleDelete}
          class="btn btn-error"
        >
          Delete
        </button>
      </div>
    </div>

    <!-- Service Control -->
    <div class="card bg-base-100 shadow-xl mb-6">
      <div class="card-body">
        <h2 class="card-title">Service Control</h2>
        
        {#if controlError}
          <div class="alert alert-error mb-4">
            <span>{controlError}</span>
            <button 
              on:click={() => controlError = ''}
              class="btn btn-sm btn-ghost"
            >
              ×
            </button>
          </div>
        {/if}

        <div class="flex gap-2">
          <button 
            on:click={() => handleServiceControl('start')}
            class="btn btn-success"
            disabled={isControlling || $selectedServer.status === 'running'}
          >
            {#if isControlling}
              <span class="loading loading-spinner loading-sm mr-2"></span>
            {/if}
            Start
          </button>
          
          <button 
            on:click={() => handleServiceControl('stop')}
            class="btn btn-error"
            disabled={isControlling || $selectedServer.status === 'stopped'}
          >
            {#if isControlling}
              <span class="loading loading-spinner loading-sm mr-2"></span>
            {/if}
            Stop
          </button>
          
          <button 
            on:click={() => handleServiceControl('restart')}
            class="btn btn-warning"
            disabled={isControlling}
          >
            {#if isControlling}
              <span class="loading loading-spinner loading-sm mr-2"></span>
            {/if}
            Restart
          </button>
        </div>
      </div>
    </div>

    <!-- Server Information -->
    <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
      <!-- Basic Info -->
      <div class="card bg-base-100 shadow-xl">
        <div class="card-body">
          <h2 class="card-title">Server Information</h2>
          <div class="space-y-2">
            <div><strong>ID:</strong> {$selectedServer.id}</div>
            <div><strong>Name:</strong> {$selectedServer.name}</div>
            <div><strong>Service Name:</strong> {$selectedServer.serviceName}</div>
            <div><strong>Path:</strong> {$selectedServer.path}</div>
            <div><strong>Created:</strong> {formatDate($selectedServer.dateCreated)}</div>
          </div>
        </div>
      </div>

      <!-- Current State -->
      {#if $selectedServer.state}
        <div class="card bg-base-100 shadow-xl">
          <div class="card-body">
            <h2 class="card-title">Current State</h2>
            <div class="space-y-2">
              <div><strong>Session:</strong> {$selectedServer.state.session}</div>
              <div><strong>Track:</strong> {$selectedServer.state.track || 'Unknown'}</div>
              <div><strong>Players:</strong> {$selectedServer.state.playerCount}/{$selectedServer.state.maxConnections}</div>
              <div><strong>Session Duration:</strong> {$selectedServer.state.sessionDurationMinutes} minutes</div>
              <div><strong>Session Started:</strong> {formatDate($selectedServer.state.sessionStart)}</div>
            </div>
          </div>
        </div>
      {:else}
        <div class="card bg-base-100 shadow-xl">
          <div class="card-body">
            <h2 class="card-title">Current State</h2>
            <p class="text-gray-600">No state information available</p>
          </div>
        </div>
      {/if}
    </div>

    <!-- Configuration Management -->
    <div class="card bg-base-100 shadow-xl mt-6">
      <div class="card-body">
        <h2 class="card-title">Configuration</h2>
        <p class="text-gray-600 mb-4">Manage server configuration files</p>
        <button 
          on:click={() => goto(`/servers/${serverId}/config`)}
          class="btn btn-primary"
        >
          Manage Configuration
        </button>
      </div>
    </div>
  {/if}
</div>

7. Navigation Integration

Add server management links to your main navigation (src/lib/components/Navigation.svelte):

<nav class="navbar bg-base-100">
  <div class="navbar-start">
    <a href="/" class="btn btn-ghost normal-case text-xl">ACC Server Manager</a>
  </div>
  
  <div class="navbar-center">
    <ul class="menu menu-horizontal px-1">
      <li><a href="/servers">Servers</a></li>
      <li><a href="/servers/create">Create Server</a></li>
      <!-- Add other navigation items -->
    </ul>
  </div>
  
  <div class="navbar-end">
    <!-- User menu or other actions -->
  </div>
</nav>

Routes Structure

Ensure your routing structure includes:

src/routes/
├── +layout.svelte              # Main layout with Steam2FA component
├── servers/
│   ├── +page.svelte           # Server list page
│   ├── create/
│   │   └── +page.svelte       # Create server page
│   └── [id]/
│       ├── +page.svelte       # Server detail page
│       ├── edit/
│       │   └── +page.svelte   # Edit server page
│       └── config/
│           └── +page.svelte   # Configuration management page

Key Features Implemented

  1. Complete CRUD Operations: Create, read, update, delete servers
  2. Service Control: Start, stop, restart server services
  3. Real-time Status: Display current server status and state
  4. Search and Filtering: Find servers by name or status
  5. Error Handling: Comprehensive error messages and recovery
  6. Steam 2FA Integration: Automatic handling during server creation
  7. Responsive Design: Works on desktop and mobile devices
  8. Loading States: Clear feedback during async operations

Authentication Integration

The API client automatically includes authentication cookies with all requests. Ensure your authentication system is set up to handle:

  1. User login/logout
  2. Session management
  3. Permission checking (ServerView, ServerCreate, ServerUpdate, ServerDelete)

Conclusion

This implementation provides a complete, production-ready Steam 2FA system for the frontend. The polling-based approach ensures compatibility with all browsers and provides reliable 2FA handling for Steam operations.

The system is designed to be:

  • User-friendly: Clear instructions and immediate feedback
  • Robust: Comprehensive error handling and recovery
  • Maintainable: Clean separation of concerns and well-documented code
  • Secure: Proper authentication and no credential exposure

Once implemented, users will receive immediate notifications when Steam 2FA is required, making server management seamless and intuitive.