From 9b52b861c10865d9b5c057a52fc17d1f33bcf065 Mon Sep 17 00:00:00 2001 From: claudinethelobster Date: Sun, 22 Feb 2026 23:38:21 -0800 Subject: [PATCH] feat(admin): add admin roles and talks review dashboard --- README.md | 13 + app/admin/talks/page.tsx | 563 ++++++++++++++++++ app/components/Header.tsx | 14 + lib/adminCheck.ts | 17 + .../20260222000000_add_admin_role.sql | 40 ++ 5 files changed, 647 insertions(+) create mode 100644 app/admin/talks/page.tsx create mode 100644 lib/adminCheck.ts create mode 100644 supabase/migrations/20260222000000_add_admin_role.sql diff --git a/README.md b/README.md index 8daada3..600dddd 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,19 @@ For OAuth providers (GitHub, Google), see the [Local OAuth Setup Guide](./docs/l - **Docker daemon errors (`Cannot connect to the Docker daemon`)** Make sure Docker Desktop is installed and running before you call `supabase start`. +## Admin Access + +Certain features (e.g. reviewing talk submissions) are restricted to admin users. +Admin status is stored as `is_admin` on the `profiles` table. To grant admin access, +set `is_admin = true` for the relevant profile row in Supabase (via Studio or SQL). + +Admin-only pages: + +- `/admin/talks` — View and manage all talk submissions (filter by status, change status) + +Access is enforced at both the database level (RLS policies) and the frontend (redirect on +non-admin access). + ## Contributing We welcome contributions from the community! If you have suggestions or improvements, feel free to open an issue or submit a pull request. diff --git a/app/admin/talks/page.tsx b/app/admin/talks/page.tsx new file mode 100644 index 0000000..f434ca2 --- /dev/null +++ b/app/admin/talks/page.tsx @@ -0,0 +1,563 @@ +"use client" +import styled from "styled-components" +import { useState, useEffect, useCallback } from "react" +import { useRouter } from "next/navigation" +import { supabaseClient } from "../../../lib/supabaseClient" +import { checkIsAdmin } from "../../../lib/adminCheck" +import { PotionBackground } from "../../components/PotionBackground" +import { Button } from "../../components/Button" + +// Types // + +type TalkStatus = + | "pending" + | "under_review" + | "approved" + | "rejected" + | "scheduled" + | "completed" + | "cancelled" + +interface TalkSubmission { + id: number + talk_title: string + talk_synopsis: string + slides_type: "url" | "upload" + slides_url: string | null + slides_file_path: string | null + status: TalkStatus + admin_notes: string | null + created_at: string + updated_at: string + profiles: { + full_name: string + email: string + handle: string | null + } +} + +// Constants // + +const TALK_STATUSES: TalkStatus[] = [ + "pending", + "under_review", + "approved", + "rejected", + "scheduled", + "completed", + "cancelled" +] + +const STATUS_COLORS: Record = { + pending: "#f59e0b", + under_review: "#3b82f6", + approved: "#10b981", + rejected: "#ef4444", + scheduled: "#8b5cf6", + completed: "#6b7280", + cancelled: "#9ca3af" +} + +// Components // + +export default function AdminTalks() { + const router = useRouter() + const [loading, setLoading] = useState(true) + const [isAdmin, setIsAdmin] = useState(false) + const [talks, setTalks] = useState([]) + const [filterStatus, setFilterStatus] = useState("all") + const [updatingId, setUpdatingId] = useState(null) + const [error, setError] = useState(null) + + const fetchTalks = useCallback(async () => { + let query = supabaseClient + .from("talk_submissions") + .select( + ` + id, + talk_title, + talk_synopsis, + slides_type, + slides_url, + slides_file_path, + status, + admin_notes, + created_at, + updated_at, + profiles ( + full_name, + email, + handle + ) + ` + ) + .order("created_at", { ascending: false }) + + if (filterStatus !== "all") { + query = query.eq("status", filterStatus) + } + + const { data, error: fetchError } = await query + + if (fetchError) { + setError(fetchError.message) + return + } + + setTalks((data as unknown as TalkSubmission[]) || []) + }, [filterStatus]) + + useEffect(() => { + const init = async () => { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + if (!user) { + router.push("/login?redirect=%2Fadmin%2Ftalks") + return + } + + const admin = await checkIsAdmin() + if (!admin) { + router.push("/") + return + } + + setIsAdmin(true) + setLoading(false) + } + + init() + }, [router]) + + useEffect(() => { + if (isAdmin) { + fetchTalks() + } + }, [isAdmin, fetchTalks]) + + const handleStatusChange = async (talkId: number, newStatus: TalkStatus) => { + setUpdatingId(talkId) + setError(null) + + try { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + const { error: updateError } = await supabaseClient + .from("talk_submissions") + .update({ + status: newStatus, + reviewed_by: user?.id, + reviewed_at: new Date().toISOString() + }) + .eq("id", talkId) + + if (updateError) throw updateError + + await fetchTalks() + } catch (err: any) { + setError(err.message || "Failed to update status") + } finally { + setUpdatingId(null) + } + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit" + }) + } + + if (loading) { + return ( + <> + + + + + Verifying admin access... + + + ) + } + + if (!isAdmin) return null + + return ( + <> + + + + + + + Talk Submissions + + {talks.length} submission{talks.length !== 1 ? "s" : ""} + {filterStatus !== "all" ? ` (${filterStatus})` : ""} + + + + {error && {error}} + + + Filter by status: + + setFilterStatus("all")}> + All + + {TALK_STATUSES.map((status) => ( + setFilterStatus(status)} + > + {status.replace("_", " ")} + + ))} + + + + {talks.length === 0 ? ( + No submissions found. + ) : ( + + {talks.map((talk) => ( + + + {talk.talk_title} + + {talk.status.replace("_", " ")} + + + + + + Submitter + + {talk.profiles.full_name} + {talk.profiles.handle && ( + + @{talk.profiles.handle} + + )} + + + + Email + {talk.profiles.email} + + + Submitted + {formatDate(talk.created_at)} + + + Updated + {formatDate(talk.updated_at)} + + + + {talk.talk_synopsis} + + {(talk.slides_url || talk.slides_file_path) && ( + + Slides: {talk.slides_type === "url" ? "URL" : "Uploaded file"} + {talk.slides_url && ( + <> + {" — "} + + {talk.slides_url} + + + )} + {talk.slides_file_path && <> — {talk.slides_file_path}} + + )} + + + Change status: + handleStatusChange(talk.id, e.target.value as TalkStatus)} + disabled={updatingId === talk.id} + > + {TALK_STATUSES.map((s) => ( + + ))} + + {updatingId === talk.id && Saving...} + + + ))} + + )} + + + + ) +} + +// Styled Components // + +const BackgroundContainer = styled.section` + background-color: #0a0a0a; + position: fixed; + height: 100vh; + width: 100vw; + top: 0; + left: 0; + z-index: -1; +` + +const Container = styled.main` + min-height: 100vh; + display: flex; + justify-content: center; + padding: 2rem 1rem; +` + +const ContentWrapper = styled.div` + width: 100%; + max-width: 960px; + display: flex; + flex-direction: column; + gap: 1.5rem; +` + +const PageHeader = styled.div` + text-align: center; +` + +const Title = styled.h1` + font-size: 2rem; + font-weight: 700; + color: white; + margin: 0; +` + +const Subtitle = styled.p` + color: rgba(255, 255, 255, 0.6); + margin: 0.5rem 0 0 0; +` + +const FilterBar = styled.div` + display: flex; + flex-direction: column; + gap: 0.75rem; +` + +const FilterLabel = styled.span` + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; + font-weight: 600; +` + +const FilterButtons = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +` + +const FilterButton = styled.button<{ $active: boolean; $color?: string }>` + padding: 0.375rem 0.75rem; + border-radius: 1rem; + font-size: 0.8125rem; + font-family: inherit; + cursor: pointer; + transition: all 0.2s ease; + text-transform: capitalize; + border: 1px solid + ${(props) => (props.$active ? props.$color || "white" : "rgba(255, 255, 255, 0.2)")}; + background-color: ${(props) => + props.$active ? (props.$color || "white") + "22" : "transparent"}; + color: ${(props) => (props.$active ? props.$color || "white" : "rgba(255, 255, 255, 0.6)")}; + + &:hover { + border-color: ${(props) => props.$color || "white"}; + color: ${(props) => props.$color || "white"}; + } +` + +const TalkList = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +` + +const TalkCard = styled.div` + background-color: rgba(21, 21, 28, 0.75); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 0.75rem; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; +` + +const CardHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +` + +const TalkTitle = styled.h2` + font-size: 1.125rem; + font-weight: 600; + color: white; + margin: 0; + flex: 1; +` + +const StatusBadge = styled.span<{ $color: string }>` + padding: 0.25rem 0.625rem; + border-radius: 1rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: capitalize; + white-space: nowrap; + color: ${(props) => props.$color}; + background-color: ${(props) => props.$color}22; + border: 1px solid ${(props) => props.$color}44; +` + +const MetaRow = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.75rem; +` + +const MetaItem = styled.div` + display: flex; + flex-direction: column; + gap: 0.125rem; +` + +const MetaLabel = styled.span` + font-size: 0.6875rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.05em; +` + +const MetaValue = styled.span` + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + gap: 0.5rem; +` + +const HandleLink = styled.a` + color: rgba(156, 163, 255, 0.9); + text-decoration: none; + font-size: 0.8125rem; + + &:hover { + text-decoration: underline; + } +` + +const Synopsis = styled.p` + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; + line-height: 1.6; + margin: 0; + white-space: pre-wrap; +` + +const SlidesInfo = styled.div` + font-size: 0.8125rem; + color: rgba(255, 255, 255, 0.5); +` + +const SlidesLink = styled.a` + color: rgba(156, 163, 255, 0.9); + text-decoration: none; + word-break: break-all; + + &:hover { + text-decoration: underline; + } +` + +const CardActions = styled.div` + display: flex; + align-items: center; + gap: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +` + +const ActionLabel = styled.span` + font-size: 0.8125rem; + color: rgba(255, 255, 255, 0.6); + white-space: nowrap; +` + +const StatusSelect = styled.select` + padding: 0.375rem 0.75rem; + border-radius: 0.375rem; + background-color: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + font-size: 0.8125rem; + font-family: inherit; + cursor: pointer; + text-transform: capitalize; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + option { + background-color: #1a1a2e; + color: white; + } +` + +const UpdatingText = styled.span` + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); + font-style: italic; +` + +const ErrorMessage = styled.div` + color: #ff6b6b; + background-color: rgba(255, 107, 107, 0.1); + padding: 0.75rem; + border-radius: 0.5rem; + font-size: 0.875rem; + text-align: center; +` + +const EmptyState = styled.div` + text-align: center; + color: rgba(255, 255, 255, 0.5); + padding: 3rem; + font-size: 1rem; +` + +const LoadingText = styled.div` + color: white; + font-size: 1.25rem; + text-align: center; + margin-top: 4rem; +` diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 3d7ef7e..067a76b 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -7,6 +7,7 @@ import { GiveATalkCTA } from "./GiveATalkCTA" import { Button } from "./Button" import { supabaseClient } from "../../lib/supabaseClient" import { getProfileFromCache } from "../../lib/profileCache" +import { checkIsAdmin } from "../../lib/adminCheck" // Components // @@ -18,6 +19,7 @@ export const Header = () => { const [userHandle, setUserHandle] = useState(null) const [profilePhoto, setProfilePhoto] = useState(null) const [userLoading, setUserLoading] = useState(true) + const [isAdmin, setIsAdmin] = useState(false) useEffect(() => { // Check initial session and load handle and photo from cache @@ -31,6 +33,7 @@ export const Header = () => { const { handle, profilePhoto } = getProfileFromCache(user) setUserHandle(handle) setProfilePhoto(profilePhoto) + checkIsAdmin().then(setIsAdmin) } setUserLoading(false) @@ -51,6 +54,7 @@ export const Header = () => { } else { setUserHandle(null) setProfilePhoto(null) + setIsAdmin(false) } setUserLoading(false) @@ -253,6 +257,16 @@ export const Header = () => { )} + {isAdmin && ( + <> + + + + Admin: Talks + + + + )} {user && ( diff --git a/lib/adminCheck.ts b/lib/adminCheck.ts new file mode 100644 index 0000000..5dd9adb --- /dev/null +++ b/lib/adminCheck.ts @@ -0,0 +1,17 @@ +import { supabaseClient } from "./supabaseClient" + +export async function checkIsAdmin(): Promise { + const { + data: { user } + } = await supabaseClient.auth.getUser() + + if (!user) return false + + const { data: profile } = await supabaseClient + .from("profiles") + .select("is_admin") + .eq("user_id", user.id) + .single() + + return profile?.is_admin === true +} diff --git a/supabase/migrations/20260222000000_add_admin_role.sql b/supabase/migrations/20260222000000_add_admin_role.sql new file mode 100644 index 0000000..bcee64d --- /dev/null +++ b/supabase/migrations/20260222000000_add_admin_role.sql @@ -0,0 +1,40 @@ +-- Add is_admin column to profiles table +ALTER TABLE profiles +ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE; + +-- Index for quick admin lookups +CREATE INDEX idx_profiles_is_admin ON profiles(is_admin) +WHERE is_admin = TRUE; + +-- Allow admins to read ALL talk submissions (not just their own) +CREATE POLICY "Admins can view all talk submissions" + ON talk_submissions + FOR SELECT + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM profiles + WHERE profiles.user_id = auth.uid() + AND profiles.is_admin = TRUE + ) + ); + +-- Allow admins to update any talk submission (status, admin_notes, reviewed_by, reviewed_at) +CREATE POLICY "Admins can update any talk submission" + ON talk_submissions + FOR UPDATE + TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM profiles + WHERE profiles.user_id = auth.uid() + AND profiles.is_admin = TRUE + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM profiles + WHERE profiles.user_id = auth.uid() + AND profiles.is_admin = TRUE + ) + );