diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 31e1e67..a205001 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -19,7 +19,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 24 - name: Create .env file and log variables diff --git a/package.json b/package.json index 6030704..633f384 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", - "@supabase/supabase-js": "^2.49.1", + "@supabase/ssr": "^0.12.4", + "@supabase/supabase-js": "^2.112.2", "@tanstack/react-query": "^5.56.2", "@tiptap/extension-image": "^2.11.5", "@tiptap/extension-placeholder": "2.2.4", diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml index 9068c97..9a1f304 100644 --- a/public/sitemap-0.xml +++ b/public/sitemap-0.xml @@ -1,25 +1,33 @@ -https://roboticspec.com2025-08-28T11:04:18.075Zdaily0.7 -https://roboticspec.com/activities2025-08-28T11:04:18.077Zdaily0.7 -https://roboticspec.com/admin2025-08-28T11:04:18.077Zdaily0.7 -https://roboticspec.com/admin/components/ActivitiesEditor2025-08-28T11:04:18.077Zdaily0.7 -https://roboticspec.com/admin/components/BubbleMenu2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/admin/components/EventsEditor2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/admin/components/FormatButton2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/admin/components/HeroEditor2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/admin/components/MarkdownEditor2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/admin/components/MenuBar2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/admin/components/ProjectsEditor2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/admin/components/ResourceEditor2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/admin/components/SelectSearch2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/admin/components/TeamEditor2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/admin/page2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/contact2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/events2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/events/components/EventCalendar2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/project2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/project/components/ProjectCard2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/resources2025-08-28T11:04:18.078Zdaily0.7 -https://roboticspec.com/resources/components/ResourceCard2025-08-28T11:04:18.078Zdaily0.7 +https://roboticspec.com2026-08-10T09:07:57.626Zdaily0.7 +https://roboticspec.com/activities2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/ActivitiesEditor2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/BlogsEditor2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/BubbleMenu2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/EventsEditor2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/FormatButton2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/HeroEditor2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/MarkdownEditor2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/MenuBar2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/ProjectsEditor2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/ResourceEditor2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/Role2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/SelectSearch2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/TeamEditor2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/role/RoleAccordion2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/role/RolesPanel2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/role/UserRow2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/components/role/UsersPanel2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/admin/page2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/contact2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/events2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/events/components/EventCalendar2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/login2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/login/callback2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/project2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/project/components/ProjectCard2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/resources2026-08-10T09:07:57.627Zdaily0.7 +https://roboticspec.com/resources/components/ResourceCard2026-08-10T09:07:57.627Zdaily0.7 \ No newline at end of file diff --git a/src/components/Admin.tsx b/src/components/Admin.tsx index 2a76699..63359ee 100644 --- a/src/components/Admin.tsx +++ b/src/components/Admin.tsx @@ -14,9 +14,22 @@ import PageHead from "./layout/PageHead"; import PageSection from "./layout/PageSection"; import ResourceEditor from "@/pages/admin/components/ResourceEditor"; import BlogsEditor from "@/pages/admin/components/BlogsEditor"; +import Role from "@/pages/admin/components/Role"; + +export enum TabValues { + HERO="hero", + PROJECTS="projects", + ACTIVITIES="activities", + EVENTS="events", + TEAM="team", + BLOGS = "blogs", + RESOURCES="resources", + ROLES="roles" + +} const Admin = () => { - const [activeTab, setActiveTab] = useState("hero"); + const [activeTab, setActiveTab] = useState(TabValues.HERO); return ( <> { className="max-w-6xl mx-auto" > - + setActiveTab(value as TabValues)} className="w-full mb-12">
- + Hero Section - + Projects - + Activities - + Events - + Team - + Blogs - + Resources + + Roles +
- {activeTab === "hero" && "Hero Section Editor"} - {activeTab === "projects" && "Projects Editor"} - {activeTab === "activities" && "Activities Editor"} - {activeTab === "events" && "Events Editor"} - {activeTab === "team" && "Team Members Editor"} - {activeTab === "blogs" && "Blogs Editor"} - {activeTab === "resources" && "Add Resources"} + {activeTab === TabValues.HERO && "Hero Section Editor"} + {activeTab === TabValues.PROJECTS && "Projects Editor"} + {activeTab === TabValues.ACTIVITIES && "Activities Editor"} + {activeTab === TabValues.EVENTS && "Events Editor"} + {activeTab === TabValues.TEAM && "Team Members Editor"} + {activeTab === TabValues.BLOGS && "Blogs Editor"} + {activeTab === TabValues.RESOURCES && "Add Resources"} + {activeTab === TabValues.ROLES && "Define Roles"} - {activeTab === "hero" && "Edit the main heading, text and background images"} - {activeTab === "projects" && "Add, edit or remove projects"} - {activeTab === "activities" && "Manage robotics activities and workshops"} - {activeTab === "events" && "Add or edit upcoming events and competitions"} - {activeTab === "team" && "Manage team members and their information"} - {activeTab === "blogs" && "Manage blog posts"} - {activeTab === "resources" && "Manage Resources and Links"} + {activeTab === TabValues.HERO && "Edit the main heading, text and background images"} + {activeTab === TabValues.PROJECTS && "Add, edit or remove projects"} + {activeTab === TabValues.ACTIVITIES && "Manage robotics activities and workshops"} + {activeTab === TabValues.EVENTS && "Add or edit upcoming events and competitions"} + {activeTab === TabValues.TEAM && "Manage team members and their information"} + {activeTab === TabValues.BLOGS && "Manage blog posts"} + {activeTab === TabValues.RESOURCES && "Manage Resources and Links"} + {activeTab === TabValues.ROLES && "Manage Roles for different society members"} - + - + - + - + - + - + - + + + +
diff --git a/src/lib/roles.ts b/src/lib/roles.ts new file mode 100644 index 0000000..37a7ba4 --- /dev/null +++ b/src/lib/roles.ts @@ -0,0 +1,282 @@ +import { client } from "./supabase/supabase"; + +/** + * The pages an admin can grant to a role. + * This is the single source of truth: the permission checklist in the admin UI + * and the runtime route guard both read from here. + */ +export const PAGES: { path: string; label: string; group?: string }[] = [ + { path: "/", label: "Home" }, + { path: "/dashboard", label: "Dashboard" }, + { path: "/settings", label: "Settings", group: "Account" }, + { path: "/admin", label: "Admin", group: "Admin" }, + { path: "/admin/roles", label: "Admin · Roles", group: "Admin" }, +]; + +export const PAGE_SIZE = 25; + +export type Role = { + id: string; + name: string; + slug: string; + description: string | null; + isDefault: boolean; + isSystem: boolean; + created_at: string; +}; + +export type AppUser = { + id: string; + email: string | null; + fullName: string | null; + avatarUrl: string | null; + created_At: string; + role: Pick | null; +}; + +export type UserPage = { + users: AppUser[]; + total: number; +}; + +export type UserQuery = { + page: number; + search?: string; + roleId?: string | null; + pageSize?: number; +}; + +function slugify(name: string) { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +/* ----------------------------------------------------------------- roles */ + +export async function fetchRoles(): Promise { + const { data, error } = await client + .from("roles") + .select("id, name, slug, description, isDefault, isSystem, created_at") + .order("isDefault", { ascending: false }) + .order("name", { ascending: true }); + if (error) throw error; + return (data ?? []) as Role[]; +} + +/** { [roleId]: memberCount } — one cheap head-count per role. */ +export async function fetchRoleCounts(roles: Role[]): Promise> { + const entries = await Promise.all( + roles.map(async (role) => { + const { count, error } = await client + .from("userRoles") + .select("id", { count: "exact", head: true }) + .eq("roleId", role.id); + if (error) throw error; + return [role.id, count ?? 0] as const; + }), + ); + return Object.fromEntries(entries); +} + +export async function createRole(input: { name: string; description?: string }) { + const { data, error } = await client + .from("roles") + .insert({ + name: input.name.trim(), + slug: slugify(input.name), + description: input.description?.trim() || null, + }) + .select() + .single(); + if (error) throw error; + return data as Role; +} + +export async function updateRole( + id: string, + patch: { name?: string; description?: string | null }, +) { + const { data, error } = await client + .from("roles") + .update({ + ...(patch.name ? { name: patch.name.trim() } : {}), + ...(patch.description !== undefined + ? { description: patch.description?.trim() || null } + : {}), + }) + .eq("id", id) + .select() + .single(); + if (error) throw error; + return data as Role; +} + +export async function deleteRole(id: string) { + const { error } = await client.from("roles").delete().eq("id", id); + if (error) throw error; +} + +/* ----------------------------------------------------------- page rules */ + +/* +export async function fetchRoleRoutes(roleId: string): Promise { + const { data, error } = await client + .from("role_routes") + .select("path") + .eq("role_id", roleId); + if (error) throw error; + return (data ?? []).map((row: { path: string }) => row.path); +} + +export async function setRoleRoutes(roleId: string, paths: string[]) { + const { error: delError } = await client + .from("role_routes") + .delete() + .eq("role_id", roleId); + if (delError) throw delError; + + if (paths.length === 0) return; + + const { error } = await client + .from("role_routes") + .insert(paths.map((path) => ({ role_id: roleId, path }))); + if (error) throw error; +} +*/ + +/* ----------------------------------------------------------------- users */ + +type ProfileRow = { + id: string; + email: string | null; + fullName: string | null; + avatarUrl: string | null; + created_At: string; + userRoles: + | { roleId: string; roles: { id: string; name: string; slug: string } | null }[] + | { roleId: string; roles: { id: string; name: string; slug: string } | null } + | null; +}; + +function toAppUser(row: ProfileRow): AppUser { + const link = Array.isArray(row.userRoles) ? row.userRoles[0] : row.userRoles; + return { + id: row.id, + email: row.email, + fullName: row.fullName, + avatarUrl: row.avatarUrl, + created_At: row.created_At, + role: link?.roles ?? null, + }; +} + +/** + * Server-side paginated user list. Never loads the whole table — safe for + * tens of thousands of members. + */ +export async function fetchUsers({ + page, + search, + roleId, + pageSize = PAGE_SIZE, +}: UserQuery): Promise { + const from = page * pageSize; + const to = from + pageSize - 1; + + const embed = roleId + ? "userRoles!inner(roleId, roles(id, name, slug))" + : "userRoles(roleId, roles(id, name, slug))"; + + let query = client + .from("profiles") + .select(`id, email, fullName, avatarUrl, created_At, ${embed}`, { + count: "exact", + }) + .order("created_At", { ascending: false }) + .range(from, to); + + if (roleId) query = query.eq("userRoles.roleId", roleId); + + const term = search?.trim(); + if (term) { + const safe = term.replace(/[%,()]/g, ""); + query = query.or(`email.ilike.%${safe}%,fullName.ilike.%${safe}%`); + } + + const { data, error, count } = await query; + if (error) throw error; + + return { + users: ((data ?? []) as unknown as ProfileRow[]).map(toAppUser), + total: count ?? 0, + }; +} + +export async function assignRole(userId: string, roleId: string) { + const { error } = await client + .from("userRoles") + .upsert({ userId, roleId }, { onConflict: "userId" }); + if (error) throw error; +} + +/* ------------------------------------------------------------ current me */ + +export async function fetchMyRole(): Promise<{ + role: Role | null; + routes: string[]; +}> { + const { data: auth } = await client.auth.getUser(); + const user = auth.user; + if (!user) return { role: null, routes: [] }; + + const { data, error } = await client + .from("profiles") + .select( + "id, email, fullName, avatarUrl, created_At, " + + "userRoles(roleId, roles(id, name, slug, description, isDefault, isSystem, created_at))" + ) + .eq("userId", user.id) // or .eq("user_id", ...) won't work — this is the profiles table, so the column is id + .maybeSingle(); + + if(error) { + console.log(error); + throw error; + } + + const profile = data as any; + + const link = Array.isArray(profile?.userRoles) + ? profile.userRoles[0] + : profile?.userRoles; + + let role = (link?.roles ?? null) as Role | null; + + // No row yet (e.g. user created before the trigger) → fall back to default. + if (!role) { + const { data: fallback } = await client + .from("roles") + .select("id, name, slug, description, isDefault, isSystem, created_at") + .eq("isDefault", true) + .maybeSingle(); + role = (fallback as Role | null) ?? null; + if (role && data) { + await assignRole(profile.id, role.id).catch(() => undefined); + } + } + + if (!role) return { role: null, routes: [] }; + + // const routes = await fetchRoleRoutes(role.id); + return { role, routes: [] }; +} + +export function isAllowed(routes: string[], path: string) { + const clean = path.split("?")[0]!.replace(/\/+$/, "") || "/"; + return routes.some((route) => { + const r = route.replace(/\/+$/, "") || "/"; + return clean === r || clean.startsWith(`${r}/`); + }); +} diff --git a/src/lib/supabase/actions/auth.actions.ts b/src/lib/supabase/actions/auth.actions.ts index b4d64ef..8ba949d 100644 --- a/src/lib/supabase/actions/auth.actions.ts +++ b/src/lib/supabase/actions/auth.actions.ts @@ -32,3 +32,29 @@ export const loginUser = async (email: string, password: string) => { return JSON.parse(JSON.stringify(data)); } + +export const loginWithGoogle = async () => { + const { data, error } = await client.auth.signInWithOAuth({ + provider: "google", + options: { + redirectTo: `${window.location.origin}/login/callback`, + queryParams: { + hd: "pec.edu.in", + }, + }, + }); + + if (error) { + console.log(error); + + toast({ + title: "Error", + description: "Failed to log in with Google. Please try again.", + variant: "destructive", + }); + + return; + } + + return data; +}; diff --git a/src/lib/useAuthRole.ts b/src/lib/useAuthRole.ts new file mode 100644 index 0000000..3c77d45 --- /dev/null +++ b/src/lib/useAuthRole.ts @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useState } from "react"; +import { fetchMyRole, isAllowed, type Role } from "./roles"; +import { client } from "./supabase/supabase"; + +export type AuthRoleState = { + loading: boolean; + userId: string | null; + role: Role | null; + routes: string[]; + isAdmin: boolean; + can: (path: string) => boolean; + refresh: () => Promise; +}; + +/** + * Current user's role + allowed pages. Falls back to the default role + * (Member) when the user has no row yet. + */ +export function useAuthRole(): AuthRoleState { + const [loading, setLoading] = useState(true); + const [userId, setUserId] = useState(null); + const [role, setRole] = useState(null); + const [routes, setRoutes] = useState([]); + + const load = useCallback(async () => { + setLoading(true); + try { + const { data } = await client.auth.getUser(); + setUserId(data.user?.id ?? null); + if (!data.user) { + setRole(null); + setRoutes([]); + return; + } + const result = await fetchMyRole(); + setRole(result.role); + setRoutes(result.routes); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + const { data: sub } = client.auth.onAuthStateChange((event) => { + if (event === "SIGNED_IN" || event === "SIGNED_OUT" || event === "USER_UPDATED") { + void load(); + } + }); + return () => sub.subscription.unsubscribe(); + }, [load]); + + return { + loading, + userId, + role, + routes, + isAdmin: role?.slug === "admin", + can: (path: string) => isAllowed(routes, path), + refresh: load, + }; +} + +/** + * Google sign-in helper. + * signInWithGoogle("/dashboard") + */ +export async function signInWithGoogle(redirectPath = "/") { + const { error } = await client.auth.signInWithOAuth({ + provider: "google", + options: { redirectTo: `${window.location.origin}${redirectPath}` }, + }); + if (error) throw error; +} diff --git a/src/pages/admin/components/Role.tsx b/src/pages/admin/components/Role.tsx new file mode 100644 index 0000000..691d28b --- /dev/null +++ b/src/pages/admin/components/Role.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ShieldAlert } from "lucide-react"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +import { fetchRoles, type Role } from "@/lib/roles"; +import { useAuthRole } from "@/lib/useAuthRole"; +import RolesPanel from "./role/RolesPanel"; +import UsersPanel from "./role/UsersPanel"; + +/** + * Drop-in tab for your existing admin page: + * + * import Role from "@/components/admin/Role"; + * ... + * + */ +const Role = () => { + const { loading, isAdmin } = useAuthRole(); + const [roles, setRoles] = useState([]); + + useEffect(() => { + if (!isAdmin) return; + fetchRoles() + .then(setRoles) + .catch(() => undefined); + }, [isAdmin]); + + if (loading) { + return ( +
+ + +
+ ); + } + + if (!isAdmin) { + return ( +
+ +

Admins only

+

+ You don't have permission to manage roles. +

+
+ ); + } + + return ( +
+
+

Roles & permissions

+

+ Define roles, choose which pages each role can open, and assign roles to members. +

+
+ + + + Users + Roles & access + + + + + + + + + + +
+ ); +} + +export default Role; \ No newline at end of file diff --git a/src/pages/admin/components/role/RoleAccordion.tsx b/src/pages/admin/components/role/RoleAccordion.tsx new file mode 100644 index 0000000..4e19ed2 --- /dev/null +++ b/src/pages/admin/components/role/RoleAccordion.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Loader2 } from "lucide-react"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; + +import { PAGE_SIZE, assignRole, fetchUsers, type AppUser, type Role } from "@/lib/roles"; +import UserRow from "./UserRow"; +import { errorMessage } from "./RolesPanel"; + +/** Users grouped inside a collapsible section per role, lazily paginated. */ +export default function RoleAccordion({ + roles, + allRoles, + search, +}: { + roles: Role[]; + allRoles: Role[]; + search: string; +}) { + const [open, setOpen] = useState([]); + + return ( + + {roles?.map((role) => ( + + + + {role?.name} + {role?.isDefault && Default} + + + + {open.includes(role?.id) && ( + + )} + + + ))} + {roles?.length === 0 && ( +

No roles to show.

+ )} +
+ ); +} + +function RoleGroup({ + role, + allRoles, + search, +}: { + role: Role; + allRoles: Role[]; + search: string; +}) { + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [loading, setLoading] = useState(true); + const [savingId, setSavingId] = useState(null); + + const load = useCallback( + async (nextPage: number, append: boolean) => { + setLoading(true); + try { + const result = await fetchUsers({ page: nextPage, search, roleId: role.id }); + setUsers((prev) => (append ? [...prev, ...result.users] : result.users)); + setTotal(result.total); + setPage(nextPage); + } catch (error) { + toast.error(errorMessage(error, `Could not load ${role.name} members`)); + } finally { + setLoading(false); + } + }, + [role.id, role.name, search], + ); + + useEffect(() => { + void load(0, false); + }, [load]); + + async function changeRole(user: AppUser, roleId: string) { + const previous = users; + setSavingId(user.id); + setUsers((prev) => prev.filter((u) => u.id !== user.id)); + setTotal((t) => Math.max(0, t - 1)); + try { + await assignRole(user.id, roleId); + const next = allRoles.find((r) => r.id === roleId); + toast.success(`${user.fullName ?? user.email} moved to ${next?.name}`); + } catch (error) { + setUsers(previous); + setTotal((t) => t + 1); + toast.error(errorMessage(error, "Could not change role")); + } finally { + setSavingId(null); + } + } + + const hasMore = users.length < total; + + return ( +
+

+ {total} member{total === 1 ? "" : "s"} +

+ +
+ + + {loading && users.length === 0 && + Array.from({ length: 3 }).map((_, i) => ( + + + + + + ))} + + {!loading && users.length === 0 && ( + + + No members in this role. + + + )} + + {users.map((user) => ( + changeRole(user, roleId)} + /> + ))} + +
+
+ + {hasMore && ( + + )} +
+ ); +} diff --git a/src/pages/admin/components/role/RolesPanel.tsx b/src/pages/admin/components/role/RolesPanel.tsx new file mode 100644 index 0000000..6e53d6d --- /dev/null +++ b/src/pages/admin/components/role/RolesPanel.tsx @@ -0,0 +1,396 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Loader2, Pencil, Plus, Shield, Trash2 } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Textarea } from "@/components/ui/textarea"; + +import { + PAGES, + createRole, + deleteRole, + fetchRoleCounts, + fetchRoles, + updateRole, + type Role, +} from "@/lib/roles"; + +type Props = { + onRolesChanged?: (roles: Role[]) => void; +}; + +export default function RolesPanel({ onRolesChanged }: Props) { + const [roles, setRoles] = useState([]); + const [counts, setCounts] = useState>({}); + const [loading, setLoading] = useState(true); + + const [editing, setEditing] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + + const [selectedRoleId, setSelectedRoleId] = useState(null); + const [routes, setRoutes] = useState([]); + const [routesLoading, setRoutesLoading] = useState(false); + const [savingRoutes, setSavingRoutes] = useState(false); + + const selectedRole = useMemo( + () => roles.find((r) => r.id === selectedRoleId) ?? null, + [roles, selectedRoleId], + ); + + async function load() { + setLoading(true); + try { + const list = await fetchRoles(); + setRoles(list); + onRolesChanged?.(list); + setCounts(await fetchRoleCounts(list)); + setSelectedRoleId((current) => current ?? list[0]?.id ?? null); + } catch (error) { + toast.error(errorMessage(error, "Could not load roles")); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!selectedRoleId) return; + let active = true; + setRoutesLoading(true); + // fetchRoleRoutes(selectedRoleId) + // .then((paths) => { + // if (active) setRoutes(paths); + // }) + // .catch((error) => toast.error(errorMessage(error, "Could not load page rules"))) + // .finally(() => { + // if (active) setRoutesLoading(false); + // }); + return () => { + active = false; + }; + }, [selectedRoleId]); + + function toggleRoute(path: string, checked: boolean) { + setRoutes((prev) => + checked ? Array.from(new Set([...prev, path])) : prev.filter((p) => p !== path), + ); + } + + async function saveRoutes() { + if (!selectedRoleId) return; + setSavingRoutes(true); + try { + // await setRoleRoutes(selectedRoleId, routes); + toast.success("Page access updated"); + } catch (error) { + toast.error(errorMessage(error, "Could not save page access")); + } finally { + setSavingRoutes(false); + } + } + + async function handleDelete() { + if (!pendingDelete) return; + try { + await deleteRole(pendingDelete.id); + toast.success(`${pendingDelete.name} deleted — its members moved to the default role`); + setPendingDelete(null); + if (selectedRoleId === pendingDelete.id) setSelectedRoleId(null); + await load(); + } catch (error) { + toast.error(errorMessage(error, "Could not delete role")); + } + } + + return ( +
+
+
+
+

Roles

+

+ Add your own roles. Member is the fixed default. +

+
+ +
+ + + + + Role + Members + Actions + + + + {loading && + Array.from({ length: 3 }).map((_, i) => ( + + + + + + ))} + + {!loading && + roles.map((role) => ( + setSelectedRoleId(role.id)} + > + +
+ {role.name} + {role.isDefault && Default} + {role.slug === "admin" && ( + + Admin + + )} +
+ {role.description && ( +

{role.description}

+ )} +
+ + {counts[role.id] ?? 0} + + + + + +
+ ))} +
+
+
+ +
+
+

+ Page access {selectedRole ? `· ${selectedRole.name}` : ""} +

+

+ Pick the pages this role is allowed to open. +

+
+ +
+ {!selectedRole && ( +

Select a role to edit its pages.

+ )} + + {selectedRole && routesLoading && } + + {selectedRole && + !routesLoading && + PAGES.map((page) => ( + + ))} +
+ + {selectedRole && ( +
+ +
+ )} +
+ + setEditing(null)} + onSaved={async () => { + setEditing(null); + await load(); + }} + /> + + !open && setPendingDelete(null)}> + + + Delete {pendingDelete?.name}? + + Everyone with this role will be moved back to the default role. This cannot be + undone. + + + + Cancel + Delete role + + + +
+ ); +} + +function RoleDialog({ + role, + onClose, + onSaved, +}: { + role: Role | "new" | null; + onClose: () => void; + onSaved: () => void | Promise; +}) { + const isNew = role === "new"; + const current = role && role !== "new" ? role : null; + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setName(current?.name ?? ""); + setDescription(current?.description ?? ""); + }, [current?.id, current?.name, current?.description]); + + async function submit() { + if (!name.trim()) { + toast.error("Give the role a name"); + return; + } + setSaving(true); + try { + if (isNew) { + await createRole({ name, description }); + toast.success("Role created"); + } else if (current) { + await updateRole(current.id, { name, description }); + toast.success("Role updated"); + } + await onSaved(); + } catch (error) { + toast.error(errorMessage(error, "Could not save role")); + } finally { + setSaving(false); + } + } + + return ( + !open && onClose()}> + + + {isNew ? "New role" : `Edit ${current?.name}`} + + Roles control which pages a member can open. + + + +
+
+ + setName(e.target.value)} + placeholder="Editor" + /> +
+
+ +