Panel web: servers, capes, player profiles, auth server settings

- Servers: live status, players and TPS; add a server and get its token
  with copy-paste setup for the plugin/mod and authlib-injector; detail
  page with leaderboard (sortable), online players and activity feed
- Capes: upload, preview, and choose who can wear each one
- Players: click a player for their skin (upload, arm style, reset),
  cape, playtime and per-server stats; disable with a reason
- Settings: Microsoft section replaced by the auth server (public
  address, Yggdrasil URL, start flag, signing key)
- Dashboard: players online now and connected servers
- Avatars come from the panel's own skins

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
This commit is contained in:
Claude committed 2026-09-28 09:12:59 +00:00
1 parent a50963f9e0
commit c7efd759a8
19 files changed
+1284 -38

No files matched your search

+49 -9
View File
@@ -2,10 +2,11 @@
import { UserPlus, Check, Pencil, Trash2, Shield, Plus, X, Search, Tag } from '@lucide/svelte';
import Avatar from '../components/Avatar.svelte';
import Modal from '../components/Modal.svelte';
import { del, get, patch, post, timeAgo } from '../lib/api';
import PlayerPanel from '../components/PlayerPanel.svelte';
import { del, duration, get, patch, post, timeAgo } from '../lib/api';
import { session } from '../lib/session.svelte';
import { toast, toastError } from '../lib/toast.svelte';
import type { Group, User } from '../lib/types';
import type { Cape, Group, User } from '../lib/types';
let users = $state<User[]>([]);
let groups = $state<Group[]>([]);
@@ -15,11 +16,27 @@
let confirm = $state<User | null>(null);
let confirmOpen = $state(false);
let newGroup = $state({ name: '', color: '#22d3ee' });
let capes = $state<Cape[]>([]);
let viewing = $state<User | null>(null);
let viewOpen = $state(false);
let avatarV = $state(0);
const refresh = () => {
get<User[]>('/api/admin/users').then((u) => (users = u)).catch(toastError);
get<Group[]>('/api/admin/groups').then((g) => (groups = g)).catch(toastError);
get<Cape[]>('/api/admin/capes').then((c) => (capes = c)).catch(() => {});
};
function openView(u: User) {
viewing = u;
viewOpen = true;
}
function updated(u: User) {
viewing = u;
users = users.map((x) => (x.id === u.id ? u : x));
avatarV++;
}
const lastSeen = (u: User) => [u.last_login, u.last_seen_ingame].filter(Boolean).sort().at(-1) ?? null;
$effect(refresh);
const pending = $derived(users.filter((u) => u.status === 'pending'));
@@ -38,7 +55,7 @@
async function saveUser() {
if (!editing) return;
try {
const body = { username: editing.username, password: editing.password || undefined, role: editing.role, status: editing.status, groups: editing.groups, email: editing.email ?? '' };
const body = { username: editing.username, password: editing.password || undefined, role: editing.role, status: editing.status, status_reason: editing.status === 'disabled' ? (editing.status_reason ?? '') : '', groups: editing.groups, email: editing.email ?? '' };
if (editing.id) await patch(`/api/admin/users/${editing.id}`, body);
else await post('/api/admin/users', body);
toast(editing.id ? 'Player updated' : `Created ${editing.username}`);
@@ -96,7 +113,7 @@
<div class="page-head">
<div>
<h1>Players</h1>
<p>Accounts sign in from the launcher and play with their username in offline mode.</p>
<p>Accounts, UUIDs, skins and capes all live here. Players sign in through the launcher, and your servers verify them with the panel.</p>
</div>
<button class="primary" onclick={openNew}><UserPlus size={18} /> Add player</button>
</div>
@@ -106,7 +123,7 @@
<strong>{pending.length} player{pending.length > 1 ? 's' : ''} waiting for approval</strong>
<div class="row wrap">
{#each pending as u}
<span class="pend"><Avatar name={u.username} size={22} /> {u.username}
<span class="pend"><Avatar name={u.username} uuid={u.uuid} size={22} /> {u.username}
<button class="sm primary" onclick={() => approve(u)}><Check size={14} /> Approve</button>
</span>
{/each}
@@ -122,13 +139,13 @@
</div>
<div class="table-wrap">
<table class="table">
<thead><tr><th>Player</th><th>Role</th><th>Groups</th><th>Last seen</th><th></th></tr></thead>
<thead><tr><th>Player</th><th>Role</th><th>Groups</th><th>Playtime</th><th>Last seen</th><th></th></tr></thead>
<tbody>
{#each shown as u (u.id)}
<tr>
<tr class="clickable" onclick={(e) => !(e.target as HTMLElement).closest('button') && openView(u)}>
<td>
<div class="row who">
<Avatar name={u.username} size={34} />
<Avatar name={u.username} uuid={u.uuid} size={34} v={avatarV} />
<div class="col tight">
<strong>{u.username}{#if u.id === session.user?.id} <span class="muted tiny">(you)</span>{/if}</strong>
<span class="mono tiny muted">{u.uuid}</span>
@@ -140,7 +157,8 @@
{#if u.status === 'pending'}<span class="badge warn">Pending</span>{:else if u.status === 'disabled'}<span class="badge bad">Disabled</span>{/if}
</td>
<td><div class="row wrap chips">{#each u.groups as g}<span class="gchip" style:--c={colorOf(g)}>{g}</span>{/each}</div></td>
<td class="muted small">{timeAgo(u.last_login)}</td>
<td class="small num">{u.playtime_secs ? duration(u.playtime_secs) : '–'}</td>
<td class="muted small">{timeAgo(lastSeen(u))}</td>
<td class="actions">
<button class="ghost icon" aria-label="Edit {u.username}" onclick={() => openEdit(u)}><Pencil size={15} /></button>
{#if u.id !== session.user?.id}
@@ -194,6 +212,11 @@
<select bind:value={editing.status}><option value="active">Active</option><option value="pending">Pending approval</option><option value="disabled">Disabled</option></select>
</label>
</div>
{#if editing.status === 'disabled'}
<label class="field">Reason <span class="help">Shown to the player in the launcher and when a server refuses them. Online players are kicked within 30 seconds.</span>
<input bind:value={editing.status_reason} maxlength="200" placeholder="e.g. Griefing at spawn — appeal on Discord" />
</label>
{/if}
{#if groups.length}
<div class="field">
<span class="lbl">Groups</span>
@@ -215,6 +238,20 @@
{/snippet}
</Modal>
<Modal bind:open={viewOpen} title={viewing?.username ?? ''} width={780}>
{#if viewing}
<div class="row head">
<span class="mono tiny muted">{viewing.uuid}</span>
{#if viewing.status === 'disabled'}<span class="badge bad">Disabled{viewing.status_reason ? ` · ${viewing.status_reason}` : ''}</span>{/if}
</div>
<PlayerPanel user={viewing} {capes} onchange={updated} />
{/if}
{#snippet footer()}
<button class="ghost" onclick={() => { if (viewing) { viewOpen = false; openEdit(viewing); } }}><Pencil size={15} /> Edit account</button>
<button class="primary" onclick={() => (viewOpen = false)}>Done</button>
{/snippet}
</Modal>
<Modal bind:open={confirmOpen} title="Delete {confirm?.username}?">
<p class="muted">They won't be able to sign in any more. This can't be undone.</p>
{#snippet footer()}
@@ -235,6 +272,9 @@
.tight { gap: 2px; }
.chips { gap: 4px; }
.gchip { font-size: 0.75rem; padding: 2px 8px; border-radius: 99px; background: color-mix(in srgb, var(--c) 16%, transparent); color: color-mix(in srgb, var(--c) 70%, white); border: 1px solid color-mix(in srgb, var(--c) 35%, transparent); }
.clickable { cursor: pointer; }
.num { font-variant-numeric: tabular-nums; }
.head { justify-content: space-between; margin-top: -8px; }
.actions { white-space: nowrap; text-align: right; }
.group { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; background: var(--bg-2); }
.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }