Add account connections, guild wallets and admin fixes [skip ci]

This commit is contained in:
SCOPEDD committed 2026-09-30 12:25:29 -04:00
1 parent 7076e847ea
commit 004862ea88
38 files changed
+1696 -458

No files matched your search

+40 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { UserRound, WifiOff, LoaderCircle, Check, ArrowRight, UserPlus } from '@lucide/svelte';
import { accountAdded, app, toast } from '../lib/store.svelte';
import { errorText, invoke } from '../lib/tauri';
import { errorText, invoke, openUrl } from '../lib/tauri';
import type { Account } from '../lib/types';
const auth = $derived(app.manifest?.auth);
@@ -23,6 +23,9 @@
let busy = $state(false);
let error = $state('');
let pendingNotice = $state(false);
let recoveryEmail = $state('');
let recovering = $state(false);
let recoveryNotice = $state('');
const canRegister = $derived(auth?.registration && auth.registration !== 'closed');
@@ -48,6 +51,33 @@
}
}
async function signInDiscord() {
error = ''; busy = true;
try {
const flow = await invoke<{url: string; state: string}>('discord_sign_in_start');
await openUrl(flow.url);
for (let i = 0; i < 120; i++) {
await new Promise(resolve => setTimeout(resolve, 1500));
const result = await invoke<{account?: Account; pending?: boolean}>('discord_sign_in_poll', {oauthState: flow.state});
if (result.account) {
await accountAdded(result.account);
toast(`Signed in as ${result.account.username}`);
return;
}
if (!result.pending) { pendingNotice = true; return; }
}
throw new Error('Discord sign-in timed out. Try again.');
} catch (err) { error = errorText(err); }
finally { busy = false; }
}
async function forgotPassword() {
error = ''; busy = true;
try { recoveryNotice = await invoke<string>('request_password_reset', {email: recoveryEmail}); }
catch (err) { error = errorText(err); }
finally { busy = false; }
}
</script>
<div class="signin">
@@ -89,6 +119,15 @@
{registering ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
</button>
{/if}
{#if method === 'panel' && !registering}
<button type="button" class="ghost sm switch" onclick={signInDiscord} disabled={busy}>Sign in with Discord</button>
<button type="button" class="ghost sm switch" onclick={() => recovering = !recovering}>Forgot password?</button>
{#if recovering}
<label class="field">Account email<input type="email" bind:value={recoveryEmail} placeholder="you@example.com" /></label>
<button type="button" disabled={busy || !recoveryEmail} onclick={forgotPassword}>Email reset link</button>
{#if recoveryNotice}<p class="help">{recoveryNotice}</p>{/if}
{/if}
{/if}
</form>
{/if}
+63 -2
View File
@@ -7,7 +7,7 @@
import Avatar from '../components/Avatar.svelte';
import ChunkMap from '../components/ChunkMap.svelte';
import Modal from '../components/Modal.svelte';
import { app, instances, selectedInstance, toast } from '../lib/store.svelte';
import { activeAccount, app, instances, selectedInstance, toast } from '../lib/store.svelte';
import { invoke } from '../lib/tauri';
import type { Guild, GuildMember, GuildPost } from '../lib/types';
@@ -16,8 +16,34 @@
let allGuilds = $state<Guild[]>([]);
let members = $state<GuildMember[]>([]);
let posts = $state<GuildPost[]>([]);
let activeTab = $state<'territory' | 'members' | 'feed'>('territory');
let activeTab = $state<'territory' | 'members' | 'feed' | 'wallet'>('territory');
let loading = $state(false);
let walletServers = $state<Array<{ id: number; name: string; instance_id: string }>>([]);
let walletServerId = $state<number | null>(null);
let wallet = $state<{ balance: number; transactions: Array<{ id: number; actor_uuid: string; kind: string; amount: number; created_at: string }> } | null>(null);
let walletAmount = $state('');
let walletBusy = $state(false);
const canWithdraw = $derived(members.some(m => m.uuid === activeAccount()?.uuid && ['leader', 'officer'].includes(m.role)));
async function loadWallet() {
if (!myGuild || !walletServerId) { wallet = null; return; }
try { wallet = await invoke('get_guild_wallet', { guildId: myGuild.id, serverId: walletServerId }); }
catch (e: any) { toast(e?.message ?? 'Unable to load guild wallet', 'error'); }
}
async function transferWallet(withdraw: boolean) {
if (!myGuild || !walletServerId || walletBusy) return;
const amount = Number(walletAmount);
if (!Number.isFinite(amount) || amount <= 0 || Math.round(amount * 100) !== amount * 100) { toast('Enter a valid amount with at most two decimals', 'error'); return; }
walletBusy = true;
try {
await invoke('transfer_guild_wallet', { guildId: myGuild.id, serverId: walletServerId, amount, withdraw });
walletAmount = '';
await loadWallet();
toast(withdraw ? 'Funds withdrawn' : 'Funds deposited', 'ok');
} catch (e: any) { toast(e?.message ?? 'Transfer failed', 'error'); }
finally { walletBusy = false; }
}
// Create Guild Modal
let createModalOpen = $state(false);
@@ -67,6 +93,16 @@
}
}
onMount(async () => {
try { walletServers = (await invoke<{ servers: typeof walletServers }>('get_public_servers')).servers; }
catch { walletServers = []; }
});
$effect(() => {
const available = walletServers.filter(s => s.instance_id === selectedInstId);
if (!available.some(s => s.id === walletServerId)) walletServerId = available[0]?.id ?? null;
});
$effect(() => { if (activeTab === 'wallet' && myGuild && walletServerId) loadWallet(); });
async function handleCreateGuild() {
if (!newGuildName.trim() || !newGuildTag.trim()) {
toast('Please enter both guild name and tag', 'error');
@@ -201,6 +237,9 @@
<button class:active={activeTab === 'feed'} onclick={() => (activeTab = 'feed')}>
<MessageSquare size={15} /> Guild Feed ({posts.length})
</button>
<button class:active={activeTab === 'wallet'} onclick={() => (activeTab = 'wallet')}>
Guild Wallet
</button>
</div>
</div>
@@ -225,6 +264,28 @@
onunclaim={() => { if (myGuild) myGuild.claims_count = Math.max(0, myGuild.claims_count - 1); }}
/>
</div>
{:else if activeTab === 'wallet'}
<div class="wallet-tab glass">
<h3>Guild Wallet</h3>
<label>Game server
<select bind:value={walletServerId}>
{#each walletServers.filter(s => s.instance_id === selectedInstId) as server}<option value={server.id}>{server.name}</option>{/each}
</select>
</label>
{#if !walletServerId}<p>Link a game server to this instance to use the guild wallet.</p>{/if}
{#if wallet}
<p>Balance: ${wallet.balance.toFixed(2)}</p>
<div class="wallet-actions">
<input type="number" min="0.01" step="0.01" placeholder="Amount" bind:value={walletAmount} aria-label="Transfer amount" />
<button class="primary sm" disabled={walletBusy || !walletServerId} onclick={() => transferWallet(false)}>Deposit</button>
{#if canWithdraw}<button class="ghost sm" disabled={walletBusy || !walletServerId} onclick={() => transferWallet(true)}>Withdraw</button>{/if}
</div>
<h4>Recent transfers</h4>
{#each wallet.transactions as tx (tx.id)}
<p>{tx.kind === 'withdraw' ? '−' : '+'}${tx.amount.toFixed(2)} · {new Date(tx.created_at).toLocaleString()}</p>
{:else}<p>No transfers yet.</p>{/each}
{/if}
</div>
{:else if activeTab === 'members'}
<div class="members-tab">
<div class="members-grid">
+42
View File
@@ -15,6 +15,36 @@
import type { Account, Gc, PlayerProfile, SkinProfile, UpdateInfo } from '../lib/types';
const serverAccount = $derived(activeAccount()?.kind === 'panel');
let discordConnection = $state<{id: string; name: string} | null>(null);
let discordBusy = $state(false);
$effect(() => {
if (serverAccount && app.settingsTab === 'account') {
invoke<{id: string; name: string} | null>('discord_connection').then(x => discordConnection = x).catch(() => discordConnection = null);
}
});
async function linkDiscord() {
discordBusy = true;
try {
const flow = await invoke<{url: string; state: string}>('discord_link_start');
await openUrl(flow.url);
for (let i = 0; i < 120; i++) {
await new Promise(resolve => setTimeout(resolve, 1500));
const result = await invoke<{linked?: boolean; pending?: boolean}>('discord_sign_in_poll', {oauthState: flow.state});
if (result.linked) {
discordConnection = await invoke('discord_connection');
toast('Discord connected'); return;
}
}
toast('Discord link timed out', 'error');
} catch (e) { toast(errorText(e), 'error'); }
finally { discordBusy = false; }
}
async function unlinkDiscord() {
discordBusy = true;
try { await invoke('discord_unlink'); discordConnection = null; toast('Discord disconnected'); }
catch (e) { toast(errorText(e), 'error'); }
finally { discordBusy = false; }
}
const tabs = $derived([
{ id: 'account', label: 'Accounts', icon: UserRound },
...(serverAccount ? [{ id: 'skin', label: 'Skin & cape', icon: Shirt }] : []),
@@ -323,6 +353,18 @@
{/each}
<button class="add" onclick={() => (app.addAccount = true)}><Plus size={16} /> Add account</button>
</div>
{#if serverAccount}
<div class="card glass col">
<h3>Connections → Discord</h3>
<p class="muted small">Connect your Discord account to sign in and show your Discord identity in SCOPENET.</p>
{#if discordConnection}
<p>Connected as <strong>{discordConnection.name}</strong></p>
<button disabled={discordBusy} onclick={unlinkDiscord}>Disconnect Discord</button>
{:else}
<button disabled={discordBusy} onclick={linkDiscord}>{discordBusy ? 'Waiting for Discord…' : 'Connect Discord'}</button>
{/if}
</div>
{/if}
{:else if app.settingsTab === 'skin'}
<h1>Skin & cape</h1>
<form class="card glass col" onsubmit={(e) => { e.preventDefault(); renameAccount(); }}>