2 Commits

12 changed files with 296 additions and 359 deletions

View File

@@ -5,9 +5,17 @@ import { ServerCreationPopupContainer } from '@/components/server/ServerCreation
import { requireAuth } from '@/lib/auth/server';
import { AppShell } from '@/components/ui/AppShell';
import { hasPermission } from '@/lib/schemas';
import { checkHealth } from '@/lib/api/server/health';
import { redirect } from 'next/navigation';
export default async function DashboardLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const session = await requireAuth();
const health = await checkHealth();
if (!health.healthy) {
redirect('/login');
}
const showUsers = hasPermission(session.user!, 'membership.view');
return (
<WebSocketProvider openToken={session.openToken!}>

View File

@@ -1,15 +1,18 @@
import { Suspense } from 'react';
import LoginForm from '@/components/login/LoginForm';
import { checkHealth } from '@/lib/api/server/health';
export const dynamic = 'force-dynamic';
export default function LoginPage({
export default async function LoginPage({
searchParams
}: {
searchParams: Promise<{ expired: boolean | undefined }>;
}) {
const health = await checkHealth();
return (
<Suspense fallback={<div>Loading...</div>}>
<LoginForm searchParams={searchParams} />
<LoginForm searchParams={searchParams} health={health} />
</Suspense>
);
}

View File

@@ -1,12 +1,11 @@
'use client';
import { useState } from 'react';
import type { AssistRules } from '@/lib/schemas/config';
import { updateAssistRulesAction } from '@/lib/actions/configuration';
interface AssistRulesEditorProps {
serverId: string;
config: AssistRules;
formData: AssistRules;
disabled?: boolean;
onFormDataChange: (data: AssistRules) => void;
}
const assistFields = [
@@ -57,42 +56,16 @@ const assistFields = [
}
];
export function AssistRulesEditor({ serverId, config }: AssistRulesEditorProps) {
const [formData, setFormData] = useState<AssistRules>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateAssistRulesAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update assist rules:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
export function AssistRulesEditor({ formData, disabled, onFormDataChange }: AssistRulesEditorProps) {
const handleInputChange = (key: keyof AssistRules, value: string | number) => {
setFormData((prev) => ({
...prev,
onFormDataChange({
...formData,
[key]: typeof value === 'string' ? parseInt(value) : value
}));
});
};
return (
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6">
<div className="max-w-3xl space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{assistFields.map(({ key, label, type }) => (
<div key={key}>
@@ -100,7 +73,7 @@ export function AssistRulesEditor({ serverId, config }: AssistRulesEditorProps)
{type === 'number' ? (
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData[key]}
onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full"
@@ -109,7 +82,7 @@ export function AssistRulesEditor({ serverId, config }: AssistRulesEditorProps)
/>
) : (
<select
disabled={isSubmitting}
disabled={disabled}
value={formData[key]}
onChange={(e) => handleInputChange(key, e.target.value)}
className="form-select w-full"
@@ -121,28 +94,6 @@ export function AssistRulesEditor({ serverId, config }: AssistRulesEditorProps)
</div>
))}
</div>
<div className="border-t border-border pt-6">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart server after saving
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,56 +1,29 @@
'use client';
import { useState } from 'react';
import type { Configuration } from '@/lib/schemas/config';
import { updateConfigurationAction } from '@/lib/actions/configuration';
interface ConfigurationEditorProps {
serverId: string;
config: Configuration;
formData: Configuration;
disabled?: boolean;
onFormDataChange: (data: Configuration) => void;
}
export function ConfigurationEditor({ serverId, config }: ConfigurationEditorProps) {
const [formData, setFormData] = useState<Configuration>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateConfigurationAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update configuration:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
export function ConfigurationEditor({ formData, disabled, onFormDataChange }: ConfigurationEditorProps) {
const handleInputChange = (key: keyof Configuration, value: string | number) => {
setFormData((prev) => ({
...prev,
onFormDataChange({
...formData,
[key]: typeof value === 'string' ? parseInt(value) : value
}));
});
};
return (
<form onSubmit={handleSubmit} className="max-w-3xl space-y-6">
<div className="max-w-3xl space-y-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">UDP Port</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData.udpPort}
onChange={(e) => handleInputChange('udpPort', e.target.value)}
className="form-input w-full"
@@ -61,7 +34,7 @@ export function ConfigurationEditor({ serverId, config }: ConfigurationEditorPro
<label className="mb-1.5 block text-sm font-medium text-secondary">TCP Port</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData.tcpPort}
onChange={(e) => handleInputChange('tcpPort', e.target.value)}
className="form-input w-full"
@@ -72,7 +45,7 @@ export function ConfigurationEditor({ serverId, config }: ConfigurationEditorPro
<label className="mb-1.5 block text-sm font-medium text-secondary">Max Connections</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData.maxConnections}
onChange={(e) => handleInputChange('maxConnections', e.target.value)}
className="form-input w-full"
@@ -82,7 +55,7 @@ export function ConfigurationEditor({ serverId, config }: ConfigurationEditorPro
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">LAN Discovery</label>
<select
disabled={isSubmitting}
disabled={disabled}
value={formData.lanDiscovery}
onChange={(e) => handleInputChange('lanDiscovery', e.target.value)}
className="form-select w-full"
@@ -95,7 +68,7 @@ export function ConfigurationEditor({ serverId, config }: ConfigurationEditorPro
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Register To Lobby</label>
<select
disabled={isSubmitting}
disabled={disabled}
value={formData.registerToLobby}
onChange={(e) => handleInputChange('registerToLobby', e.target.value)}
className="form-select w-full"
@@ -105,28 +78,6 @@ export function ConfigurationEditor({ serverId, config }: ConfigurationEditorPro
</select>
</div>
</div>
<div className="border-t border-border pt-6">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart server after saving
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,12 +1,11 @@
'use client';
import { useState } from 'react';
import type { EventConfig, Session } from '@/lib/schemas/config';
import { updateEventConfigAction } from '@/lib/actions/configuration';
interface EventConfigEditorProps {
serverId: string;
config: EventConfig;
formData: EventConfig;
disabled?: boolean;
onFormDataChange: (data: EventConfig) => void;
}
const sessionTypes = [
@@ -15,49 +14,19 @@ const sessionTypes = [
{ value: 'R', label: 'Race' }
];
export function EventConfigEditor({ serverId, config }: EventConfigEditorProps) {
const [formData, setFormData] = useState<EventConfig>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
if (key === 'sessions') {
formDataObj.append(key, JSON.stringify(value));
} else {
formDataObj.append(key, value.toString());
}
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateEventConfigAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update event config:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
export function EventConfigEditor({ formData, disabled, onFormDataChange }: EventConfigEditorProps) {
const handleInputChange = (key: keyof EventConfig, value: string | number) => {
if (key === 'sessions') return;
setFormData((prev) => ({
...prev,
onFormDataChange({
...formData,
[key]:
typeof formData[key] === 'number'
? typeof value === 'string'
? parseFloat(value) || 0
: value
: value
}));
});
};
const handleSessionChange = (index: number, field: keyof Session, value: string | number) => {
@@ -67,11 +36,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
[field]:
field === 'sessionType' ? value : typeof value === 'string' ? parseFloat(value) || 0 : value
};
setFormData((prev) => ({
...prev,
sessions: newSessions
}));
onFormDataChange({ ...formData, sessions: newSessions });
};
const addSession = () => {
@@ -82,22 +47,15 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
sessionType: 'P',
sessionDurationMinutes: 20
};
setFormData((prev) => ({
...prev,
sessions: [...prev.sessions, newSession]
}));
onFormDataChange({ ...formData, sessions: [...formData.sessions, newSession] });
};
const removeSession = (index: number) => {
setFormData((prev) => ({
...prev,
sessions: prev.sessions.filter((_, i) => i !== index)
}));
onFormDataChange({ ...formData, sessions: formData.sessions.filter((_, i) => i !== index) });
};
return (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8">
<div className="max-w-4xl space-y-8">
<div className="space-y-6">
<h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">
Basic Event Settings
@@ -106,7 +64,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Track</label>
<select
disabled={isSubmitting}
disabled={disabled}
value={formData.track}
onChange={(e) => handleInputChange('track', e.target.value)}
className="form-select w-full"
@@ -145,7 +103,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData.preRaceWaitingTimeSeconds}
onChange={(e) => handleInputChange('preRaceWaitingTimeSeconds', e.target.value)}
className="form-input w-full"
@@ -159,7 +117,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData.sessionOverTimeSeconds}
onChange={(e) => handleInputChange('sessionOverTimeSeconds', e.target.value)}
className="form-input w-full"
@@ -173,7 +131,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData.postQualySeconds}
onChange={(e) => handleInputChange('postQualySeconds', e.target.value)}
className="form-input w-full"
@@ -187,7 +145,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData.postRaceSeconds}
onChange={(e) => handleInputChange('postRaceSeconds', e.target.value)}
className="form-input w-full"
@@ -208,7 +166,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData.ambientTemp}
onChange={(e) => handleInputChange('ambientTemp', e.target.value)}
className="form-input w-full"
@@ -223,7 +181,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData.cloudLevel}
onChange={(e) => handleInputChange('cloudLevel', e.target.value)}
className="form-input w-full"
@@ -237,7 +195,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
<label className="mb-1.5 block text-sm font-medium text-secondary">Rain (0.0-1.0)</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData.rain}
onChange={(e) => handleInputChange('rain', e.target.value)}
className="form-input w-full"
@@ -253,7 +211,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData.weatherRandomness}
onChange={(e) => handleInputChange('weatherRandomness', e.target.value)}
className="form-input w-full"
@@ -267,7 +225,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
Simracer Weather Conditions
</label>
<select
disabled={isSubmitting}
disabled={disabled}
value={formData.simracerWeatherConditions}
onChange={(e) => handleInputChange('simracerWeatherConditions', e.target.value)}
className="form-select w-full"
@@ -282,7 +240,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
Fixed Condition Qualification
</label>
<select
disabled={isSubmitting}
disabled={disabled}
value={formData.isFixedConditionQualification}
onChange={(e) => handleInputChange('isFixedConditionQualification', e.target.value)}
className="form-select w-full"
@@ -300,7 +258,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
<button
type="button"
onClick={addSession}
disabled={isSubmitting}
disabled={disabled}
className="rounded-md border border-border bg-overlay px-3 py-1.5 text-xs font-medium text-secondary hover:bg-border disabled:opacity-40"
>
+ Add Session
@@ -315,7 +273,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
<button
type="button"
onClick={() => removeSession(index)}
disabled={isSubmitting}
disabled={disabled}
className="rounded px-2 py-1 text-xs text-red hover:bg-red-bg disabled:opacity-40"
>
Remove
@@ -328,7 +286,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
Session Type
</label>
<select
disabled={isSubmitting}
disabled={disabled}
value={session.sessionType}
onChange={(e) => handleSessionChange(index, 'sessionType', e.target.value)}
className="form-select w-full text-sm"
@@ -347,7 +305,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={session.hourOfDay}
onChange={(e) => handleSessionChange(index, 'hourOfDay', e.target.value)}
className="form-input w-full text-sm"
@@ -362,7 +320,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={session.dayOfWeekend}
onChange={(e) => handleSessionChange(index, 'dayOfWeekend', e.target.value)}
className="form-input w-full text-sm"
@@ -377,7 +335,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={session.timeMultiplier}
onChange={(e) => handleSessionChange(index, 'timeMultiplier', e.target.value)}
className="form-input w-full text-sm"
@@ -392,7 +350,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={session.sessionDurationMinutes}
onChange={(e) =>
handleSessionChange(index, 'sessionDurationMinutes', e.target.value)
@@ -406,28 +364,6 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps)
))}
</div>
</div>
<div className="border-t border-border pt-6">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart server after saving
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,12 +1,11 @@
'use client';
import { useState } from 'react';
import type { EventRules } from '@/lib/schemas/config';
import { updateEventRulesAction } from '@/lib/actions/configuration';
interface EventRulesEditorProps {
serverId: string;
config: EventRules;
formData: EventRules;
disabled?: boolean;
onFormDataChange: (data: EventRules) => void;
}
const numberFields = [
@@ -68,42 +67,13 @@ const booleanFields = [
}
];
export function EventRulesEditor({ serverId, config }: EventRulesEditorProps) {
const [formData, setFormData] = useState<EventRules>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value.toString());
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateEventRulesAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update event rules:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (key: keyof EventRules, value: string | number | boolean) => {
setFormData((prev) => ({
...prev,
[key]: value
}));
export function EventRulesEditor({ formData, disabled, onFormDataChange }: EventRulesEditorProps) {
const handleInputChange = (key: keyof EventRules, value: number) => {
onFormDataChange({ ...formData, [key]: value });
};
return (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8">
<div className="max-w-4xl space-y-8">
<div className="space-y-6">
<h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">Race Rules</h3>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
@@ -112,7 +82,7 @@ export function EventRulesEditor({ serverId, config }: EventRulesEditorProps) {
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)}
className="form-input w-full"
@@ -133,9 +103,9 @@ export function EventRulesEditor({ serverId, config }: EventRulesEditorProps) {
<div key={key}>
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<select
disabled={isSubmitting}
disabled={disabled}
value={formData[key] ? 'true' : 'false'}
onChange={(e) => handleInputChange(key, e.target.value === 'true')}
onChange={(e) => handleInputChange(key, e.target.value === 'true' ? 1 : 0)}
className="form-select w-full"
>
<option value="false">No</option>
@@ -145,28 +115,6 @@ export function EventRulesEditor({ serverId, config }: EventRulesEditorProps) {
))}
</div>
</div>
<div className="border-t border-border pt-6">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart server after saving
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,12 +1,11 @@
'use client';
import { useState } from 'react';
import type { ServerSettings } from '@/lib/schemas/config';
import { updateServerSettingsAction } from '@/lib/actions/configuration';
interface ServerSettingsEditorProps {
serverId: string;
config: ServerSettings;
formData: ServerSettings;
disabled?: boolean;
onFormDataChange: (data: ServerSettings) => void;
}
const textFields = [
@@ -87,42 +86,13 @@ const selectFields = [
}
];
export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorProps) {
const [formData, setFormData] = useState<ServerSettings>(config);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
const formDataObj = new FormData();
Object.entries(formData).forEach(([key, value]) => {
formDataObj.append(key, value?.toString() ?? '');
});
if (restart) {
formDataObj.append('restart', 'on');
}
try {
const result = await updateServerSettingsAction(serverId, formDataObj);
if (!result.success) {
console.error('Failed to update server settings:', result.message);
}
} finally {
setIsSubmitting(false);
}
};
export function ServerSettingsEditor({ formData, disabled, onFormDataChange }: ServerSettingsEditorProps) {
const handleInputChange = (key: keyof ServerSettings, value: string | number) => {
setFormData((prev) => ({
...prev,
[key]: value
}));
onFormDataChange({ ...formData, [key]: value });
};
return (
<form onSubmit={handleSubmit} className="max-w-4xl space-y-8">
<div className="max-w-4xl space-y-8">
<div className="space-y-6">
<h3 className="border-b border-border-muted pb-2 text-sm font-semibold text-primary">
Basic Settings
@@ -133,7 +103,7 @@ export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorP
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<input
type={type}
disabled={isSubmitting}
disabled={disabled}
value={formData[key] as string}
onChange={(e) => handleInputChange(key, e.target.value)}
className="form-input w-full"
@@ -144,7 +114,7 @@ export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorP
<div>
<label className="mb-1.5 block text-sm font-medium text-secondary">Car Group</label>
<select
disabled={isSubmitting}
disabled={disabled}
value={formData.carGroup}
onChange={(e) => handleInputChange('carGroup', e.target.value)}
className="form-select w-full"
@@ -169,7 +139,7 @@ export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorP
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<input
type="number"
disabled={isSubmitting}
disabled={disabled}
value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value) || 0)}
className="form-input w-full"
@@ -190,7 +160,7 @@ export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorP
<div key={key}>
<label className="mb-1.5 block text-sm font-medium text-secondary">{label}</label>
<select
disabled={isSubmitting}
disabled={disabled}
value={formData[key] as number}
onChange={(e) => handleInputChange(key, parseInt(e.target.value))}
className="form-select w-full"
@@ -206,7 +176,7 @@ export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorP
Formation Lap Type
</label>
<select
disabled={isSubmitting}
disabled={disabled}
value={formData.formationLapType}
onChange={(e) => handleInputChange('formationLapType', parseInt(e.target.value))}
className="form-select w-full"
@@ -220,28 +190,6 @@ export function ServerSettingsEditor({ serverId, config }: ServerSettingsEditorP
</div>
</div>
</div>
<div className="border-t border-border pt-6">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart server after saving
</label>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -2,6 +2,8 @@
import { clearExpiredSessionAction, loginAction, LoginResult } from '@/lib/actions/auth';
import { use, useActionState, useEffect } from 'react';
import type { HealthStatus } from '@/lib/api/server/health';
import { Badge } from '@/components/ui/Badge';
const initialState: LoginResult = {
message: '',
@@ -9,9 +11,11 @@ const initialState: LoginResult = {
};
export default function LoginForm({
searchParams
searchParams,
health
}: {
searchParams: Promise<{ expired: boolean | undefined }>;
health: HealthStatus;
}) {
const params = use(searchParams);
const expired = params.expired;
@@ -28,6 +32,13 @@ export default function LoginForm({
<div className="text-center">
<h1 className="text-xl font-bold text-primary">ACC Server Manager</h1>
<p className="mt-1 text-sm text-muted">Sign in to manage your servers</p>
<div className="mt-2 flex justify-center">
{health.healthy ? (
<Badge variant="green">API v{health.version}</Badge>
) : (
<Badge variant="red">API unreachable</Badge>
)}
</div>
</div>
{expired && (
<div className="rounded-md border border-yellow/30 bg-yellow-bg px-3 py-2 text-sm text-yellow">

View File

@@ -11,6 +11,7 @@ import { LeaderboardManager } from '@/components/leaderboard/LeaderboardManager'
import { useState } from 'react';
import { StateHistoryStats } from '@/lib/schemas';
import { Leaderboard } from '@/lib/schemas/leaderboard';
import { bulkUpdateConfigurationsAction } from '@/lib/actions/configuration';
interface ServerConfigurationTabsProps {
serverId: string;
@@ -28,6 +29,14 @@ const tabs = [
{ id: ServerTab.leaderboard, name: 'Leaderboard', icon: '🏆' }
];
const configTabs = new Set([
ServerTab.configuration,
ServerTab.assistRules,
ServerTab.event,
ServerTab.eventRules,
ServerTab.settings
]);
export function ServerConfigurationTabs({
serverId,
configurations,
@@ -36,25 +45,95 @@ export function ServerConfigurationTabs({
}: ServerConfigurationTabsProps) {
const [currentTab, setCurrentTab] = useState(ServerTab.statistics);
const [configData, setConfigData] = useState(configurations.configuration);
const [assistData, setAssistData] = useState(configurations.assistRules);
const [eventData, setEventData] = useState(configurations.event);
const [eventRulesData, setEventRulesData] = useState(configurations.eventRules);
const [settingsData, setSettingsData] = useState(configurations.settings);
const [restart, setRestart] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const handleSaveAll = async () => {
setIsSubmitting(true);
setSaveError(null);
setSaveSuccess(false);
const result = await bulkUpdateConfigurationsAction(
serverId,
{
configuration: configData,
assistRules: assistData,
event: eventData,
eventRules: eventRulesData,
settings: settingsData
},
restart
);
if (result.success) {
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
} else {
setSaveError(result.message);
}
setIsSubmitting(false);
};
const isConfigTab = configTabs.has(currentTab);
const renderTabContent = () => {
switch (currentTab) {
case ServerTab.statistics:
return <StatisticsDashboard stats={statistics} />;
case ServerTab.configuration:
return <ConfigurationEditor serverId={serverId} config={configurations.configuration} />;
return (
<ConfigurationEditor
formData={configData}
disabled={isSubmitting}
onFormDataChange={setConfigData}
/>
);
case ServerTab.assistRules:
return <AssistRulesEditor serverId={serverId} config={configurations.assistRules} />;
return (
<AssistRulesEditor
formData={assistData}
disabled={isSubmitting}
onFormDataChange={setAssistData}
/>
);
case ServerTab.event:
return <EventConfigEditor serverId={serverId} config={configurations.event} />;
return (
<EventConfigEditor
formData={eventData}
disabled={isSubmitting}
onFormDataChange={setEventData}
/>
);
case ServerTab.eventRules:
return <EventRulesEditor serverId={serverId} config={configurations.eventRules} />;
return (
<EventRulesEditor
formData={eventRulesData}
disabled={isSubmitting}
onFormDataChange={setEventRulesData}
/>
);
case ServerTab.settings:
return <ServerSettingsEditor serverId={serverId} config={configurations.settings} />;
return (
<ServerSettingsEditor
formData={settingsData}
disabled={isSubmitting}
onFormDataChange={setSettingsData}
/>
);
case ServerTab.leaderboard:
return <LeaderboardManager serverId={serverId} initialData={leaderboard} />;
@@ -92,6 +171,37 @@ export function ServerConfigurationTabs({
})}
</nav>
{isConfigTab && (
<div className="flex items-center justify-between border-b border-border-muted bg-overlay px-5 py-3">
<div className="flex items-center gap-3">
{saveError && (
<p className="text-sm text-red">{saveError}</p>
)}
{saveSuccess && (
<p className="text-sm text-green">All configurations saved.</p>
)}
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-muted">
<input
type="checkbox"
checked={restart}
onChange={(e) => setRestart(e.target.checked)}
className="h-4 w-4 rounded border-border bg-overlay accent-green focus:ring-1 focus:ring-blue"
/>
Restart after saving
</label>
<button
onClick={handleSaveAll}
disabled={isSubmitting}
className="rounded-md bg-btn-green border border-btn-green px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-btn-green-hover disabled:cursor-not-allowed disabled:opacity-40"
>
{isSubmitting ? 'Saving…' : 'Save All'}
</button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto p-5">{renderTabContent()}</div>
</div>
);

View File

@@ -2,10 +2,11 @@
import { revalidatePath } from 'next/cache';
import { requireAuth } from '@/lib/auth/server';
import { updateServerConfiguration } from '@/lib/api/server/configuration';
import { updateServerConfiguration, bulkUpdateServerConfigurations } from '@/lib/api/server/configuration';
import {
assistRulesSchema,
ConfigFile,
configurationsSchema,
eventConfigSchema,
eventRulesSchema,
serverSettingsSchema
@@ -13,6 +14,7 @@ import {
import type {
Configuration,
AssistRules,
Configurations,
EventConfig,
EventRules,
ServerSettings
@@ -232,3 +234,28 @@ export async function updateEventRulesAction(serverId: string, formData: FormDat
};
}
}
export async function bulkUpdateConfigurationsAction(
serverId: string,
configs: Configurations,
restart: boolean
) {
try {
const session = await requireAuth();
const validated = configurationsSchema.safeParse(configs);
if (!validated.success) {
return { success: false, message: validated.error.message };
}
await bulkUpdateServerConfigurations(session.token!, serverId, validated.data, restart);
revalidatePath(`/dashboard/server/${serverId}`);
return { success: true, message: 'All configurations updated successfully' };
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Failed to update configurations'
};
}
}

View File

@@ -8,14 +8,23 @@ import {
} from '@/lib/schemas/config';
import * as z from 'zod';
export type ConfigAuditRecord = {
id: string;
serverId: string;
configFile: string;
oldConfig: string;
newConfig: string;
changedAt: string;
};
const serverRoute = '/server';
export async function getServerConfigurations(
token: string,
serverId: string
): Promise<Configurations> {
const response = await fetchServerAPI<Configurations>(`${serverRoute}/${serverId}/config`, token);
return configurationsSchema.parse(response.data);
const response = await fetchServerAPI<Configurations>(`${serverRoute}/${serverId}/config`, token);
return configurationsSchema.parse(response.data);
}
export function validateConfig(
@@ -45,8 +54,26 @@ export async function updateServerConfiguration(
config: Config,
restart = false
): Promise<void> {
await fetchServerAPI(`${serverRoute}/${serverId}/config/${configType}?override=true`, token, 'PUT', {
...validateConfig(configType, config),
restart
});
await fetchServerAPI(
`${serverRoute}/${serverId}/config/${configType}?override=true&restart=${restart}`,
token,
'PUT',
{
...validateConfig(configType, config)
}
);
}
export async function bulkUpdateServerConfigurations(
token: string,
serverId: string,
configs: Configurations,
restart = false
): Promise<void> {
await fetchServerAPI<ConfigAuditRecord[]>(
`${serverRoute}/${serverId}/config?override=true&restart=${restart}`,
token,
'PATCH',
configs
);
}

View File

@@ -0,0 +1,17 @@
const BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
export type HealthStatus = { healthy: true; version: string } | { healthy: false; version: null };
export async function checkHealth(): Promise<HealthStatus> {
try {
const response = await fetch(`${BASE_URL}/system/health`, {
method: 'GET',
next: { revalidate: 0 }
});
if (!response.ok) return { healthy: false, version: null };
const version = await response.text();
return { healthy: true, version: version.trim() || 'unknown' };
} catch {
return { healthy: false, version: null };
}
}