fix: resolve frontend routing, API mismatches, and missing UI components
- Add servers list page and missing routes (servers, settings redirect, account security)
- Fix members page .map error (API returns { data } wrapper, not flat array)
- Fix auth store fetchUser expecting flat User but API returns { user } wrapper
- Add node token display dialog after creation
- Add allocation management UI to node detail page
- Add account security page with password change
- Add change-password API endpoint
- Add node servers and stats API endpoints
- Fix config save using PATCH instead of PUT, add api.put method
- Fix audit logs field name mismatch (userName vs username)
- Replace admin nodes page to avoid orgId dependency
- Remove duplicate sidebar nav items
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d7d8fd5339
commit
c9fe2bd9fe
|
|
@ -171,6 +171,39 @@ export default async function authRoutes(app: FastifyInstance) {
|
|||
return { success: true };
|
||||
});
|
||||
|
||||
// POST /api/auth/change-password
|
||||
app.post('/change-password', { onRequest: [app.authenticate] }, async (request) => {
|
||||
const { currentPassword, newPassword } = request.body as {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
if (!currentPassword || !newPassword || newPassword.length < 8) {
|
||||
throw AppError.badRequest('New password must be at least 8 characters');
|
||||
}
|
||||
|
||||
const user = await app.db.query.users.findFirst({
|
||||
where: eq(users.id, request.user.sub),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw AppError.notFound('User not found');
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(user.passwordHash, currentPassword);
|
||||
if (!isValid) {
|
||||
throw AppError.unauthorized('Current password is incorrect', 'INVALID_PASSWORD');
|
||||
}
|
||||
|
||||
const newHash = await hashPassword(newPassword);
|
||||
await app.db
|
||||
.update(users)
|
||||
.set({ passwordHash: newHash, updatedAt: new Date() })
|
||||
.where(eq(users.id, user.id));
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
app.get('/me', { onRequest: [app.authenticate] }, async (request) => {
|
||||
const payload = request.user;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { nodes, allocations } from '@source/database';
|
||||
import { nodes, allocations, servers, games } from '@source/database';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { requirePermission } from '../../lib/permissions.js';
|
||||
import { createAuditLog } from '../../lib/audit.js';
|
||||
|
|
@ -133,6 +133,58 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
|||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// GET /api/organizations/:orgId/nodes/:nodeId/servers
|
||||
app.get('/:nodeId/servers', { schema: NodeParamSchema }, async (request) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.read');
|
||||
|
||||
const serverList = await app.db
|
||||
.select({
|
||||
id: servers.id,
|
||||
name: servers.name,
|
||||
status: servers.status,
|
||||
memoryLimit: servers.memoryLimit,
|
||||
cpuLimit: servers.cpuLimit,
|
||||
gameName: games.name,
|
||||
})
|
||||
.from(servers)
|
||||
.leftJoin(games, eq(servers.gameId, games.id))
|
||||
.where(and(eq(servers.nodeId, nodeId), eq(servers.organizationId, orgId)));
|
||||
|
||||
return { data: serverList };
|
||||
});
|
||||
|
||||
// GET /api/organizations/:orgId/nodes/:nodeId/stats
|
||||
// Returns basic stats from DB; real-time stats come from daemon via gRPC
|
||||
app.get('/:nodeId/stats', { schema: NodeParamSchema }, async (request) => {
|
||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||
await requirePermission(request, orgId, 'node.read');
|
||||
|
||||
const node = await app.db.query.nodes.findFirst({
|
||||
where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)),
|
||||
});
|
||||
if (!node) throw AppError.notFound('Node not found');
|
||||
|
||||
const serverList = await app.db
|
||||
.select({ id: servers.id, status: servers.status })
|
||||
.from(servers)
|
||||
.where(eq(servers.nodeId, nodeId));
|
||||
|
||||
const totalServers = serverList.length;
|
||||
const activeServers = serverList.filter((s) => s.status === 'running').length;
|
||||
|
||||
return {
|
||||
cpuPercent: 0,
|
||||
memoryUsed: 0,
|
||||
memoryTotal: node.memoryTotal,
|
||||
diskUsed: 0,
|
||||
diskTotal: node.diskTotal,
|
||||
activeServers,
|
||||
totalServers,
|
||||
uptime: 0,
|
||||
};
|
||||
});
|
||||
|
||||
// === Allocations ===
|
||||
|
||||
// GET /api/organizations/:orgId/nodes/:nodeId/allocations
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { RegisterPage } from '@/pages/auth/register';
|
|||
// App pages
|
||||
import { OrganizationsPage } from '@/pages/organizations/index';
|
||||
import { DashboardPage } from '@/pages/dashboard/index';
|
||||
import { ServersPage } from '@/pages/servers/index';
|
||||
import { CreateServerPage } from '@/pages/servers/create';
|
||||
import { NodesPage } from '@/pages/nodes/index';
|
||||
import { NodeDetailPage } from '@/pages/nodes/detail';
|
||||
|
|
@ -35,7 +36,9 @@ import { ServerSettingsPage } from '@/pages/server/settings';
|
|||
// Admin pages
|
||||
import { AdminUsersPage } from '@/pages/admin/users';
|
||||
import { AdminGamesPage } from '@/pages/admin/games';
|
||||
import { AdminNodesPage } from '@/pages/admin/nodes';
|
||||
import { AdminAuditLogsPage } from '@/pages/admin/audit-logs';
|
||||
import { AccountSecurityPage } from '@/pages/account/security';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -87,11 +90,16 @@ export function App() {
|
|||
|
||||
{/* Org-scoped routes */}
|
||||
<Route path="/org/:orgId/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/org/:orgId/servers" element={<ServersPage />} />
|
||||
<Route path="/org/:orgId/servers/new" element={<CreateServerPage />} />
|
||||
<Route path="/org/:orgId/nodes" element={<NodesPage />} />
|
||||
<Route path="/org/:orgId/nodes/:nodeId" element={<NodeDetailPage />} />
|
||||
<Route path="/org/:orgId/settings" element={<Navigate to="members" replace />} />
|
||||
<Route path="/org/:orgId/settings/members" element={<MembersPage />} />
|
||||
|
||||
{/* Account */}
|
||||
<Route path="/account/security" element={<AccountSecurityPage />} />
|
||||
|
||||
{/* Server detail */}
|
||||
<Route path="/org/:orgId/servers/:serverId" element={<ServerLayout />}>
|
||||
<Route index element={<Navigate to="console" replace />} />
|
||||
|
|
@ -108,7 +116,7 @@ export function App() {
|
|||
{/* Admin */}
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/admin/games" element={<AdminGamesPage />} />
|
||||
<Route path="/admin/nodes" element={<NodesPage />} />
|
||||
<Route path="/admin/nodes" element={<AdminNodesPage />} />
|
||||
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -32,8 +32,7 @@ export function Sidebar() {
|
|||
{ label: 'Dashboard', href: `/org/${orgId}/dashboard`, icon: LayoutDashboard },
|
||||
{ label: 'Servers', href: `/org/${orgId}/servers`, icon: Server },
|
||||
{ label: 'Nodes', href: `/org/${orgId}/nodes`, icon: Network },
|
||||
{ label: 'Members', href: `/org/${orgId}/settings/members`, icon: Users },
|
||||
{ label: 'Settings', href: `/org/${orgId}/settings`, icon: Settings },
|
||||
{ label: 'Settings', href: `/org/${orgId}/settings/members`, icon: Settings },
|
||||
]
|
||||
: [];
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,12 @@ export const api = {
|
|||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PATCH',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Shield, Key } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
export function AccountSecurityPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: (body: { currentPassword: string; newPassword: string }) =>
|
||||
api.post('/auth/change-password', body),
|
||||
onSuccess: () => {
|
||||
toast.success('Password changed successfully');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to change password. Check your current password.');
|
||||
},
|
||||
});
|
||||
|
||||
const handleChangePassword = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
toast.error('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
changePasswordMutation.mutate({ currentPassword, newPassword });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Account Settings</h1>
|
||||
<p className="text-muted-foreground">Manage your account security</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>Your account information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Username</span>
|
||||
<span className="font-medium">{user?.username}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Email</span>
|
||||
<span className="font-medium">{user?.email}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Role</span>
|
||||
<span className="font-medium">{user?.isSuperAdmin ? 'Super Admin' : 'User'}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
<div>
|
||||
<CardTitle>Change Password</CardTitle>
|
||||
<CardDescription>Update your account password</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">Current Password</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">New Password</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm New Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={changePasswordMutation.isPending}>
|
||||
{changePasswordMutation.isPending ? 'Changing...' : 'Change Password'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge';
|
|||
interface AuditLog {
|
||||
id: string;
|
||||
action: string;
|
||||
username: string;
|
||||
userName: string;
|
||||
ipAddress: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
|
|
@ -36,7 +36,7 @@ export function AdminAuditLogsPage() {
|
|||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline">{log.action}</Badge>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium">{log.username}</span>
|
||||
<span className="font-medium">{log.userName}</span>
|
||||
{log.ipAddress && (
|
||||
<span className="text-muted-foreground"> from {log.ipAddress}</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Network, Wifi, WifiOff } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface NodeItem {
|
||||
id: string;
|
||||
name: string;
|
||||
fqdn: string;
|
||||
daemonPort: number;
|
||||
grpcPort: number;
|
||||
memoryTotal: number;
|
||||
diskTotal: number;
|
||||
isOnline: boolean;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export function AdminNodesPage() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin-nodes'],
|
||||
queryFn: () => api.get<{ data: NodeItem[] }>('/admin/nodes'),
|
||||
});
|
||||
|
||||
const nodes = data?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">All Nodes</h1>
|
||||
<p className="text-muted-foreground">{nodes.length} nodes across all organizations</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{nodes.map((node) => (
|
||||
<Card key={node.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Network className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">{node.name}</CardTitle>
|
||||
</div>
|
||||
<Badge variant={node.isOnline ? 'default' : 'destructive'}>
|
||||
{node.isOnline ? (
|
||||
<><Wifi className="mr-1 h-3 w-3" /> Online</>
|
||||
) : (
|
||||
<><WifiOff className="mr-1 h-3 w-3" /> Offline</>
|
||||
)}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{node.fqdn}:{node.daemonPort}</p>
|
||||
<div className="mt-3 flex gap-4 text-sm">
|
||||
<span>{formatBytes(node.memoryTotal)} RAM</span>
|
||||
<span>{formatBytes(node.diskTotal)} Disk</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{nodes.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
No nodes registered
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Network,
|
||||
|
|
@ -9,14 +10,26 @@ import {
|
|||
MemoryStick,
|
||||
HardDrive,
|
||||
Server,
|
||||
Activity,
|
||||
Plus,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
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 { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface NodeDetail {
|
||||
id: string;
|
||||
|
|
@ -51,8 +64,20 @@ interface ServerSummary {
|
|||
gameName: string;
|
||||
}
|
||||
|
||||
interface Allocation {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
serverId: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export function NodeDetailPage() {
|
||||
const { orgId, nodeId } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [allocOpen, setAllocOpen] = useState(false);
|
||||
const [allocIp, setAllocIp] = useState('0.0.0.0');
|
||||
const [allocPorts, setAllocPorts] = useState('');
|
||||
|
||||
const { data: node } = useQuery({
|
||||
queryKey: ['node', orgId, nodeId],
|
||||
|
|
@ -73,6 +98,40 @@ export function NodeDetailPage() {
|
|||
),
|
||||
});
|
||||
|
||||
const { data: allocData } = useQuery({
|
||||
queryKey: ['allocations', orgId, nodeId],
|
||||
queryFn: () =>
|
||||
api.get<{ data: Allocation[] }>(
|
||||
`/organizations/${orgId}/nodes/${nodeId}/allocations`,
|
||||
),
|
||||
});
|
||||
|
||||
const allocations = allocData?.data ?? [];
|
||||
|
||||
const createAllocMutation = useMutation({
|
||||
mutationFn: (body: { ip: string; ports: number[] }) =>
|
||||
api.post(`/organizations/${orgId}/nodes/${nodeId}/allocations`, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['allocations', orgId, nodeId] });
|
||||
setAllocOpen(false);
|
||||
setAllocPorts('');
|
||||
toast.success('Allocations created');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to create allocations');
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddAllocations = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const ports = parsePorts(allocPorts);
|
||||
if (ports.length === 0) {
|
||||
toast.error('Enter valid ports (e.g. 25565, 25566-25570)');
|
||||
return;
|
||||
}
|
||||
createAllocMutation.mutate({ ip: allocIp, ports });
|
||||
};
|
||||
|
||||
const servers = serversData?.data ?? [];
|
||||
|
||||
if (!node) {
|
||||
|
|
@ -234,10 +293,102 @@ export function NodeDetailPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Allocations */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
<CardTitle>Allocations</CardTitle>
|
||||
</div>
|
||||
<Dialog open={allocOpen} onOpenChange={setAllocOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4" /> Add Ports
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Allocations</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleAddAllocations} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>IP Address</Label>
|
||||
<Input
|
||||
value={allocIp}
|
||||
onChange={(e) => setAllocIp(e.target.value)}
|
||||
placeholder="0.0.0.0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Ports</Label>
|
||||
<Input
|
||||
value={allocPorts}
|
||||
onChange={(e) => setAllocPorts(e.target.value)}
|
||||
placeholder="25565, 25566-25570"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Comma-separated ports or ranges (e.g. 25565, 25566-25570)
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createAllocMutation.isPending}>
|
||||
{createAllocMutation.isPending ? 'Adding...' : 'Add'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{allocations.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
No allocations yet. Add ports to assign to servers.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{allocations.map((alloc) => (
|
||||
<div
|
||||
key={alloc.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="font-mono text-sm">
|
||||
{alloc.ip}:{alloc.port}
|
||||
</div>
|
||||
<Badge variant={alloc.serverId ? 'default' : 'outline'}>
|
||||
{alloc.serverId ? 'In use' : 'Available'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse port input like "25565, 25566-25570, 27015" into flat number array */
|
||||
function parsePorts(input: string): number[] {
|
||||
const ports: number[] = [];
|
||||
const parts = input.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
for (const part of parts) {
|
||||
if (part.includes('-')) {
|
||||
const [startStr, endStr] = part.split('-');
|
||||
const start = parseInt(startStr!, 10);
|
||||
const end = parseInt(endStr!, 10);
|
||||
if (isNaN(start) || isNaN(end) || start > end || start < 1 || end > 65535) continue;
|
||||
for (let p = start; p <= end; p++) ports.push(p);
|
||||
} else {
|
||||
const p = parseInt(part, 10);
|
||||
if (!isNaN(p) && p >= 1 && p <= 65535) ports.push(p);
|
||||
}
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Network, Wifi, WifiOff } from 'lucide-react';
|
||||
import { Plus, Network, Wifi, WifiOff, Copy, Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -16,6 +17,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface NodeItem {
|
||||
|
|
@ -29,6 +31,10 @@ interface NodeItem {
|
|||
isOnline: boolean;
|
||||
}
|
||||
|
||||
interface CreatedNode extends NodeItem {
|
||||
daemonToken: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: { total: number };
|
||||
|
|
@ -38,6 +44,9 @@ export function NodesPage() {
|
|||
const { orgId } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tokenDialog, setTokenDialog] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [fqdn, setFqdn] = useState('');
|
||||
const [daemonPort, setDaemonPort] = useState(8443);
|
||||
|
|
@ -52,17 +61,28 @@ export function NodesPage() {
|
|||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.post(`/organizations/${orgId}/nodes`, body),
|
||||
onSuccess: () => {
|
||||
api.post<CreatedNode>(`/organizations/${orgId}/nodes`, body),
|
||||
onSuccess: (node) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['nodes', orgId] });
|
||||
setOpen(false);
|
||||
setName('');
|
||||
setFqdn('');
|
||||
// Show token dialog
|
||||
setCreatedToken(node.daemonToken);
|
||||
setTokenDialog(true);
|
||||
setCopied(false);
|
||||
},
|
||||
});
|
||||
|
||||
const nodes = data?.data ?? [];
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
await navigator.clipboard.writeText(createdToken);
|
||||
setCopied(true);
|
||||
toast.success('Token copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -151,6 +171,45 @@ export function NodesPage() {
|
|||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Token display dialog */}
|
||||
<Dialog open={tokenDialog} onOpenChange={setTokenDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Node Created Successfully</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save this daemon token now. It will not be shown again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Label>Daemon Token</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={createdToken}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyToken}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use this token in your daemon configuration file (config.yml) to authenticate with the panel.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setTokenDialog(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{nodes.map((node) => (
|
||||
<Link key={node.id} to={`/org/${orgId}/nodes/${node.id}`}>
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ function ConfigEditor({
|
|||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: { entries: ConfigEntry[] }) =>
|
||||
api.patch(
|
||||
api.put(
|
||||
`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`,
|
||||
data,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { useParams, Link } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Server, Plus } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { statusBadgeVariant } from '@/lib/utils';
|
||||
|
||||
interface ServerSummary {
|
||||
id: string;
|
||||
uuid: string;
|
||||
name: string;
|
||||
status: string;
|
||||
gameName: string;
|
||||
nodeName: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: { total: number; page: number; perPage: number; totalPages: number };
|
||||
}
|
||||
|
||||
export function ServersPage() {
|
||||
const { orgId } = useParams();
|
||||
|
||||
const { data: serversData } = useQuery({
|
||||
queryKey: ['servers', orgId],
|
||||
queryFn: () => api.get<PaginatedResponse<ServerSummary>>(`/organizations/${orgId}/servers`),
|
||||
});
|
||||
|
||||
const servers = serversData?.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Servers</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{servers.length} server{servers.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Link to={`/org/${orgId}/servers/new`}>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Server
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{servers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Server className="mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-muted-foreground">No servers yet</p>
|
||||
<Link to={`/org/${orgId}/servers/new`}>
|
||||
<Button variant="outline" className="mt-4">
|
||||
Create your first server
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{servers.map((server) => (
|
||||
<Link key={server.id} to={`/org/${orgId}/servers/${server.id}/console`}>
|
||||
<Card className="transition-colors hover:border-primary/50">
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{server.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{server.gameName} · {server.nodeName} · :{server.port}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -33,11 +33,13 @@ export function MembersPage() {
|
|||
const [email, setEmail] = useState('');
|
||||
const [role, setRole] = useState<'admin' | 'user'>('user');
|
||||
|
||||
const { data: members } = useQuery({
|
||||
const { data: membersData } = useQuery({
|
||||
queryKey: ['members', orgId],
|
||||
queryFn: () => api.get<Member[]>(`/organizations/${orgId}/members`),
|
||||
queryFn: () => api.get<{ data: Member[] }>(`/organizations/${orgId}/members`),
|
||||
});
|
||||
|
||||
const members = membersData?.data ?? [];
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (body: { email: string; role: string }) =>
|
||||
api.post(`/organizations/${orgId}/members`, body),
|
||||
|
|
@ -114,7 +116,7 @@ export function MembersPage() {
|
|||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{(members ?? []).map((member) => (
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium">{member.username}</p>
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||
|
||||
fetchUser: async () => {
|
||||
try {
|
||||
const user = await api.get<User>('/auth/me');
|
||||
set({ user, isAuthenticated: true, isLoading: false });
|
||||
const data = await api.get<{ user: User }>('/auth/me');
|
||||
set({ user: data.user, isAuthenticated: true, isLoading: false });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||
|
|
|
|||
Loading…
Reference in New Issue