Add level editor for superadmins
- Create LevelEditor.svelte with permission-gated level list - Create LevelEditForm.svelte for editing level metadata - Add getAllLevelsForAdmin() and updateLevelAsAdmin() to CloudLevelService - Add /editor/:levelId route to App.svelte - Show Level Editor link in header only for admins with canManageOfficial - Add migration to use internal UUID for admins table - Fix auth0_sub -> auth0_id column name in supabaseService 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6655abeeec
commit
ad2656a61f
248
src/components/editor/LevelEditForm.svelte
Normal file
248
src/components/editor/LevelEditForm.svelte
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { Link, navigate } from 'svelte-routing';
|
||||||
|
import { CloudLevelService, type CloudLevelEntry } from '../../services/cloudLevelService';
|
||||||
|
import Button from '../shared/Button.svelte';
|
||||||
|
import Section from '../shared/Section.svelte';
|
||||||
|
import FormGroup from '../shared/FormGroup.svelte';
|
||||||
|
import Select from '../shared/Select.svelte';
|
||||||
|
import Checkbox from '../shared/Checkbox.svelte';
|
||||||
|
import NumberInput from '../shared/NumberInput.svelte';
|
||||||
|
import InfoBox from '../shared/InfoBox.svelte';
|
||||||
|
|
||||||
|
export let levelId: string = '';
|
||||||
|
|
||||||
|
let isLoading = true;
|
||||||
|
let isAuthorized = false;
|
||||||
|
let isSaving = false;
|
||||||
|
let level: CloudLevelEntry | null = null;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let name = '';
|
||||||
|
let slug = '';
|
||||||
|
let description = '';
|
||||||
|
let difficulty = 'pilot';
|
||||||
|
let estimatedTime = '';
|
||||||
|
let tags = '';
|
||||||
|
let sortOrder = 0;
|
||||||
|
let defaultLocked = false;
|
||||||
|
let levelType = 'private';
|
||||||
|
let missionBriefText = '';
|
||||||
|
|
||||||
|
// Message state
|
||||||
|
let message = '';
|
||||||
|
let messageType: 'success' | 'error' | 'warning' = 'success';
|
||||||
|
let showMessage = false;
|
||||||
|
|
||||||
|
const difficultyOptions = [
|
||||||
|
{ value: 'recruit', label: 'Recruit' },
|
||||||
|
{ value: 'pilot', label: 'Pilot' },
|
||||||
|
{ value: 'captain', label: 'Captain' },
|
||||||
|
{ value: 'commander', label: 'Commander' },
|
||||||
|
{ value: 'test', label: 'Test' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const levelTypeOptions = [
|
||||||
|
{ value: 'official', label: 'Official' },
|
||||||
|
{ value: 'private', label: 'Private' },
|
||||||
|
{ value: 'pending_review', label: 'Pending Review' },
|
||||||
|
{ value: 'published', label: 'Published' },
|
||||||
|
{ value: 'rejected', label: 'Rejected' }
|
||||||
|
];
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadLevel();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadLevel() {
|
||||||
|
isLoading = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const service = CloudLevelService.getInstance();
|
||||||
|
const permissions = await service.getAdminPermissions();
|
||||||
|
|
||||||
|
if (!permissions?.canManageOfficial) {
|
||||||
|
isAuthorized = false;
|
||||||
|
isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthorized = true;
|
||||||
|
level = await service.getLevelById(levelId);
|
||||||
|
|
||||||
|
if (!level) {
|
||||||
|
error = 'Level not found';
|
||||||
|
isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate form fields
|
||||||
|
name = level.name;
|
||||||
|
slug = level.slug || '';
|
||||||
|
description = level.description || '';
|
||||||
|
difficulty = level.difficulty || 'pilot';
|
||||||
|
estimatedTime = level.estimatedTime || '';
|
||||||
|
tags = level.tags?.join(', ') || '';
|
||||||
|
sortOrder = level.sortOrder || 0;
|
||||||
|
defaultLocked = level.defaultLocked || false;
|
||||||
|
levelType = level.levelType || 'private';
|
||||||
|
missionBriefText = level.missionBrief?.join('\n') || '';
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Failed to load level';
|
||||||
|
console.error('[LevelEditForm] Error:', err);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!level) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
showMessage = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const service = CloudLevelService.getInstance();
|
||||||
|
const tagsArray = tags.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||||
|
const missionBriefArray = missionBriefText.split('\n').filter(l => l.trim().length > 0);
|
||||||
|
|
||||||
|
const updated = await service.updateLevelAsAdmin(level.id, {
|
||||||
|
name,
|
||||||
|
slug: slug || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
difficulty,
|
||||||
|
estimatedTime: estimatedTime || undefined,
|
||||||
|
tags: tagsArray,
|
||||||
|
sortOrder,
|
||||||
|
defaultLocked,
|
||||||
|
levelType,
|
||||||
|
missionBrief: missionBriefArray
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
level = updated;
|
||||||
|
message = 'Level saved successfully!';
|
||||||
|
messageType = 'success';
|
||||||
|
} else {
|
||||||
|
message = 'Failed to save level';
|
||||||
|
messageType = 'error';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
message = 'Error saving level';
|
||||||
|
messageType = 'error';
|
||||||
|
console.error('[LevelEditForm] Save error:', err);
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
showMessage = true;
|
||||||
|
setTimeout(() => { showMessage = false; }, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
navigate('/editor');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="editor-container">
|
||||||
|
<Link to="/editor" class="back-link">← Back to Level List</Link>
|
||||||
|
|
||||||
|
<h1>📝 Edit Level</h1>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<Section title="Loading...">
|
||||||
|
<p>Loading level data...</p>
|
||||||
|
</Section>
|
||||||
|
{:else if !isAuthorized}
|
||||||
|
<Section title="🚫 Access Denied">
|
||||||
|
<p>You do not have permission to edit levels.</p>
|
||||||
|
</Section>
|
||||||
|
{:else if error}
|
||||||
|
<Section title="❌ Error">
|
||||||
|
<p>{error}</p>
|
||||||
|
<Button variant="secondary" on:click={() => navigate('/editor')}>Back to List</Button>
|
||||||
|
</Section>
|
||||||
|
{:else if level}
|
||||||
|
<p class="subtitle">Editing: {level.name}</p>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<Section title="Basic Info">
|
||||||
|
<FormGroup label="Name" helpText="Display name for the level">
|
||||||
|
<input type="text" class="settings-input" bind:value={name} />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Slug" helpText="URL-friendly identifier (optional)">
|
||||||
|
<input type="text" class="settings-input" bind:value={slug} placeholder="e.g., mission-1" />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Description" helpText="Brief description of the level">
|
||||||
|
<textarea class="settings-textarea" bind:value={description} rows="3"></textarea>
|
||||||
|
</FormGroup>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Settings">
|
||||||
|
<FormGroup label="Difficulty">
|
||||||
|
<Select bind:value={difficulty} options={difficultyOptions} />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Level Type">
|
||||||
|
<Select bind:value={levelType} options={levelTypeOptions} />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Sort Order" helpText="Order in level list (lower = first)">
|
||||||
|
<NumberInput bind:value={sortOrder} min={0} max={1000} step={1} />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Estimated Time" helpText="e.g., '2-3 min'">
|
||||||
|
<input type="text" class="settings-input" bind:value={estimatedTime} />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Locked by Default">
|
||||||
|
<Checkbox bind:checked={defaultLocked} label="Require unlock" />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup label="Tags" helpText="Comma-separated tags">
|
||||||
|
<input type="text" class="settings-input" bind:value={tags} placeholder="action, hard" />
|
||||||
|
</FormGroup>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Mission Brief" description="Text shown before level starts (one line per entry)">
|
||||||
|
<textarea class="settings-textarea mission-brief" bind:value={missionBriefText} rows="6"
|
||||||
|
placeholder="Welcome to your first mission... Navigate through the asteroid field..."></textarea>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<Button variant="primary" on:click={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? 'Saving...' : '💾 Save Changes'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" on:click={handleCancel} disabled={isSaving}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InfoBox {message} type={messageType} visible={showMessage} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.settings-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--color-border, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--color-bg-input, #1a1a2e);
|
||||||
|
color: var(--color-text-primary, #fff);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-brief {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,29 +1,118 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Link } from 'svelte-routing';
|
import { onMount } from 'svelte';
|
||||||
|
import { Link, navigate } from 'svelte-routing';
|
||||||
|
import { CloudLevelService, type CloudLevelEntry } from '../../services/cloudLevelService';
|
||||||
import Button from '../shared/Button.svelte';
|
import Button from '../shared/Button.svelte';
|
||||||
import Section from '../shared/Section.svelte';
|
import Section from '../shared/Section.svelte';
|
||||||
|
|
||||||
// This is a simplified stub - full implementation would be much larger
|
let isLoading = true;
|
||||||
// For now, just show a placeholder
|
let isAuthorized = false;
|
||||||
|
let levels: CloudLevelEntry[] = [];
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await checkPermissionsAndLoad();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function checkPermissionsAndLoad() {
|
||||||
|
isLoading = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const service = CloudLevelService.getInstance();
|
||||||
|
const permissions = await service.getAdminPermissions();
|
||||||
|
console.log('[LevelEditor] Admin permissions:', permissions);
|
||||||
|
|
||||||
|
if (!permissions?.canManageOfficial) {
|
||||||
|
console.log('[LevelEditor] Access denied - canManageOfficial:', permissions?.canManageOfficial);
|
||||||
|
isAuthorized = false;
|
||||||
|
isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthorized = true;
|
||||||
|
levels = await service.getAllLevelsForAdmin();
|
||||||
|
console.log('[LevelEditor] Loaded levels:', levels.length);
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Failed to load levels';
|
||||||
|
console.error('[LevelEditor] Error:', err);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(levelId: string) {
|
||||||
|
navigate(`/editor/${levelId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLevelTypeLabel(levelType: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
official: '🏆 Official',
|
||||||
|
private: '🔒 Private',
|
||||||
|
pending_review: '⏳ Pending',
|
||||||
|
published: '🌐 Published',
|
||||||
|
rejected: '❌ Rejected'
|
||||||
|
};
|
||||||
|
return labels[levelType] || levelType;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<Link to="/" class="back-link">← Back to Game</Link>
|
<Link to="/" class="back-link">← Back to Game</Link>
|
||||||
|
|
||||||
<h1>📝 Level Editor</h1>
|
<h1>📝 Level Editor</h1>
|
||||||
<p class="subtitle">Create and customize your own asteroid field levels</p>
|
<p class="subtitle">Manage and edit game levels (Superadmin Only)</p>
|
||||||
|
|
||||||
<Section title="🚧 Under Construction">
|
{#if isLoading}
|
||||||
<p>The level editor is being migrated to Svelte.</p>
|
<Section title="Loading...">
|
||||||
<p>This component will include:</p>
|
<p>Checking permissions and loading levels...</p>
|
||||||
<ul>
|
</Section>
|
||||||
<li>Difficulty presets</li>
|
{:else if !isAuthorized}
|
||||||
<li>Ship, base, and celestial object configuration</li>
|
<Section title="🚫 Access Denied">
|
||||||
<li>Asteroid generation settings</li>
|
<p>You do not have permission to access the level editor.</p>
|
||||||
<li>JSON editor</li>
|
<p>This feature requires superadmin (canManageOfficial) privileges.</p>
|
||||||
<li>Save/load functionality</li>
|
</Section>
|
||||||
</ul>
|
{:else if error}
|
||||||
</Section>
|
<Section title="❌ Error">
|
||||||
|
<p>{error}</p>
|
||||||
|
<Button variant="secondary" on:click={checkPermissionsAndLoad}>Retry</Button>
|
||||||
|
</Section>
|
||||||
|
{:else}
|
||||||
|
<Section title="All Levels ({levels.length})">
|
||||||
|
{#if levels.length === 0}
|
||||||
|
<p>No levels found.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="level-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Difficulty</th>
|
||||||
|
<th>Order</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each levels as level}
|
||||||
|
<tr>
|
||||||
|
<td class="level-name">{level.name}</td>
|
||||||
|
<td class="level-type">{getLevelTypeLabel(level.levelType)}</td>
|
||||||
|
<td class="level-difficulty">{level.difficulty}</td>
|
||||||
|
<td class="level-order">{level.sortOrder}</td>
|
||||||
|
<td class="level-actions">
|
||||||
|
<Button variant="primary" on:click={() => handleEdit(level.id)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<Button variant="secondary" on:click={() => history.back()}>← Back</Button>
|
<Button variant="secondary" on:click={() => history.back()}>← Back</Button>
|
||||||
@ -31,12 +120,49 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
ul {
|
.level-table {
|
||||||
padding-left: var(--space-lg, 1.5rem);
|
overflow-x: auto;
|
||||||
color: var(--color-text-secondary, #ccc);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
table {
|
||||||
margin: var(--space-xs, 0.25rem) 0;
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: var(--color-bg-secondary, #1a1a2e);
|
||||||
|
color: var(--color-text-primary, #fff);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: var(--color-bg-hover, #252540);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-type {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-difficulty {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-order {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-actions {
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
import LevelSelect from '../game/LevelSelect.svelte';
|
import LevelSelect from '../game/LevelSelect.svelte';
|
||||||
import PlayLevel from '../game/PlayLevel.svelte';
|
import PlayLevel from '../game/PlayLevel.svelte';
|
||||||
import LevelEditor from '../editor/LevelEditor.svelte';
|
import LevelEditor from '../editor/LevelEditor.svelte';
|
||||||
|
import LevelEditForm from '../editor/LevelEditForm.svelte';
|
||||||
import SettingsScreen from '../settings/SettingsScreen.svelte';
|
import SettingsScreen from '../settings/SettingsScreen.svelte';
|
||||||
import ControlsScreen from '../controls/ControlsScreen.svelte';
|
import ControlsScreen from '../controls/ControlsScreen.svelte';
|
||||||
import Leaderboard from '../leaderboard/Leaderboard.svelte';
|
import Leaderboard from '../leaderboard/Leaderboard.svelte';
|
||||||
@ -45,6 +46,9 @@
|
|||||||
<PlayLevel {params} />
|
<PlayLevel {params} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/editor"><LevelEditor /></Route>
|
<Route path="/editor"><LevelEditor /></Route>
|
||||||
|
<Route path="/editor/:levelId" let:params>
|
||||||
|
<LevelEditForm levelId={params.levelId} />
|
||||||
|
</Route>
|
||||||
<Route path="/settings"><SettingsScreen /></Route>
|
<Route path="/settings"><SettingsScreen /></Route>
|
||||||
<Route path="/controls"><ControlsScreen /></Route>
|
<Route path="/controls"><ControlsScreen /></Route>
|
||||||
<Route path="/leaderboard"><Leaderboard /></Route>
|
<Route path="/leaderboard"><Leaderboard /></Route>
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { Link } from 'svelte-routing';
|
import { Link } from 'svelte-routing';
|
||||||
import { authStore } from '../../stores/auth';
|
import { authStore } from '../../stores/auth';
|
||||||
|
import { CloudLevelService } from '../../services/cloudLevelService';
|
||||||
import UserProfile from '../auth/UserProfile.svelte';
|
import UserProfile from '../auth/UserProfile.svelte';
|
||||||
|
|
||||||
let visible = false;
|
let visible = false;
|
||||||
|
let isAdmin = false;
|
||||||
|
|
||||||
// Show header when not in game
|
// Show header when not in game
|
||||||
$: visible = true; // We'll control visibility via parent component if needed
|
$: visible = true; // We'll control visibility via parent component if needed
|
||||||
@ -16,6 +19,18 @@
|
|||||||
$: webLaunchUrl = typeof window !== 'undefined'
|
$: webLaunchUrl = typeof window !== 'undefined'
|
||||||
? `https://www.oculus.com/open_url/?url=${encodeURIComponent(window.location.href)}`
|
? `https://www.oculus.com/open_url/?url=${encodeURIComponent(window.location.href)}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// Check admin permissions when auth changes
|
||||||
|
$: if ($authStore.isAuthenticated) {
|
||||||
|
checkAdminStatus();
|
||||||
|
} else {
|
||||||
|
isAdmin = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAdminStatus() {
|
||||||
|
const permissions = await CloudLevelService.getInstance().getAdminPermissions();
|
||||||
|
isAdmin = permissions?.canManageOfficial ?? false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if visible}
|
{#if visible}
|
||||||
@ -33,7 +48,9 @@
|
|||||||
<Link to="/controls" class="nav-link controls-link">🎮 Customize Controls</Link>
|
<Link to="/controls" class="nav-link controls-link">🎮 Customize Controls</Link>
|
||||||
<Link to="/leaderboard" class="nav-link leaderboard-link">🏆 Leaderboard</Link>
|
<Link to="/leaderboard" class="nav-link leaderboard-link">🏆 Leaderboard</Link>
|
||||||
<UserProfile />
|
<UserProfile />
|
||||||
<Link to="/editor" class="nav-link editor-link">📝 Level Editor</Link>
|
{#if isAdmin}
|
||||||
|
<Link to="/editor" class="nav-link">📝 Level Editor</Link>
|
||||||
|
{/if}
|
||||||
<Link to="/settings" class="nav-link settings-link">⚙️ Settings</Link>
|
<Link to="/settings" class="nav-link settings-link">⚙️ Settings</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { SupabaseService } from './supabaseService';
|
import { SupabaseService } from './supabaseService';
|
||||||
import { AuthService } from './authService';
|
|
||||||
import type { LevelConfig } from '../levels/config/levelConfig';
|
import type { LevelConfig } from '../levels/config/levelConfig';
|
||||||
import log from '../core/logger';
|
import log from '../core/logger';
|
||||||
|
|
||||||
@ -617,6 +616,100 @@ export class CloudLevelService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================
|
||||||
|
// ADMIN LEVEL MANAGEMENT
|
||||||
|
// =========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all levels for admin editing (requires canManageOfficial)
|
||||||
|
*/
|
||||||
|
public async getAllLevelsForAdmin(): Promise<CloudLevelEntry[]> {
|
||||||
|
const permissions = await this.getAdminPermissions();
|
||||||
|
if (!permissions?.canManageOfficial) {
|
||||||
|
log.warn('[CloudLevelService] Not authorized to view all levels');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await SupabaseService.getInstance().getAuthenticatedClient();
|
||||||
|
if (!client) {
|
||||||
|
log.warn('[CloudLevelService] Not authenticated');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('levels')
|
||||||
|
.select('*')
|
||||||
|
.order('level_type', { ascending: true })
|
||||||
|
.order('sort_order', { ascending: true })
|
||||||
|
.order('name', { ascending: true });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('[CloudLevelService] Failed to fetch all levels:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (data || []).map(rowToEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a level as admin (can update additional fields)
|
||||||
|
*/
|
||||||
|
public async updateLevelAsAdmin(
|
||||||
|
id: string,
|
||||||
|
updates: {
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
difficulty?: string;
|
||||||
|
estimatedTime?: string;
|
||||||
|
tags?: string[];
|
||||||
|
config?: LevelConfig;
|
||||||
|
missionBrief?: string[];
|
||||||
|
sortOrder?: number;
|
||||||
|
defaultLocked?: boolean;
|
||||||
|
levelType?: string;
|
||||||
|
}
|
||||||
|
): Promise<CloudLevelEntry | null> {
|
||||||
|
const permissions = await this.getAdminPermissions();
|
||||||
|
if (!permissions?.canManageOfficial) {
|
||||||
|
log.warn('[CloudLevelService] Not authorized to update level as admin');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await SupabaseService.getInstance().getAuthenticatedClient();
|
||||||
|
if (!client) {
|
||||||
|
log.warn('[CloudLevelService] Not authenticated');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (updates.name !== undefined) updateData.name = updates.name;
|
||||||
|
if (updates.slug !== undefined) updateData.slug = updates.slug;
|
||||||
|
if (updates.description !== undefined) updateData.description = updates.description;
|
||||||
|
if (updates.difficulty !== undefined) updateData.difficulty = updates.difficulty;
|
||||||
|
if (updates.estimatedTime !== undefined) updateData.estimated_time = updates.estimatedTime;
|
||||||
|
if (updates.tags !== undefined) updateData.tags = updates.tags;
|
||||||
|
if (updates.config !== undefined) updateData.config = updates.config;
|
||||||
|
if (updates.missionBrief !== undefined) updateData.mission_brief = updates.missionBrief;
|
||||||
|
if (updates.sortOrder !== undefined) updateData.sort_order = updates.sortOrder;
|
||||||
|
if (updates.defaultLocked !== undefined) updateData.default_locked = updates.defaultLocked;
|
||||||
|
if (updates.levelType !== undefined) updateData.level_type = updates.levelType;
|
||||||
|
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('levels')
|
||||||
|
.update(updateData)
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('[CloudLevelService] Failed to update level as admin:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data ? rowToEntry(data) : null;
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// ADMIN STATUS CHECK
|
// ADMIN STATUS CHECK
|
||||||
// =========================================
|
// =========================================
|
||||||
@ -625,20 +718,22 @@ export class CloudLevelService {
|
|||||||
* Check if current user is an admin
|
* Check if current user is an admin
|
||||||
*/
|
*/
|
||||||
public async isCurrentUserAdmin(): Promise<boolean> {
|
public async isCurrentUserAdmin(): Promise<boolean> {
|
||||||
const client = await SupabaseService.getInstance().getAuthenticatedClient();
|
const supabaseService = SupabaseService.getInstance();
|
||||||
|
const client = await supabaseService.getAuthenticatedClient();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = AuthService.getInstance().getUser();
|
// Get internal user ID (UUID)
|
||||||
if (!user?.sub) {
|
const internalUserId = await supabaseService.ensureUserExists();
|
||||||
|
if (!internalUserId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from('admins')
|
.from('admins')
|
||||||
.select('is_active')
|
.select('is_active')
|
||||||
.eq('user_id', user.sub)
|
.eq('user_id', internalUserId)
|
||||||
.eq('is_active', true)
|
.eq('is_active', true)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@ -658,27 +753,40 @@ export class CloudLevelService {
|
|||||||
canManageOfficial: boolean;
|
canManageOfficial: boolean;
|
||||||
canViewAnalytics: boolean;
|
canViewAnalytics: boolean;
|
||||||
} | null> {
|
} | null> {
|
||||||
const client = await SupabaseService.getInstance().getAuthenticatedClient();
|
const supabaseService = SupabaseService.getInstance();
|
||||||
|
const client = await supabaseService.getAuthenticatedClient();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
log.warn('[CloudLevelService] getAdminPermissions: No authenticated client');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = AuthService.getInstance().getUser();
|
// Get internal user ID (UUID)
|
||||||
if (!user?.sub) {
|
const internalUserId = await supabaseService.ensureUserExists();
|
||||||
|
if (!internalUserId) {
|
||||||
|
log.warn('[CloudLevelService] getAdminPermissions: No internal user ID');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info('[CloudLevelService] Checking admin permissions for user:', internalUserId);
|
||||||
|
|
||||||
const { data, error } = await client
|
const { data, error } = await client
|
||||||
.from('admins')
|
.from('admins')
|
||||||
.select('can_review_levels, can_manage_admins, can_manage_official, can_view_analytics')
|
.select('can_review_levels, can_manage_admins, can_manage_official, can_view_analytics')
|
||||||
.eq('user_id', user.sub)
|
.eq('user_id', internalUserId)
|
||||||
.eq('is_active', true)
|
.eq('is_active', true)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error || !data) {
|
if (error) {
|
||||||
|
log.warn('[CloudLevelService] Admin query error:', error.message, error.code);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
log.warn('[CloudLevelService] No admin record found for user');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('[CloudLevelService] Admin permissions found:', data);
|
||||||
return {
|
return {
|
||||||
canReviewLevels: data.can_review_levels,
|
canReviewLevels: data.can_review_levels,
|
||||||
canManageAdmins: data.can_manage_admins,
|
canManageAdmins: data.can_manage_admins,
|
||||||
|
|||||||
@ -114,7 +114,7 @@ export class SupabaseService {
|
|||||||
const { data: existingUser, error: fetchError } = await client
|
const { data: existingUser, error: fetchError } = await client
|
||||||
.from('users')
|
.from('users')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('auth0_sub', user.sub)
|
.eq('auth0_id', user.sub)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
@ -126,7 +126,7 @@ export class SupabaseService {
|
|||||||
const { data: newUser, error: insertError } = await client
|
const { data: newUser, error: insertError } = await client
|
||||||
.from('users')
|
.from('users')
|
||||||
.insert({
|
.insert({
|
||||||
auth0_sub: user.sub,
|
auth0_id: user.sub,
|
||||||
display_name: user.name || user.nickname || 'Player'
|
display_name: user.name || user.nickname || 'Player'
|
||||||
})
|
})
|
||||||
.select('id')
|
.select('id')
|
||||||
|
|||||||
151
supabase/migrations/004_admins_use_internal_id.sql
Normal file
151
supabase/migrations/004_admins_use_internal_id.sql
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
-- ===========================================
|
||||||
|
-- ADMINS TABLE: Use internal user ID
|
||||||
|
-- Changes admins.user_id from Auth0 TEXT to internal UUID
|
||||||
|
-- ===========================================
|
||||||
|
|
||||||
|
-- ===========================================
|
||||||
|
-- DROP DEPENDENT POLICIES
|
||||||
|
-- ===========================================
|
||||||
|
DROP POLICY IF EXISTS "levels_admin_all" ON levels;
|
||||||
|
|
||||||
|
-- ===========================================
|
||||||
|
-- BACKUP EXISTING ADMINS
|
||||||
|
-- ===========================================
|
||||||
|
CREATE TEMP TABLE temp_admins_backup AS
|
||||||
|
SELECT
|
||||||
|
internal_user_id,
|
||||||
|
display_name,
|
||||||
|
email,
|
||||||
|
can_review_levels,
|
||||||
|
can_manage_admins,
|
||||||
|
can_manage_official,
|
||||||
|
can_view_analytics,
|
||||||
|
is_active,
|
||||||
|
expires_at,
|
||||||
|
created_at,
|
||||||
|
created_by,
|
||||||
|
notes
|
||||||
|
FROM admins
|
||||||
|
WHERE internal_user_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ===========================================
|
||||||
|
-- DROP AND RECREATE ADMINS TABLE
|
||||||
|
-- ===========================================
|
||||||
|
DROP TABLE admins CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE admins (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID UNIQUE NOT NULL REFERENCES users(id), -- Internal UUID, not Auth0
|
||||||
|
|
||||||
|
-- Admin info
|
||||||
|
display_name TEXT,
|
||||||
|
email TEXT,
|
||||||
|
|
||||||
|
-- Permissions
|
||||||
|
can_review_levels BOOLEAN DEFAULT true,
|
||||||
|
can_manage_admins BOOLEAN DEFAULT false,
|
||||||
|
can_manage_official BOOLEAN DEFAULT false,
|
||||||
|
can_view_analytics BOOLEAN DEFAULT false,
|
||||||
|
|
||||||
|
-- Status
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
created_by UUID REFERENCES users(id), -- Also use internal ID
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_admins_user_id ON admins(user_id);
|
||||||
|
CREATE INDEX idx_admins_active ON admins(is_active) WHERE is_active = true;
|
||||||
|
|
||||||
|
-- ===========================================
|
||||||
|
-- RESTORE ADMINS
|
||||||
|
-- ===========================================
|
||||||
|
INSERT INTO admins (
|
||||||
|
user_id, display_name, email,
|
||||||
|
can_review_levels, can_manage_admins, can_manage_official, can_view_analytics,
|
||||||
|
is_active, expires_at, created_at, notes
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
internal_user_id,
|
||||||
|
display_name,
|
||||||
|
email,
|
||||||
|
can_review_levels,
|
||||||
|
can_manage_admins,
|
||||||
|
can_manage_official,
|
||||||
|
can_view_analytics,
|
||||||
|
is_active,
|
||||||
|
expires_at,
|
||||||
|
created_at,
|
||||||
|
notes
|
||||||
|
FROM temp_admins_backup;
|
||||||
|
|
||||||
|
DROP TABLE temp_admins_backup;
|
||||||
|
|
||||||
|
-- ===========================================
|
||||||
|
-- UPDATE HELPER FUNCTIONS
|
||||||
|
-- ===========================================
|
||||||
|
|
||||||
|
-- is_admin: Check if current user is an active admin
|
||||||
|
CREATE OR REPLACE FUNCTION is_admin() RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM admins
|
||||||
|
WHERE user_id = auth_user_id()
|
||||||
|
AND is_active = true
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- has_admin_permission: Check specific permission
|
||||||
|
CREATE OR REPLACE FUNCTION has_admin_permission(permission TEXT) 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;
|
||||||
|
|
||||||
|
RETURN EXISTS (
|
||||||
|
SELECT 1 FROM admins
|
||||||
|
WHERE user_id = v_user_id
|
||||||
|
AND is_active = true
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
AND (
|
||||||
|
(permission = 'can_review_levels' AND can_review_levels = true) OR
|
||||||
|
(permission = 'can_manage_admins' AND can_manage_admins = true) OR
|
||||||
|
(permission = 'can_manage_official' AND can_manage_official = true) OR
|
||||||
|
(permission = 'can_view_analytics' AND can_view_analytics = true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ===========================================
|
||||||
|
-- RECREATE RLS POLICIES
|
||||||
|
-- ===========================================
|
||||||
|
CREATE POLICY "levels_admin_all" ON levels FOR ALL
|
||||||
|
USING (is_admin());
|
||||||
|
|
||||||
|
-- ===========================================
|
||||||
|
-- ENABLE RLS ON ADMINS
|
||||||
|
-- ===========================================
|
||||||
|
ALTER TABLE admins ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Only admins with can_manage_admins can view admin table
|
||||||
|
CREATE POLICY "admins_select" ON admins FOR SELECT
|
||||||
|
USING (has_admin_permission('can_manage_admins') OR user_id = auth_user_id());
|
||||||
|
|
||||||
|
-- Only admins with can_manage_admins can modify
|
||||||
|
CREATE POLICY "admins_insert" ON admins FOR INSERT
|
||||||
|
WITH CHECK (has_admin_permission('can_manage_admins'));
|
||||||
|
|
||||||
|
CREATE POLICY "admins_update" ON admins FOR UPDATE
|
||||||
|
USING (has_admin_permission('can_manage_admins'));
|
||||||
|
|
||||||
|
CREATE POLICY "admins_delete" ON admins FOR DELETE
|
||||||
|
USING (has_admin_permission('can_manage_admins'));
|
||||||
Loading…
Reference in New Issue
Block a user