Discord community: role sync, announcements webhook, launcher invite

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjMbLQujBHunCCu5GpsHaT
This commit is contained in:
Claude committed 2026-09-30 18:20:33 +00:00
1 parent 1e9428945f
commit 485a81e0a4
11 files changed
+513 -2

No files matched your search

+1 -1
View File
@@ -71,7 +71,7 @@ export interface User {
last_seen_ingame: string | null;
}
export interface Group { id: number; name: string; color: string; members: number; luckperms_group?: string }
export interface Group { id: number; name: string; color: string; members: number; luckperms_group?: string; discord_role?: string }
export interface Settings {
auth: {
+52
View File
@@ -16,6 +16,32 @@
let testAddress = $state('');
let testingEmail = $state(false);
let savingConnections = $state(false);
let disc = $state<any>(null);
let discWebhook = $state('');
let discRoles = $state<{ id: string; name: string }[]>([]);
let savingDisc = $state(false);
let syncingDisc = $state(false);
const loadDisc = () => get('/api/admin/discord').then((x) => (disc = x)).catch(toastError);
async function saveDisc() {
savingDisc = true;
try {
disc = await put('/api/admin/discord', { role_sync: disc.role_sync, invite_url: disc.invite_url, webhook_url: discWebhook, notify_achievements: disc.notify_achievements, notify_guilds: disc.notify_guilds, notify_members: disc.notify_members });
discWebhook = '';
toast('Discord community settings saved');
loadDisc();
} catch (e) { toastError(e); } finally { savingDisc = false; }
}
async function syncDisc() {
syncingDisc = true;
try {
const r = await post<any>('/api/admin/discord/sync', {});
toast(`Checked ${r.checked} · ${r.groups_added} groups added · ${r.roles_added} roles added${r.not_in_server ? ` · ${r.not_in_server} not in server` : ''}`);
} catch (e) { toastError(e); } finally { syncingDisc = false; }
}
async function testDisc() {
try { await post('/api/admin/discord/test', {}); toast('Test message sent'); } catch (e) { toastError(e); }
}
let info = $state<AuthServerInfo | null>(null);
const loadInfo = () => get<AuthServerInfo>('/api/admin/auth-server').then((x) => (info = x)).catch(toastError);
@@ -23,6 +49,7 @@
$effect(() => {
get<Settings>('/api/admin/settings').then((x) => { s = x; usernameRules = x.username_blocklist.join('\n'); }).catch(toastError);
get('/api/admin/connections').then((x) => (connections = x)).catch(toastError);
loadDisc();
loadInfo();
});
@@ -173,6 +200,31 @@
<label class="field">Discord server ID<input bind:value={connections.discord_guild_id} placeholder="Optional" autocomplete="off" /></label>
<span class="help">Secrets are never returned to your browser after saving. Type <code>-</code> in a secret field to remove it.</span>
</section>
{#if disc}
<section class="card col">
<div class="section-title"><KeyRound size={18} /><h2>Discord community</h2></div>
<p class="help">{disc.linked_accounts} linked account{disc.linked_accounts === 1 ? '' : 's'}. Role sync needs the bot token and server ID above{disc.bot_ready ? '' : ' (not saved yet)'}. Sync only ever <em>adds</em>: it never removes a role or group. Map roles to groups under Players → Groups.</p>
<label class="field">Role sync
<select bind:value={disc.role_sync}>
<option value="off">Off</option>
<option value="discord_to_panel">Discord roles → panel groups</option>
<option value="panel_to_discord">Panel groups → Discord roles</option>
<option value="both">Both directions</option>
</select>
</label>
<label class="field">Invite link shown in the launcher<input bind:value={disc.invite_url} placeholder="https://discord.gg/yourcode" autocomplete="off" /></label>
<label class="field">Announcements webhook<input type="password" bind:value={discWebhook} placeholder={disc.webhook_set ? 'Saved — type to replace, or - to remove' : 'https://discord.com/api/webhooks/…'} autocomplete="off" /></label>
<Toggle bind:checked={disc.notify_achievements} label="Announce achievements" />
<Toggle bind:checked={disc.notify_guilds} label="Announce new guilds" />
<Toggle bind:checked={disc.notify_members} label="Announce new players" />
<div class="row wrap">
<button class="primary" disabled={savingDisc} onclick={saveDisc}>{savingDisc ? 'Saving…' : 'Save community settings'}</button>
<button disabled={syncingDisc || disc.role_sync === 'off' || !disc.bot_ready} onclick={syncDisc}>{syncingDisc ? 'Syncing…' : 'Sync roles now'}</button>
<button disabled={!disc.webhook_set} onclick={testDisc}>Send test announcement</button>
</div>
<span class="help">Roles also sync automatically every 15 minutes.</span>
</section>
{/if}
<section class="card col">
<div class="section-title"><Download size={18} /><h2>Resend SMTP</h2></div>
<p class="help">Verify the sender domain in Resend, then enter an API key. SCOPENET connects to <code>smtp.resend.com</code> over TLS.</p>
+13
View File
@@ -111,6 +111,17 @@
}
}
async function mapDiscord(g: Group, value: string) {
try {
await put(`/api/admin/groups/${g.id}/discord-role`, { discord_role: value.trim() });
toast(value.trim() ? `${g.name} is mapped to a Discord role` : `${g.name} is no longer mapped to Discord`);
refresh();
} catch (e) {
toastError(e);
refresh();
}
}
async function removeGroup(g: Group) {
try {
await del(`/api/admin/groups/${g.id}`);
@@ -194,6 +205,8 @@
<strong>{g.name}</strong>
<span class="muted tiny">{g.members} member{g.members === 1 ? '' : 's'}</span>
<span class="spacer"></span>
<input class="lp" value={g.discord_role ?? ''} placeholder="Discord role ID" maxlength="24" inputmode="numeric" aria-label="Discord role ID for {g.name}" title="Map to a Discord role (right-click role → Copy role ID)"
onchange={(e) => mapDiscord(g, e.currentTarget.value)} />
<input class="lp" value={g.luckperms_group ?? ''} placeholder="LuckPerms group" maxlength="64" aria-label="LuckPerms group for {g.name}" title="Map to a LuckPerms group"
onchange={(e) => mapLp(g, e.currentTarget.value)} />
<button class="ghost icon" aria-label="Delete group {g.name}" onclick={() => removeGroup(g)}><X size={14} /></button>