From ad2656a61f2ec6f0a180b46342c09597a44b63f9 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Mon, 1 Dec 2025 19:57:48 -0600 Subject: [PATCH] Add level editor for superadmins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/editor/LevelEditForm.svelte | 248 ++++++++++++++++++ src/components/editor/LevelEditor.svelte | 166 ++++++++++-- src/components/layouts/App.svelte | 4 + src/components/layouts/AppHeader.svelte | 19 +- src/services/cloudLevelService.ts | 128 ++++++++- src/services/supabaseService.ts | 4 +- .../migrations/004_admins_use_internal_id.sql | 151 +++++++++++ 7 files changed, 687 insertions(+), 33 deletions(-) create mode 100644 src/components/editor/LevelEditForm.svelte create mode 100644 supabase/migrations/004_admins_use_internal_id.sql diff --git a/src/components/editor/LevelEditForm.svelte b/src/components/editor/LevelEditForm.svelte new file mode 100644 index 0000000..bd6f4be --- /dev/null +++ b/src/components/editor/LevelEditForm.svelte @@ -0,0 +1,248 @@ + + +
+ ← Back to Level List + +

📝 Edit Level

+ + {#if isLoading} +
+

Loading level data...

+
+ {:else if !isAuthorized} +
+

You do not have permission to edit levels.

+
+ {:else if error} +
+

{error}

+ +
+ {:else if level} +

Editing: {level.name}

+ +
+
+ + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+ +
+ + +
+ + + {/if} +
+ + diff --git a/src/components/editor/LevelEditor.svelte b/src/components/editor/LevelEditor.svelte index 0fbd6fc..34c8bf5 100644 --- a/src/components/editor/LevelEditor.svelte +++ b/src/components/editor/LevelEditor.svelte @@ -1,29 +1,118 @@
← Back to Game

📝 Level Editor

-

Create and customize your own asteroid field levels

+

Manage and edit game levels (Superadmin Only)

-
-

The level editor is being migrated to Svelte.

-

This component will include:

-
    -
  • Difficulty presets
  • -
  • Ship, base, and celestial object configuration
  • -
  • Asteroid generation settings
  • -
  • JSON editor
  • -
  • Save/load functionality
  • -
-
+ {#if isLoading} +
+

Checking permissions and loading levels...

+
+ {:else if !isAuthorized} +
+

You do not have permission to access the level editor.

+

This feature requires superadmin (canManageOfficial) privileges.

+
+ {:else if error} +
+

{error}

+ +
+ {:else} +
+ {#if levels.length === 0} +

No levels found.

+ {:else} +
+ + + + + + + + + + + + {#each levels as level} + + + + + + + + {/each} + +
NameTypeDifficultyOrderActions
{level.name}{getLevelTypeLabel(level.levelType)}{level.difficulty}{level.sortOrder} + +
+
+ {/if} +
+ {/if}
@@ -31,12 +120,49 @@
diff --git a/src/components/layouts/App.svelte b/src/components/layouts/App.svelte index c0a3ecf..d9109e8 100644 --- a/src/components/layouts/App.svelte +++ b/src/components/layouts/App.svelte @@ -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 @@ + + + diff --git a/src/components/layouts/AppHeader.svelte b/src/components/layouts/AppHeader.svelte index 9a4d6a0..e82cf67 100644 --- a/src/components/layouts/AppHeader.svelte +++ b/src/components/layouts/AppHeader.svelte @@ -1,9 +1,12 @@ {#if visible} @@ -33,7 +48,9 @@ 🎮 Customize Controls 🏆 Leaderboard - 📝 Level Editor + {#if isAdmin} + 📝 Level Editor + {/if} ⚙️ Settings
diff --git a/src/services/cloudLevelService.ts b/src/services/cloudLevelService.ts index 9262e61..a73fcb7 100644 --- a/src/services/cloudLevelService.ts +++ b/src/services/cloudLevelService.ts @@ -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 { + 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 { + 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 = {}; + 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 { - 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, diff --git a/src/services/supabaseService.ts b/src/services/supabaseService.ts index dee6491..57af7f4 100644 --- a/src/services/supabaseService.ts +++ b/src/services/supabaseService.ts @@ -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') diff --git a/supabase/migrations/004_admins_use_internal_id.sql b/supabase/migrations/004_admins_use_internal_id.sql new file mode 100644 index 0000000..5b4f885 --- /dev/null +++ b/supabase/migrations/004_admins_use_internal_id.sql @@ -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'));