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">
|
||||
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 Section from '../shared/Section.svelte';
|
||||
|
||||
// This is a simplified stub - full implementation would be much larger
|
||||
// For now, just show a placeholder
|
||||
let isLoading = true;
|
||||
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>
|
||||
|
||||
<div class="editor-container">
|
||||
<Link to="/" class="back-link">← Back to Game</Link>
|
||||
|
||||
<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">
|
||||
<p>The level editor is being migrated to Svelte.</p>
|
||||
<p>This component will include:</p>
|
||||
<ul>
|
||||
<li>Difficulty presets</li>
|
||||
<li>Ship, base, and celestial object configuration</li>
|
||||
<li>Asteroid generation settings</li>
|
||||
<li>JSON editor</li>
|
||||
<li>Save/load functionality</li>
|
||||
</ul>
|
||||
</Section>
|
||||
{#if isLoading}
|
||||
<Section title="Loading...">
|
||||
<p>Checking permissions and loading levels...</p>
|
||||
</Section>
|
||||
{:else if !isAuthorized}
|
||||
<Section title="🚫 Access Denied">
|
||||
<p>You do not have permission to access the level editor.</p>
|
||||
<p>This feature requires superadmin (canManageOfficial) privileges.</p>
|
||||
</Section>
|
||||
{:else if error}
|
||||
<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">
|
||||
<Button variant="secondary" on:click={() => history.back()}>← Back</Button>
|
||||
@ -31,12 +120,49 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
ul {
|
||||
padding-left: var(--space-lg, 1.5rem);
|
||||
color: var(--color-text-secondary, #ccc);
|
||||
.level-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: var(--space-xs, 0.25rem) 0;
|
||||
table {
|
||||
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>
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
import LevelSelect from '../game/LevelSelect.svelte';
|
||||
import PlayLevel from '../game/PlayLevel.svelte';
|
||||
import LevelEditor from '../editor/LevelEditor.svelte';
|
||||
import LevelEditForm from '../editor/LevelEditForm.svelte';
|
||||
import SettingsScreen from '../settings/SettingsScreen.svelte';
|
||||
import ControlsScreen from '../controls/ControlsScreen.svelte';
|
||||
import Leaderboard from '../leaderboard/Leaderboard.svelte';
|
||||
@ -45,6 +46,9 @@
|
||||
<PlayLevel {params} />
|
||||
</Route>
|
||||
<Route path="/editor"><LevelEditor /></Route>
|
||||
<Route path="/editor/:levelId" let:params>
|
||||
<LevelEditForm levelId={params.levelId} />
|
||||
</Route>
|
||||
<Route path="/settings"><SettingsScreen /></Route>
|
||||
<Route path="/controls"><ControlsScreen /></Route>
|
||||
<Route path="/leaderboard"><Leaderboard /></Route>
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Link } from 'svelte-routing';
|
||||
import { authStore } from '../../stores/auth';
|
||||
import { CloudLevelService } from '../../services/cloudLevelService';
|
||||
import UserProfile from '../auth/UserProfile.svelte';
|
||||
|
||||
let visible = false;
|
||||
let isAdmin = false;
|
||||
|
||||
// Show header when not in game
|
||||
$: visible = true; // We'll control visibility via parent component if needed
|
||||
@ -16,6 +19,18 @@
|
||||
$: webLaunchUrl = typeof window !== 'undefined'
|
||||
? `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>
|
||||
|
||||
{#if visible}
|
||||
@ -33,7 +48,9 @@
|
||||
<Link to="/controls" class="nav-link controls-link">🎮 Customize Controls</Link>
|
||||
<Link to="/leaderboard" class="nav-link leaderboard-link">🏆 Leaderboard</Link>
|
||||
<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>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { SupabaseService } from './supabaseService';
|
||||
import { AuthService } from './authService';
|
||||
import type { LevelConfig } from '../levels/config/levelConfig';
|
||||
import log from '../core/logger';
|
||||
|
||||
@ -617,6 +616,100 @@ export class CloudLevelService {
|
||||
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
|
||||
// =========================================
|
||||
@ -625,20 +718,22 @@ export class CloudLevelService {
|
||||
* Check if current user is an admin
|
||||
*/
|
||||
public async isCurrentUserAdmin(): Promise<boolean> {
|
||||
const client = await SupabaseService.getInstance().getAuthenticatedClient();
|
||||
const supabaseService = SupabaseService.getInstance();
|
||||
const client = await supabaseService.getAuthenticatedClient();
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = AuthService.getInstance().getUser();
|
||||
if (!user?.sub) {
|
||||
// Get internal user ID (UUID)
|
||||
const internalUserId = await supabaseService.ensureUserExists();
|
||||
if (!internalUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { data, error } = await client
|
||||
.from('admins')
|
||||
.select('is_active')
|
||||
.eq('user_id', user.sub)
|
||||
.eq('user_id', internalUserId)
|
||||
.eq('is_active', true)
|
||||
.single();
|
||||
|
||||
@ -658,27 +753,40 @@ export class CloudLevelService {
|
||||
canManageOfficial: boolean;
|
||||
canViewAnalytics: boolean;
|
||||
} | null> {
|
||||
const client = await SupabaseService.getInstance().getAuthenticatedClient();
|
||||
const supabaseService = SupabaseService.getInstance();
|
||||
const client = await supabaseService.getAuthenticatedClient();
|
||||
if (!client) {
|
||||
log.warn('[CloudLevelService] getAdminPermissions: No authenticated client');
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = AuthService.getInstance().getUser();
|
||||
if (!user?.sub) {
|
||||
// Get internal user ID (UUID)
|
||||
const internalUserId = await supabaseService.ensureUserExists();
|
||||
if (!internalUserId) {
|
||||
log.warn('[CloudLevelService] getAdminPermissions: No internal user ID');
|
||||
return null;
|
||||
}
|
||||
|
||||
log.info('[CloudLevelService] Checking admin permissions for user:', internalUserId);
|
||||
|
||||
const { data, error } = await client
|
||||
.from('admins')
|
||||
.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)
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
if (error) {
|
||||
log.warn('[CloudLevelService] Admin query error:', error.message, error.code);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
log.warn('[CloudLevelService] No admin record found for user');
|
||||
return null;
|
||||
}
|
||||
|
||||
log.info('[CloudLevelService] Admin permissions found:', data);
|
||||
return {
|
||||
canReviewLevels: data.can_review_levels,
|
||||
canManageAdmins: data.can_manage_admins,
|
||||
|
||||
@ -114,7 +114,7 @@ export class SupabaseService {
|
||||
const { data: existingUser, error: fetchError } = await client
|
||||
.from('users')
|
||||
.select('id')
|
||||
.eq('auth0_sub', user.sub)
|
||||
.eq('auth0_id', user.sub)
|
||||
.single();
|
||||
|
||||
if (existingUser) {
|
||||
@ -126,7 +126,7 @@ export class SupabaseService {
|
||||
const { data: newUser, error: insertError } = await client
|
||||
.from('users')
|
||||
.insert({
|
||||
auth0_sub: user.sub,
|
||||
auth0_id: user.sub,
|
||||
display_name: user.name || user.nickname || 'Player'
|
||||
})
|
||||
.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