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:
hibna 2026-02-22 13:07:00 +03:00
parent d7d8fd5339
commit c9fe2bd9fe
14 changed files with 618 additions and 17 deletions

View File

@ -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;

View File

@ -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

View File

@ -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>

View File

@ -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 },
]
: [];

View File

@ -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',

View File

@ -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>
);
}

View File

@ -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>
)}

View File

@ -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>
);
}

View File

@ -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">

View File

@ -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}`}>

View File

@ -112,7 +112,7 @@ function ConfigEditor({
const saveMutation = useMutation({
mutationFn: (data: { entries: ConfigEntry[] }) =>
api.patch(
api.put(
`/organizations/${orgId}/servers/${serverId}/config/${configIndex}`,
data,
),

View File

@ -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} &middot; {server.nodeName} &middot; :{server.port}
</p>
</div>
</div>
<Badge variant={statusBadgeVariant(server.status)}>{server.status}</Badge>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}

View File

@ -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>

View File

@ -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 });