diff --git a/apps/api/src/routes/auth/index.ts b/apps/api/src/routes/auth/index.ts index 5daa104..f873a29 100644 --- a/apps/api/src/routes/auth/index.ts +++ b/apps/api/src/routes/auth/index.ts @@ -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; diff --git a/apps/api/src/routes/nodes/index.ts b/apps/api/src/routes/nodes/index.ts index 2c0bf93..ef5d09f 100644 --- a/apps/api/src/routes/nodes/index.ts +++ b/apps/api/src/routes/nodes/index.ts @@ -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 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7b57a54..00138fb 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 */} } /> + } /> } /> } /> } /> + } /> } /> + {/* Account */} + } /> + {/* Server detail */} }> } /> @@ -108,7 +116,7 @@ export function App() { {/* Admin */} } /> } /> - } /> + } /> } /> diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 953d5f7..642deae 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -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 }, ] : []; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 03aafa1..525a9b2 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -92,6 +92,12 @@ export const api = { body: body ? JSON.stringify(body) : undefined, }), + put: (path: string, body?: unknown) => + request(path, { + method: 'PUT', + body: body ? JSON.stringify(body) : undefined, + }), + patch: (path: string, body?: unknown) => request(path, { method: 'PATCH', diff --git a/apps/web/src/pages/account/security.tsx b/apps/web/src/pages/account/security.tsx new file mode 100644 index 0000000..b6a6dbc --- /dev/null +++ b/apps/web/src/pages/account/security.tsx @@ -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 ( +
+
+ +
+

Account Settings

+

Manage your account security

+
+
+ + + + Profile + Your account information + + +
+ Username + {user?.username} +
+ +
+ Email + {user?.email} +
+ +
+ Role + {user?.isSuperAdmin ? 'Super Admin' : 'User'} +
+
+
+ + + +
+ +
+ Change Password + Update your account password +
+
+
+ +
+
+ + setCurrentPassword(e.target.value)} + required + /> +
+
+ + setNewPassword(e.target.value)} + required + minLength={8} + /> +
+
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + /> +
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/pages/admin/audit-logs.tsx b/apps/web/src/pages/admin/audit-logs.tsx index 483791f..99d0fea 100644 --- a/apps/web/src/pages/admin/audit-logs.tsx +++ b/apps/web/src/pages/admin/audit-logs.tsx @@ -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; createdAt: string; @@ -36,7 +36,7 @@ export function AdminAuditLogsPage() {
{log.action} - {log.username} + {log.userName} {log.ipAddress && ( from {log.ipAddress} )} diff --git a/apps/web/src/pages/admin/nodes.tsx b/apps/web/src/pages/admin/nodes.tsx new file mode 100644 index 0000000..ad9c5fb --- /dev/null +++ b/apps/web/src/pages/admin/nodes.tsx @@ -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 ( +
+
+

All Nodes

+

{nodes.length} nodes across all organizations

+
+ +
+ {nodes.map((node) => ( + + +
+ + {node.name} +
+ + {node.isOnline ? ( + <> Online + ) : ( + <> Offline + )} + +
+ +

{node.fqdn}:{node.daemonPort}

+
+ {formatBytes(node.memoryTotal)} RAM + {formatBytes(node.diskTotal)} Disk +
+
+
+ ))} + {nodes.length === 0 && ( + + + No nodes registered + + + )} +
+
+ ); +} diff --git a/apps/web/src/pages/nodes/detail.tsx b/apps/web/src/pages/nodes/detail.tsx index 79d5550..5e2528c 100644 --- a/apps/web/src/pages/nodes/detail.tsx +++ b/apps/web/src/pages/nodes/detail.tsx @@ -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() {
+ + {/* Allocations */} + + +
+ + Allocations +
+ + + + + + + Add Allocations + +
+
+ + setAllocIp(e.target.value)} + placeholder="0.0.0.0" + required + /> +
+
+ + setAllocPorts(e.target.value)} + placeholder="25565, 25566-25570" + required + /> +

+ Comma-separated ports or ranges (e.g. 25565, 25566-25570) +

+
+ + + +
+
+
+
+ + {allocations.length === 0 ? ( +

+ No allocations yet. Add ports to assign to servers. +

+ ) : ( +
+ {allocations.map((alloc) => ( +
+
+ {alloc.ip}:{alloc.port} +
+ + {alloc.serverId ? 'In use' : 'Available'} + +
+ ))} +
+ )} +
+
); } +/** 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 (
diff --git a/apps/web/src/pages/nodes/index.tsx b/apps/web/src/pages/nodes/index.tsx index aee8047..12b7755 100644 --- a/apps/web/src/pages/nodes/index.tsx +++ b/apps/web/src/pages/nodes/index.tsx @@ -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 { 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) => - api.post(`/organizations/${orgId}/nodes`, body), - onSuccess: () => { + api.post(`/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 (
@@ -151,6 +171,45 @@ export function NodesPage() {
+ {/* Token display dialog */} + + + + Node Created Successfully + + Save this daemon token now. It will not be shown again. + + +
+ +
+ + +
+

+ Use this token in your daemon configuration file (config.yml) to authenticate with the panel. +

+
+ + + +
+
+
{nodes.map((node) => ( diff --git a/apps/web/src/pages/server/config.tsx b/apps/web/src/pages/server/config.tsx index 3b44548..3f2a51a 100644 --- a/apps/web/src/pages/server/config.tsx +++ b/apps/web/src/pages/server/config.tsx @@ -112,7 +112,7 @@ function ConfigEditor({ const saveMutation = useMutation({ mutationFn: (data: { entries: ConfigEntry[] }) => - api.patch( + api.put( `/organizations/${orgId}/servers/${serverId}/config/${configIndex}`, data, ), diff --git a/apps/web/src/pages/servers/index.tsx b/apps/web/src/pages/servers/index.tsx new file mode 100644 index 0000000..965210c --- /dev/null +++ b/apps/web/src/pages/servers/index.tsx @@ -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 { + 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>(`/organizations/${orgId}/servers`), + }); + + const servers = serversData?.data ?? []; + + return ( +
+
+
+

Servers

+

+ {servers.length} server{servers.length !== 1 ? 's' : ''} +

+
+ + + +
+ + {servers.length === 0 ? ( + + + +

No servers yet

+ + + +
+
+ ) : ( +
+ {servers.map((server) => ( + + + +
+
+ +
+
+

{server.name}

+

+ {server.gameName} · {server.nodeName} · :{server.port} +

+
+
+ {server.status} +
+
+ + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/settings/members.tsx b/apps/web/src/pages/settings/members.tsx index 9220d45..069f501 100644 --- a/apps/web/src/pages/settings/members.tsx +++ b/apps/web/src/pages/settings/members.tsx @@ -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(`/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() {
- {(members ?? []).map((member) => ( + {members.map((member) => (

{member.username}

diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts index c8eb87c..67a6c5d 100644 --- a/apps/web/src/stores/auth.ts +++ b/apps/web/src/stores/auth.ts @@ -56,8 +56,8 @@ export const useAuthStore = create((set) => ({ fetchUser: async () => { try { - const user = await api.get('/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 });