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

1986 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`)
```typescript
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`)
```typescript
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`)
```typescript
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`)
```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`):
```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):
```json
{
"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:
```typescript
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
```javascript
// 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
```bash
# 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`)
```typescript
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`)
```typescript
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`)
```typescript
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`)
```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`)
```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`)
```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`):
```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.