Add My Levels tab, profile page, and token auth system
- Add My Levels tab to website level selection for viewing private levels - Add profile page for generating/managing editor plugin tokens - Create user_tokens table and RPC functions for token-based auth - Fix cloudLevelService to use maybeSingle() for admin and level queries - Fix getLevelById to try authenticated client first for private levels - Add rotation support to asteroids, base, sun, and planets - Remove deprecated difficultyConfig from level files - Add editor script components for BabylonJS Editor integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
496bb50095
commit
fe88c2bf47
30
gameEditor/src/scripts/editorScripts/AsteroidComponent.ts
Normal file
30
gameEditor/src/scripts/editorScripts/AsteroidComponent.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* BabylonJS Editor script component for asteroids
|
||||
* Copy this to your Editor workspace: src/scenes/scripts/AsteroidComponent.ts
|
||||
*
|
||||
* Attach to asteroid meshes to expose game properties in Inspector.
|
||||
*/
|
||||
import { Mesh } from "@babylonjs/core/Meshes/mesh";
|
||||
|
||||
import {
|
||||
visibleAsNumber,
|
||||
visibleAsString,
|
||||
visibleAsVector3,
|
||||
} from "babylonjs-editor-tools";
|
||||
|
||||
export default class AsteroidComponent extends Mesh {
|
||||
@visibleAsVector3("Linear Velocity", { step: 0.1 })
|
||||
public linearVelocity = { x: 0, y: 0, z: 0 };
|
||||
|
||||
@visibleAsVector3("Angular Velocity", { step: 0.01 })
|
||||
public angularVelocity = { x: 0, y: 0, z: 0 };
|
||||
|
||||
@visibleAsNumber("Mass", { min: 1, max: 1000, step: 10 })
|
||||
public mass: number = 200;
|
||||
|
||||
@visibleAsString("Target ID", { description: "Reference to a TargetComponent node" })
|
||||
public targetId: string = "";
|
||||
|
||||
@visibleAsString("Target Mode", { description: "orbit | moveToward | (empty)" })
|
||||
public targetMode: string = "";
|
||||
}
|
||||
17
gameEditor/src/scripts/editorScripts/BaseComponent.ts
Normal file
17
gameEditor/src/scripts/editorScripts/BaseComponent.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* BabylonJS Editor script component for the start base
|
||||
* Copy this to your Editor workspace: src/scenes/scripts/BaseComponent.ts
|
||||
*
|
||||
* Attach to a mesh to mark it as the start base (yellow cylinder constraint zone).
|
||||
*/
|
||||
import { Mesh } from "@babylonjs/core/Meshes/mesh";
|
||||
|
||||
import { visibleAsString } from "babylonjs-editor-tools";
|
||||
|
||||
export default class BaseComponent extends Mesh {
|
||||
@visibleAsString("Base GLB Path", { description: "Path to base GLB model" })
|
||||
public baseGlbPath: string = "base.glb";
|
||||
|
||||
@visibleAsString("Landing GLB Path", { description: "Path to landing zone GLB" })
|
||||
public landingGlbPath: string = "";
|
||||
}
|
||||
17
gameEditor/src/scripts/editorScripts/PlanetComponent.ts
Normal file
17
gameEditor/src/scripts/editorScripts/PlanetComponent.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* BabylonJS Editor script component for planets
|
||||
* Copy this to your Editor workspace: src/scenes/scripts/PlanetComponent.ts
|
||||
*
|
||||
* Attach to a mesh to configure planet properties.
|
||||
*/
|
||||
import { Mesh } from "@babylonjs/core/Meshes/mesh";
|
||||
|
||||
import { visibleAsNumber, visibleAsString } from "babylonjs-editor-tools";
|
||||
|
||||
export default class PlanetComponent extends Mesh {
|
||||
@visibleAsNumber("Diameter", { min: 10, max: 1000, step: 10 })
|
||||
public diameter: number = 100;
|
||||
|
||||
@visibleAsString("Texture Path", { description: "Path to planet texture" })
|
||||
public texturePath: string = "";
|
||||
}
|
||||
17
gameEditor/src/scripts/editorScripts/ShipComponent.ts
Normal file
17
gameEditor/src/scripts/editorScripts/ShipComponent.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* BabylonJS Editor script component for player ship spawn
|
||||
* Copy this to your Editor workspace: src/scenes/scripts/ShipComponent.ts
|
||||
*
|
||||
* Attach to a mesh/transform node to mark player spawn point.
|
||||
*/
|
||||
import { Mesh } from "@babylonjs/core/Meshes/mesh";
|
||||
|
||||
import { visibleAsVector3 } from "babylonjs-editor-tools";
|
||||
|
||||
export default class ShipComponent extends Mesh {
|
||||
@visibleAsVector3("Start Velocity", { step: 0.1 })
|
||||
public linearVelocity = { x: 0, y: 0, z: 0 };
|
||||
|
||||
@visibleAsVector3("Start Angular Vel", { step: 0.01 })
|
||||
public angularVelocity = { x: 0, y: 0, z: 0 };
|
||||
}
|
||||
17
gameEditor/src/scripts/editorScripts/SunComponent.ts
Normal file
17
gameEditor/src/scripts/editorScripts/SunComponent.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* BabylonJS Editor script component for the sun
|
||||
* Copy this to your Editor workspace: src/scenes/scripts/SunComponent.ts
|
||||
*
|
||||
* Attach to a mesh to mark it as the sun. Position from transform.
|
||||
*/
|
||||
import { Mesh } from "@babylonjs/core/Meshes/mesh";
|
||||
|
||||
import { visibleAsNumber } from "babylonjs-editor-tools";
|
||||
|
||||
export default class SunComponent extends Mesh {
|
||||
@visibleAsNumber("Diameter", { min: 10, max: 200, step: 5 })
|
||||
public diameter: number = 50;
|
||||
|
||||
@visibleAsNumber("Intensity", { min: 0, max: 5000000, step: 100000 })
|
||||
public intensity: number = 1000000;
|
||||
}
|
||||
15
gameEditor/src/scripts/editorScripts/TargetComponent.ts
Normal file
15
gameEditor/src/scripts/editorScripts/TargetComponent.ts
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* BabylonJS Editor script component for orbit/movement targets
|
||||
* Copy this to your Editor workspace: src/scenes/scripts/TargetComponent.ts
|
||||
*
|
||||
* Attach to a TransformNode to create an invisible target point.
|
||||
* Asteroids can reference this by targetId to orbit or move toward.
|
||||
*/
|
||||
import { TransformNode } from "@babylonjs/core/Meshes/transformNode";
|
||||
|
||||
import { visibleAsString } from "babylonjs-editor-tools";
|
||||
|
||||
export default class TargetComponent extends TransformNode {
|
||||
@visibleAsString("Display Name", { description: "Friendly name for this target" })
|
||||
public displayName: string = "Target";
|
||||
}
|
||||
@ -124,13 +124,5 @@
|
||||
-0.972535786522579
|
||||
]
|
||||
}
|
||||
],
|
||||
"difficultyConfig": {
|
||||
"rockCount": 4,
|
||||
"forceMultiplier": 1,
|
||||
"rockSizeMin": 3,
|
||||
"rockSizeMax": 5,
|
||||
"distanceMin": 100,
|
||||
"distanceMax": 200
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -257,7 +257,8 @@
|
||||
-4.8069811132136699,
|
||||
-0.16093262088047533
|
||||
]
|
||||
},{
|
||||
},
|
||||
{
|
||||
"id": "asteroid-11",
|
||||
"position": [
|
||||
300.953057070289603,
|
||||
@ -276,14 +277,5 @@
|
||||
-0.16093262088047533
|
||||
]
|
||||
}
|
||||
|
||||
],
|
||||
"difficultyConfig": {
|
||||
"rockCount": 10,
|
||||
"forceMultiplier": 1,
|
||||
"rockSizeMin": 8,
|
||||
"rockSizeMax": 20,
|
||||
"distanceMin": 225,
|
||||
"distanceMax": 300
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -429,13 +429,5 @@
|
||||
-0.6354712781926715
|
||||
]
|
||||
}
|
||||
],
|
||||
"difficultyConfig": {
|
||||
"rockCount": 20,
|
||||
"forceMultiplier": 1.2,
|
||||
"rockSizeMin": 5,
|
||||
"rockSizeMax": 40,
|
||||
"distanceMin": 230,
|
||||
"distanceMax": 450
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -17,7 +17,10 @@
|
||||
"Don't run into things, it damages your hull"
|
||||
],
|
||||
"unlockRequirements": [],
|
||||
"tags": ["tutorial", "easy"],
|
||||
"tags": [
|
||||
"tutorial",
|
||||
"easy"
|
||||
],
|
||||
"defaultLocked": false
|
||||
},
|
||||
{
|
||||
@ -36,7 +39,9 @@
|
||||
"Some of the asteroids are a little more distant"
|
||||
],
|
||||
"unlockRequirements": [],
|
||||
"tags": ["medium"],
|
||||
"tags": [
|
||||
"medium"
|
||||
],
|
||||
"defaultLocked": true
|
||||
},
|
||||
{
|
||||
@ -54,8 +59,12 @@
|
||||
"Asteroids are faster and more dangerous",
|
||||
"Plan your route carefully"
|
||||
],
|
||||
"unlockRequirements": ["rescue-mission"],
|
||||
"tags": ["hard"],
|
||||
"unlockRequirements": [
|
||||
"rescue-mission"
|
||||
],
|
||||
"tags": [
|
||||
"hard"
|
||||
],
|
||||
"defaultLocked": true
|
||||
},
|
||||
{
|
||||
@ -73,8 +82,13 @@
|
||||
"Multiple base trips may be necessary",
|
||||
"Test your skills to the limit"
|
||||
],
|
||||
"unlockRequirements": ["deep-space-patrol"],
|
||||
"tags": ["very-hard", "combat"],
|
||||
"unlockRequirements": [
|
||||
"deep-space-patrol"
|
||||
],
|
||||
"tags": [
|
||||
"very-hard",
|
||||
"combat"
|
||||
],
|
||||
"defaultLocked": true
|
||||
},
|
||||
{
|
||||
@ -92,8 +106,13 @@
|
||||
"Only the best pilots succeed",
|
||||
"May the stars guide you"
|
||||
],
|
||||
"unlockRequirements": ["enemy-territory"],
|
||||
"tags": ["very-hard", "endurance"],
|
||||
"unlockRequirements": [
|
||||
"enemy-territory"
|
||||
],
|
||||
"tags": [
|
||||
"very-hard",
|
||||
"endurance"
|
||||
],
|
||||
"defaultLocked": true
|
||||
},
|
||||
{
|
||||
@ -112,8 +131,13 @@
|
||||
"Complete this to prove your mastery",
|
||||
"Good luck, Commander"
|
||||
],
|
||||
"unlockRequirements": ["the-gauntlet"],
|
||||
"tags": ["extreme", "final-boss"],
|
||||
"unlockRequirements": [
|
||||
"the-gauntlet"
|
||||
],
|
||||
"tags": [
|
||||
"extreme",
|
||||
"final-boss"
|
||||
],
|
||||
"defaultLocked": true
|
||||
}
|
||||
]
|
||||
|
||||
@ -999,13 +999,5 @@
|
||||
-0.7867959684450998
|
||||
]
|
||||
}
|
||||
],
|
||||
"difficultyConfig": {
|
||||
"rockCount": 50,
|
||||
"forceMultiplier": 1.3,
|
||||
"rockSizeMin": 2,
|
||||
"rockSizeMax": 8,
|
||||
"distanceMin": 90,
|
||||
"distanceMax": 280
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -999,13 +999,5 @@
|
||||
0.19715448756571075
|
||||
]
|
||||
}
|
||||
],
|
||||
"difficultyConfig": {
|
||||
"rockCount": 50,
|
||||
"forceMultiplier": 1.3,
|
||||
"rockSizeMin": 2,
|
||||
"rockSizeMax": 8,
|
||||
"distanceMin": 90,
|
||||
"distanceMax": 280
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -239,13 +239,5 @@
|
||||
-0.16093262088047533
|
||||
]
|
||||
}
|
||||
],
|
||||
"difficultyConfig": {
|
||||
"rockCount": 10,
|
||||
"forceMultiplier": 1,
|
||||
"rockSizeMin": 8,
|
||||
"rockSizeMax": 20,
|
||||
"distanceMin": 225,
|
||||
"distanceMax": 300
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -145,13 +145,5 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"difficultyConfig": {
|
||||
"rockCount": 5,
|
||||
"forceMultiplier": 0.8,
|
||||
"rockSizeMin": 10,
|
||||
"rockSizeMax": 15,
|
||||
"distanceMin": 220,
|
||||
"distanceMax": 250
|
||||
},
|
||||
"useOrbitConstraints": true
|
||||
}
|
||||
@ -999,13 +999,5 @@
|
||||
0.6469944623807775
|
||||
]
|
||||
}
|
||||
],
|
||||
"difficultyConfig": {
|
||||
"rockCount": 50,
|
||||
"forceMultiplier": 1.3,
|
||||
"rockSizeMin": 2,
|
||||
"rockSizeMax": 8,
|
||||
"distanceMin": 90,
|
||||
"distanceMax": 280
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Link } from 'svelte-routing';
|
||||
import { authStore } from '../../stores/auth';
|
||||
import Button from '../shared/Button.svelte';
|
||||
|
||||
@ -16,7 +17,7 @@
|
||||
<span class="loading">Loading...</span>
|
||||
{:else if $authStore.isAuthenticated && $authStore.user}
|
||||
<div class="user-info">
|
||||
<span class="user-name">{$authStore.user.name || $authStore.user.email}</span>
|
||||
<Link to="/profile" class="user-name-link">{$authStore.user.name || $authStore.user.email}</Link>
|
||||
<Button variant="secondary" on:click={handleLogout}>Logout</Button>
|
||||
</div>
|
||||
{:else}
|
||||
@ -37,9 +38,13 @@
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
:global(.user-name-link) {
|
||||
color: var(--color-text, #fff);
|
||||
font-size: var(--font-size-sm, 0.875rem);
|
||||
text-decoration: none;
|
||||
}
|
||||
:global(.user-name-link:hover) {
|
||||
color: var(--color-primary, #4fc3f7);
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { levelRegistryStore } from '../../stores/levelRegistry';
|
||||
import { authStore } from '../../stores/auth';
|
||||
import { CloudLevelService, type CloudLevelEntry } from '../../services/cloudLevelService';
|
||||
import LevelCard from './LevelCard.svelte';
|
||||
import ProgressBar from './ProgressBar.svelte';
|
||||
import Button from '../shared/Button.svelte';
|
||||
|
||||
// Get levels in order (by sortOrder from Supabase)
|
||||
const LEVEL_ORDER = [
|
||||
@ -13,28 +15,64 @@
|
||||
'final-challenge'
|
||||
];
|
||||
|
||||
let activeTab: 'official' | 'my-levels' = 'official';
|
||||
let myLevels: CloudLevelEntry[] = [];
|
||||
let loadingMyLevels = false;
|
||||
let myLevelsLoaded = false;
|
||||
|
||||
// Reactive declarations for store values
|
||||
$: isReady = $levelRegistryStore.isInitialized;
|
||||
$: levels = $levelRegistryStore.levels;
|
||||
|
||||
// Load my levels when tab switches and user is authenticated
|
||||
$: if (activeTab === 'my-levels' && $authStore.isAuthenticated && !myLevelsLoaded) {
|
||||
loadMyLevels();
|
||||
}
|
||||
|
||||
async function loadMyLevels() {
|
||||
loadingMyLevels = true;
|
||||
try {
|
||||
myLevels = await CloudLevelService.getInstance().getMyLevels();
|
||||
myLevelsLoaded = true;
|
||||
} catch (err) {
|
||||
console.error('Failed to load my levels:', err);
|
||||
} finally {
|
||||
loadingMyLevels = false;
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tab: 'official' | 'my-levels') {
|
||||
activeTab = tab;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="mainDiv">
|
||||
<div id="levelSelect" class:ready={isReady}>
|
||||
<!-- Hero Section -->
|
||||
<div class="hero">
|
||||
<h1 class="hero-title">🚀 Space Combat VR</h1>
|
||||
<p class="hero-subtitle">
|
||||
Pilot your spaceship through asteroid fields and complete missions
|
||||
</p>
|
||||
<h1 class="hero-title">Space Combat VR</h1>
|
||||
<p class="hero-subtitle">Pilot your spaceship through asteroid fields and complete missions</p>
|
||||
</div>
|
||||
<!-- Level Selection Section -->
|
||||
|
||||
<div class="level-section">
|
||||
<h2 class="level-header">Your Mission</h2>
|
||||
<p class="level-description">
|
||||
Choose your level and prepare for launch
|
||||
</p>
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'official'}
|
||||
on:click={() => switchTab('official')}
|
||||
>
|
||||
Official Levels
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'my-levels'}
|
||||
on:click={() => switchTab('my-levels')}
|
||||
>
|
||||
My Levels
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-container" id="levelCardsContainer">
|
||||
{#if activeTab === 'official'}
|
||||
{#if !isReady}
|
||||
<div class="loading-message">Loading levels...</div>
|
||||
{:else if levels.size === 0}
|
||||
@ -46,25 +84,77 @@
|
||||
{#each LEVEL_ORDER as levelId}
|
||||
{@const entry = levels.get(levelId)}
|
||||
{#if entry}
|
||||
<LevelCard
|
||||
{levelId}
|
||||
levelEntry={entry}
|
||||
/>
|
||||
<LevelCard {levelId} levelEntry={entry} />
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
{#if !$authStore.isAuthenticated}
|
||||
<div class="no-levels-message">
|
||||
<h2>Sign In Required</h2>
|
||||
<p>Sign in to view your custom levels.</p>
|
||||
<Button variant="primary" on:click={() => authStore.login()}>Sign In</Button>
|
||||
</div>
|
||||
{:else if loadingMyLevels}
|
||||
<div class="loading-message">Loading your levels...</div>
|
||||
{:else if myLevels.length === 0}
|
||||
<div class="no-levels-message">
|
||||
<h2>No Custom Levels</h2>
|
||||
<p>You haven't created any levels yet. Use the BabylonJS Editor plugin to create levels.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each myLevels as level (level.id)}
|
||||
<LevelCard levelId={level.id} levelEntry={level} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Inherits from global styles.css */
|
||||
/* Most classes already defined */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #aaa;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: rgba(79, 195, 247, 0.2);
|
||||
border-color: #4fc3f7;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.loading-message, .no-levels-message {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: var(--space-2xl, 2rem);
|
||||
}
|
||||
|
||||
.no-levels-message h2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.no-levels-message p {
|
||||
margin-bottom: 1rem;
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
import { Main } from '../../main';
|
||||
import type { LevelConfig } from '../../levels/config/levelConfig';
|
||||
import { LevelRegistry } from '../../levels/storage/levelRegistry';
|
||||
import { CloudLevelService } from '../../services/cloudLevelService';
|
||||
import { progressionStore } from '../../stores/progression';
|
||||
import log from '../../core/logger';
|
||||
import { DefaultScene } from '../../core/defaultScene';
|
||||
@ -86,17 +87,23 @@
|
||||
throw new Error('Main instance not found');
|
||||
}
|
||||
|
||||
// Get full level entry from registry
|
||||
// Get full level entry from registry first
|
||||
const registry = LevelRegistry.getInstance();
|
||||
const levelEntry = registry.getLevelEntry(levelName);
|
||||
let levelEntry = registry.getLevelEntry(levelName);
|
||||
|
||||
// If not in registry, try fetching by ID (for private/custom levels)
|
||||
if (!levelEntry) {
|
||||
log.info('[PlayLevel] Level not in registry, fetching from cloud:', levelName);
|
||||
levelEntry = await CloudLevelService.getInstance().getLevelById(levelName);
|
||||
}
|
||||
|
||||
if (!levelEntry) {
|
||||
throw new Error(`Level "${levelName}" not found`);
|
||||
}
|
||||
|
||||
// Check if level is unlocked (deep link protection)
|
||||
const isDefault = levelEntry.levelType === 'official';
|
||||
if (!progressionStore.isLevelUnlocked(levelEntry.name, isDefault)) {
|
||||
// Check if level is unlocked (skip for private levels)
|
||||
const isOfficial = levelEntry.levelType === 'official';
|
||||
if (isOfficial && !progressionStore.isLevelUnlocked(levelEntry.name, true)) {
|
||||
log.warn('[PlayLevel] Level locked, redirecting to level select');
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
import SettingsScreen from '../settings/SettingsScreen.svelte';
|
||||
import ControlsScreen from '../controls/ControlsScreen.svelte';
|
||||
import Leaderboard from '../leaderboard/Leaderboard.svelte';
|
||||
import ProfilePage from '../profile/ProfilePage.svelte';
|
||||
|
||||
// Initialize Auth0 when component mounts
|
||||
onMount(async () => {
|
||||
@ -56,6 +57,7 @@
|
||||
<Route path="/settings"><SettingsScreen /></Route>
|
||||
<Route path="/controls"><ControlsScreen /></Route>
|
||||
<Route path="/leaderboard"><Leaderboard /></Route>
|
||||
<Route path="/profile"><ProfilePage /></Route>
|
||||
</div>
|
||||
</div>
|
||||
</Router>
|
||||
|
||||
70
src/components/profile/NewTokenDisplay.svelte
Normal file
70
src/components/profile/NewTokenDisplay.svelte
Normal file
@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
export let token: string;
|
||||
export let onClear: () => void;
|
||||
|
||||
let copied = false;
|
||||
|
||||
async function copyToken() {
|
||||
await navigator.clipboard.writeText(token);
|
||||
copied = true;
|
||||
setTimeout(() => { copied = false; }, 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="new-token-box">
|
||||
<div class="warning-header">
|
||||
<strong>Save this token now!</strong>
|
||||
<span>You won't be able to see it again.</span>
|
||||
</div>
|
||||
|
||||
<div class="token-display">
|
||||
<code>{token}</code>
|
||||
</div>
|
||||
|
||||
<div class="token-actions">
|
||||
<button class="copy-btn" on:click={copyToken}>
|
||||
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
||||
</button>
|
||||
<button class="dismiss-btn" on:click={onClear}>I've saved it</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.new-token-box {
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
border: 1px solid #4fc3f7;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.warning-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
.warning-header span { font-size: 0.85rem; opacity: 0.8; }
|
||||
.token-display {
|
||||
background: #1a1a1a;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.token-display code {
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
color: #81c784;
|
||||
}
|
||||
.token-actions { display: flex; gap: 0.5rem; }
|
||||
.copy-btn, .dismiss-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.copy-btn { background: #4fc3f7; color: #000; }
|
||||
.dismiss-btn { background: #444; color: #fff; }
|
||||
</style>
|
||||
98
src/components/profile/ProfilePage.svelte
Normal file
98
src/components/profile/ProfilePage.svelte
Normal file
@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { Link } from 'svelte-routing';
|
||||
import { authStore } from '../../stores/auth';
|
||||
import { SupabaseService } from '../../services/supabaseService';
|
||||
import Button from '../shared/Button.svelte';
|
||||
import Section from '../shared/Section.svelte';
|
||||
import InfoBox from '../shared/InfoBox.svelte';
|
||||
import TokenList from './TokenList.svelte';
|
||||
import NewTokenDisplay from './NewTokenDisplay.svelte';
|
||||
|
||||
let message = '';
|
||||
let messageType: 'success' | 'error' | 'warning' = 'success';
|
||||
let showMessage = false;
|
||||
let newToken: string | null = null;
|
||||
let isGenerating = false;
|
||||
let refreshKey = 0;
|
||||
|
||||
async function handleGenerateToken() {
|
||||
isGenerating = true;
|
||||
try {
|
||||
const client = await SupabaseService.getInstance().getAuthenticatedClient();
|
||||
if (!client) {
|
||||
// Session is stale - refresh auth store to show login button
|
||||
await authStore.refresh();
|
||||
showNotification('Session expired. Please sign in again.', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await client.rpc('create_user_token', { p_name: 'Editor Token' });
|
||||
if (error) throw error;
|
||||
|
||||
newToken = data;
|
||||
refreshKey++; // Trigger token list refresh
|
||||
showNotification('Token generated! Copy it now - you won\'t see it again.', 'success');
|
||||
} catch (err) {
|
||||
showNotification(`Failed to generate token: ${(err as Error).message}`, 'error');
|
||||
} finally {
|
||||
isGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTokenRevoked() {
|
||||
refreshKey++;
|
||||
showNotification('Token revoked', 'success');
|
||||
}
|
||||
|
||||
function handleClearNewToken() {
|
||||
newToken = null;
|
||||
}
|
||||
|
||||
function showNotification(msg: string, type: 'success' | 'error' | 'warning') {
|
||||
message = msg;
|
||||
messageType = type;
|
||||
showMessage = true;
|
||||
setTimeout(() => { showMessage = false; }, 5000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor-container">
|
||||
<Link to="/" class="back-link">← Back to Game</Link>
|
||||
|
||||
<h1>Profile</h1>
|
||||
|
||||
{#if !$authStore.isAuthenticated}
|
||||
<Section title="Sign In Required">
|
||||
<p>Please sign in to manage your profile and editor tokens.</p>
|
||||
<Button variant="primary" on:click={() => authStore.login()}>Sign In</Button>
|
||||
</Section>
|
||||
{:else}
|
||||
<p class="subtitle">Welcome, {$authStore.user?.name || $authStore.user?.email || 'Player'}</p>
|
||||
|
||||
<div class="settings-grid">
|
||||
<Section title="Editor Tokens" description="Generate tokens to authenticate the BabylonJS Editor plugin.">
|
||||
{#if newToken}
|
||||
<NewTokenDisplay token={newToken} onClear={handleClearNewToken} />
|
||||
{/if}
|
||||
|
||||
<div class="token-actions">
|
||||
<Button variant="primary" on:click={handleGenerateToken} disabled={isGenerating}>
|
||||
{isGenerating ? 'Generating...' : 'Generate New Token'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#key refreshKey}
|
||||
<TokenList onRevoke={handleTokenRevoked} />
|
||||
{/key}
|
||||
</Section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<InfoBox {message} type={messageType} visible={showMessage} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.token-actions {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
112
src/components/profile/TokenList.svelte
Normal file
112
src/components/profile/TokenList.svelte
Normal file
@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { SupabaseService } from '../../services/supabaseService';
|
||||
import { authStore } from '../../stores/auth';
|
||||
import Button from '../shared/Button.svelte';
|
||||
|
||||
export let onRevoke: () => void;
|
||||
|
||||
interface Token {
|
||||
id: string;
|
||||
name: string;
|
||||
token_prefix: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
is_revoked: boolean;
|
||||
}
|
||||
|
||||
let tokens: Token[] = [];
|
||||
let loading = true;
|
||||
let revoking: string | null = null;
|
||||
|
||||
onMount(loadTokens);
|
||||
|
||||
async function loadTokens() {
|
||||
loading = true;
|
||||
try {
|
||||
const client = await SupabaseService.getInstance().getAuthenticatedClient();
|
||||
if (!client) {
|
||||
await authStore.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await client
|
||||
.from('user_tokens')
|
||||
.select('id, name, token_prefix, created_at, last_used_at, is_revoked')
|
||||
.eq('is_revoked', false)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
tokens = data || [];
|
||||
} catch (err) {
|
||||
console.error('Failed to load tokens:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(tokenId: string) {
|
||||
if (!confirm('Revoke this token? The editor plugin will need a new token to authenticate.')) return;
|
||||
|
||||
revoking = tokenId;
|
||||
try {
|
||||
const client = await SupabaseService.getInstance().getAuthenticatedClient();
|
||||
if (!client) throw new Error('Not authenticated');
|
||||
|
||||
const { error } = await client.rpc('revoke_user_token', { p_token_id: tokenId });
|
||||
if (error) throw error;
|
||||
|
||||
tokens = tokens.filter(t => t.id !== tokenId);
|
||||
onRevoke();
|
||||
} catch (err) {
|
||||
console.error('Failed to revoke token:', err);
|
||||
} finally {
|
||||
revoking = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return 'Never';
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="token-list">
|
||||
{#if loading}
|
||||
<p class="loading">Loading tokens...</p>
|
||||
{:else if tokens.length === 0}
|
||||
<p class="empty">No active tokens. Generate one to use with the editor plugin.</p>
|
||||
{:else}
|
||||
{#each tokens as token (token.id)}
|
||||
<div class="token-item">
|
||||
<div class="token-info">
|
||||
<span class="token-prefix">{token.token_prefix}...</span>
|
||||
<span class="token-meta">Created {formatDate(token.created_at)}</span>
|
||||
{#if token.last_used_at}
|
||||
<span class="token-meta">Last used {formatDate(token.last_used_at)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Button variant="danger" on:click={() => handleRevoke(token.id)} disabled={revoking === token.id}>
|
||||
{revoking === token.id ? 'Revoking...' : 'Revoke'}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.token-list { margin-top: 1rem; }
|
||||
.token-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.token-info { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.token-prefix { font-family: monospace; font-size: 1rem; color: #4fc3f7; }
|
||||
.token-meta { font-size: 0.75rem; color: #888; }
|
||||
.loading, .empty { color: #888; font-style: italic; }
|
||||
</style>
|
||||
@ -36,6 +36,7 @@ export class Rock {
|
||||
|
||||
interface RockConfig {
|
||||
position: Vector3;
|
||||
rotation?: Vector3;
|
||||
scale: number;
|
||||
linearVelocity: Vector3;
|
||||
angularVelocity: Vector3;
|
||||
@ -170,7 +171,8 @@ export class RockFactory {
|
||||
useOrbitConstraint: boolean = true,
|
||||
hidden: boolean = false,
|
||||
targetPosition?: Vector3,
|
||||
targetMode?: 'orbit' | 'moveToward'
|
||||
targetMode?: 'orbit' | 'moveToward',
|
||||
rotation?: Vector3
|
||||
): Rock {
|
||||
if (!this._asteroidMesh) {
|
||||
throw new Error('[RockFactory] Asteroid mesh not loaded. Call initMesh() first.');
|
||||
@ -179,6 +181,7 @@ export class RockFactory {
|
||||
const rock = new InstancedMesh("asteroid-" + i, this._asteroidMesh as Mesh);
|
||||
rock.scaling = new Vector3(scale, scale, scale);
|
||||
rock.position = position;
|
||||
if (rotation) rock.rotation = rotation;
|
||||
rock.name = "asteroid-" + i;
|
||||
rock.id = "asteroid-" + i;
|
||||
rock.metadata = { type: 'asteroid' };
|
||||
@ -188,6 +191,7 @@ export class RockFactory {
|
||||
// Store config for deferred physics initialization
|
||||
const config: RockConfig = {
|
||||
position,
|
||||
rotation,
|
||||
scale,
|
||||
linearVelocity,
|
||||
angularVelocity,
|
||||
|
||||
@ -36,7 +36,8 @@ export default class StarBase {
|
||||
public static async addToScene(
|
||||
position?: Vector3Array,
|
||||
baseGlbPath: string = 'base.glb',
|
||||
hidden: boolean = false
|
||||
hidden: boolean = false,
|
||||
rotation?: Vector3Array
|
||||
): Promise<StarBaseMeshResult> {
|
||||
const importMeshes = await loadAsset(baseGlbPath, "default", { hidden });
|
||||
|
||||
@ -49,9 +50,12 @@ export default class StarBase {
|
||||
baseMesh.metadata.baseGlbPath = baseGlbPath;
|
||||
}
|
||||
|
||||
// Apply position
|
||||
(importMeshes.container.rootNodes[0] as TransformNode).position =
|
||||
position ? new Vector3(position[0], position[1], position[2]) : new Vector3(0, 0, 0);
|
||||
// Apply position and rotation to root node
|
||||
const rootNode = importMeshes.container.rootNodes[0] as TransformNode;
|
||||
rootNode.position = position ? new Vector3(position[0], position[1], position[2]) : new Vector3(0, 0, 0);
|
||||
if (rotation) {
|
||||
rootNode.rotation = new Vector3(rotation[0], rotation[1], rotation[2]);
|
||||
}
|
||||
|
||||
this._loadedBase = { baseMesh, landingMesh, container: importMeshes.container };
|
||||
|
||||
|
||||
@ -75,6 +75,7 @@ export interface ShipConfig {
|
||||
*/
|
||||
interface StartBaseConfig {
|
||||
position?: Vector3Array; // Defaults to [0, 0, 0] if not specified
|
||||
rotation?: Vector3Array;
|
||||
baseGlbPath?: string; // Path to base GLB model (defaults to 'base.glb')
|
||||
landingGlbPath?: string; // Path to landing zone GLB model (uses same file as base, different mesh name)
|
||||
}
|
||||
@ -84,6 +85,7 @@ interface StartBaseConfig {
|
||||
*/
|
||||
export interface SunConfig {
|
||||
position: Vector3Array;
|
||||
rotation?: Vector3Array;
|
||||
diameter: number;
|
||||
intensity?: number; // Light intensity
|
||||
scale?: Vector3Array; // Independent x/y/z scaling
|
||||
@ -126,6 +128,7 @@ export interface TargetConfig {
|
||||
export interface AsteroidConfig {
|
||||
id: string;
|
||||
position: Vector3Array;
|
||||
rotation?: Vector3Array;
|
||||
scale: number; // Uniform scale applied to all axes
|
||||
linearVelocity: Vector3Array;
|
||||
angularVelocity?: Vector3Array;
|
||||
@ -134,18 +137,6 @@ export interface AsteroidConfig {
|
||||
targetMode?: 'orbit' | 'moveToward'; // How asteroid interacts with target
|
||||
}
|
||||
|
||||
/**
|
||||
* Difficulty configuration settings
|
||||
*/
|
||||
interface DifficultyConfig {
|
||||
rockCount: number;
|
||||
forceMultiplier: number;
|
||||
rockSizeMin: number;
|
||||
rockSizeMax: number;
|
||||
distanceMin: number;
|
||||
distanceMax: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete level configuration
|
||||
*/
|
||||
@ -170,9 +161,6 @@ export interface LevelConfig {
|
||||
planets: PlanetConfig[];
|
||||
asteroids: AsteroidConfig[];
|
||||
|
||||
// Optional: include original difficulty config for reference
|
||||
difficultyConfig?: DifficultyConfig;
|
||||
|
||||
// Physics configuration
|
||||
useOrbitConstraints?: boolean; // Default: true - constrains asteroids to orbit at fixed distance
|
||||
|
||||
|
||||
@ -131,8 +131,9 @@ export class LevelDeserializer {
|
||||
*/
|
||||
private async createStartBaseMesh(hidden: boolean) {
|
||||
const position = this.config.startBase?.position;
|
||||
const rotation = this.config.startBase?.rotation;
|
||||
const baseGlbPath = this.config.startBase?.baseGlbPath || 'base.glb';
|
||||
return await StarBase.addToScene(position, baseGlbPath, hidden);
|
||||
return await StarBase.addToScene(position, baseGlbPath, hidden, rotation);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -161,6 +162,9 @@ export class LevelDeserializer {
|
||||
if (config.scale) {
|
||||
sun.scaling = this.arrayToVector3(config.scale);
|
||||
}
|
||||
if (config.rotation) {
|
||||
sun.rotation = this.arrayToVector3(config.rotation);
|
||||
}
|
||||
|
||||
return sun;
|
||||
}
|
||||
@ -217,6 +221,9 @@ export class LevelDeserializer {
|
||||
material.unlit = true;
|
||||
planet.material = material;
|
||||
planet.renderingGroupId = 2;
|
||||
if (planetConfig.rotation) {
|
||||
planet.rotation = this.arrayToVector3(planetConfig.rotation);
|
||||
}
|
||||
|
||||
planets.push(planet);
|
||||
this._planets.push({ mesh: planet, diameter: planetConfig.diameter });
|
||||
@ -249,6 +256,7 @@ export class LevelDeserializer {
|
||||
}
|
||||
|
||||
// Create mesh only (no physics)
|
||||
const rotation = asteroidConfig.rotation ? this.arrayToVector3(asteroidConfig.rotation) : undefined;
|
||||
RockFactory.createRockMesh(
|
||||
i,
|
||||
this.arrayToVector3(asteroidConfig.position),
|
||||
@ -259,7 +267,8 @@ export class LevelDeserializer {
|
||||
useOrbitConstraints,
|
||||
hidden,
|
||||
targetPosition,
|
||||
asteroidConfig.targetMode
|
||||
asteroidConfig.targetMode,
|
||||
rotation
|
||||
);
|
||||
|
||||
const mesh = this.scene.getMeshByName(asteroidConfig.id);
|
||||
|
||||
@ -388,6 +388,9 @@ export class Level1 implements Level {
|
||||
// Get ship config and add ship to scene
|
||||
const shipConfig = this._deserializer.getShipConfig();
|
||||
await this._ship.addToScene(new Vector3(...shipConfig.position), hidden);
|
||||
if (shipConfig.rotation) {
|
||||
this._ship.transformNode.rotation = new Vector3(...shipConfig.rotation);
|
||||
}
|
||||
|
||||
// Create XR camera rig
|
||||
const cameraRig = new TransformNode("xrCameraRig", DefaultScene.MainScene);
|
||||
|
||||
@ -215,10 +215,27 @@ export class CloudLevelService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a level by ID
|
||||
* Get a level by ID (tries authenticated client first for private levels)
|
||||
*/
|
||||
public async getLevelById(id: string): Promise<CloudLevelEntry | null> {
|
||||
const client = SupabaseService.getInstance().getClient();
|
||||
const supabaseService = SupabaseService.getInstance();
|
||||
|
||||
// Try authenticated client first (needed for private levels)
|
||||
const authClient = await supabaseService.getAuthenticatedClient();
|
||||
if (authClient) {
|
||||
const { data, error } = await authClient
|
||||
.from('levels')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!error && data) {
|
||||
return rowToEntry(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to public client for public levels
|
||||
const client = supabaseService.getClient();
|
||||
if (!client) {
|
||||
log.warn('[CloudLevelService] Supabase not configured');
|
||||
return null;
|
||||
@ -228,12 +245,10 @@ export class CloudLevelService {
|
||||
.from('levels')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
if (error.code !== 'PGRST116') { // Not found is not an error
|
||||
log.error('[CloudLevelService] Failed to fetch level:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -774,7 +789,7 @@ export class CloudLevelService {
|
||||
.select('can_review_levels, can_manage_admins, can_manage_official, can_view_analytics')
|
||||
.eq('user_id', internalUserId)
|
||||
.eq('is_active', true)
|
||||
.single();
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
log.warn('[CloudLevelService] Admin query error:', error.message, error.code);
|
||||
|
||||
188
supabase/migrations/005_user_tokens.sql
Normal file
188
supabase/migrations/005_user_tokens.sql
Normal file
@ -0,0 +1,188 @@
|
||||
-- ===========================================
|
||||
-- USER TOKENS MIGRATION
|
||||
-- API tokens for editor plugin authentication
|
||||
-- ===========================================
|
||||
|
||||
-- ===========================================
|
||||
-- USER_TOKENS TABLE
|
||||
-- ===========================================
|
||||
CREATE TABLE IF NOT EXISTS user_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Editor Token',
|
||||
token_hash TEXT NOT NULL, -- SHA256 hash of token
|
||||
token_prefix TEXT NOT NULL, -- First 8 chars for display (e.g., "abc12345...")
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ, -- NULL = never expires
|
||||
is_revoked BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_tokens_user_id ON user_tokens(user_id);
|
||||
CREATE INDEX idx_user_tokens_hash ON user_tokens(token_hash);
|
||||
|
||||
-- ===========================================
|
||||
-- FUNCTION: Create a new token for current user
|
||||
-- Returns the raw token (only time it's visible)
|
||||
-- ===========================================
|
||||
CREATE OR REPLACE FUNCTION create_user_token(p_name TEXT DEFAULT 'Editor Token')
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_raw_token TEXT;
|
||||
v_token_hash TEXT;
|
||||
BEGIN
|
||||
-- Get current user's internal ID
|
||||
v_user_id := auth_user_id();
|
||||
IF v_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Not authenticated';
|
||||
END IF;
|
||||
|
||||
-- Generate secure random token (32 bytes = 64 hex chars)
|
||||
v_raw_token := encode(gen_random_bytes(32), 'hex');
|
||||
v_token_hash := encode(sha256(v_raw_token::bytea), 'hex');
|
||||
|
||||
-- Insert token record
|
||||
INSERT INTO user_tokens (user_id, name, token_hash, token_prefix)
|
||||
VALUES (v_user_id, p_name, v_token_hash, substring(v_raw_token, 1, 8));
|
||||
|
||||
-- Return raw token (only time user sees it)
|
||||
RETURN v_raw_token;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- ===========================================
|
||||
-- FUNCTION: Validate token and return user_id
|
||||
-- Used by plugin to authenticate requests
|
||||
-- ===========================================
|
||||
CREATE OR REPLACE FUNCTION validate_user_token(p_token TEXT)
|
||||
RETURNS UUID AS $$
|
||||
DECLARE
|
||||
v_token_hash TEXT;
|
||||
v_user_id UUID;
|
||||
BEGIN
|
||||
v_token_hash := encode(sha256(p_token::bytea), 'hex');
|
||||
|
||||
SELECT user_id INTO v_user_id
|
||||
FROM user_tokens
|
||||
WHERE token_hash = v_token_hash
|
||||
AND is_revoked = FALSE
|
||||
AND (expires_at IS NULL OR expires_at > NOW());
|
||||
|
||||
-- Update last_used_at if valid
|
||||
IF v_user_id IS NOT NULL THEN
|
||||
UPDATE user_tokens
|
||||
SET last_used_at = NOW()
|
||||
WHERE token_hash = v_token_hash;
|
||||
END IF;
|
||||
|
||||
RETURN v_user_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- ===========================================
|
||||
-- FUNCTION: Revoke a token
|
||||
-- ===========================================
|
||||
CREATE OR REPLACE FUNCTION revoke_user_token(p_token_id UUID)
|
||||
RETURNS BOOLEAN AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
BEGIN
|
||||
v_user_id := auth_user_id();
|
||||
IF v_user_id IS NULL THEN
|
||||
RETURN FALSE;
|
||||
END IF;
|
||||
|
||||
UPDATE user_tokens
|
||||
SET is_revoked = TRUE
|
||||
WHERE id = p_token_id AND user_id = v_user_id;
|
||||
|
||||
RETURN FOUND;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- ===========================================
|
||||
-- RLS POLICIES
|
||||
-- ===========================================
|
||||
ALTER TABLE user_tokens ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can only see their own tokens
|
||||
CREATE POLICY "user_tokens_select_own" ON user_tokens FOR SELECT
|
||||
USING (user_id = auth_user_id());
|
||||
|
||||
-- Users can only delete their own tokens
|
||||
CREATE POLICY "user_tokens_delete_own" ON user_tokens FOR DELETE
|
||||
USING (user_id = auth_user_id());
|
||||
|
||||
-- Insert/update via functions only (SECURITY DEFINER)
|
||||
|
||||
-- ===========================================
|
||||
-- RPC FUNCTIONS FOR PLUGIN ACCESS
|
||||
-- These bypass RLS using the editor token
|
||||
-- ===========================================
|
||||
|
||||
-- Get user's levels using editor token
|
||||
CREATE OR REPLACE FUNCTION get_my_levels_by_token(p_token TEXT)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
difficulty TEXT,
|
||||
level_type TEXT,
|
||||
config JSONB,
|
||||
updated_at TIMESTAMPTZ
|
||||
) AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
BEGIN
|
||||
v_user_id := validate_user_token(p_token);
|
||||
IF v_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Invalid or expired token';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT l.id, l.name, l.description, l.difficulty, l.level_type, l.config, l.updated_at
|
||||
FROM levels l
|
||||
WHERE l.user_id = v_user_id
|
||||
ORDER BY l.updated_at DESC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Save/update a level using editor token
|
||||
CREATE OR REPLACE FUNCTION save_level_by_token(
|
||||
p_token TEXT,
|
||||
p_name TEXT,
|
||||
p_difficulty TEXT,
|
||||
p_config JSONB,
|
||||
p_level_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS UUID AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_result_id UUID;
|
||||
BEGIN
|
||||
v_user_id := validate_user_token(p_token);
|
||||
IF v_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Invalid or expired token';
|
||||
END IF;
|
||||
|
||||
IF p_level_id IS NOT NULL THEN
|
||||
-- Update existing level (only if owned by user)
|
||||
UPDATE levels
|
||||
SET name = p_name, difficulty = p_difficulty, config = p_config, updated_at = NOW()
|
||||
WHERE id = p_level_id AND user_id = v_user_id
|
||||
RETURNING id INTO v_result_id;
|
||||
|
||||
IF v_result_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Level not found or not owned by user';
|
||||
END IF;
|
||||
ELSE
|
||||
-- Create new level
|
||||
INSERT INTO levels (user_id, name, difficulty, config, level_type)
|
||||
VALUES (v_user_id, p_name, p_difficulty, p_config, 'private')
|
||||
RETURNING id INTO v_result_id;
|
||||
END IF;
|
||||
|
||||
RETURN v_result_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
Loading…
Reference in New Issue
Block a user