Dashbaord fixes
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
autocomplete?: AutoFill;
|
autocomplete?: AutoFill;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
name?: string;
|
||||||
oninput?: (e: Event) => void;
|
oninput?: (e: Event) => void;
|
||||||
onchange?: (e: Event) => void;
|
onchange?: (e: Event) => void;
|
||||||
onkeydown?: (e: KeyboardEvent) => void;
|
onkeydown?: (e: KeyboardEvent) => void;
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
required = false,
|
required = false,
|
||||||
autocomplete,
|
autocomplete,
|
||||||
icon,
|
icon,
|
||||||
|
name,
|
||||||
oninput,
|
oninput,
|
||||||
onchange,
|
onchange,
|
||||||
onkeydown,
|
onkeydown,
|
||||||
@@ -79,6 +81,7 @@
|
|||||||
{disabled}
|
{disabled}
|
||||||
{required}
|
{required}
|
||||||
{autocomplete}
|
{autocomplete}
|
||||||
|
{name}
|
||||||
{oninput}
|
{oninput}
|
||||||
{onchange}
|
{onchange}
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect, fail } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
// Cast helper for columns not yet in generated types
|
// Cast helper for columns not yet in generated types
|
||||||
function db(supabase: any) {
|
function db(supabase: any) {
|
||||||
return supabase as any;
|
return supabase as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
async function requireAdmin(locals: any) {
|
||||||
const { session, user } = await locals.safeGetSession();
|
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)
|
const { data: profile } = await db(locals.supabase)
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('is_platform_admin')
|
.select('is_platform_admin')
|
||||||
@@ -24,6 +20,12 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
error(403, 'Access denied. Platform admin only.');
|
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
|
// Fetch all platform data in parallel
|
||||||
const [
|
const [
|
||||||
orgsResult,
|
orgsResult,
|
||||||
@@ -45,8 +47,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
.order('created_at', { ascending: false }),
|
.order('created_at', { ascending: false }),
|
||||||
db(locals.supabase)
|
db(locals.supabase)
|
||||||
.from('org_members')
|
.from('org_members')
|
||||||
.select('id, user_id, org_id, role')
|
.select('id, user_id, org_id, role'),
|
||||||
.order('created_at', { ascending: false }),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const organizations = orgsResult.data ?? [];
|
const organizations = orgsResult.data ?? [];
|
||||||
@@ -83,6 +84,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
currentUserId: user.id,
|
||||||
organizations: organizations.map((o: any) => ({
|
organizations: organizations.map((o: any) => ({
|
||||||
...o,
|
...o,
|
||||||
memberCount: orgMemberCounts[o.id] || 0,
|
memberCount: orgMemberCounts[o.id] || 0,
|
||||||
@@ -90,6 +92,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
})),
|
})),
|
||||||
profiles,
|
profiles,
|
||||||
events,
|
events,
|
||||||
|
orgMembers,
|
||||||
stats: {
|
stats: {
|
||||||
totalUsers: profiles.length,
|
totalUsers: profiles.length,
|
||||||
totalOrgs: organizations.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">
|
<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();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -8,6 +10,13 @@
|
|||||||
let userSearch = $state("");
|
let userSearch = $state("");
|
||||||
let eventSearch = $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(
|
const filteredOrgs = $derived(
|
||||||
orgSearch
|
orgSearch
|
||||||
? data.organizations.filter((o: any) =>
|
? 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) {
|
function timeAgo(dateStr: string | null) {
|
||||||
if (!dateStr) return "—";
|
if (!dateStr) return "—";
|
||||||
const diff = Date.now() - new Date(dateStr).getTime();
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
@@ -64,10 +78,33 @@
|
|||||||
draft: "text-light/40 bg-light/5",
|
draft: "text-light/40 bg-light/5",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find org name by id
|
const eventStatuses = ["planning", "active", "completed", "archived"];
|
||||||
|
|
||||||
const orgMap = $derived(
|
const orgMap = $derived(
|
||||||
Object.fromEntries(data.organizations.map((o: any) => [o.id, o])),
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -104,13 +141,12 @@
|
|||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-6 py-6 space-y-6">
|
<div class="max-w-7xl mx-auto px-6 py-6 space-y-6">
|
||||||
<!-- Stats Overview -->
|
<!-- 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="Total Users" value={data.stats.totalUsers} icon="group" />
|
||||||
<StatCard label="Organizations" value={data.stats.totalOrgs} icon="business" />
|
<StatCard label="Organizations" value={data.stats.totalOrgs} icon="business" />
|
||||||
<StatCard label="Total Events" value={data.stats.totalEvents} icon="event" />
|
<StatCard label="Total Events" value={data.stats.totalEvents} icon="event" />
|
||||||
<StatCard label="Memberships" value={data.stats.totalMemberships} icon="badge" />
|
<StatCard label="New Users (7d)" value={data.stats.newUsersLast7d} icon="person_add" />
|
||||||
<StatCard label="New (7d)" value={data.stats.newUsersLast7d} icon="person_add" />
|
<StatCard label="New Users (30d)" value={data.stats.newUsersLast30d} icon="trending_up" />
|
||||||
<StatCard label="New (30d)" value={data.stats.newUsersLast30d} icon="trending_up" />
|
|
||||||
<StatCard label="Active Events" value={data.stats.activeEvents} icon="play_circle" />
|
<StatCard label="Active Events" value={data.stats.activeEvents} icon="play_circle" />
|
||||||
<StatCard label="Planning" value={data.stats.planningEvents} icon="edit_calendar" />
|
<StatCard label="Planning" value={data.stats.planningEvents} icon="edit_calendar" />
|
||||||
</div>
|
</div>
|
||||||
@@ -134,13 +170,7 @@
|
|||||||
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="font-heading text-body text-white">Recent Organizations</h3>
|
<h3 class="font-heading text-body text-white">Recent Organizations</h3>
|
||||||
<button
|
<button type="button" class="text-[11px] text-primary hover:text-primary/80 transition-colors" onclick={() => (activeTab = "organizations")}>View all →</button>
|
||||||
type="button"
|
|
||||||
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
|
|
||||||
onclick={() => (activeTab = "organizations")}
|
|
||||||
>
|
|
||||||
View all →
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each data.organizations.slice(0, 5) as org}
|
{#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="bg-dark/30 border border-light/5 rounded-xl p-5">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="font-heading text-body text-white">Recent Users</h3>
|
<h3 class="font-heading text-body text-white">Recent Users</h3>
|
||||||
<button
|
<button type="button" class="text-[11px] text-primary hover:text-primary/80 transition-colors" onclick={() => (activeTab = "users")}>View all →</button>
|
||||||
type="button"
|
|
||||||
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
|
|
||||||
onclick={() => (activeTab = "users")}
|
|
||||||
>
|
|
||||||
View all →
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each data.profiles.slice(0, 5) as profile}
|
{#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="bg-dark/30 border border-light/5 rounded-xl p-5 lg:col-span-2">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="font-heading text-body text-white">Recent Events</h3>
|
<h3 class="font-heading text-body text-white">Recent Events</h3>
|
||||||
<button
|
<button type="button" class="text-[11px] text-primary hover:text-primary/80 transition-colors" onclick={() => (activeTab = "events")}>View all →</button>
|
||||||
type="button"
|
|
||||||
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
|
|
||||||
onclick={() => (activeTab = "events")}
|
|
||||||
>
|
|
||||||
View all →
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{#if data.events.length > 0}
|
{#if data.events.length > 0}
|
||||||
<div class="overflow-x-auto">
|
<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>
|
<p class="text-body-sm text-light/30 text-center py-4">No events yet</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{:else if activeTab === "organizations"}
|
{: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">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">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">Created</th>
|
||||||
|
<th class="text-[10px] text-light/40 font-body py-3 px-4 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -287,11 +329,31 @@
|
|||||||
<Badge variant="primary" size="sm">{org.eventCount}</Badge>
|
<Badge variant="primary" size="sm">{org.eventCount}</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4 text-[11px] text-light/30">{formatDate(org.created_at)}</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>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{#if filteredOrgs.length === 0}
|
{#if filteredOrgs.length === 0}
|
||||||
<tr>
|
<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"}
|
{orgSearch ? "No organizations match your search" : "No organizations yet"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -313,8 +375,10 @@
|
|||||||
<tr class="border-b border-light/5 bg-dark/20">
|
<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">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">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">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">Joined</th>
|
||||||
|
<th class="text-[10px] text-light/40 font-body py-3 px-4 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -327,6 +391,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4 text-body-sm text-light/40">{profile.email}</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">
|
<td class="py-3 px-4">
|
||||||
{#if profile.is_platform_admin}
|
{#if profile.is_platform_admin}
|
||||||
<Badge variant="error" size="sm">Platform Admin</Badge>
|
<Badge variant="error" size="sm">Platform Admin</Badge>
|
||||||
@@ -335,11 +402,50 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4 text-[11px] text-light/30">{formatDate(profile.created_at)}</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>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{#if filteredUsers.length === 0}
|
{#if filteredUsers.length === 0}
|
||||||
<tr>
|
<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"}
|
{userSearch ? "No users match your search" : "No users yet"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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">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">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">Created</th>
|
||||||
|
<th class="text-[10px] text-light/40 font-body py-3 px-4 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -380,18 +487,47 @@
|
|||||||
{orgMap[event.org_id]?.name ?? "—"}
|
{orgMap[event.org_id]?.name ?? "—"}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-3 px-4">
|
<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'}">
|
<form method="POST" action="?/updateEventStatus" use:enhance={enhanceAction} class="inline">
|
||||||
{event.status}
|
<input type="hidden" name="id" value={event.id} />
|
||||||
</span>
|
<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>
|
||||||
<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.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/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 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>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{#if filteredEvents.length === 0}
|
{#if filteredEvents.length === 0}
|
||||||
<tr>
|
<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"}
|
{eventSearch ? "No events match your search" : "No events yet"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -404,3 +540,122 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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