Dashbaord fixes
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
required?: boolean;
|
||||
autocomplete?: AutoFill;
|
||||
icon?: string;
|
||||
name?: string;
|
||||
oninput?: (e: Event) => void;
|
||||
onchange?: (e: Event) => void;
|
||||
onkeydown?: (e: KeyboardEvent) => void;
|
||||
@@ -35,6 +36,7 @@
|
||||
required = false,
|
||||
autocomplete,
|
||||
icon,
|
||||
name,
|
||||
oninput,
|
||||
onchange,
|
||||
onkeydown,
|
||||
@@ -79,6 +81,7 @@
|
||||
{disabled}
|
||||
{required}
|
||||
{autocomplete}
|
||||
{name}
|
||||
{oninput}
|
||||
{onchange}
|
||||
{onkeydown}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error, redirect, fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
|
||||
// Cast helper for columns not yet in generated types
|
||||
function db(supabase: any) {
|
||||
return supabase as any;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
async function requireAdmin(locals: any) {
|
||||
const { session, user } = await locals.safeGetSession();
|
||||
if (!session || !user) redirect(303, '/login');
|
||||
|
||||
if (!session || !user) {
|
||||
redirect(303, '/login');
|
||||
}
|
||||
|
||||
// Check platform admin status
|
||||
const { data: profile } = await db(locals.supabase)
|
||||
.from('profiles')
|
||||
.select('is_platform_admin')
|
||||
@@ -24,6 +20,12 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
error(403, 'Access denied. Platform admin only.');
|
||||
}
|
||||
|
||||
return { session, user };
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const { user } = await requireAdmin(locals);
|
||||
|
||||
// Fetch all platform data in parallel
|
||||
const [
|
||||
orgsResult,
|
||||
@@ -45,8 +47,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
.order('created_at', { ascending: false }),
|
||||
db(locals.supabase)
|
||||
.from('org_members')
|
||||
.select('id, user_id, org_id, role')
|
||||
.order('created_at', { ascending: false }),
|
||||
.select('id, user_id, org_id, role'),
|
||||
]);
|
||||
|
||||
const organizations = orgsResult.data ?? [];
|
||||
@@ -83,6 +84,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
}
|
||||
|
||||
return {
|
||||
currentUserId: user.id,
|
||||
organizations: organizations.map((o: any) => ({
|
||||
...o,
|
||||
memberCount: orgMemberCounts[o.id] || 0,
|
||||
@@ -90,6 +92,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
})),
|
||||
profiles,
|
||||
events,
|
||||
orgMembers,
|
||||
stats: {
|
||||
totalUsers: profiles.length,
|
||||
totalOrgs: organizations.length,
|
||||
@@ -102,3 +105,152 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
deleteOrg: async ({ request, locals }) => {
|
||||
await requireAdmin(locals);
|
||||
const formData = await request.formData();
|
||||
const orgId = formData.get('id') as string;
|
||||
if (!orgId) return fail(400, { error: 'Missing org ID' });
|
||||
|
||||
const { error: err } = await db(locals.supabase)
|
||||
.from('organizations')
|
||||
.delete()
|
||||
.eq('id', orgId);
|
||||
|
||||
if (err) return fail(500, { error: err.message });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
deleteUser: async ({ request, locals }) => {
|
||||
await requireAdmin(locals);
|
||||
const formData = await request.formData();
|
||||
const userId = formData.get('id') as string;
|
||||
if (!userId) return fail(400, { error: 'Missing user ID' });
|
||||
|
||||
// Delete profile (cascades from auth.users FK)
|
||||
const { error: err } = await db(locals.supabase)
|
||||
.from('profiles')
|
||||
.delete()
|
||||
.eq('id', userId);
|
||||
|
||||
if (err) return fail(500, { error: err.message });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
deleteEvent: async ({ request, locals }) => {
|
||||
await requireAdmin(locals);
|
||||
const formData = await request.formData();
|
||||
const eventId = formData.get('id') as string;
|
||||
if (!eventId) return fail(400, { error: 'Missing event ID' });
|
||||
|
||||
const { error: err } = await db(locals.supabase)
|
||||
.from('events')
|
||||
.delete()
|
||||
.eq('id', eventId);
|
||||
|
||||
if (err) return fail(500, { error: err.message });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
updateEventStatus: async ({ request, locals }) => {
|
||||
await requireAdmin(locals);
|
||||
const formData = await request.formData();
|
||||
const eventId = formData.get('id') as string;
|
||||
const status = formData.get('status') as string;
|
||||
if (!eventId || !status) return fail(400, { error: 'Missing event ID or status' });
|
||||
|
||||
const { error: err } = await db(locals.supabase)
|
||||
.from('events')
|
||||
.update({ status })
|
||||
.eq('id', eventId);
|
||||
|
||||
if (err) return fail(500, { error: err.message });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
toggleAdmin: async ({ request, locals }) => {
|
||||
await requireAdmin(locals);
|
||||
const formData = await request.formData();
|
||||
const userId = formData.get('id') as string;
|
||||
const isAdmin = formData.get('is_admin') === 'true';
|
||||
if (!userId) return fail(400, { error: 'Missing user ID' });
|
||||
|
||||
const { error: err } = await db(locals.supabase)
|
||||
.from('profiles')
|
||||
.update({ is_platform_admin: isAdmin })
|
||||
.eq('id', userId);
|
||||
|
||||
if (err) return fail(500, { error: err.message });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
updateOrg: async ({ request, locals }) => {
|
||||
await requireAdmin(locals);
|
||||
const formData = await request.formData();
|
||||
const orgId = formData.get('id') as string;
|
||||
const name = formData.get('name') as string;
|
||||
const slug = formData.get('slug') as string;
|
||||
if (!orgId) return fail(400, { error: 'Missing org ID' });
|
||||
|
||||
const updates: Record<string, string> = {};
|
||||
if (name) updates.name = name;
|
||||
if (slug) updates.slug = slug;
|
||||
|
||||
const { error: err } = await db(locals.supabase)
|
||||
.from('organizations')
|
||||
.update(updates)
|
||||
.eq('id', orgId);
|
||||
|
||||
if (err) return fail(500, { error: err.message });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
updateEvent: async ({ request, locals }) => {
|
||||
await requireAdmin(locals);
|
||||
const formData = await request.formData();
|
||||
const eventId = formData.get('id') as string;
|
||||
const name = formData.get('name') as string;
|
||||
const slug = formData.get('slug') as string;
|
||||
const status = formData.get('status') as string;
|
||||
const startDate = formData.get('start_date') as string;
|
||||
const endDate = formData.get('end_date') as string;
|
||||
if (!eventId) return fail(400, { error: 'Missing event ID' });
|
||||
|
||||
const updates: Record<string, string | null> = {};
|
||||
if (name) updates.name = name;
|
||||
if (slug) updates.slug = slug;
|
||||
if (status) updates.status = status;
|
||||
if (startDate) updates.start_date = startDate;
|
||||
if (endDate) updates.end_date = endDate;
|
||||
|
||||
const { error: err } = await db(locals.supabase)
|
||||
.from('events')
|
||||
.update(updates)
|
||||
.eq('id', eventId);
|
||||
|
||||
if (err) return fail(500, { error: err.message });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
updateUser: async ({ request, locals }) => {
|
||||
await requireAdmin(locals);
|
||||
const formData = await request.formData();
|
||||
const userId = formData.get('id') as string;
|
||||
const fullName = formData.get('full_name') as string;
|
||||
const email = formData.get('email') as string;
|
||||
if (!userId) return fail(400, { error: 'Missing user ID' });
|
||||
|
||||
const updates: Record<string, any> = {};
|
||||
if (fullName !== null) updates.full_name = fullName;
|
||||
if (email) updates.email = email;
|
||||
|
||||
const { error: err } = await db(locals.supabase)
|
||||
.from('profiles')
|
||||
.update(updates)
|
||||
.eq('id', userId);
|
||||
|
||||
if (err) return fail(500, { error: err.message });
|
||||
return { success: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Button, Badge, Avatar, Card, StatCard, TabBar, Input } from "$lib/components/ui";
|
||||
import { enhance } from "$app/forms";
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import { Button, Badge, Avatar, StatCard, TabBar, Input, Modal } from "$lib/components/ui";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -8,6 +10,13 @@
|
||||
let userSearch = $state("");
|
||||
let eventSearch = $state("");
|
||||
|
||||
// Modal state
|
||||
let confirmModal = $state<{ type: string; id: string; name: string } | null>(null);
|
||||
let editOrgModal = $state<any>(null);
|
||||
let editUserModal = $state<any>(null);
|
||||
let editEventModal = $state<any>(null);
|
||||
let actionLoading = $state(false);
|
||||
|
||||
const filteredOrgs = $derived(
|
||||
orgSearch
|
||||
? data.organizations.filter((o: any) =>
|
||||
@@ -44,6 +53,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateInput(dateStr: string | null) {
|
||||
if (!dateStr) return "";
|
||||
return new Date(dateStr).toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string | null) {
|
||||
if (!dateStr) return "—";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
@@ -64,10 +78,33 @@
|
||||
draft: "text-light/40 bg-light/5",
|
||||
};
|
||||
|
||||
// Find org name by id
|
||||
const eventStatuses = ["planning", "active", "completed", "archived"];
|
||||
|
||||
const orgMap = $derived(
|
||||
Object.fromEntries(data.organizations.map((o: any) => [o.id, o])),
|
||||
);
|
||||
|
||||
// Count orgs per user
|
||||
const userOrgCounts = $derived(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const m of data.orgMembers) {
|
||||
counts[m.user_id] = (counts[m.user_id] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
function enhanceAction() {
|
||||
return ({ result }: any) => {
|
||||
actionLoading = false;
|
||||
if (result.type === "success") {
|
||||
confirmModal = null;
|
||||
editOrgModal = null;
|
||||
editUserModal = null;
|
||||
editEventModal = null;
|
||||
invalidateAll();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -104,13 +141,12 @@
|
||||
|
||||
<div class="max-w-7xl mx-auto px-6 py-6 space-y-6">
|
||||
<!-- Stats Overview -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">
|
||||
<StatCard label="Total Users" value={data.stats.totalUsers} icon="group" />
|
||||
<StatCard label="Organizations" value={data.stats.totalOrgs} icon="business" />
|
||||
<StatCard label="Total Events" value={data.stats.totalEvents} icon="event" />
|
||||
<StatCard label="Memberships" value={data.stats.totalMemberships} icon="badge" />
|
||||
<StatCard label="New (7d)" value={data.stats.newUsersLast7d} icon="person_add" />
|
||||
<StatCard label="New (30d)" value={data.stats.newUsersLast30d} icon="trending_up" />
|
||||
<StatCard label="New Users (7d)" value={data.stats.newUsersLast7d} icon="person_add" />
|
||||
<StatCard label="New Users (30d)" value={data.stats.newUsersLast30d} icon="trending_up" />
|
||||
<StatCard label="Active Events" value={data.stats.activeEvents} icon="play_circle" />
|
||||
<StatCard label="Planning" value={data.stats.planningEvents} icon="edit_calendar" />
|
||||
</div>
|
||||
@@ -134,13 +170,7 @@
|
||||
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-heading text-body text-white">Recent Organizations</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
|
||||
onclick={() => (activeTab = "organizations")}
|
||||
>
|
||||
View all →
|
||||
</button>
|
||||
<button type="button" class="text-[11px] text-primary hover:text-primary/80 transition-colors" onclick={() => (activeTab = "organizations")}>View all →</button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each data.organizations.slice(0, 5) as org}
|
||||
@@ -168,13 +198,7 @@
|
||||
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-heading text-body text-white">Recent Users</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
|
||||
onclick={() => (activeTab = "users")}
|
||||
>
|
||||
View all →
|
||||
</button>
|
||||
<button type="button" class="text-[11px] text-primary hover:text-primary/80 transition-colors" onclick={() => (activeTab = "users")}>View all →</button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each data.profiles.slice(0, 5) as profile}
|
||||
@@ -204,13 +228,7 @@
|
||||
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 lg:col-span-2">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-heading text-body text-white">Recent Events</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
|
||||
onclick={() => (activeTab = "events")}
|
||||
>
|
||||
View all →
|
||||
</button>
|
||||
<button type="button" class="text-[11px] text-primary hover:text-primary/80 transition-colors" onclick={() => (activeTab = "events")}>View all →</button>
|
||||
</div>
|
||||
{#if data.events.length > 0}
|
||||
<div class="overflow-x-auto">
|
||||
@@ -252,6 +270,29 @@
|
||||
<p class="text-body-sm text-light/30 text-center py-4">No events yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Event Status Legend -->
|
||||
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 lg:col-span-2">
|
||||
<h3 class="font-heading text-body text-white mb-3">Event Status Guide</h3>
|
||||
<div class="grid md:grid-cols-4 gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full shrink-0 text-amber-400 bg-amber-400/10">planning</span>
|
||||
<p class="text-[11px] text-light/40">Event is being prepared. Team is setting up departments, tasks, and logistics.</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full shrink-0 text-emerald-400 bg-emerald-400/10">active</span>
|
||||
<p class="text-[11px] text-light/40">Event is currently live/happening. All systems go.</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full shrink-0 text-blue-400 bg-blue-400/10">completed</span>
|
||||
<p class="text-[11px] text-light/40">Event has finished. Data is preserved for review.</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full shrink-0 text-light/40 bg-light/5">archived</span>
|
||||
<p class="text-[11px] text-light/40">Event is hidden from normal views. Can be restored.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if activeTab === "organizations"}
|
||||
@@ -268,6 +309,7 @@
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4">Members</th>
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4">Events</th>
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4">Created</th>
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -287,11 +329,31 @@
|
||||
<Badge variant="primary" size="sm">{org.eventCount}</Badge>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-[11px] text-light/30">{formatDate(org.created_at)}</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/30 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
||||
title="Edit organization"
|
||||
onclick={() => (editOrgModal = { id: org.id, name: org.name, slug: org.slug })}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 16px;">edit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/30 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
|
||||
title="Delete organization"
|
||||
onclick={() => (confirmModal = { type: "deleteOrg", id: org.id, name: org.name })}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 16px;">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if filteredOrgs.length === 0}
|
||||
<tr>
|
||||
<td colspan="5" class="py-8 text-center text-body-sm text-light/30">
|
||||
<td colspan="6" class="py-8 text-center text-body-sm text-light/30">
|
||||
{orgSearch ? "No organizations match your search" : "No organizations yet"}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -313,8 +375,10 @@
|
||||
<tr class="border-b border-light/5 bg-dark/20">
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4">User</th>
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4">Email</th>
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4">Organizations</th>
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4">Role</th>
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4">Joined</th>
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -327,6 +391,9 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-body-sm text-light/40">{profile.email}</td>
|
||||
<td class="py-3 px-4">
|
||||
<Badge variant="default" size="sm">{userOrgCounts()[profile.id] ?? 0}</Badge>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
{#if profile.is_platform_admin}
|
||||
<Badge variant="error" size="sm">Platform Admin</Badge>
|
||||
@@ -335,11 +402,50 @@
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-3 px-4 text-[11px] text-light/30">{formatDate(profile.created_at)}</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/30 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
||||
title="Edit user"
|
||||
onclick={() => (editUserModal = { id: profile.id, full_name: profile.full_name ?? "", email: profile.email, is_platform_admin: profile.is_platform_admin })}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 16px;">edit</span>
|
||||
</button>
|
||||
{#if profile.is_platform_admin && profile.id !== data.currentUserId}
|
||||
<form method="POST" action="?/toggleAdmin" use:enhance={enhanceAction}>
|
||||
<input type="hidden" name="id" value={profile.id} />
|
||||
<input type="hidden" name="is_admin" value="false" />
|
||||
<button type="submit" class="p-1.5 text-amber-400 hover:text-amber-300 hover:bg-amber-400/10 rounded-lg transition-colors" title="Remove admin">
|
||||
<span class="material-symbols-rounded" style="font-size: 16px; font-variation-settings: 'FILL' 1;">shield</span>
|
||||
</button>
|
||||
</form>
|
||||
{:else if !profile.is_platform_admin}
|
||||
<form method="POST" action="?/toggleAdmin" use:enhance={enhanceAction}>
|
||||
<input type="hidden" name="id" value={profile.id} />
|
||||
<input type="hidden" name="is_admin" value="true" />
|
||||
<button type="submit" class="p-1.5 text-light/30 hover:text-amber-400 hover:bg-amber-400/10 rounded-lg transition-colors" title="Make admin">
|
||||
<span class="material-symbols-rounded" style="font-size: 16px;">shield</span>
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if profile.id !== data.currentUserId}
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/30 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
|
||||
title="Delete user"
|
||||
onclick={() => (confirmModal = { type: "deleteUser", id: profile.id, name: profile.full_name ?? profile.email })}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 16px;">delete</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if filteredUsers.length === 0}
|
||||
<tr>
|
||||
<td colspan="4" class="py-8 text-center text-body-sm text-light/30">
|
||||
<td colspan="6" class="py-8 text-center text-body-sm text-light/30">
|
||||
{userSearch ? "No users match your search" : "No users yet"}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -365,6 +471,7 @@
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4">Start</th>
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4">End</th>
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4">Created</th>
|
||||
<th class="text-[10px] text-light/40 font-body py-3 px-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -380,18 +487,47 @@
|
||||
{orgMap[event.org_id]?.name ?? "—"}
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full capitalize {statusColors[event.status] ?? 'text-light/40 bg-light/5'}">
|
||||
{event.status}
|
||||
</span>
|
||||
<form method="POST" action="?/updateEventStatus" use:enhance={enhanceAction} class="inline">
|
||||
<input type="hidden" name="id" value={event.id} />
|
||||
<select
|
||||
name="status"
|
||||
class="text-[10px] px-2 py-0.5 rounded-full capitalize cursor-pointer border-0 appearance-none bg-transparent {statusColors[event.status] ?? 'text-light/40 bg-light/5'}"
|
||||
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||
>
|
||||
{#each eventStatuses as s}
|
||||
<option value={s} selected={event.status === s} class="bg-surface text-white">{s}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-[11px] text-light/40">{formatDate(event.start_date)}</td>
|
||||
<td class="py-3 px-4 text-[11px] text-light/40">{formatDate(event.end_date)}</td>
|
||||
<td class="py-3 px-4 text-[11px] text-light/30">{timeAgo(event.created_at)}</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/30 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
||||
title="Edit event"
|
||||
onclick={() => (editEventModal = { id: event.id, name: event.name, slug: event.slug, status: event.status, start_date: formatDateInput(event.start_date), end_date: formatDateInput(event.end_date) })}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 16px;">edit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/30 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
|
||||
title="Delete event"
|
||||
onclick={() => (confirmModal = { type: "deleteEvent", id: event.id, name: event.name })}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 16px;">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#if filteredEvents.length === 0}
|
||||
<tr>
|
||||
<td colspan="6" class="py-8 text-center text-body-sm text-light/30">
|
||||
<td colspan="7" class="py-8 text-center text-body-sm text-light/30">
|
||||
{eventSearch ? "No events match your search" : "No events yet"}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -404,3 +540,122 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Modal isOpen={!!confirmModal} onClose={() => (confirmModal = null)} title="Confirm Delete">
|
||||
{#if confirmModal}
|
||||
<p class="text-light/70 text-body-sm mb-2">
|
||||
Are you sure you want to delete <strong class="text-white">{confirmModal.name}</strong>?
|
||||
</p>
|
||||
<p class="text-red-400/70 text-[11px] mb-4">This action cannot be undone. All associated data will be permanently removed.</p>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/{confirmModal.type}"
|
||||
use:enhance={() => { actionLoading = true; return enhanceAction(); }}
|
||||
>
|
||||
<input type="hidden" name="id" value={confirmModal.id} />
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button variant="secondary" onclick={() => (confirmModal = null)}>Cancel</Button>
|
||||
<Button variant="danger" type="submit" loading={actionLoading}>Delete</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<!-- Edit Organization Modal -->
|
||||
<Modal isOpen={!!editOrgModal} onClose={() => (editOrgModal = null)} title="Edit Organization">
|
||||
{#if editOrgModal}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateOrg"
|
||||
use:enhance={() => { actionLoading = true; return enhanceAction(); }}
|
||||
class="space-y-4"
|
||||
>
|
||||
<input type="hidden" name="id" value={editOrgModal.id} />
|
||||
<Input label="Name" name="name" bind:value={editOrgModal.name} />
|
||||
<Input label="Slug" name="slug" bind:value={editOrgModal.slug} />
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button variant="secondary" onclick={() => (editOrgModal = null)}>Cancel</Button>
|
||||
<Button type="submit" loading={actionLoading}>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<!-- Edit User Modal -->
|
||||
<Modal isOpen={!!editUserModal} onClose={() => (editUserModal = null)} title="Edit User">
|
||||
{#if editUserModal}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateUser"
|
||||
use:enhance={() => { actionLoading = true; return enhanceAction(); }}
|
||||
class="space-y-4"
|
||||
>
|
||||
<input type="hidden" name="id" value={editUserModal.id} />
|
||||
<Input label="Full Name" name="full_name" bind:value={editUserModal.full_name} />
|
||||
<Input label="Email" name="email" bind:value={editUserModal.email} />
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<label class="flex items-center gap-2 text-body-sm text-light/60 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={editUserModal.is_platform_admin} class="accent-primary" />
|
||||
Platform Admin
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button variant="secondary" onclick={() => (editUserModal = null)}>Cancel</Button>
|
||||
<Button type="submit" loading={actionLoading}>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<!-- Edit Event Modal -->
|
||||
<Modal isOpen={!!editEventModal} onClose={() => (editEventModal = null)} title="Edit Event">
|
||||
{#if editEventModal}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateEvent"
|
||||
use:enhance={() => { actionLoading = true; return enhanceAction(); }}
|
||||
class="space-y-4"
|
||||
>
|
||||
<input type="hidden" name="id" value={editEventModal.id} />
|
||||
<Input label="Name" name="name" bind:value={editEventModal.name} />
|
||||
<Input label="Slug" name="slug" bind:value={editEventModal.slug} />
|
||||
<div>
|
||||
<label class="block text-body-sm text-light/60 mb-1">Status</label>
|
||||
<select
|
||||
name="status"
|
||||
bind:value={editEventModal.status}
|
||||
class="w-full bg-dark/50 border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50"
|
||||
>
|
||||
{#each eventStatuses as s}
|
||||
<option value={s} class="bg-surface">{s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-body-sm text-light/60 mb-1">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
name="start_date"
|
||||
bind:value={editEventModal.start_date}
|
||||
class="w-full bg-dark/50 border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-body-sm text-light/60 mb-1">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
name="end_date"
|
||||
bind:value={editEventModal.end_date}
|
||||
class="w-full bg-dark/50 border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button variant="secondary" onclick={() => (editEventModal = null)}>Cancel</Button>
|
||||
<Button type="submit" loading={actionLoading}>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
56
supabase/migrations/031_platform_admin_rls.sql
Normal file
56
supabase/migrations/031_platform_admin_rls.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- Platform admins can read/write all data across the platform
|
||||
-- This bypasses org-membership-based RLS for users with is_platform_admin = true
|
||||
|
||||
-- Helper function to check if current user is a platform admin
|
||||
CREATE OR REPLACE FUNCTION is_platform_admin()
|
||||
RETURNS BOOLEAN AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE id = auth.uid()
|
||||
AND is_platform_admin = true
|
||||
);
|
||||
$$ LANGUAGE sql SECURITY DEFINER STABLE;
|
||||
|
||||
-- Organizations: platform admins can do everything
|
||||
CREATE POLICY "Platform admins full access to organizations" ON organizations
|
||||
USING (is_platform_admin()) WITH CHECK (is_platform_admin());
|
||||
|
||||
-- Org Members: platform admins can see all memberships
|
||||
CREATE POLICY "Platform admins full access to org_members" ON org_members
|
||||
USING (is_platform_admin()) WITH CHECK (is_platform_admin());
|
||||
|
||||
-- Profiles: platform admins can update any profile
|
||||
CREATE POLICY "Platform admins can update profiles" ON profiles FOR UPDATE
|
||||
USING (is_platform_admin()) WITH CHECK (is_platform_admin());
|
||||
|
||||
-- Events: platform admins can do everything
|
||||
CREATE POLICY "Platform admins full access to events" ON events
|
||||
USING (is_platform_admin()) WITH CHECK (is_platform_admin());
|
||||
|
||||
-- Event members: platform admins can do everything
|
||||
CREATE POLICY "Platform admins full access to event_members" ON event_members
|
||||
USING (is_platform_admin()) WITH CHECK (is_platform_admin());
|
||||
|
||||
-- Documents: platform admins can do everything
|
||||
CREATE POLICY "Platform admins full access to documents" ON documents
|
||||
USING (is_platform_admin()) WITH CHECK (is_platform_admin());
|
||||
|
||||
-- Kanban boards: platform admins can do everything
|
||||
CREATE POLICY "Platform admins full access to kanban_boards" ON kanban_boards
|
||||
USING (is_platform_admin()) WITH CHECK (is_platform_admin());
|
||||
|
||||
-- Calendar events: platform admins can do everything
|
||||
CREATE POLICY "Platform admins full access to calendar_events" ON calendar_events
|
||||
USING (is_platform_admin()) WITH CHECK (is_platform_admin());
|
||||
|
||||
-- Org roles: platform admins can do everything
|
||||
CREATE POLICY "Platform admins full access to org_roles" ON org_roles
|
||||
USING (is_platform_admin()) WITH CHECK (is_platform_admin());
|
||||
|
||||
-- Org invites: platform admins can do everything
|
||||
CREATE POLICY "Platform admins full access to org_invites" ON org_invites
|
||||
USING (is_platform_admin()) WITH CHECK (is_platform_admin());
|
||||
|
||||
-- Event departments: platform admins can do everything
|
||||
CREATE POLICY "Platform admins full access to event_departments" ON event_departments
|
||||
USING (is_platform_admin()) WITH CHECK (is_platform_admin());
|
||||
Reference in New Issue
Block a user