Files
SCOPENET-MC/panel/web/src/pages/Users.svelte
T
Claude af9147ec75 Add admin panel web UI (Svelte 5)
Dashboard with launch stats, instance manager with Modrinth/CurseForge
search and zip import, player and group management, launcher design
editor with live preview, and sign-in settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ARcGWxLx21FwXJ3yfGriS
2026-09-28 04:32:15 +00:00

248 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
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 { session } from '../lib/session.svelte';
import { toast, toastError } from '../lib/toast.svelte';
import type { Group, User } from '../lib/types';
let users = $state<User[]>([]);
let groups = $state<Group[]>([]);
let filter = $state('');
let editing = $state<Partial<User> & { password?: string } | null>(null);
let editOpen = $state(false);
let confirm = $state<User | null>(null);
let confirmOpen = $state(false);
let newGroup = $state({ name: '', color: '#22d3ee' });
const refresh = () => {
get<User[]>('/api/admin/users').then((u) => (users = u)).catch(toastError);
get<Group[]>('/api/admin/groups').then((g) => (groups = g)).catch(toastError);
};
$effect(refresh);
const pending = $derived(users.filter((u) => u.status === 'pending'));
const shown = $derived(users.filter((u) => !filter || u.username.toLowerCase().includes(filter.toLowerCase()) || u.groups.some((g) => g.toLowerCase().includes(filter.toLowerCase()))));
const colorOf = (name: string) => groups.find((g) => g.name === name)?.color ?? '#8b6cff';
function openNew() {
editing = { username: '', password: '', role: 'player', status: 'active', groups: [], email: '' };
editOpen = true;
}
function openEdit(u: User) {
editing = { ...u, password: '' };
editOpen = true;
}
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 ?? '' };
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}`);
editOpen = false;
refresh();
} catch (e) {
toastError(e);
}
}
async function approve(u: User) {
try {
await patch(`/api/admin/users/${u.id}`, { status: 'active' });
toast(`${u.username} approved`);
refresh();
} catch (e) {
toastError(e);
}
}
async function removeUser() {
if (!confirm) return;
try {
await del(`/api/admin/users/${confirm.id}`);
toast('Player deleted');
confirmOpen = false;
refresh();
} catch (e) {
toastError(e);
}
}
async function addGroup(e: SubmitEvent) {
e.preventDefault();
try {
await post('/api/admin/groups', newGroup);
newGroup = { name: '', color: newGroup.color };
refresh();
} catch (err) {
toastError(err);
}
}
async function removeGroup(g: Group) {
try {
await del(`/api/admin/groups/${g.id}`);
refresh();
} catch (e) {
toastError(e);
}
}
</script>
<div class="page">
<div class="page-head">
<div>
<h1>Players</h1>
<p>Accounts sign in from the launcher and play with their username in offline mode.</p>
</div>
<button class="primary" onclick={openNew}><UserPlus size={18} /> Add player</button>
</div>
{#if pending.length}
<section class="card pending">
<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}
<button class="sm primary" onclick={() => approve(u)}><Check size={14} /> Approve</button>
</span>
{/each}
</div>
</section>
{/if}
<div class="grid layout">
<section class="card">
<div class="section-title">
<div class="search"><Search size={16} /><input bind:value={filter} placeholder="Search players or groups" /></div>
<span class="hint">{users.length} accounts</span>
</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>
<tbody>
{#each shown as u (u.id)}
<tr>
<td>
<div class="row who">
<Avatar name={u.username} size={34} />
<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>
</div>
</div>
</td>
<td>
{#if u.role === 'admin'}<span class="badge accent"><Shield size={11} /> Admin</span>{:else}<span class="badge">Player</span>{/if}
{#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="actions">
<button class="ghost icon" aria-label="Edit {u.username}" onclick={() => openEdit(u)}><Pencil size={15} /></button>
{#if u.id !== session.user?.id}
<button class="ghost icon" aria-label="Delete {u.username}" onclick={() => { confirm = u; confirmOpen = true; }}><Trash2 size={15} /></button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
<section class="card col">
<div class="row"><Tag size={18} /><h3>Groups</h3></div>
<p class="muted small">Use groups to give VIPs, staff or testers access to private instances.</p>
<div class="col tight">
{#each groups as g (g.id)}
<div class="group">
<span class="dot" style:background={g.color}></span>
<strong>{g.name}</strong>
<span class="muted tiny">{g.members} member{g.members === 1 ? '' : 's'}</span>
<span class="spacer"></span>
<button class="ghost icon" aria-label="Delete group {g.name}" onclick={() => removeGroup(g)}><X size={14} /></button>
</div>
{/each}
</div>
<form class="row" onsubmit={addGroup}>
<input type="color" bind:value={newGroup.color} aria-label="Group colour" />
<input bind:value={newGroup.name} placeholder="New group" maxlength="32" />
<button class="icon" disabled={!newGroup.name.trim()} aria-label="Add group"><Plus size={16} /></button>
</form>
</section>
</div>
</div>
<Modal bind:open={editOpen} title={editing?.id ? `Edit ${editing.username}` : 'Add player'}>
{#if editing}
{#if !editing.id}
<label class="field">Username <span class="help">3–16 letters, numbers or _. This is their in-game name.</span><input bind:value={editing.username} /></label>
{/if}
<label class="field">{editing.id ? 'New password' : 'Password'} <span class="help">{editing.id ? 'Leave empty to keep the current password.' : 'At least 8 characters.'}</span>
<input type="password" bind:value={editing.password} autocomplete="new-password" />
</label>
<label class="field">Email (optional)<input type="email" bind:value={editing.email} /></label>
<div class="grid two">
<label class="field">Role
<select bind:value={editing.role}><option value="player">Player</option><option value="admin">Admin</option></select>
</label>
<label class="field">Status
<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 groups.length}
<div class="field">
<span class="lbl">Groups</span>
<div class="row wrap">
{#each groups as g}
{@const on = editing.groups?.includes(g.name)}
<button type="button" class="gpick" class:on style:--c={g.color}
onclick={() => editing && (editing.groups = on ? editing.groups!.filter((x) => x !== g.name) : [...(editing.groups ?? []), g.name])}>
<span class="dot" style:background={g.color}></span>{g.name}
</button>
{/each}
</div>
</div>
{/if}
{/if}
{#snippet footer()}
<button class="ghost" onclick={() => (editOpen = false)}>Cancel</button>
<button class="primary" onclick={saveUser}>{editing?.id ? 'Save' : 'Create player'}</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()}
<button class="ghost" onclick={() => (confirmOpen = false)}>Cancel</button>
<button class="danger" onclick={removeUser}><Trash2 size={16} /> Delete</button>
{/snippet}
</Modal>
<style>
.layout { grid-template-columns: 1fr 300px; align-items: start; }
@media (max-width: 1100px) { .layout { grid-template-columns: 1fr; } }
.pending { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; border-color: rgba(251, 191, 36, 0.3); background: rgba(251, 191, 36, 0.05); }
.pend { display: inline-flex; align-items: center; gap: 8px; padding: 5px 5px 5px 8px; border-radius: 12px; background: var(--bg-2); border: 1px solid var(--line); }
.search { display: flex; align-items: center; gap: 8px; flex: 1; max-width: 360px; background: var(--bg-2); border: 1px solid var(--line-strong); border-radius: var(--radius-sm); padding: 0 12px; color: var(--muted); }
.search input { border: none; background: none; padding: 9px 0; }
.table-wrap { overflow-x: auto; }
.who { gap: 12px; }
.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); }
.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; }
form.row input[type='color'] { width: 42px; flex-shrink: 0; }
.two { grid-template-columns: 1fr 1fr; }
.field { display: flex; flex-direction: column; gap: 7px; }
.lbl { font-size: 0.85rem; color: var(--text-2); font-weight: 500; }
.gpick { padding: 6px 12px; border-radius: 99px; font-size: 0.85rem; background: var(--bg-2); }
.gpick.on { border-color: var(--c); background: color-mix(in srgb, var(--c) 18%, transparent); }
</style>