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:
19 files changed
+1284
-38
No files matched your search
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user