Refactor OAuth to use env vars
This commit is contained in:
parent
0a031469cc
commit
e04c6206db
2
.cursorignore
Normal file
2
.cursorignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
archive
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -22,3 +22,6 @@ Thumbs.db
|
|||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
.data
|
.data
|
||||||
|
|
||||||
|
.env
|
||||||
|
archive
|
||||||
BIN
src/lib/assets/beatleader-logo.png
Normal file
BIN
src/lib/assets/beatleader-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import beatleaderLogo from '$lib/assets/beatleader-logo.png';
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: '/', label: 'Home' },
|
{ href: '/', label: 'Home' },
|
||||||
{ href: '/tools', label: 'Tools' },
|
{ href: '/tools', label: 'Tools' },
|
||||||
@ -7,7 +10,81 @@
|
|||||||
let open = false;
|
let open = false;
|
||||||
const toggle = () => (open = !open);
|
const toggle = () => (open = !open);
|
||||||
const close = () => (open = false);
|
const close = () => (open = false);
|
||||||
const year = new Date().getFullYear();
|
|
||||||
|
type BeatLeaderIdentity = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BeatLeaderPlayer = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
avatar?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BeatLeaderProfile = {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let user: BeatLeaderProfile | null = null;
|
||||||
|
let loginHref = '/auth/beatleader/login';
|
||||||
|
let checkingSession = true;
|
||||||
|
|
||||||
|
const getProfileUrl = (id?: string) => (id ? `https://beatleader.com/u/${encodeURIComponent(id)}` : 'https://beatleader.com');
|
||||||
|
|
||||||
|
function extractPlayer(payload: unknown): BeatLeaderProfile | null {
|
||||||
|
if (!payload || typeof payload !== 'object') return null;
|
||||||
|
const { identity, player } = payload as { identity?: BeatLeaderIdentity | null; player?: BeatLeaderPlayer | null };
|
||||||
|
|
||||||
|
const sourcePlayer = player ?? null;
|
||||||
|
const sourceIdentity = identity ?? null;
|
||||||
|
|
||||||
|
const id = sourcePlayer?.id ?? sourceIdentity?.id;
|
||||||
|
const name = sourcePlayer?.name ?? sourceIdentity?.name;
|
||||||
|
const avatar = sourcePlayer?.avatar ?? null;
|
||||||
|
|
||||||
|
if (!id && !name) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof id === 'string' ? id : undefined,
|
||||||
|
name: typeof name === 'string' ? name : undefined,
|
||||||
|
avatar: typeof avatar === 'string' ? avatar : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const redirectTarget = `${window.location.pathname}${window.location.search}${window.location.hash}` || '/';
|
||||||
|
loginHref = `/auth/beatleader/login?redirect_uri=${encodeURIComponent(redirectTarget)}`;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/beatleader/me');
|
||||||
|
if (res.ok) {
|
||||||
|
const json = (await res.json()) as unknown;
|
||||||
|
const profile = extractPlayer(json);
|
||||||
|
if (profile) {
|
||||||
|
user = profile;
|
||||||
|
}
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as Record<string, unknown>;
|
||||||
|
const suggested = body?.login;
|
||||||
|
if (typeof suggested === 'string') {
|
||||||
|
loginHref = suggested;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parsing errors for 401 responses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to determine BeatLeader session state', err);
|
||||||
|
} finally {
|
||||||
|
checkingSession = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="sticky top-0 z-40 backdrop-blur supports-[backdrop-filter]:bg-surface/50 border-b border-white/10">
|
<header class="sticky top-0 z-40 backdrop-blur supports-[backdrop-filter]:bg-surface/50 border-b border-white/10">
|
||||||
@ -24,7 +101,30 @@
|
|||||||
{#each links as link}
|
{#each links as link}
|
||||||
<a href={link.href} class="text-muted hover:text-white transition">{link.label}</a>
|
<a href={link.href} class="text-muted hover:text-white transition">{link.label}</a>
|
||||||
{/each}
|
{/each}
|
||||||
<a href="/tools/beatleader-compare" class="btn-neon">Compare Players</a>
|
{#if checkingSession}
|
||||||
|
<span class="text-sm text-muted">Connecting…</span>
|
||||||
|
{:else if user}
|
||||||
|
<a
|
||||||
|
href={getProfileUrl(user.id)}
|
||||||
|
class="flex items-center gap-3 rounded-md border border-white/10 px-3 py-1.5 text-sm transition hover:bg-white/10"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
title="View your BeatLeader profile"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={user.avatar ?? beatleaderLogo}
|
||||||
|
alt="BeatLeader avatar"
|
||||||
|
class="h-8 w-8 rounded-full object-cover shadow-sm"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span class="font-medium text-white">{user.name ?? 'BeatLeader user'}</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a href={loginHref} class="btn-neon inline-flex items-center gap-2 px-3 py-1.5">
|
||||||
|
<img src={beatleaderLogo} alt="BeatLeader" class="h-6 w-6" />
|
||||||
|
<span>Login</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<button class="md:hidden btn-neon px-3 py-1.5" on:click={toggle} aria-expanded={open} aria-controls="mobile-nav">
|
<button class="md:hidden btn-neon px-3 py-1.5" on:click={toggle} aria-expanded={open} aria-controls="mobile-nav">
|
||||||
@ -39,7 +139,30 @@
|
|||||||
{#each links as link}
|
{#each links as link}
|
||||||
<a href={link.href} on:click={close} class="text-muted hover:text-white transition">{link.label}</a>
|
<a href={link.href} on:click={close} class="text-muted hover:text-white transition">{link.label}</a>
|
||||||
{/each}
|
{/each}
|
||||||
<a href="/tools/beatleader-compare" on:click={close} class="btn-neon w-max">Compare Players</a>
|
{#if checkingSession}
|
||||||
|
<span class="text-sm text-muted">Connecting…</span>
|
||||||
|
{:else if user}
|
||||||
|
<a
|
||||||
|
href={getProfileUrl(user.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
on:click={close}
|
||||||
|
class="flex items-center gap-3 rounded-md border border-white/10 px-3 py-2 text-sm transition hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={user.avatar ?? beatleaderLogo}
|
||||||
|
alt="BeatLeader avatar"
|
||||||
|
class="h-10 w-10 rounded-full object-cover shadow-sm"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span class="font-medium text-white">{user.name ?? 'BeatLeader user'}</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<a href={loginHref} on:click={close} class="btn-neon inline-flex items-center gap-2 w-max px-3 py-2">
|
||||||
|
<img src={beatleaderLogo} alt="BeatLeader" class="h-6 w-6" />
|
||||||
|
<span>Login</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { Cookies } from '@sveltejs/kit';
|
|||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
const TOKEN_URL = 'https://api.beatleader.com/oauth2/token';
|
const TOKEN_URL = 'https://api.beatleader.com/oauth2/token';
|
||||||
|
|
||||||
@ -206,10 +207,21 @@ export async function getValidAccessToken(cookies: Cookies): Promise<string | nu
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAuthorizeUrl(origin: string, scopes: string[]): URL {
|
function getClientCredentials(): { client_id: string; client_secret: string } {
|
||||||
|
const envClientId = env.BL_CLIENT_ID;
|
||||||
|
const envClientSecret = env.BL_CLIENT_SECRET;
|
||||||
|
if (envClientId && envClientSecret) {
|
||||||
|
return { client_id: envClientId, client_secret: envClientSecret };
|
||||||
|
}
|
||||||
const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE);
|
const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE);
|
||||||
const clientId = creds?.client_id;
|
if (creds?.client_id && creds?.client_secret) {
|
||||||
if (!clientId) throw new Error('BeatLeader OAuth is not configured. Visit /tools/beatleader-oauth to set it up.');
|
return { client_id: creds.client_id, client_secret: creds.client_secret };
|
||||||
|
}
|
||||||
|
throw new Error('BeatLeader OAuth is not configured. Set BL_CLIENT_ID and BL_CLIENT_SECRET.');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthorizeUrl(origin: string, scopes: string[]): URL {
|
||||||
|
const { client_id: clientId } = getClientCredentials();
|
||||||
const redirectUri = `${origin}/auth/beatleader/callback`;
|
const redirectUri = `${origin}/auth/beatleader/callback`;
|
||||||
const url = new URL('https://api.beatleader.com/oauth2/authorize');
|
const url = new URL('https://api.beatleader.com/oauth2/authorize');
|
||||||
url.searchParams.set('client_id', clientId);
|
url.searchParams.set('client_id', clientId);
|
||||||
@ -222,10 +234,7 @@ export function buildAuthorizeUrl(origin: string, scopes: string[]): URL {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function exchangeCodeForTokens(origin: string, code: string): Promise<{ access_token: string; refresh_token?: string; expires_in?: number } | null> {
|
export async function exchangeCodeForTokens(origin: string, code: string): Promise<{ access_token: string; refresh_token?: string; expires_in?: number } | null> {
|
||||||
const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE);
|
const { client_id: clientId, client_secret: clientSecret } = getClientCredentials();
|
||||||
const clientId = creds?.client_id;
|
|
||||||
const clientSecret = creds?.client_secret;
|
|
||||||
if (!clientId || !clientSecret) return null;
|
|
||||||
const redirectUri = `${origin}/auth/beatleader/callback`;
|
const redirectUri = `${origin}/auth/beatleader/callback`;
|
||||||
|
|
||||||
const res = await fetch(TOKEN_URL, {
|
const res = await fetch(TOKEN_URL, {
|
||||||
@ -287,7 +296,12 @@ export function storeOAuthCredentials(input: { client_id: string; client_secret:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function readOAuthCredentials(): { client_id: string; client_secret: string; scopes?: string[]; redirect_urls?: string[] } | null {
|
export function readOAuthCredentials(): { client_id: string; client_secret: string; scopes?: string[]; redirect_urls?: string[] } | null {
|
||||||
return readJsonPersistent(CREDS_FILE);
|
try {
|
||||||
|
const { client_id: clientId, client_secret: clientSecret } = getClientCredentials();
|
||||||
|
return { client_id: clientId, client_secret: clientSecret };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
65
src/routes/api/beatleader/me/+server.ts
Normal file
65
src/routes/api/beatleader/me/+server.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import type { RequestHandler } from '@sveltejs/kit';
|
||||||
|
import { createBeatLeaderAPI } from '$lib/server/beatleader';
|
||||||
|
import { clearTokens, getValidAccessToken } from '$lib/server/beatleaderAuth';
|
||||||
|
|
||||||
|
type BeatLeaderIdentity = { id?: string; name?: string };
|
||||||
|
type BeatLeaderPlayer = { id?: string; name?: string; avatar?: string | null };
|
||||||
|
|
||||||
|
const IDENTITY_ENDPOINT = 'https://api.beatleader.com/oauth2/identity';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ cookies, fetch }) => {
|
||||||
|
const token = await getValidAccessToken(cookies);
|
||||||
|
if (!token) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Unauthorized', login: '/auth/beatleader/login' }),
|
||||||
|
{ status: 401, headers: { 'content-type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identityRes = await fetch(IDENTITY_ENDPOINT, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (identityRes.status === 401) {
|
||||||
|
clearTokens(cookies);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Unauthorized', login: '/auth/beatleader/login' }),
|
||||||
|
{ status: 401, headers: { 'content-type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!identityRes.ok) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Identity lookup failed: ${identityRes.status}` }),
|
||||||
|
{ status: 502, headers: { 'content-type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identityJson = (await identityRes.json()) as BeatLeaderIdentity;
|
||||||
|
const identity: BeatLeaderIdentity = {
|
||||||
|
id: typeof identityJson.id === 'string' ? identityJson.id : undefined,
|
||||||
|
name: typeof identityJson.name === 'string' ? identityJson.name : undefined
|
||||||
|
};
|
||||||
|
const playerId = identity.id ?? null;
|
||||||
|
|
||||||
|
let player: BeatLeaderPlayer | null = null;
|
||||||
|
if (playerId) {
|
||||||
|
try {
|
||||||
|
const api = createBeatLeaderAPI(fetch, token, undefined);
|
||||||
|
const raw = (await api.getPlayer(playerId)) as Record<string, unknown>;
|
||||||
|
const candidate: BeatLeaderPlayer = {
|
||||||
|
id: typeof raw?.id === 'string' ? (raw.id as string) : playerId,
|
||||||
|
name: typeof raw?.name === 'string' ? (raw.name as string) : identity.name,
|
||||||
|
avatar: typeof raw?.avatar === 'string' ? (raw.avatar as string) : null
|
||||||
|
};
|
||||||
|
player = candidate;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch BeatLeader player profile', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ identity, player }), {
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
@ -1,12 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
</script>
|
||||||
|
|
||||||
<section class="py-8 prose prose-invert max-w-none">
|
<section class="py-8 prose prose-invert max-w-none">
|
||||||
<h1 class="font-display tracking-widest">Guides</h1>
|
<h1 class="font-display tracking-widest">Guides</h1>
|
||||||
<p>Community-written tips and guides for improving your Beat Saber game. Contributions welcome.</p>
|
<p>Community-written tips and guides for improving your Beat Saber game. Contributions welcome.</p>
|
||||||
|
|
||||||
<div class="not-prose grid gap-4 sm:grid-cols-2 lg:grid-cols-3 mt-6">
|
<div class="not-prose grid gap-4 sm:grid-cols-2 lg:grid-cols-3 mt-6">
|
||||||
|
{#if dev}
|
||||||
<a href="/guides/beatleader-auth" class="card-surface p-5 block">
|
<a href="/guides/beatleader-auth" class="card-surface p-5 block">
|
||||||
<h3 class="font-semibold">BeatLeader Authentication</h3>
|
<h3 class="font-semibold">BeatLeader Authentication</h3>
|
||||||
<p class="mt-1 text-sm text-muted">Connect BeatLeader to enhance tools like Compare Players.</p>
|
<p class="mt-1 text-sm text-muted">Connect BeatLeader to enhance tools like Compare Players.</p>
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
<a href="/guides/finding-new-songs" class="card-surface p-5 block">
|
<a href="/guides/finding-new-songs" class="card-surface p-5 block">
|
||||||
<h3 class="font-semibold">Finding New Songs (BeatLeader)</h3>
|
<h3 class="font-semibold">Finding New Songs (BeatLeader)</h3>
|
||||||
<p class="mt-1 text-sm text-muted">Month-by-month search using unranked stars, tech rating, and friend filters.</p>
|
<p class="mt-1 text-sm text-muted">Month-by-month search using unranked stars, tech rating, and friend filters.</p>
|
||||||
|
|||||||
12
src/routes/guides/beatleader-auth/+page.server.ts
Normal file
12
src/routes/guides/beatleader-auth/+page.server.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { dev } from '$app/environment';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
if (!dev) {
|
||||||
|
throw error(404, 'Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
16
src/routes/tools/+layout.server.ts
Normal file
16
src/routes/tools/+layout.server.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { getValidAccessToken } from '$lib/server/beatleaderAuth';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ cookies, url }) => {
|
||||||
|
const token = await getValidAccessToken(cookies);
|
||||||
|
if (!token) {
|
||||||
|
const pathWithQuery = `${url.pathname}${url.search}` || '/tools';
|
||||||
|
throw redirect(302, `/auth/beatleader/login?redirect_uri=${encodeURIComponent(pathWithQuery)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasBeatLeaderOAuth: true };
|
||||||
|
};
|
||||||
|
|
||||||
@ -1,283 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
|
|
||||||
let name = '';
|
|
||||||
let clientId = '';
|
|
||||||
let origin = '';
|
|
||||||
let redirectUrls = '';
|
|
||||||
let scopes: string[] = ['scp:profile', 'scp:offline_access'];
|
|
||||||
let iconFile: File | null = null;
|
|
||||||
let message: string | null = null;
|
|
||||||
let created: any = null;
|
|
||||||
let existing: { client_id: string; client_secret: string } | null = null;
|
|
||||||
let manualClientId = '';
|
|
||||||
let manualClientSecret = '';
|
|
||||||
let recommendedClientId = generateDefaultClientId();
|
|
||||||
let copying: string | null = null;
|
|
||||||
let returnTo: string | null = null;
|
|
||||||
let blCredsStatus: 'unknown' | 'configured' | 'missing' = 'unknown';
|
|
||||||
let blManageUrl: string | null = null;
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
if (browser) {
|
|
||||||
try { origin = location.origin; } catch {}
|
|
||||||
try { redirectUrls = origin + '/auth/beatleader/callback'; } catch {}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const sp = new URLSearchParams(location.search);
|
|
||||||
const ret = sp.get('return');
|
|
||||||
if (ret) returnTo = ret;
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
const redirect = location.pathname + (location.search || '');
|
|
||||||
blManageUrl = `/tools/beatleader-oauth?return=${encodeURIComponent(redirect)}`;
|
|
||||||
const statusRes = await fetch('/api/beatleader/oauth/status');
|
|
||||||
if (statusRes.ok) {
|
|
||||||
const st: any = await statusRes.json();
|
|
||||||
blCredsStatus = st?.hasCreds ? 'configured' : 'missing';
|
|
||||||
} else {
|
|
||||||
blCredsStatus = 'unknown';
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
blCredsStatus = 'unknown';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/beatleader/oauth/creds');
|
|
||||||
if (res.ok) {
|
|
||||||
const data: any = await res.json();
|
|
||||||
const hasId = typeof data?.client_id === 'string' && data.client_id.length > 0;
|
|
||||||
const hasSecret = typeof data?.client_secret === 'string' && data.client_secret.length > 0;
|
|
||||||
existing = hasId && hasSecret ? { client_id: data.client_id, client_secret: data.client_secret } : null;
|
|
||||||
} else {
|
|
||||||
existing = null;
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
function onIconChange(ev: Event) {
|
|
||||||
const input = ev.target as HTMLInputElement;
|
|
||||||
const f = input.files?.[0] ?? null;
|
|
||||||
if (!f) {
|
|
||||||
iconFile = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
|
||||||
if (!validTypes.includes(f.type)) {
|
|
||||||
message = 'Icon must be JPG, PNG, or GIF';
|
|
||||||
iconFile = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (f.size > 5 * 1024 * 1024) {
|
|
||||||
message = 'Icon must be under 5MB';
|
|
||||||
iconFile = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
message = null;
|
|
||||||
iconFile = f;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveManual(ev: SubmitEvent) {
|
|
||||||
ev.preventDefault();
|
|
||||||
message = null;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/beatleader/oauth/creds', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ client_id: manualClientId, client_secret: manualClientSecret })
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
existing = { client_id: manualClientId, client_secret: manualClientSecret };
|
|
||||||
manualClientId = '';
|
|
||||||
manualClientSecret = '';
|
|
||||||
message = 'Saved credentials.';
|
|
||||||
} catch (err) {
|
|
||||||
message = err instanceof Error ? err.message : 'Failed to save credentials';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function register(ev: SubmitEvent) {
|
|
||||||
ev.preventDefault();
|
|
||||||
message = null;
|
|
||||||
created = null;
|
|
||||||
try {
|
|
||||||
// Perform registration directly against BeatLeader using browser cookies (must be logged in on beatleader.com)
|
|
||||||
const url = new URL('https://api.beatleader.com/developer/app');
|
|
||||||
url.searchParams.set('name', name);
|
|
||||||
url.searchParams.set('clientId', clientId || generateClientId());
|
|
||||||
url.searchParams.set('scopes', scopes.join(','));
|
|
||||||
url.searchParams.set('redirectUrls', redirectUrls);
|
|
||||||
|
|
||||||
let blRes: Response;
|
|
||||||
if (iconFile) {
|
|
||||||
const buf = await iconFile.arrayBuffer();
|
|
||||||
blRes = await fetch(url.toString(), {
|
|
||||||
method: 'POST',
|
|
||||||
body: buf,
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
blRes = await fetch(url.toString(), {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!blRes.ok) throw new Error(await blRes.text());
|
|
||||||
const data = await blRes.json();
|
|
||||||
created = data;
|
|
||||||
|
|
||||||
const savedClientId = data?.clientId ?? data?.client_id;
|
|
||||||
const savedClientSecret = data?.clientSecret ?? data?.client_secret;
|
|
||||||
if (!savedClientId || !savedClientSecret) throw new Error('BeatLeader response missing credentials');
|
|
||||||
|
|
||||||
// Persist credentials locally
|
|
||||||
const saveRes = await fetch('/api/beatleader/oauth/creds', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ client_id: savedClientId, client_secret: savedClientSecret })
|
|
||||||
});
|
|
||||||
if (!saveRes.ok) throw new Error(await saveRes.text());
|
|
||||||
|
|
||||||
existing = { client_id: savedClientId, client_secret: savedClientSecret };
|
|
||||||
message = 'Registered successfully. Credentials saved.';
|
|
||||||
} catch (err) {
|
|
||||||
const base = err instanceof Error ? err.message : 'Registration failed';
|
|
||||||
message = base || 'Registration failed. Ensure you are logged into BeatLeader in this browser.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateClientId(): string {
|
|
||||||
return 'bl_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateDefaultClientId(): string {
|
|
||||||
try {
|
|
||||||
// Prefer uuid for readability; strip dashes
|
|
||||||
const uuid = (crypto?.randomUUID?.() ?? '') as string;
|
|
||||||
if (uuid) return 'bl_' + uuid.replace(/-/g, '');
|
|
||||||
} catch {}
|
|
||||||
return generateClientId();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copy(text: string, kind: string) {
|
|
||||||
try {
|
|
||||||
copying = kind;
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => (copying = null), 800);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="py-8">
|
|
||||||
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader OAuth Setup</h1>
|
|
||||||
<p class="mt-2 text-muted">Register an OAuth application and store credentials for this site.</p>
|
|
||||||
|
|
||||||
<div class="mt-2 text-xs text-muted flex flex-wrap items-center gap-2">
|
|
||||||
{#if blCredsStatus === 'unknown'}
|
|
||||||
<span class="rounded bg-white/10 px-2 py-1">Checking BeatLeader access…</span>
|
|
||||||
{:else if blCredsStatus === 'configured'}
|
|
||||||
<span class="rounded bg-emerald-500/20 text-emerald-300 px-2 py-1">BeatLeader access: Connected</span>
|
|
||||||
{#if blManageUrl}
|
|
||||||
<a class="underline hover:text-white" href={blManageUrl}>Manage</a>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<span class="rounded bg-rose-500/20 text-rose-300 px-2 py-1">BeatLeader access: Not connected</span>
|
|
||||||
{#if blManageUrl}
|
|
||||||
<a class="underline hover:text-white" href={blManageUrl}>Configure</a>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if returnTo}
|
|
||||||
<div class="mt-4">
|
|
||||||
<a class="rounded-md border border-white/10 px-3 py-1.5 text-sm hover:border-white/20" href={returnTo}>Return to previous page</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-6 grid gap-3 max-w-xl text-sm">
|
|
||||||
<div class="text-muted">Manual setup (recommended):</div>
|
|
||||||
<ol class="list-decimal pl-5 grid gap-2">
|
|
||||||
<li>
|
|
||||||
Open the BeatLeader developer portal:
|
|
||||||
<a class="underline" href="https://beatleader.com/developer" target="_blank" rel="noreferrer">beatleader.com/developer</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Create a new application using the following values:
|
|
||||||
<div class="mt-2 grid gap-2">
|
|
||||||
<div>
|
|
||||||
<div class="text-muted">Suggested Client ID</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<code>{recommendedClientId}</code>
|
|
||||||
<button type="button" class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20" on:click={() => copy(recommendedClientId, 'clientId')}>
|
|
||||||
{copying === 'clientId' ? 'Copied' : 'Copy'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-muted">Redirect URL</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<code>{origin}/auth/beatleader/callback</code>
|
|
||||||
<button type="button" class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20" on:click={() => copy(`${origin}/auth/beatleader/callback`, 'redirect')}>
|
|
||||||
{copying === 'redirect' ? 'Copied' : 'Copy'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-muted">Scopes</div>
|
|
||||||
<code>scp:profile{scopes.includes('scp:offline_access') ? ',scp:offline_access' : ''}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
After creation, copy the shown client secret and paste both values below to save locally.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if existing}
|
|
||||||
<div class="mt-4 text-sm">
|
|
||||||
<div class="text-muted">Existing credentials found.</div>
|
|
||||||
<div class="mt-1">Client ID: <code>{existing.client_id}</code></div>
|
|
||||||
<div>Client Secret: <code>{existing.client_secret}</code></div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<details class="mt-4">
|
|
||||||
<summary class="cursor-pointer text-sm text-muted">Enter credentials manually</summary>
|
|
||||||
<form class="mt-3 grid gap-3 max-w-xl" on:submit|preventDefault={saveManual}>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-muted" for="mClientId">Client ID</label>
|
|
||||||
<input id="mClientId" class="w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={manualClientId} required />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-muted" for="mClientSecret">Client Secret</label>
|
|
||||||
<input id="mClientSecret" class="w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={manualClientSecret} required />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button class="btn-neon">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{#if message}
|
|
||||||
<div class="mt-4 text-sm">{message}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if created}
|
|
||||||
<div class="mt-6 text-sm">
|
|
||||||
<div><strong>Client ID:</strong> <code>{created.clientId}</code></div>
|
|
||||||
<div><strong>Client Secret:</strong> <code>{created.clientSecret}</code></div>
|
|
||||||
<div class="mt-2">
|
|
||||||
<a class="rounded-md border border-white/10 px-3 py-1.5 text-sm hover:border-white/20" href="/auth/beatleader/login">Proceed to Login</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user