feat: overhaul server automation, files editor, and CS2 setup workflows
This commit is contained in:
+16
-3
@@ -1,4 +1,9 @@
|
||||
const API_BASE = '/api';
|
||||
const RAW_API_BASE = (
|
||||
(import.meta.env.VITE_API_URL as string | undefined) ??
|
||||
(import.meta.env.VITE_API_BASE_URL as string | undefined) ??
|
||||
'/api'
|
||||
).trim();
|
||||
const API_BASE = (RAW_API_BASE || '/api').replace(/\/+$/, '');
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string>;
|
||||
@@ -36,7 +41,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...fetchOptions, headers });
|
||||
const res = await fetch(url, {
|
||||
...fetchOptions,
|
||||
credentials: fetchOptions.credentials ?? 'include',
|
||||
headers,
|
||||
});
|
||||
|
||||
const shouldHandle401WithRefresh =
|
||||
res.status === 401 &&
|
||||
@@ -49,7 +58,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
const refreshed = await refreshToken();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${localStorage.getItem('access_token')}`;
|
||||
const retry = await fetch(url, { ...fetchOptions, headers });
|
||||
const retry = await fetch(url, {
|
||||
...fetchOptions,
|
||||
credentials: fetchOptions.credentials ?? 'include',
|
||||
headers,
|
||||
});
|
||||
if (!retry.ok) throw new ApiError(retry.status, await retry.json().catch(() => null));
|
||||
if (retry.status === 204) return undefined as T;
|
||||
return retry.json();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Gamepad2 } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -23,16 +24,55 @@ interface Game {
|
||||
dockerImage: string;
|
||||
defaultPort: number;
|
||||
startupCommand: string;
|
||||
automationRules: unknown[];
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: { total: number };
|
||||
interface GamesResponse {
|
||||
data: Game[];
|
||||
}
|
||||
|
||||
function extractApiMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
|
||||
const maybeMessage = (error.data as { message?: unknown }).message;
|
||||
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
|
||||
return maybeMessage;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatAutomationRules(value: unknown): string {
|
||||
if (!Array.isArray(value)) {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return '[]';
|
||||
}
|
||||
}
|
||||
|
||||
function parseAutomationRules(raw: string): { rules: unknown[]; error: string | null } {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { rules: [], error: 'Automation JSON must be an array.' };
|
||||
}
|
||||
return { rules: parsed, error: null };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid JSON';
|
||||
return { rules: [], error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminGamesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [automationOpen, setAutomationOpen] = useState(false);
|
||||
const [selectedGame, setSelectedGame] = useState<Game | null>(null);
|
||||
const [automationJson, setAutomationJson] = useState('[]');
|
||||
const [automationError, setAutomationError] = useState<string | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [dockerImage, setDockerImage] = useState('');
|
||||
@@ -41,7 +81,7 @@ export function AdminGamesPage() {
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin-games'],
|
||||
queryFn: () => api.get<PaginatedResponse<Game>>('/admin/games'),
|
||||
queryFn: () => api.get<GamesResponse>('/admin/games'),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
@@ -53,11 +93,70 @@ export function AdminGamesPage() {
|
||||
setSlug('');
|
||||
setDockerImage('');
|
||||
setStartupCommand('');
|
||||
toast.success('Game created');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to create game'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateAutomationMutation = useMutation({
|
||||
mutationFn: ({ gameId, rules }: { gameId: string; rules: unknown[] }) =>
|
||||
api.patch(`/admin/games/${gameId}`, { automationRules: rules }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-games'] });
|
||||
setAutomationOpen(false);
|
||||
setSelectedGame(null);
|
||||
setAutomationError(null);
|
||||
toast.success('Automation rules updated');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to save automation rules'));
|
||||
},
|
||||
});
|
||||
|
||||
const games = data?.data ?? [];
|
||||
|
||||
const openAutomationDialog = (game: Game) => {
|
||||
setSelectedGame(game);
|
||||
setAutomationJson(formatAutomationRules(game.automationRules));
|
||||
setAutomationError(null);
|
||||
setAutomationOpen(true);
|
||||
};
|
||||
|
||||
const saveAutomationRules = () => {
|
||||
if (!selectedGame) return;
|
||||
|
||||
const parsed = parseAutomationRules(automationJson);
|
||||
if (parsed.error) {
|
||||
setAutomationError(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setAutomationError(null);
|
||||
updateAutomationMutation.mutate({
|
||||
gameId: selectedGame.id,
|
||||
rules: parsed.rules,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAutomationTabKey = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
event.preventDefault();
|
||||
const textarea = event.currentTarget;
|
||||
const selectionStart = textarea.selectionStart;
|
||||
const selectionEnd = textarea.selectionEnd;
|
||||
const nextValue = `${automationJson.slice(0, selectionStart)} ${automationJson.slice(selectionEnd)}`;
|
||||
const nextCursor = selectionStart + 2;
|
||||
|
||||
setAutomationJson(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(nextCursor, nextCursor);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -142,11 +241,65 @@ export function AdminGamesPage() {
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-xs">{game.dockerImage}</p>
|
||||
<p>Port: {game.defaultPort}</p>
|
||||
<p>Automation: {Array.isArray(game.automationRules) ? game.automationRules.length : 0} workflow</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => openAutomationDialog(game)}
|
||||
>
|
||||
Manage Automation
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={automationOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setAutomationOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
setSelectedGame(null);
|
||||
setAutomationError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Automation Rules
|
||||
{selectedGame ? ` - ${selectedGame.name}` : ''}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>JSON</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supported events: server.created, server.install.completed, server.power.started, server.power.stopped
|
||||
</p>
|
||||
<textarea
|
||||
value={automationJson}
|
||||
onChange={(event) => setAutomationJson(event.target.value)}
|
||||
onKeyDown={handleAutomationTabKey}
|
||||
spellCheck={false}
|
||||
className="min-h-[320px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
{automationError && <p className="text-sm text-destructive">{automationError}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={saveAutomationRules}
|
||||
disabled={updateAutomationMutation.isPending || !selectedGame}
|
||||
>
|
||||
{updateAutomationMutation.isPending ? 'Saving...' : 'Save Automation'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,10 +142,10 @@ export function NodeDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const memPercent = stats
|
||||
const memPercent = stats && stats.memoryTotal > 0
|
||||
? Math.round((stats.memoryUsed / stats.memoryTotal) * 100)
|
||||
: 0;
|
||||
const diskPercent = stats
|
||||
const diskPercent = stats && stats.diskTotal > 0
|
||||
? Math.round((stats.diskUsed / stats.diskTotal) * 100)
|
||||
: 0;
|
||||
|
||||
|
||||
@@ -133,6 +133,10 @@ function ConfigEditor({
|
||||
? entries.filter((e) => configFile.editableKeys!.includes(e.key))
|
||||
: entries;
|
||||
|
||||
const entriesToSave = configFile.editableKeys
|
||||
? entries.filter((e) => configFile.editableKeys!.includes(e.key))
|
||||
: entries;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
@@ -149,7 +153,7 @@ function ConfigEditor({
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate({ entries })}
|
||||
onClick={() => saveMutation.mutate({ entries: entriesToSave })}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
|
||||
+735
-163
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,27 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useOutletContext, useParams } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Download,
|
||||
Puzzle,
|
||||
Search,
|
||||
Download,
|
||||
Trash2,
|
||||
Star,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
Star,
|
||||
Trash2,
|
||||
Upload,
|
||||
Store,
|
||||
Plus,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -50,9 +54,46 @@ interface SpigetResult {
|
||||
external: boolean;
|
||||
}
|
||||
|
||||
interface MarketplacePlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
source: 'spiget' | 'manual';
|
||||
externalId: string | null;
|
||||
downloadUrl: string | null;
|
||||
version: string | null;
|
||||
updatedAt: string;
|
||||
isInstalled: boolean;
|
||||
installId: string | null;
|
||||
installedVersion: string | null;
|
||||
isActive: boolean;
|
||||
installedAt: string | null;
|
||||
}
|
||||
|
||||
interface MarketplaceResponse {
|
||||
game: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
};
|
||||
plugins: MarketplacePlugin[];
|
||||
}
|
||||
|
||||
function extractApiMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
|
||||
const maybeMessage = (error.data as { message?: unknown }).message;
|
||||
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
|
||||
return maybeMessage;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function PluginsPage() {
|
||||
const { orgId, serverId } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { server } = useOutletContext<{ server?: { gameSlug: string } }>();
|
||||
const isMinecraft = server?.gameSlug === 'minecraft-java';
|
||||
|
||||
const { data: pluginsData } = useQuery({
|
||||
queryKey: ['plugins', orgId, serverId],
|
||||
@@ -65,29 +106,41 @@ export function PluginsPage() {
|
||||
const installed = pluginsData?.plugins ?? [];
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="installed" className="space-y-4">
|
||||
<TabsList>
|
||||
<Tabs defaultValue="marketplace" className="space-y-4">
|
||||
<TabsList className="flex h-auto w-full flex-wrap">
|
||||
<TabsTrigger value="marketplace">
|
||||
<Store className="mr-1.5 h-3.5 w-3.5" />
|
||||
Marketplace
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="installed">
|
||||
<Puzzle className="mr-1.5 h-3.5 w-3.5" />
|
||||
Installed ({installed.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="search">
|
||||
<Search className="mr-1.5 h-3.5 w-3.5" />
|
||||
Search Plugins
|
||||
</TabsTrigger>
|
||||
{isMinecraft && (
|
||||
<TabsTrigger value="search">
|
||||
<Search className="mr-1.5 h-3.5 w-3.5" />
|
||||
Spiget Search
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="manual">
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Manual Install
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="marketplace">
|
||||
<MarketplacePlugins orgId={orgId!} serverId={serverId!} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="installed">
|
||||
<InstalledPlugins installed={installed} orgId={orgId!} serverId={serverId!} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="search">
|
||||
<SpigetSearch orgId={orgId!} serverId={serverId!} />
|
||||
</TabsContent>
|
||||
{isMinecraft && (
|
||||
<TabsContent value="search">
|
||||
<SpigetSearch orgId={orgId!} serverId={serverId!} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="manual">
|
||||
<ManualInstall orgId={orgId!} serverId={serverId!} />
|
||||
@@ -96,6 +149,369 @@ export function PluginsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editingPluginId, setEditingPluginId] = useState<string | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [downloadUrl, setDownloadUrl] = useState('');
|
||||
const [version, setVersion] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['plugin-marketplace', orgId, serverId, searchTerm],
|
||||
queryFn: () =>
|
||||
api.get<MarketplaceResponse>(
|
||||
`/organizations/${orgId}/servers/${serverId}/plugins/marketplace`,
|
||||
searchTerm ? { q: searchTerm } : undefined,
|
||||
),
|
||||
});
|
||||
|
||||
const installMutation = useMutation({
|
||||
mutationFn: (pluginId: string) =>
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/${pluginId}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Plugin installed');
|
||||
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Plugin install failed'));
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallMutation = useMutation({
|
||||
mutationFn: (installId: string) =>
|
||||
api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/${installId}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Plugin uninstalled');
|
||||
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Plugin uninstall failed'));
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
name: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
downloadUrl: string;
|
||||
version?: string;
|
||||
}) =>
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/marketplace`, body),
|
||||
onSuccess: () => {
|
||||
toast.success('Marketplace plugin added');
|
||||
setCreateOpen(false);
|
||||
setName('');
|
||||
setSlug('');
|
||||
setDownloadUrl('');
|
||||
setVersion('');
|
||||
setDescription('');
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to add marketplace plugin'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (pluginId: string) =>
|
||||
api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/marketplace/${pluginId}`),
|
||||
onSuccess: () => {
|
||||
toast.success('Marketplace plugin removed');
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to remove marketplace plugin'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
pluginId: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
downloadUrl?: string;
|
||||
version?: string;
|
||||
}) =>
|
||||
api.patch(
|
||||
`/organizations/${orgId}/servers/${serverId}/plugins/marketplace/${body.pluginId}`,
|
||||
{
|
||||
name: body.name,
|
||||
slug: body.slug,
|
||||
description: body.description,
|
||||
downloadUrl: body.downloadUrl,
|
||||
version: body.version,
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
toast.success('Marketplace plugin updated');
|
||||
setEditOpen(false);
|
||||
setEditingPluginId(null);
|
||||
setName('');
|
||||
setSlug('');
|
||||
setDownloadUrl('');
|
||||
setVersion('');
|
||||
setDescription('');
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to update marketplace plugin'));
|
||||
},
|
||||
});
|
||||
|
||||
const openEditDialog = (plugin: MarketplacePlugin) => {
|
||||
setEditingPluginId(plugin.id);
|
||||
setName(plugin.name);
|
||||
setSlug(plugin.slug);
|
||||
setDownloadUrl(plugin.downloadUrl ?? '');
|
||||
setVersion(plugin.version ?? '');
|
||||
setDescription(plugin.description ?? '');
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const plugins = data?.plugins ?? [];
|
||||
const gameName = data?.game.name ?? 'Game';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{gameName} Marketplace</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Oyununuza uygun eklentileri tek tıkla kur/kaldırın.
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Plugin Ekle
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Marketplace Plugin Ekle</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate({
|
||||
name,
|
||||
slug: slug || undefined,
|
||||
description: description || undefined,
|
||||
downloadUrl,
|
||||
version: version || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Plugin Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug (optional)</Label>
|
||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Download URL</Label>
|
||||
<Input
|
||||
type="url"
|
||||
value={downloadUrl}
|
||||
onChange={(e) => setDownloadUrl(e.target.value)}
|
||||
placeholder="https://example.com/plugin.jar"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Version (optional)</Label>
|
||||
<Input value={version} onChange={(e) => setVersion(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description (optional)</Label>
|
||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Ekleniyor...' : 'Ekle'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={editOpen}
|
||||
onOpenChange={(open) => {
|
||||
setEditOpen(open);
|
||||
if (!open) {
|
||||
setEditingPluginId(null);
|
||||
setName('');
|
||||
setSlug('');
|
||||
setDownloadUrl('');
|
||||
setVersion('');
|
||||
setDescription('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Marketplace Plugin Düzenle</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!editingPluginId) return;
|
||||
updateMutation.mutate({
|
||||
pluginId: editingPluginId,
|
||||
name,
|
||||
slug: slug || undefined,
|
||||
description: description || undefined,
|
||||
downloadUrl: downloadUrl || undefined,
|
||||
version: version || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Plugin Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug (optional)</Label>
|
||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Download URL (optional)</Label>
|
||||
<Input
|
||||
type="url"
|
||||
value={downloadUrl}
|
||||
onChange={(e) => setDownloadUrl(e.target.value)}
|
||||
placeholder="https://example.com/plugin.jar"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Version (optional)</Label>
|
||||
<Input value={version} onChange={(e) => setVersion(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description (optional)</Label>
|
||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? 'Kaydediliyor...' : 'Kaydet'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Plugin ara..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') setSearchTerm(search.trim());
|
||||
}}
|
||||
/>
|
||||
<Button onClick={() => setSearchTerm(search.trim())}>
|
||||
<Search className="h-4 w-4" />
|
||||
Ara
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Marketplace yükleniyor...</p>}
|
||||
|
||||
{!isLoading && plugins.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<Store className="mb-3 h-10 w-10 text-muted-foreground/60" />
|
||||
<p className="font-medium">Bu oyun için plugin bulunamadı</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Yetkili kullanıcılar yukarıdan marketplace plugin ekleyebilir.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{plugins.map((plugin) => (
|
||||
<Card key={plugin.id}>
|
||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium">{plugin.name}</p>
|
||||
<Badge variant="outline">{plugin.source}</Badge>
|
||||
{plugin.version && <Badge variant="secondary">v{plugin.version}</Badge>}
|
||||
{plugin.isInstalled && <Badge>Installed</Badge>}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-sm text-muted-foreground">{plugin.description}</p>
|
||||
)}
|
||||
{plugin.downloadUrl && (
|
||||
<p className="line-clamp-1 text-xs text-muted-foreground">{plugin.downloadUrl}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{plugin.isInstalled && plugin.installId ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => uninstallMutation.mutate(plugin.installId!)}
|
||||
disabled={uninstallMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Kaldır
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => installMutation.mutate(plugin.id)}
|
||||
disabled={installMutation.isPending}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Kur
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => openEditDialog(plugin)}
|
||||
title="Marketplace kaydını düzenle"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => deleteMutation.mutate(plugin.id)}
|
||||
disabled={deleteMutation.isPending || plugin.isInstalled}
|
||||
title={plugin.isInstalled ? 'Önce sunucudan kaldırın' : 'Marketplace kaydını sil'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledPlugins({
|
||||
installed,
|
||||
orgId,
|
||||
@@ -111,12 +527,22 @@ function InstalledPlugins({
|
||||
mutationFn: (id: string) =>
|
||||
api.patch(`/organizations/${orgId}/servers/${serverId}/plugins/${id}/toggle`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }),
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Plugin durumu güncellenemedi'));
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }),
|
||||
onSuccess: () => {
|
||||
toast.success('Plugin kaldırıldı');
|
||||
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Plugin kaldırılamadı'));
|
||||
},
|
||||
});
|
||||
|
||||
if (installed.length === 0) {
|
||||
@@ -126,7 +552,7 @@ function InstalledPlugins({
|
||||
<Puzzle className="mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground">No plugins installed</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Search for plugins or install manually
|
||||
Marketplace sekmesinden tek tıkla kurulum yapabilirsiniz.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -199,7 +625,14 @@ function SpigetSearch({ orgId, serverId }: { orgId: string; serverId: string })
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/spiget`, {
|
||||
resourceId,
|
||||
}),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }),
|
||||
onSuccess: () => {
|
||||
toast.success('Spiget plugin installed');
|
||||
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Spiget install failed'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
@@ -270,11 +703,16 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
|
||||
mutationFn: (body: { name: string; fileName: string; version?: string }) =>
|
||||
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/manual`, body),
|
||||
onSuccess: () => {
|
||||
toast.success('Plugin registered');
|
||||
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||
setName('');
|
||||
setFileName('');
|
||||
setVersion('');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Plugin registration failed'));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -307,7 +745,7 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Upload the file to /plugins/ directory via the Files tab first
|
||||
Upload the file to the correct plugin directory via Files tab first.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useOutletContext } from 'react-router';
|
||||
import { useNavigate, useOutletContext, useParams } from 'react-router';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
import { ApiError, api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
|
||||
interface ServerDetail {
|
||||
@@ -19,13 +21,61 @@ interface ServerDetail {
|
||||
environment?: Record<string, string>;
|
||||
}
|
||||
|
||||
type AutomationEvent =
|
||||
| 'server.created'
|
||||
| 'server.install.completed'
|
||||
| 'server.power.started'
|
||||
| 'server.power.stopped';
|
||||
|
||||
interface AutomationRunResult {
|
||||
workflowsMatched: number;
|
||||
workflowsExecuted: number;
|
||||
workflowsSkipped: number;
|
||||
workflowsFailed: number;
|
||||
actionFailures: number;
|
||||
failures: Array<{
|
||||
level: 'action' | 'workflow';
|
||||
workflowId: string;
|
||||
actionId?: string;
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface AutomationRunResponse {
|
||||
success: boolean;
|
||||
event: AutomationEvent;
|
||||
force: boolean;
|
||||
result: AutomationRunResult;
|
||||
}
|
||||
|
||||
const AUTOMATION_EVENTS: AutomationEvent[] = [
|
||||
'server.created',
|
||||
'server.install.completed',
|
||||
'server.power.started',
|
||||
'server.power.stopped',
|
||||
];
|
||||
|
||||
function extractApiMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
|
||||
const maybeMessage = (error.data as { message?: unknown }).message;
|
||||
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
|
||||
return maybeMessage;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function ServerSettingsPage() {
|
||||
const { orgId, serverId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { server } = useOutletContext<{ server?: ServerDetail }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [name, setName] = useState(server?.name ?? '');
|
||||
const [description, setDescription] = useState(server?.description ?? '');
|
||||
const [automationEvent, setAutomationEvent] = useState<AutomationEvent>('server.install.completed');
|
||||
const [forceAutomationRun, setForceAutomationRun] = useState(false);
|
||||
const [lastAutomationResult, setLastAutomationResult] = useState<AutomationRunResult | null>(null);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
@@ -35,6 +85,38 @@ export function ServerSettingsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/organizations/${orgId}/servers/${serverId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', orgId] });
|
||||
navigate(`/org/${orgId}/servers`);
|
||||
},
|
||||
});
|
||||
|
||||
const automationRunMutation = useMutation({
|
||||
mutationFn: (body: { event: AutomationEvent; force: boolean }) =>
|
||||
api.post<AutomationRunResponse>(`/organizations/${orgId}/servers/${serverId}/automation/run`, body),
|
||||
onSuccess: (response) => {
|
||||
setLastAutomationResult(response.result);
|
||||
if (response.result.workflowsFailed > 0 || response.result.actionFailures > 0) {
|
||||
const firstFailure = response.result.failures[0]?.message;
|
||||
toast.error(
|
||||
firstFailure
|
||||
? `Automation failed: ${firstFailure}`
|
||||
: `Automation completed with errors (${response.result.workflowsFailed} workflow failures)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Automation completed: ${response.result.workflowsExecuted} workflows executed`,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(extractApiMessage(error, 'Failed to run automation'));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@@ -87,13 +169,125 @@ export function ServerSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Automation</CardTitle>
|
||||
<CardDescription>Manually trigger an automation event for this server</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Event</Label>
|
||||
<Select value={automationEvent} onValueChange={(value) => setAutomationEvent(value as AutomationEvent)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AUTOMATION_EVENTS.map((eventName) => (
|
||||
<SelectItem key={eventName} value={eventName}>
|
||||
{eventName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={forceAutomationRun ? 'default' : 'outline'}
|
||||
onClick={() => setForceAutomationRun((prev) => !prev)}
|
||||
>
|
||||
{forceAutomationRun ? 'Force: ON' : 'Force: OFF'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => automationRunMutation.mutate({ event: automationEvent, force: forceAutomationRun })}
|
||||
disabled={automationRunMutation.isPending}
|
||||
>
|
||||
{automationRunMutation.isPending ? 'Running...' : 'Run Automation Event'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enabling force will rerun workflows that are marked runOncePerServer.
|
||||
</p>
|
||||
|
||||
{lastAutomationResult && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Matched</p>
|
||||
<p className="text-lg font-semibold">{lastAutomationResult.workflowsMatched}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Executed</p>
|
||||
<p className="text-lg font-semibold">{lastAutomationResult.workflowsExecuted}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Skipped</p>
|
||||
<p className="text-lg font-semibold">{lastAutomationResult.workflowsSkipped}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Failed</p>
|
||||
<p className="text-lg font-semibold">{lastAutomationResult.workflowsFailed}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">Action Failures</p>
|
||||
<p className="text-lg font-semibold">{lastAutomationResult.actionFailures}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastAutomationResult.failures.length > 0 && (
|
||||
<div className="space-y-2 rounded-md border border-destructive/40 bg-destructive/5 p-3">
|
||||
<p className="text-sm font-medium text-destructive">Failure Details</p>
|
||||
<div className="space-y-1">
|
||||
{lastAutomationResult.failures.slice(0, 5).map((failure, index) => (
|
||||
<p key={`${failure.workflowId}-${failure.actionId ?? 'workflow'}-${index}`} className="text-xs text-destructive">
|
||||
[{failure.workflowId}{failure.actionId ? ` > ${failure.actionId}` : ''}] {failure.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{automationRunMutation.isSuccess && lastAutomationResult && lastAutomationResult.failures.length === 0 && (
|
||||
<p className="text-xs text-green-600">Automation run completed successfully.</p>
|
||||
)}
|
||||
|
||||
{automationRunMutation.isSuccess && lastAutomationResult && lastAutomationResult.failures.length > 0 && (
|
||||
<p className="text-xs text-destructive">
|
||||
Automation run completed with {lastAutomationResult.failures.length} error(s).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{automationRunMutation.isError && (
|
||||
<p className="text-xs text-destructive">
|
||||
Failed to run automation event.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>Irreversible actions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="destructive">Delete Server</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!window.confirm('Delete this server permanently? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting...' : 'Delete Server'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -51,8 +51,8 @@ export function CreateServerPage() {
|
||||
const [cpuLimit, setCpuLimit] = useState(100);
|
||||
|
||||
const { data: gamesData } = useQuery({
|
||||
queryKey: ['admin-games'],
|
||||
queryFn: () => api.get<PaginatedResponse<Game>>('/admin/games'),
|
||||
queryKey: ['games'],
|
||||
queryFn: () => api.get<PaginatedResponse<Game>>('/games'),
|
||||
});
|
||||
|
||||
const { data: nodesData } = useQuery({
|
||||
|
||||
@@ -24,6 +24,32 @@ interface Member {
|
||||
username: string;
|
||||
email: string;
|
||||
role: 'admin' | 'user';
|
||||
customPermissions: Record<string, boolean>;
|
||||
}
|
||||
|
||||
type MembershipPreset = 'admin' | 'moderator' | 'user';
|
||||
|
||||
const MODERATOR_PERMISSIONS: Record<string, boolean> = {
|
||||
'plugin.manage': true,
|
||||
};
|
||||
|
||||
function getMemberPreset(member: Member): MembershipPreset {
|
||||
if (member.role === 'admin') return 'admin';
|
||||
if (member.customPermissions?.['plugin.manage']) return 'moderator';
|
||||
return 'user';
|
||||
}
|
||||
|
||||
function buildPresetPayload(preset: MembershipPreset): {
|
||||
role: 'admin' | 'user';
|
||||
customPermissions: Record<string, boolean>;
|
||||
} {
|
||||
if (preset === 'admin') {
|
||||
return { role: 'admin', customPermissions: {} };
|
||||
}
|
||||
if (preset === 'moderator') {
|
||||
return { role: 'user', customPermissions: MODERATOR_PERMISSIONS };
|
||||
}
|
||||
return { role: 'user', customPermissions: {} };
|
||||
}
|
||||
|
||||
export function MembersPage() {
|
||||
@@ -32,6 +58,7 @@ export function MembersPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState<'admin' | 'user'>('user');
|
||||
const [updatingMemberId, setUpdatingMemberId] = useState<string | null>(null);
|
||||
|
||||
const { data: membersData } = useQuery({
|
||||
queryKey: ['members', orgId],
|
||||
@@ -58,6 +85,26 @@ export function MembersPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
memberId,
|
||||
preset,
|
||||
}: {
|
||||
memberId: string;
|
||||
preset: MembershipPreset;
|
||||
}) =>
|
||||
api.patch(`/organizations/${orgId}/members/${memberId}`, buildPresetPayload(preset)),
|
||||
onMutate: ({ memberId }) => {
|
||||
setUpdatingMemberId(memberId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['members', orgId] });
|
||||
},
|
||||
onSettled: () => {
|
||||
setUpdatingMemberId(null);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -123,9 +170,28 @@ export function MembersPage() {
|
||||
<p className="text-sm text-muted-foreground">{member.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={member.role === 'admin' ? 'default' : 'secondary'}>
|
||||
{member.role}
|
||||
<Badge variant={getMemberPreset(member) === 'admin' ? 'default' : 'secondary'}>
|
||||
{getMemberPreset(member)}
|
||||
</Badge>
|
||||
<Select
|
||||
value={getMemberPreset(member)}
|
||||
onValueChange={(value) =>
|
||||
updateMutation.mutate({
|
||||
memberId: member.id,
|
||||
preset: value as MembershipPreset,
|
||||
})
|
||||
}
|
||||
disabled={updateMutation.isPending && updatingMemberId === member.id}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[130px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="moderator">Moderator</SelectItem>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
Reference in New Issue
Block a user