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:
Michael Mainguy 2025-12-01 19:57:48 -06:00
parent 6655abeeec
commit ad2656a61f
7 changed files with 687 additions and 33 deletions

View 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...&#10;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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,

View File

@ -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')

View 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'));