From 659aa4f66a354ddbcd0a9bec4b24c7e60a96761c Mon Sep 17 00:00:00 2001 From: andyrodrigues30 Date: Fri, 21 Aug 2026 17:56:43 +0100 Subject: [PATCH 1/9] feat: update navbar for special roles Signed-off-by: andyrodrigues30 --- app/src/components/Navbar.tsx | 69 +++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/app/src/components/Navbar.tsx b/app/src/components/Navbar.tsx index 8f7bd6d1..9cb5a7ca 100644 --- a/app/src/components/Navbar.tsx +++ b/app/src/components/Navbar.tsx @@ -1,4 +1,4 @@ -import { Menu, Search, User, X } from "lucide-react"; +import { Menu, Search, Shield, User, X } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; @@ -16,25 +16,37 @@ import { import { Separator } from "@/components/ui/separator"; import { Logo } from "@/components/Logo"; -// `roles`, when set, hides the item from anyone holding none of them. -const navItems: Array<{ label: string; to: string; roles?: Array }> = [ +const navItems: Array<{ label: string; to: string }> = [ { label: "Browse", to: "/browse" }, { label: "Subjects", to: "/subjects" }, { label: "Objectives", to: "/objectives" }, { label: "Todo", to: "/todos" }, - { label: "Review", to: "/review", roles: ["verifier", "admin"] }, ]; // Profile isn't here because its link needs the signed-in username. const profileItems = [{ label: "Settings", to: "/settings" }]; +// `roles`, when set, hides the item from anyone holding none of them. +const sepcialRoleItems: Array<{ + label: string; + to: string; + roles?: Array; +}> = [ + { + label: "Review Queue", + to: "/review", + roles: ["verifier", "lead-verifier", "admin"], + }, + { label: "Dashboard", to: "/dashboard", roles: ["lead-verifier", "admin"] }, +]; + export function Navbar() { const [mobileOpen, setMobileOpen] = useState(false); const [query, setQuery] = useState(""); const { session, roles, currentProfile } = useAuth(); const navigate = useNavigate(); - const visibleNavItems = navItems.filter( + const visibleRoleItems = sepcialRoleItems.filter( (item) => !item.roles || item.roles.some((r) => roles.includes(r)) ); @@ -62,7 +74,7 @@ export function Navbar() {
+ {roles.length > 0 && ( +
+ + + + + + + {visibleRoleItems.map((item) => ( + + + {item.label} + + + ))} + + +
+ )} + {session ? ( /* Desktop Profile Dropdown */
@@ -189,7 +227,7 @@ export function Navbar() { {/* Nav */}
- {visibleNavItems.map((item) => ( + {navItems.map((item) => ( + {roles.length > 0 && ( + <> + {visibleRoleItems.map((item) => ( + setMobileOpen(false)} + className="py-2 font-mono text-sm text-muted-foreground uppercase hover:text-foreground" + > + {item.label} + + ))} + + )} + + + {session ? ( <> {currentProfile && ( From 86fb32671d6465ca59f800b22941ff57352760bd Mon Sep 17 00:00:00 2001 From: andyrodrigues30 Date: Fri, 21 Aug 2026 20:44:35 +0100 Subject: [PATCH 2/9] feat: add sidebar page layouts Signed-off-by: andyrodrigues30 --- app/src/components/Navbar.tsx | 4 +- .../components/sidebar/DashboardSidebar.tsx | 113 ++++++++++++++++++ app/src/routeTree.gen.ts | 113 ++++++++++++++++++ app/src/routes/dashboard.tsx | 29 +++++ app/src/routes/dashboard/assignments.tsx | 19 +++ app/src/routes/dashboard/index.tsx | 7 ++ app/src/routes/dashboard/members.tsx | 19 +++ app/src/routes/dashboard/roles.tsx | 19 +++ 8 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 app/src/components/sidebar/DashboardSidebar.tsx create mode 100644 app/src/routes/dashboard.tsx create mode 100644 app/src/routes/dashboard/assignments.tsx create mode 100644 app/src/routes/dashboard/index.tsx create mode 100644 app/src/routes/dashboard/members.tsx create mode 100644 app/src/routes/dashboard/roles.tsx diff --git a/app/src/components/Navbar.tsx b/app/src/components/Navbar.tsx index 9cb5a7ca..246ae61d 100644 --- a/app/src/components/Navbar.tsx +++ b/app/src/components/Navbar.tsx @@ -1,4 +1,4 @@ -import { Menu, Search, Shield, User, X } from "lucide-react"; +import { Menu, Search, ShieldCogCorner, User, X } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; @@ -119,7 +119,7 @@ export function Navbar() { size="icon" className="h-9 w-9 rounded-md" > - + diff --git a/app/src/components/sidebar/DashboardSidebar.tsx b/app/src/components/sidebar/DashboardSidebar.tsx new file mode 100644 index 00000000..adc3ef6b --- /dev/null +++ b/app/src/components/sidebar/DashboardSidebar.tsx @@ -0,0 +1,113 @@ +import { Link, useRouterState } from "@tanstack/react-router"; +import { FileCheckCorner, Shield, Users } from "lucide-react"; +import type { LucideProps } from "lucide-react"; +import type { ForwardRefExoticComponent, RefAttributes } from "react"; +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; +import { useAuth } from "@/lib/authContext"; + +const items: Array<{ + label: string; + to: string; + icon: ForwardRefExoticComponent< + Omit & RefAttributes + >; + roles?: Array; +}> = [ + { + label: "Members", + to: "/dashboard/members", + icon: Users, + roles: ["admin"], + }, + { + label: "Roles", + to: "/dashboard/roles", + icon: Shield, + roles: ["admin"], + }, + { + label: "Verifier Assignments", + to: "/dashboard/assignments", + icon: FileCheckCorner, + roles: ["lead-verifier", "admin"], + }, +]; + +export const DashboardSidebar = () => { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + + const { roles } = useAuth(); + + const isAdmin = roles.includes("admin"); + const isLeadVerifier = roles.includes("lead-verifier"); + + const visibleItems = items.filter( + (item) => !item.roles || item.roles.some((r) => roles.includes(r)) + ); + + return ( + + ); +}; + +export const DashboardTabs = () => { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + + return ( + + ); +}; diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index 975881c1..b5fadd33 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as ProfileRouteImport } from './routes/profile' import { Route as ObjectivesRouteImport } from './routes/objectives' import { Route as LoginRouteImport } from './routes/login' import { Route as ForgotPasswordRouteImport } from './routes/forgot-password' +import { Route as DashboardRouteImport } from './routes/dashboard' import { Route as ContributeRouteImport } from './routes/contribute' import { Route as BrowseRouteImport } from './routes/browse' import { Route as IndexRouteImport } from './routes/index' @@ -28,6 +29,7 @@ import { Route as SettingsIndexRouteImport } from './routes/settings.index' import { Route as ReviewIndexRouteImport } from './routes/review.index' import { Route as ObjectivesIndexRouteImport } from './routes/objectives.index' import { Route as GuidesIndexRouteImport } from './routes/guides/index' +import { Route as DashboardIndexRouteImport } from './routes/dashboard/index' import { Route as SubjectsSlugRouteImport } from './routes/subjects.$slug' import { Route as SettingsProfileRouteImport } from './routes/settings.profile' import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' @@ -36,6 +38,9 @@ import { Route as ReviewCaseIdRouteImport } from './routes/review.$caseId' import { Route as ProfileUsernameRouteImport } from './routes/profile.$username' import { Route as ObjectivesSlugRouteImport } from './routes/objectives/$slug' import { Route as GuidesSlugRouteImport } from './routes/guides/$slug' +import { Route as DashboardRolesRouteImport } from './routes/dashboard/roles' +import { Route as DashboardMembersRouteImport } from './routes/dashboard/members' +import { Route as DashboardAssignmentsRouteImport } from './routes/dashboard/assignments' import { Route as ObjectivesSlugIndexRouteImport } from './routes/objectives/$slug/index' import { Route as GuidesSlugIndexRouteImport } from './routes/guides/$slug/index' import { Route as GuidesSlugWalkthroughRouteImport } from './routes/guides/$slug/walkthrough' @@ -99,6 +104,11 @@ const ForgotPasswordRoute = ForgotPasswordRouteImport.update({ path: '/forgot-password', getParentRoute: () => rootRouteImport, } as any) +const DashboardRoute = DashboardRouteImport.update({ + id: '/dashboard', + path: '/dashboard', + getParentRoute: () => rootRouteImport, +} as any) const ContributeRoute = ContributeRouteImport.update({ id: '/contribute', path: '/contribute', @@ -139,6 +149,11 @@ const GuidesIndexRoute = GuidesIndexRouteImport.update({ path: '/guides/', getParentRoute: () => rootRouteImport, } as any) +const DashboardIndexRoute = DashboardIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => DashboardRoute, +} as any) const SubjectsSlugRoute = SubjectsSlugRouteImport.update({ id: '/$slug', path: '/$slug', @@ -179,6 +194,21 @@ const GuidesSlugRoute = GuidesSlugRouteImport.update({ path: '/guides/$slug', getParentRoute: () => rootRouteImport, } as any) +const DashboardRolesRoute = DashboardRolesRouteImport.update({ + id: '/roles', + path: '/roles', + getParentRoute: () => DashboardRoute, +} as any) +const DashboardMembersRoute = DashboardMembersRouteImport.update({ + id: '/members', + path: '/members', + getParentRoute: () => DashboardRoute, +} as any) +const DashboardAssignmentsRoute = DashboardAssignmentsRouteImport.update({ + id: '/assignments', + path: '/assignments', + getParentRoute: () => DashboardRoute, +} as any) const ObjectivesSlugIndexRoute = ObjectivesSlugIndexRouteImport.update({ id: '/', path: '/', @@ -223,6 +253,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/browse': typeof BrowseRoute '/contribute': typeof ContributeRoute + '/dashboard': typeof DashboardRouteWithChildren '/forgot-password': typeof ForgotPasswordRoute '/login': typeof LoginRoute '/objectives': typeof ObjectivesRouteWithChildren @@ -234,6 +265,9 @@ export interface FileRoutesByFullPath { '/subjects': typeof SubjectsRouteWithChildren '/todos': typeof TodosRoute '/verify-email': typeof VerifyEmailRoute + '/dashboard/assignments': typeof DashboardAssignmentsRoute + '/dashboard/members': typeof DashboardMembersRoute + '/dashboard/roles': typeof DashboardRolesRoute '/guides/$slug': typeof GuidesSlugRouteWithChildren '/objectives/$slug': typeof ObjectivesSlugRouteWithChildren '/profile/$username': typeof ProfileUsernameRoute @@ -242,6 +276,7 @@ export interface FileRoutesByFullPath { '/settings/appearance': typeof SettingsAppearanceRoute '/settings/profile': typeof SettingsProfileRoute '/subjects/$slug': typeof SubjectsSlugRoute + '/dashboard/': typeof DashboardIndexRoute '/guides/': typeof GuidesIndexRoute '/objectives/': typeof ObjectivesIndexRoute '/review/': typeof ReviewIndexRoute @@ -266,12 +301,16 @@ export interface FileRoutesByTo { '/reset-password': typeof ResetPasswordRoute '/todos': typeof TodosRoute '/verify-email': typeof VerifyEmailRoute + '/dashboard/assignments': typeof DashboardAssignmentsRoute + '/dashboard/members': typeof DashboardMembersRoute + '/dashboard/roles': typeof DashboardRolesRoute '/profile/$username': typeof ProfileUsernameRoute '/review/$caseId': typeof ReviewCaseIdRoute '/settings/account': typeof SettingsAccountRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/profile': typeof SettingsProfileRoute '/subjects/$slug': typeof SubjectsSlugRoute + '/dashboard': typeof DashboardIndexRoute '/guides': typeof GuidesIndexRoute '/objectives': typeof ObjectivesIndexRoute '/review': typeof ReviewIndexRoute @@ -290,6 +329,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/browse': typeof BrowseRoute '/contribute': typeof ContributeRoute + '/dashboard': typeof DashboardRouteWithChildren '/forgot-password': typeof ForgotPasswordRoute '/login': typeof LoginRoute '/objectives': typeof ObjectivesRouteWithChildren @@ -301,6 +341,9 @@ export interface FileRoutesById { '/subjects': typeof SubjectsRouteWithChildren '/todos': typeof TodosRoute '/verify-email': typeof VerifyEmailRoute + '/dashboard/assignments': typeof DashboardAssignmentsRoute + '/dashboard/members': typeof DashboardMembersRoute + '/dashboard/roles': typeof DashboardRolesRoute '/guides/$slug': typeof GuidesSlugRouteWithChildren '/objectives/$slug': typeof ObjectivesSlugRouteWithChildren '/profile/$username': typeof ProfileUsernameRoute @@ -309,6 +352,7 @@ export interface FileRoutesById { '/settings/appearance': typeof SettingsAppearanceRoute '/settings/profile': typeof SettingsProfileRoute '/subjects/$slug': typeof SubjectsSlugRoute + '/dashboard/': typeof DashboardIndexRoute '/guides/': typeof GuidesIndexRoute '/objectives/': typeof ObjectivesIndexRoute '/review/': typeof ReviewIndexRoute @@ -328,6 +372,7 @@ export interface FileRouteTypes { | '/' | '/browse' | '/contribute' + | '/dashboard' | '/forgot-password' | '/login' | '/objectives' @@ -339,6 +384,9 @@ export interface FileRouteTypes { | '/subjects' | '/todos' | '/verify-email' + | '/dashboard/assignments' + | '/dashboard/members' + | '/dashboard/roles' | '/guides/$slug' | '/objectives/$slug' | '/profile/$username' @@ -347,6 +395,7 @@ export interface FileRouteTypes { | '/settings/appearance' | '/settings/profile' | '/subjects/$slug' + | '/dashboard/' | '/guides/' | '/objectives/' | '/review/' @@ -371,12 +420,16 @@ export interface FileRouteTypes { | '/reset-password' | '/todos' | '/verify-email' + | '/dashboard/assignments' + | '/dashboard/members' + | '/dashboard/roles' | '/profile/$username' | '/review/$caseId' | '/settings/account' | '/settings/appearance' | '/settings/profile' | '/subjects/$slug' + | '/dashboard' | '/guides' | '/objectives' | '/review' @@ -394,6 +447,7 @@ export interface FileRouteTypes { | '/' | '/browse' | '/contribute' + | '/dashboard' | '/forgot-password' | '/login' | '/objectives' @@ -405,6 +459,9 @@ export interface FileRouteTypes { | '/subjects' | '/todos' | '/verify-email' + | '/dashboard/assignments' + | '/dashboard/members' + | '/dashboard/roles' | '/guides/$slug' | '/objectives/$slug' | '/profile/$username' @@ -413,6 +470,7 @@ export interface FileRouteTypes { | '/settings/appearance' | '/settings/profile' | '/subjects/$slug' + | '/dashboard/' | '/guides/' | '/objectives/' | '/review/' @@ -431,6 +489,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute BrowseRoute: typeof BrowseRoute ContributeRoute: typeof ContributeRoute + DashboardRoute: typeof DashboardRouteWithChildren ForgotPasswordRoute: typeof ForgotPasswordRoute LoginRoute: typeof LoginRoute ObjectivesRoute: typeof ObjectivesRouteWithChildren @@ -525,6 +584,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ForgotPasswordRouteImport parentRoute: typeof rootRouteImport } + '/dashboard': { + id: '/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof DashboardRouteImport + parentRoute: typeof rootRouteImport + } '/contribute': { id: '/contribute' path: '/contribute' @@ -581,6 +647,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GuidesIndexRouteImport parentRoute: typeof rootRouteImport } + '/dashboard/': { + id: '/dashboard/' + path: '/' + fullPath: '/dashboard/' + preLoaderRoute: typeof DashboardIndexRouteImport + parentRoute: typeof DashboardRoute + } '/subjects/$slug': { id: '/subjects/$slug' path: '/$slug' @@ -637,6 +710,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GuidesSlugRouteImport parentRoute: typeof rootRouteImport } + '/dashboard/roles': { + id: '/dashboard/roles' + path: '/roles' + fullPath: '/dashboard/roles' + preLoaderRoute: typeof DashboardRolesRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/members': { + id: '/dashboard/members' + path: '/members' + fullPath: '/dashboard/members' + preLoaderRoute: typeof DashboardMembersRouteImport + parentRoute: typeof DashboardRoute + } + '/dashboard/assignments': { + id: '/dashboard/assignments' + path: '/assignments' + fullPath: '/dashboard/assignments' + preLoaderRoute: typeof DashboardAssignmentsRouteImport + parentRoute: typeof DashboardRoute + } '/objectives/$slug/': { id: '/objectives/$slug/' path: '/' @@ -689,6 +783,24 @@ declare module '@tanstack/react-router' { } } +interface DashboardRouteChildren { + DashboardAssignmentsRoute: typeof DashboardAssignmentsRoute + DashboardMembersRoute: typeof DashboardMembersRoute + DashboardRolesRoute: typeof DashboardRolesRoute + DashboardIndexRoute: typeof DashboardIndexRoute +} + +const DashboardRouteChildren: DashboardRouteChildren = { + DashboardAssignmentsRoute: DashboardAssignmentsRoute, + DashboardMembersRoute: DashboardMembersRoute, + DashboardRolesRoute: DashboardRolesRoute, + DashboardIndexRoute: DashboardIndexRoute, +} + +const DashboardRouteWithChildren = DashboardRoute._addFileChildren( + DashboardRouteChildren, +) + interface ObjectivesSlugRouteChildren { ObjectivesSlugIndexRoute: typeof ObjectivesSlugIndexRoute ObjectivesSlugRevisionsRevisionIdRoute: typeof ObjectivesSlugRevisionsRevisionIdRoute @@ -799,6 +911,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BrowseRoute: BrowseRoute, ContributeRoute: ContributeRoute, + DashboardRoute: DashboardRouteWithChildren, ForgotPasswordRoute: ForgotPasswordRoute, LoginRoute: LoginRoute, ObjectivesRoute: ObjectivesRouteWithChildren, diff --git a/app/src/routes/dashboard.tsx b/app/src/routes/dashboard.tsx new file mode 100644 index 00000000..14818346 --- /dev/null +++ b/app/src/routes/dashboard.tsx @@ -0,0 +1,29 @@ +import { Outlet, createFileRoute } from "@tanstack/react-router"; +import { + DashboardSidebar, + DashboardTabs, +} from "@/components/sidebar/DashboardSidebar"; +import { requireSession } from "@/lib/auth"; + +export const Route = createFileRoute("/dashboard")({ + ssr: false, + beforeLoad: requireSession, + component: RouteComponent, +}); + +function RouteComponent() { + return ( +
+
+ {/* Left Sidebar Navigation */} + + + {/* Right Content Area */} +
+ + +
+
+
+ ); +} diff --git a/app/src/routes/dashboard/assignments.tsx b/app/src/routes/dashboard/assignments.tsx new file mode 100644 index 00000000..bd032410 --- /dev/null +++ b/app/src/routes/dashboard/assignments.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/dashboard/assignments")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( +
+
+

+ Manage Verifier Assignments +

+
+ +
+
+ ); +} diff --git a/app/src/routes/dashboard/index.tsx b/app/src/routes/dashboard/index.tsx new file mode 100644 index 00000000..aa46ac6e --- /dev/null +++ b/app/src/routes/dashboard/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +export const Route = createFileRoute("/dashboard/")({ + beforeLoad: () => { + throw redirect({ to: "/dashboard/members" }); + }, +}); diff --git a/app/src/routes/dashboard/members.tsx b/app/src/routes/dashboard/members.tsx new file mode 100644 index 00000000..0bdbde87 --- /dev/null +++ b/app/src/routes/dashboard/members.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/dashboard/members")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( +
+
+

+ Manage Members +

+
+ +
+
+ ); +} diff --git a/app/src/routes/dashboard/roles.tsx b/app/src/routes/dashboard/roles.tsx new file mode 100644 index 00000000..35ce4d4c --- /dev/null +++ b/app/src/routes/dashboard/roles.tsx @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/dashboard/roles")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( +
+
+

+ Manage Roles +

+
+ +
+
+ ); +} From 224b51a076c126a9d256b3cc5479cc5911ce2b1c Mon Sep 17 00:00:00 2001 From: andyrodrigues30 Date: Fri, 21 Aug 2026 20:46:40 +0100 Subject: [PATCH 3/9] feat: fix review queue heading size Signed-off-by: andyrodrigues30 --- app/src/routes/review.index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/routes/review.index.tsx b/app/src/routes/review.index.tsx index acf14599..df9228e6 100644 --- a/app/src/routes/review.index.tsx +++ b/app/src/routes/review.index.tsx @@ -28,7 +28,7 @@ function Shell({ children }: { children: React.ReactNode }) {
-

+

Review Queue

From 9b725f00ddaac406acbc82a657688d0a98c546b6 Mon Sep 17 00:00:00 2001 From: andyrodrigues30 Date: Sat, 22 Aug 2026 00:19:38 +0100 Subject: [PATCH 4/9] feat: create manage members page Signed-off-by: andyrodrigues30 --- .../{ => tables}/ActivityColumnFilters.tsx | 0 .../{profile => tables}/ActivityTable.tsx | 2 +- app/src/components/tables/MembersTable.tsx | 164 ++++++++++++++++++ app/src/routes/dashboard/assignments.tsx | 2 +- app/src/routes/dashboard/members.tsx | 42 ++++- app/src/routes/dashboard/roles.tsx | 2 +- app/src/routes/profile.$username.tsx | 2 +- 7 files changed, 204 insertions(+), 10 deletions(-) rename app/src/components/{ => tables}/ActivityColumnFilters.tsx (100%) rename app/src/components/{profile => tables}/ActivityTable.tsx (99%) create mode 100644 app/src/components/tables/MembersTable.tsx diff --git a/app/src/components/ActivityColumnFilters.tsx b/app/src/components/tables/ActivityColumnFilters.tsx similarity index 100% rename from app/src/components/ActivityColumnFilters.tsx rename to app/src/components/tables/ActivityColumnFilters.tsx diff --git a/app/src/components/profile/ActivityTable.tsx b/app/src/components/tables/ActivityTable.tsx similarity index 99% rename from app/src/components/profile/ActivityTable.tsx rename to app/src/components/tables/ActivityTable.tsx index 84a994bd..f0c1a098 100644 --- a/app/src/components/profile/ActivityTable.tsx +++ b/app/src/components/tables/ActivityTable.tsx @@ -16,7 +16,7 @@ import { ChoiceColumnFilter, DateColumnFilter, TextColumnFilter, -} from "@/components/ActivityColumnFilters"; +} from "@/components/tables/ActivityColumnFilters"; import { Table, TableBody, diff --git a/app/src/components/tables/MembersTable.tsx b/app/src/components/tables/MembersTable.tsx new file mode 100644 index 00000000..cf1d4dff --- /dev/null +++ b/app/src/components/tables/MembersTable.tsx @@ -0,0 +1,164 @@ +import { useState } from "react"; +import { Checkbox } from "../ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +export const MembersTable = () => { + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const profiles = [ + { + id: "001", + username: "andrea", + display_name: "Andrea", + bio: "Software Engineer", + date_created: "08-14-2026", + date_updated: "08-21-2026", + is_afk: false, + is_suspended: false, + }, + { + id: "002", + username: "bob", + display_name: "Bob", + bio: "Writer", + date_created: "08-14-2026", + date_updated: "08-21-2026", + is_afk: true, + is_suspended: false, + }, + ]; + + const allSelected = + profiles.length > 0 && + profiles.every((profile) => selectedIds.has(profile.id)); + + function toggleProfile(profileId: string) { + setSelectedIds((current) => { + const next = new Set(current); + + if (next.has(profileId)) { + next.delete(profileId); + } else { + next.add(profileId); + } + + return next; + }); + } + + function toggleAll() { + setSelectedIds((current) => { + if (allSelected) { + const next = new Set(current); + profiles.forEach((profile) => next.delete(profile.id)); + return next; + } + + return new Set(profiles.map((profile) => profile.id)); + }); + } + + return ( + + + + + + + + + Username + + + + Display Name + + + + Bio + + + + Date Created + + + + Date Updated + + + + AFK + + + + Suspended + + + + + + {profiles.map((profile) => ( + + + toggleProfile(profile.id)} + aria-label={`Select ${profile.username}`} + /> + + + + {profile.username} + + + + {profile.display_name} + + + + {profile.bio || "—"} + + + + {profile.date_created} + + + + {profile.date_updated} + + + + + {profile.is_afk ? "true" : "false"} + + + + + + {profile.is_suspended ? "true" : "false"} + + + + ))} + +
+ ); +}; diff --git a/app/src/routes/dashboard/assignments.tsx b/app/src/routes/dashboard/assignments.tsx index bd032410..f530b22f 100644 --- a/app/src/routes/dashboard/assignments.tsx +++ b/app/src/routes/dashboard/assignments.tsx @@ -6,7 +6,7 @@ export const Route = createFileRoute("/dashboard/assignments")({ function RouteComponent() { return ( -
+

Manage Verifier Assignments diff --git a/app/src/routes/dashboard/members.tsx b/app/src/routes/dashboard/members.tsx index 0bdbde87..3beac88a 100644 --- a/app/src/routes/dashboard/members.tsx +++ b/app/src/routes/dashboard/members.tsx @@ -1,4 +1,13 @@ import { createFileRoute } from "@tanstack/react-router"; +import { Ban, Ellipsis, SquareArrowRightExit } from "lucide-react"; +import { MembersTable } from "@/components/tables/MembersTable"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; export const Route = createFileRoute("/dashboard/members")({ component: RouteComponent, @@ -6,14 +15,35 @@ export const Route = createFileRoute("/dashboard/members")({ function RouteComponent() { return ( -
-
-

- Manage Members -

+
+
+
+

+ Manage Members +

+
+ +
+ + + +
-
+
+
+ +
+
); } diff --git a/app/src/routes/dashboard/roles.tsx b/app/src/routes/dashboard/roles.tsx index 35ce4d4c..02820772 100644 --- a/app/src/routes/dashboard/roles.tsx +++ b/app/src/routes/dashboard/roles.tsx @@ -6,7 +6,7 @@ export const Route = createFileRoute("/dashboard/roles")({ function RouteComponent() { return ( -
+

Manage Roles diff --git a/app/src/routes/profile.$username.tsx b/app/src/routes/profile.$username.tsx index 89c9c642..96dea483 100644 --- a/app/src/routes/profile.$username.tsx +++ b/app/src/routes/profile.$username.tsx @@ -4,7 +4,7 @@ import type { ProfileActivitySearch } from "@bluelearn/schemas"; import { getAvatarUrl, getInitials } from "@/lib/profile"; import { getProfilePage } from "@/lib/api/identity"; import { cn } from "@/lib/utils"; -import { ActivityTable } from "@/components/profile/ActivityTable"; +import { ActivityTable } from "@/components/tables/ActivityTable"; import { Separator } from "@/components/ui/separator"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; From cd36bfb4746a706dcd24d6bedaf9299fc202d06a Mon Sep 17 00:00:00 2001 From: andyrodrigues30 Date: Sat, 22 Aug 2026 00:33:50 +0100 Subject: [PATCH 5/9] feat: add manage roles table Signed-off-by: andyrodrigues30 --- app/src/components/tables/RolesTable.tsx | 146 +++++++++++++++++++++++ app/src/routes/dashboard/members.tsx | 13 +- app/src/routes/dashboard/roles.tsx | 39 +++++- 3 files changed, 181 insertions(+), 17 deletions(-) create mode 100644 app/src/components/tables/RolesTable.tsx diff --git a/app/src/components/tables/RolesTable.tsx b/app/src/components/tables/RolesTable.tsx new file mode 100644 index 00000000..fd3c0317 --- /dev/null +++ b/app/src/components/tables/RolesTable.tsx @@ -0,0 +1,146 @@ +import { useState } from "react"; +import { Checkbox } from "../ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +export const RolesTable = () => { + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const roleData = [ + { + id: "001", + username: "andrea", + roles: ["admin", "curator"], + date_created: "08-14-2026", + date_updated: "08-21-2026", + is_afk: false, + }, + { + id: "002", + username: "bob", + roles: ["verifier"], + date_created: "08-14-2026", + date_updated: "08-21-2026", + is_afk: true, + }, + ]; + + const allSelected = + roleData.length > 0 && + roleData.every((profile) => selectedIds.has(profile.id)); + + function toggleUser(userId: string) { + setSelectedIds((current) => { + const next = new Set(current); + + if (next.has(userId)) { + next.delete(userId); + } else { + next.add(userId); + } + + return next; + }); + } + + function toggleAll() { + setSelectedIds((current) => { + if (allSelected) { + const next = new Set(current); + roleData.forEach((user) => next.delete(user.id)); + return next; + } + + return new Set(roleData.map((user) => user.id)); + }); + } + + return ( + + + + + + + + + Username + + + + Roles + + + + Date Created + + + + Date Updated + + + + AFK + + + + + + {roleData.map((user) => ( + + + toggleUser(user.id)} + aria-label={`Select ${user.username}`} + /> + + + + {user.username} + + + + {user.roles.map((role) => ( + + {role} + + ))} + + + + {user.date_created} + + + + {user.date_updated} + + + + + {user.is_afk ? "true" : "false"} + + + + ))} + +
+ ); +}; diff --git a/app/src/routes/dashboard/members.tsx b/app/src/routes/dashboard/members.tsx index 3beac88a..10635903 100644 --- a/app/src/routes/dashboard/members.tsx +++ b/app/src/routes/dashboard/members.tsx @@ -1,13 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; -import { Ban, Ellipsis, SquareArrowRightExit } from "lucide-react"; +import { Ban } from "lucide-react"; import { MembersTable } from "@/components/tables/MembersTable"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; export const Route = createFileRoute("/dashboard/members")({ component: RouteComponent, @@ -24,11 +18,6 @@ function RouteComponent() {

- - + + + + +
-
+
+
+ +
+
); } From 6a24dfe5d4aae984552a1db26c3a366d6e2bd245 Mon Sep 17 00:00:00 2001 From: andyrodrigues30 Date: Sat, 22 Aug 2026 00:47:40 +0100 Subject: [PATCH 6/9] feat: add action button on verifier assignmement page Signed-off-by: andyrodrigues30 --- .../components/icons/UserRoundArrowLeft.tsx | 21 +++++++++++++++++++ app/src/routes/dashboard/assignments.tsx | 12 +---------- 2 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 app/src/components/icons/UserRoundArrowLeft.tsx diff --git a/app/src/components/icons/UserRoundArrowLeft.tsx b/app/src/components/icons/UserRoundArrowLeft.tsx new file mode 100644 index 00000000..067f7686 --- /dev/null +++ b/app/src/components/icons/UserRoundArrowLeft.tsx @@ -0,0 +1,21 @@ +export const UserRoundArrowLeft = () => { + return ( + + + + + + + ); +}; diff --git a/app/src/routes/dashboard/assignments.tsx b/app/src/routes/dashboard/assignments.tsx index f530b22f..0e9f2ad7 100644 --- a/app/src/routes/dashboard/assignments.tsx +++ b/app/src/routes/dashboard/assignments.tsx @@ -5,15 +5,5 @@ export const Route = createFileRoute("/dashboard/assignments")({ }); function RouteComponent() { - return ( -
-
-

- Manage Verifier Assignments -

-
- -
-
- ); + return
Hello "/dashboard/assignments"!
; } From 100c825255d74c9bc8fd9ad7aeef60dbe46cea3b Mon Sep 17 00:00:00 2001 From: andyrodrigues30 Date: Sat, 22 Aug 2026 07:41:19 +0100 Subject: [PATCH 7/9] feat: update user table statuses Signed-off-by: andyrodrigues30 --- app/src/components/tables/MembersTable.tsx | 23 ++++------------------ app/src/components/tables/RolesTable.tsx | 10 +++++----- 2 files changed, 9 insertions(+), 24 deletions(-) diff --git a/app/src/components/tables/MembersTable.tsx b/app/src/components/tables/MembersTable.tsx index cf1d4dff..8f0726c0 100644 --- a/app/src/components/tables/MembersTable.tsx +++ b/app/src/components/tables/MembersTable.tsx @@ -21,8 +21,7 @@ export const MembersTable = () => { bio: "Software Engineer", date_created: "08-14-2026", date_updated: "08-21-2026", - is_afk: false, - is_suspended: false, + status: "active", }, { id: "002", @@ -31,8 +30,7 @@ export const MembersTable = () => { bio: "Writer", date_created: "08-14-2026", date_updated: "08-21-2026", - is_afk: true, - is_suspended: false, + status: "suspended", }, ]; @@ -99,11 +97,7 @@ export const MembersTable = () => { - AFK - - - - Suspended + Status @@ -144,16 +138,7 @@ export const MembersTable = () => { variant="outline" className="mono-micro rounded-full border border-badge-border bg-badge tracking-[0.08em] text-badge-foreground" > - {profile.is_afk ? "true" : "false"} - - - - - - {profile.is_suspended ? "true" : "false"} + {profile.status} diff --git a/app/src/components/tables/RolesTable.tsx b/app/src/components/tables/RolesTable.tsx index fd3c0317..529e1ef9 100644 --- a/app/src/components/tables/RolesTable.tsx +++ b/app/src/components/tables/RolesTable.tsx @@ -20,7 +20,7 @@ export const RolesTable = () => { roles: ["admin", "curator"], date_created: "08-14-2026", date_updated: "08-21-2026", - is_afk: false, + status: "active", }, { id: "002", @@ -28,7 +28,7 @@ export const RolesTable = () => { roles: ["verifier"], date_created: "08-14-2026", date_updated: "08-21-2026", - is_afk: true, + status: "suspended", }, ]; @@ -91,7 +91,7 @@ export const RolesTable = () => { - AFK + Status @@ -111,7 +111,7 @@ export const RolesTable = () => { {user.username} - + {user.roles.map((role) => ( { variant="outline" className="mono-micro rounded-full border border-badge-border bg-badge tracking-[0.08em] text-badge-foreground" > - {user.is_afk ? "true" : "false"} + {user.status} From 01351d677b48997ddd25af7384eb171c85b5f5e1 Mon Sep 17 00:00:00 2001 From: andyrodrigues30 Date: Sat, 22 Aug 2026 07:45:16 +0100 Subject: [PATCH 8/9] feat: create assignments page Signed-off-by: andyrodrigues30 --- app/src/routes/dashboard/assignments.tsx | 27 +++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/app/src/routes/dashboard/assignments.tsx b/app/src/routes/dashboard/assignments.tsx index 0e9f2ad7..d03ac159 100644 --- a/app/src/routes/dashboard/assignments.tsx +++ b/app/src/routes/dashboard/assignments.tsx @@ -1,9 +1,34 @@ import { createFileRoute } from "@tanstack/react-router"; +import { UserRoundArrowLeft } from "@/components/icons/UserRoundArrowLeft"; +import { Button } from "@/components/ui/button"; export const Route = createFileRoute("/dashboard/assignments")({ component: RouteComponent, }); function RouteComponent() { - return
Hello "/dashboard/assignments"!
; + return ( +
+
+
+

+ Manage Guide Assignments +

+
+ +
+ +
+
+ +
+
+ {/* AssignmentsTable */} +
+
+
+ ); } From d4d5c3003bb6ff3c87a1b4ac57cd3d1c7f48ef7f Mon Sep 17 00:00:00 2001 From: James Potter Date: Wed, 2 Sep 2026 23:26:23 -0400 Subject: [PATCH 9/9] feat: added dashboard functionality Signed-off-by: James Potter --- api/src/database.types.ts | 56 ++- api/src/index.ts | 7 +- api/src/middleware/rateLimits.ts | 1 + api/src/routes/dashboard.ts | 94 ++++ api/src/services/dashboard.service.ts | 409 ++++++++++++++++++ api/src/services/identity.service.ts | 2 +- .../components/tables/AssignmentsTable.tsx | 189 ++++++++ app/src/components/tables/MembersTable.tsx | 82 ++-- app/src/components/tables/RolesTable.tsx | 81 ++-- app/src/lib/api/dashboard.ts | 178 ++++++++ app/src/routes/dashboard/assignments.tsx | 56 ++- app/src/routes/dashboard/members.tsx | 65 ++- app/src/routes/dashboard/roles.tsx | 105 ++++- .../20260824230913_add_user_statuses.sql | 83 ++++ .../20260830160839_role_and_status_perms.sql | 54 +++ ...0260902210237_reassign_panel_member_fn.sql | 68 +++ supabase/seed.sql | 2 +- 17 files changed, 1416 insertions(+), 116 deletions(-) create mode 100644 api/src/routes/dashboard.ts create mode 100644 api/src/services/dashboard.service.ts create mode 100644 app/src/components/tables/AssignmentsTable.tsx create mode 100644 app/src/lib/api/dashboard.ts create mode 100644 supabase/migrations/20260824230913_add_user_statuses.sql create mode 100644 supabase/migrations/20260830160839_role_and_status_perms.sql create mode 100644 supabase/migrations/20260902210237_reassign_panel_member_fn.sql diff --git a/api/src/database.types.ts b/api/src/database.types.ts index e364c042..7431d2fa 100644 --- a/api/src/database.types.ts +++ b/api/src/database.types.ts @@ -7,10 +7,30 @@ export type Json = | Json[] export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: "14.5" + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + extensions?: Json + operationName?: string + query?: string + variables?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } } public: { Tables: { @@ -1039,6 +1059,24 @@ export type Database = { }, ] } + user_statuses: { + Row: { + status: Database["public"]["Enums"]["user_status"] + updated_at: string | null + user_id: string + } + Insert: { + status?: Database["public"]["Enums"]["user_status"] + updated_at?: string | null + user_id: string + } + Update: { + status?: Database["public"]["Enums"]["user_status"] + updated_at?: string | null + user_id?: string + } + Relationships: [] + } votes: { Row: { created_at: string @@ -1233,6 +1271,10 @@ export type Database = { Args: { p_revision_id: string } Returns: string } + reassign_panel_member: { + Args: { p_member_id: string; p_panel_id: string } + Returns: string + } revise_guide_revision: { Args: { p_revision_id: string } Returns: string @@ -1284,6 +1326,7 @@ export type Database = { seat_status: "assigned" | "recused" | "replaced" | "completed" subject_status: "draft" | "published" todo_status: "open" | "resolved" + user_status: "active" | "inactive" | "suspended" vote_direction: "up" | "down" } CompositeTypes: { @@ -1410,6 +1453,9 @@ export type CompositeTypes< : never export const Constants = { + graphql_public: { + Enums: {}, + }, public: { Enums: { app_role: ["verifier", "moderator", "curator", "admin", "official"], @@ -1447,7 +1493,9 @@ export const Constants = { seat_status: ["assigned", "recused", "replaced", "completed"], subject_status: ["draft", "published"], todo_status: ["open", "resolved"], + user_status: ["active", "inactive", "suspended"], vote_direction: ["up", "down"], }, }, } as const + diff --git a/api/src/index.ts b/api/src/index.ts index 0d9211ad..c0a0e8db 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -28,6 +28,7 @@ import { subjectsRouter } from "./routes/subjects"; import { reviewsRouter } from "./routes/reviews"; import { mediaRouter } from "./routes/media"; import { searchRouter } from "./routes/search"; +import { dashboardRouter } from "./routes/dashboard"; const app = new Hono() .use((c, next) => cors({ origin: c.env.APP_URL })(c, next)) @@ -47,7 +48,8 @@ const app = new Hono() .route("/subjects", subjectsRouter) .route("/reviews", reviewsRouter) .route("/media", mediaRouter) - .route("/search", searchRouter); + .route("/search", searchRouter) + .route("/dashboard", dashboardRouter); // Services throw ServiceError to signal HTTP-meaningful failures; map them to // JSON here so handlers stay free of repeated `if (error) return c.json(...)`. @@ -67,10 +69,11 @@ async function scheduled(event: ScheduledController, env: Bindings) { ); if (event.cron === "*/5 * * * *") { - await Promise.allSettled([ + const results = await Promise.allSettled([ assemblePendingPanels(supabase), sweepExpiredReviewSeats(supabase), ]); + console.log(results); } if (event.cron === "0 */12 * * *") await promoteAllCanonicals(supabase); } diff --git a/api/src/middleware/rateLimits.ts b/api/src/middleware/rateLimits.ts index 0738df11..4a1cd2d1 100644 --- a/api/src/middleware/rateLimits.ts +++ b/api/src/middleware/rateLimits.ts @@ -3,5 +3,6 @@ export const CONTRIBUTION = { windowSeconds: 3_600, max: 60 } as const; export const MODERATION = { windowSeconds: 3_600, max: 30 } as const; export const HEAVY = { windowSeconds: 3_600, max: 30 } as const; export const DESTRUCTIVE = { windowSeconds: 3_600, max: 5 } as const; +export const DASHBOARD = { windowSeconds: 60, max: 10 } as const; export const READ = { windowSeconds: 60, max: 600, keyBy: "ip" } as const; export const SEARCH = { windowSeconds: 60, max: 30, keyBy: "ip" } as const; diff --git a/api/src/routes/dashboard.ts b/api/src/routes/dashboard.ts new file mode 100644 index 00000000..b94f5300 --- /dev/null +++ b/api/src/routes/dashboard.ts @@ -0,0 +1,94 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import type { HonoEnv } from "../types"; +import { requireUser } from "../middleware/auth.middleware"; +import { + getUserStatus, + markUserStatus, + suspendUser, + unsuspendUser, + addRole, + removeRole, + fetchRolesTable, + fetchMembersTable, + fetchAssignmentsTable, + reassignPanelMember, + updateStatusSchema, + updateRoleSchema, + roleParamSchema, +} from "../services/dashboard.service"; + +export const dashboardRouter = new Hono() + .use("*", requireUser) + // Get user status (Active, Inactive, Suspended) + .get("/:id/status", async (c) => { + const { id } = c.req.param(); + const status = await getUserStatus(c.get("supabase"), id); + return c.json({ status }, 200); + }) + + // Change users status + .patch("/:id/status", zValidator("json", updateStatusSchema), async (c) => { + const { id: userId } = c.req.param(); + const { status } = c.req.valid("json"); + const data = await markUserStatus(c.get("supabase"), userId, status); + return c.json({ data }, 200); + }) + + // Add role to user + .post("/:id/role", zValidator("json", updateRoleSchema), async (c) => { + const { id: userId } = c.req.param(); + const { role } = c.req.valid("json"); + await addRole(c.get("supabase"), userId, role); + return c.json({ success: true }, 200); + }) + + // Remove role from user + .delete( + "/:id/role/:roleName", + zValidator("param", roleParamSchema), + async (c) => { + const { id, roleName } = c.req.valid("param"); + await removeRole(c.get("supabase"), id, roleName); + return c.json({ success: true }, 200); + } + ) + + // Fetch roles table + .get("/roles", async (c) => { + const data = await fetchRolesTable(c.get("supabase")); + return c.json({ data }, 200); + }) + + // Fetch members table + .get("/members", async (c) => { + const data = await fetchMembersTable(c.get("supabase")); + return c.json({ data }, 200); + }) + + // Fetch assignments table + .get("/assignments", async (c) => { + const data = await fetchAssignmentsTable(c.get("supabase")); + return c.json({ data }, 200); + }) + + // Suspend user + .patch("/:id/suspend", async (c) => { + const { id } = c.req.param(); + await suspendUser(c.get("supabase"), id); + return c.json({ success: true }, 200); + }) + + // Unsuspend user + .patch("/:id/unsuspend", async (c) => { + const { id } = c.req.param(); + await unsuspendUser(c.get("supabase"), id); + return c.json({ success: true }, 200); + }) + + // Reassign a panel member + .patch("/:id/reassign/:panel_id", async (c) => { + const { id, panel_id } = c.req.param(); + await reassignPanelMember(c.get("supabase"), id, panel_id); + return c.json({ success: true }, 200); + }); diff --git a/api/src/services/dashboard.service.ts b/api/src/services/dashboard.service.ts new file mode 100644 index 00000000..ac78ddc6 --- /dev/null +++ b/api/src/services/dashboard.service.ts @@ -0,0 +1,409 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "../database.types"; +import type { ProfileActivityRow } from "./identity.service"; +import { ServiceError } from "../lib/service-error"; +import { z } from "zod"; + +// roles and status types +export type UserStatus = + Database["public"]["Tables"]["user_statuses"]["Row"]["status"]; +export type UserRole = + Database["public"]["Tables"]["user_roles"]["Row"]["role"]; + +// user status schema, will move to schemas later +const userStatusValues = [ + "active", + "inactive", + "suspended", +] as const satisfies readonly UserStatus[]; + +export const userStatusSchema = z.enum(userStatusValues); +export const updateStatusSchema = z.object({ + status: userStatusSchema, +}); + +// user role schema +const userRoleValues = [ + "verifier", + "moderator", + "curator", + "admin", + "official", +] as const satisfies readonly UserRole[]; + +export const userRoleSchema = z.enum(userRoleValues); +export const updateRoleSchema = z.object({ + role: userRoleSchema, +}); +export const roleParamSchema = z.object({ + id: z.string(), + roleName: userRoleSchema, +}); + +type DB = SupabaseClient; + +export type DashboardAssignmentRow = ProfileActivityRow & { + username: string; + created_at: string; + updated_at: string; + time_limit: number; + user_status: UserStatus; +}; + +export type RoleRow = { + id: string; + username: string; + roles: string[]; + date_created: string; + date_updated: string; + status: string; +}; + +// everything fetched by fetchAllAssignments +type AssignmentSourceRow = { + id: string; + member_id: string | null; + status: string; + assigned_at: string; + expires_at: string | null; + review_panels: { + id: string; + case_id: string; + review_cases: { + id: string; + case_type: string; + status: string; + created_at: string; + updated_at: string; + guide_review_cases: { + guide_revision_id: string; + guide_revisions: { + title: string | null; + change_summary: string | null; + } | null; + } | null; + }; + }; +}; + +// fetch status for specific user +export async function getUserStatus(supabase: DB, userId: string) { + const { data, error } = await supabase + .from("user_statuses") + .select("status") + .eq("user_id", userId) + .single(); + + if (error) { + console.error(error); + if (error.code === "PGRST116") { + throw new ServiceError("Could not fetch status: User not found.", 404); + } + throw new ServiceError("Failed to fetch user status.", 500); + } + + return data.status; +} + +// set user status +export async function markUserStatus( + supabase: DB, + userId: string, + status: UserStatus +) { + const { data, error } = await supabase + .from("user_statuses") + .update({ status: status }) + .eq("user_id", userId) + .select(); + + if (error) { + console.error(error); + throw new ServiceError("Failed to update user status.", 500); + } + if (!data || data.length === 0) { + throw new ServiceError("Could not update status: User not found", 404); + } + + return data; +} + +// add role to user +export async function addRole(supabase: DB, userId: string, role: UserRole) { + const { error } = await supabase + .from("user_roles") + .insert({ user_id: userId, role }); + + if (error) { + console.error(error); + throw new ServiceError("Could not add role to user.", 500); + } +} + +// remove role from user +export async function removeRole(supabase: DB, userId: string, role: UserRole) { + const { error } = await supabase + .from("user_roles") + .delete() + .eq("user_id", userId) + .eq("role", role); + + if (error) { + console.error(error); + throw new ServiceError("Could not remove role from user.", 500); + } +} + +// fetch all statuses and map them to id data +async function fetchStatuses( + supabase: DB, + ids: string[] +): Promise> { + const { data, error } = await supabase + .from("user_statuses") + .select("user_id, status") + .in("user_id", ids); + + if (error) { + console.error(error); + throw new ServiceError("Failed to batch select user statuses.", 500); + } + if (!data) { + throw new ServiceError("User statuses not found.", 404); + } + + const statusMap = new Map(); + for (const row of data ?? []) { + statusMap.set(row.user_id, row.status); + } + + return statusMap; +} + +// fetch all roles (site-wide) and map to id data +async function fetchAllRoles( + supabase: DB, + ids: string[] +): Promise> { + const { data, error } = await supabase + .from("user_roles") + .select("*") + .in("user_id", ids); + + if (error) { + console.error(error); + throw new ServiceError("Failed to batch select user roles.", 500); + } + + const roleMap = new Map(); + for (const row of data ?? []) { + if (!roleMap.has(row.user_id)) { + roleMap.set(row.user_id, [row.role]); + } else { + roleMap.get(row.user_id)!.push(row.role); + } + } + + return roleMap; +} + +// return map of all usernames +async function getUsernames( + supabase: DB, + ids: string[] +): Promise> { + const { data, error } = await supabase + .from("profiles") + .select("id, username") + .in("id", ids); + + if (error) { + console.error(error); + throw new ServiceError("Failed to fetch username list.", 500); + } + + const usernameMap = new Map(); + for (const row of data ?? []) { + usernameMap.set(row.id, row.username); + } + + return usernameMap; +} + +// return full list of user assignments +async function fetchAllAssignments(supabase: DB) { + const { data: raw, error } = await supabase + .from("panel_members") + .select( + `id, member_id, status, assigned_at, expires_at, + review_panels!inner( + id, case_id, + review_cases!inner( + id, case_type, status, created_at, updated_at, + guide_review_cases( + guide_revision_id, + guide_revisions(title, change_summary) + ) + ) + )` + ) + .in("status", ["assigned", "completed"]); + + if (error) { + console.error(error); + throw new ServiceError("Failed to load assignments", 500); + } + + const rows = (raw ?? []) as unknown as AssignmentSourceRow[]; + + return rows.map((r) => { + const rc = r.review_panels.review_cases; + return { + user_id: r.member_id, + panel_id: r.review_panels.id, + date_created: rc.created_at, + date_updated: rc.updated_at, + type: rc.case_type, + title: rc.guide_review_cases?.guide_revisions?.title ?? null, + change_summary: + rc.guide_review_cases?.guide_revisions?.change_summary ?? null, + status: r.status, + expires_at: r.expires_at, + }; + }); +} + +// fetch a list of all user ids for above global selection functions +export async function getUserIds(supabase: DB) { + const { data, error } = await supabase.from("profiles").select("id"); + + if (error) { + console.error(error); + throw new ServiceError("Could not fetch user list.", 500); + } + if (!data) { + throw new ServiceError("Could not fetch user list.", 500); + } + + return data.map((r) => { + return r.id; + }); +} + +// select all data from across different table for roles table +export async function fetchRolesTable(supabase: DB) { + const ids = await getUserIds(supabase); + + const [profiles, statuses, roles] = await Promise.all([ + supabase.from("profiles").select("id, username, created_at, updated_at"), + fetchStatuses(supabase, ids), + fetchAllRoles(supabase, ids), + ]); + + // quick check for profiles errors + if (profiles.error || !profiles.data) { + throw new ServiceError("Could not batch select profiles.", 500); + } + + return profiles.data.map((profile) => ({ + id: profile.id, + username: profile.username, + roles: roles.get(profile.id) ?? [], + date_created: profile.created_at, + date_updated: profile.updated_at, + status: statuses.get(profile.id), + })); +} + +// fetch data for the members table +export async function fetchMembersTable(supabase: DB) { + const ids = await getUserIds(supabase); + const [profiles, statuses] = await Promise.all([ + supabase + .from("profiles") + .select("id, username, display_name, created_at, updated_at, bio"), + fetchStatuses(supabase, ids), + ]); + + // quick check for profiles errors + if (profiles.error || !profiles.data) { + throw new ServiceError("Could not batch select profiles.", 500); + } + + return profiles.data.map((profile) => ({ + id: profile.id, + username: profile.username, + display_name: profile.display_name, + bio: profile.bio, + date_created: profile.created_at, + date_updated: profile.updated_at, + status: statuses.get(profile.id), + })); +} + +// get assignments table +export async function fetchAssignmentsTable(supabase: DB) { + const ids = await getUserIds(supabase); + const [profiles, statuses, assignments] = await Promise.all([ + getUsernames(supabase, ids), + fetchStatuses(supabase, ids), + fetchAllAssignments(supabase), + ]); + + return assignments.map((a) => ({ + id: a.user_id, + panel_id: a.panel_id, + username: profiles.get(a.user_id!), + type: a.type, + title: a.title ?? "", + date_created: a.date_created, + date_updated: a.date_updated, + change_summary: a.change_summary ?? "", + status: a.status, + user_status: statuses.get(a.user_id!), + time_left: a.expires_at, + })); +} + +// suspend a user +export async function suspendUser(supabase: DB, userId: string) { + const [, profile] = await Promise.all([ + markUserStatus(supabase, userId, "suspended"), + supabase.from("profiles").update({ is_suspended: true }).eq("id", userId), + ]); + + if (profile.error || !profile) { + console.error(profile.error); + throw new ServiceError("Failed to mark user profile as suspended", 500); + } +} + +// unsuspend a user +export async function unsuspendUser(supabase: DB, userId: string) { + const [, profile] = await Promise.all([ + markUserStatus(supabase, userId, "active"), + supabase.from("profiles").update({ is_suspended: false }).eq("id", userId), + ]); + + if (profile.error || !profile) { + console.error(profile.error); + throw new ServiceError("Failed to mark user profile as unsuspended", 500); + } +} + +// reassign a member of a panel +export async function reassignPanelMember( + supabase: DB, + userId: string, + panelId: string +) { + const { error } = await supabase.rpc("reassign_panel_member", { + p_panel_id: panelId, + p_member_id: userId, + }); + + if (error) { + console.error(error); + throw new ServiceError("Failed to reassign panel member", 500); + } +} diff --git a/api/src/services/identity.service.ts b/api/src/services/identity.service.ts index 11bfe31d..c77d75ce 100644 --- a/api/src/services/identity.service.ts +++ b/api/src/services/identity.service.ts @@ -23,7 +23,7 @@ type ObjectiveDraft = { updated_at: string; }; -async function fetchRoles(supabase: DB, userId: string) { +export async function fetchRoles(supabase: DB, userId: string) { const { data } = await supabase .from("user_roles") .select("role") diff --git a/app/src/components/tables/AssignmentsTable.tsx b/app/src/components/tables/AssignmentsTable.tsx new file mode 100644 index 00000000..b1b644f8 --- /dev/null +++ b/app/src/components/tables/AssignmentsTable.tsx @@ -0,0 +1,189 @@ +import { useEffect, useState } from "react"; +import { Checkbox } from "../ui/checkbox"; +import type { AssignmentTable } from "@/lib/api/dashboard"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { formatDate } from "@/lib/guideUtils"; +import { deadlineTickMs, formatTimeRemaining } from "@/lib/reviewDeadline"; + +type AssignmentsTableProps = { + assignmentsData: AssignmentTable; + selectedIds: Set; + setSelectedIds: (ids: Set) => void; +}; + +function ExpireCell({ expiresAt }: { expiresAt: string | null }) { + const [now, setNow] = useState(() => Date.now()); + const expiresMs = expiresAt ? new Date(expiresAt).getTime() : null; + + useEffect(() => { + if (expiresMs === null) return; + + const diffMs = expiresMs - Date.now(); + if (diffMs <= 0) return; + const timer = setTimeout(() => setNow(Date.now()), deadlineTickMs(diffMs)); + return () => clearTimeout(timer); + }, [expiresMs, now]); + + if (expiresMs === null) + return -; + + const diffMs = expiresMs - now; + if (diffMs < 0) { + return Expired; + } + + return {formatTimeRemaining(diffMs)}; +} + +export const AssignmentsTable = ({ + assignmentsData, + selectedIds, + setSelectedIds, +}: AssignmentsTableProps) => { + const allSelected = + assignmentsData.length > 0 && + assignmentsData.every((profile: any) => selectedIds.has(profile.id)); + + function toggleUser(userId: string) { + const next = new Set(selectedIds); + + if (next.has(userId)) { + next.delete(userId); + } else { + next.add(userId); + } + setSelectedIds(next); + } + + function toggleAll() { + if (allSelected) { + const next = new Set(selectedIds); + assignmentsData.forEach((assignment: any) => next.delete(assignment.id)); + setSelectedIds(next); + } + + setSelectedIds( + new Set(assignmentsData.map((assignment: any) => assignment.id)) + ); + } + + return ( + + + + + + + + + Assignee + + + + Assignee Status + + + + Time Left + + + + Status + + + + Type + + + + Title + + + + Change Summary + + + + Date Created + + + + Date Updated + + + + + + {assignmentsData.map((assignment: any) => ( + + + toggleUser(assignment.id)} + aria-label={`Select ${assignment.assignmentname}`} + /> + + + + {assignment.username} + + + + + {assignment.user_status ?? "No status."} + + + + + + + + + + {assignment.status} + + + + + {assignment.type} + + + + {assignment.title} + + + + {assignment.change_summary ?? ""} + + + + {formatDate(new Date(assignment.date_created))} + + + + {formatDate(new Date(assignment.date_updated))} + + + ))} + +
+ ); +}; diff --git a/app/src/components/tables/MembersTable.tsx b/app/src/components/tables/MembersTable.tsx index 8f0726c0..dcce735e 100644 --- a/app/src/components/tables/MembersTable.tsx +++ b/app/src/components/tables/MembersTable.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; import { Checkbox } from "../ui/checkbox"; +import type { MemberRow } from "@/lib/api/dashboard"; import { Badge } from "@/components/ui/badge"; import { Table, @@ -9,59 +9,41 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { formatDate } from "@/lib/guideUtils"; -export const MembersTable = () => { - const [selectedIds, setSelectedIds] = useState>(new Set()); - - const profiles = [ - { - id: "001", - username: "andrea", - display_name: "Andrea", - bio: "Software Engineer", - date_created: "08-14-2026", - date_updated: "08-21-2026", - status: "active", - }, - { - id: "002", - username: "bob", - display_name: "Bob", - bio: "Writer", - date_created: "08-14-2026", - date_updated: "08-21-2026", - status: "suspended", - }, - ]; +type MembersTableProps = { + MemberData: Array; + selectedIds: Set; + setSelectedIds: (ids: Set) => void; +}; +export const MembersTable = ({ + MemberData: profiles, + selectedIds, + setSelectedIds, +}: MembersTableProps) => { const allSelected = profiles.length > 0 && - profiles.every((profile) => selectedIds.has(profile.id)); + profiles.every((profile: MemberRow) => selectedIds.has(profile.id)); function toggleProfile(profileId: string) { - setSelectedIds((current) => { - const next = new Set(current); - - if (next.has(profileId)) { - next.delete(profileId); - } else { - next.add(profileId); - } - - return next; - }); + const next = new Set(selectedIds); + if (next.has(profileId)) { + next.delete(profileId); + } else { + next.add(profileId); + } + setSelectedIds(next); } function toggleAll() { - setSelectedIds((current) => { - if (allSelected) { - const next = new Set(current); - profiles.forEach((profile) => next.delete(profile.id)); - return next; - } - - return new Set(profiles.map((profile) => profile.id)); - }); + if (allSelected) { + const next = new Set(selectedIds); + profiles.forEach((profile: MemberRow) => next.delete(profile.id)); + setSelectedIds(next); + } else { + setSelectedIds(new Set(profiles.map((profile: MemberRow) => profile.id))); + } } return ( @@ -103,7 +85,7 @@ export const MembersTable = () => { - {profiles.map((profile) => ( + {profiles.map((profile: MemberRow) => ( { - {profile.display_name} + {profile.display_name ?? profile.username} @@ -126,11 +108,11 @@ export const MembersTable = () => { - {profile.date_created} + {formatDate(new Date(profile.date_created))} - {profile.date_updated} + {formatDate(new Date(profile.date_updated))} @@ -138,7 +120,7 @@ export const MembersTable = () => { variant="outline" className="mono-micro rounded-full border border-badge-border bg-badge tracking-[0.08em] text-badge-foreground" > - {profile.status} + {profile.status ?? "No Status"} diff --git a/app/src/components/tables/RolesTable.tsx b/app/src/components/tables/RolesTable.tsx index 529e1ef9..97209500 100644 --- a/app/src/components/tables/RolesTable.tsx +++ b/app/src/components/tables/RolesTable.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { Checkbox } from "../ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { @@ -9,57 +8,42 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { formatDate } from "@/lib/guideUtils"; -export const RolesTable = () => { - const [selectedIds, setSelectedIds] = useState>(new Set()); - - const roleData = [ - { - id: "001", - username: "andrea", - roles: ["admin", "curator"], - date_created: "08-14-2026", - date_updated: "08-21-2026", - status: "active", - }, - { - id: "002", - username: "bob", - roles: ["verifier"], - date_created: "08-14-2026", - date_updated: "08-21-2026", - status: "suspended", - }, - ]; +type RolesTableProps = { + roleData: Array; + selectedIds: Set; + setSelectedIds: (ids: Set) => void; +}; +export const RolesTable = ({ + roleData, + selectedIds, + setSelectedIds, +}: RolesTableProps) => { const allSelected = roleData.length > 0 && - roleData.every((profile) => selectedIds.has(profile.id)); + roleData.every((profile: any) => selectedIds.has(profile.id)); function toggleUser(userId: string) { - setSelectedIds((current) => { - const next = new Set(current); - - if (next.has(userId)) { - next.delete(userId); - } else { - next.add(userId); - } - - return next; - }); + const next = new Set(selectedIds); + + if (next.has(userId)) { + next.delete(userId); + } else { + next.add(userId); + } + setSelectedIds(next); } function toggleAll() { - setSelectedIds((current) => { - if (allSelected) { - const next = new Set(current); - roleData.forEach((user) => next.delete(user.id)); - return next; - } - - return new Set(roleData.map((user) => user.id)); - }); + if (allSelected) { + const next = new Set(selectedIds); + roleData.forEach((user: any) => next.delete(user.id)); + setSelectedIds(next); + } + + setSelectedIds(new Set(roleData.map((user: any) => user.id))); } return ( @@ -97,7 +81,7 @@ export const RolesTable = () => { - {roleData.map((user) => ( + {roleData.map((user: any) => ( { - {user.roles.map((role) => ( + {user.roles.map((role: string, i: number) => ( @@ -123,11 +108,11 @@ export const RolesTable = () => { - {user.date_created} + {formatDate(new Date(user.date_created))} - {user.date_updated} + {formatDate(new Date(user.date_updated))} @@ -135,7 +120,7 @@ export const RolesTable = () => { variant="outline" className="mono-micro rounded-full border border-badge-border bg-badge tracking-[0.08em] text-badge-foreground" > - {user.status} + {user.status ?? "No status"} diff --git a/app/src/lib/api/dashboard.ts b/app/src/lib/api/dashboard.ts new file mode 100644 index 00000000..7d9f4e46 --- /dev/null +++ b/app/src/lib/api/dashboard.ts @@ -0,0 +1,178 @@ +import { toast } from "sonner"; +import type { InferRequestType, InferResponseType } from "hono/client"; +import { client } from "@/lib/api/apiClient"; +import { assertOk } from "@/lib/api/apiHelpers"; + +const dashboard = client.dashboard; + +type FetchOptions = { signal?: AbortSignal }; + +export type UserStatus = InferRequestType< + (typeof dashboard)[":id"]["status"]["$patch"] +>["json"]["status"]; +export type UserRole = InferRequestType< + (typeof dashboard)[":id"]["role"]["$post"] +>["json"]["role"]; +export type DashboardRoleRow = InferResponseType< + (typeof dashboard)["roles"]["$get"] +>["data"]; +export type MemberRow = InferResponseType< + (typeof dashboard)["members"]["$get"] +>["data"][number]; +export type AssignmentTable = InferResponseType< + (typeof dashboard)["assignments"]["$get"] +>["data"]; + +// Get a user's current status +export async function getUserStatus(id: string, { signal }: FetchOptions = {}) { + const res = await dashboard[":id"].status.$get( + { param: { id } }, + { init: { signal } } + ); + + await assertOk(res); + const { status } = await res.json(); + + return status; +} + +// toggle user status from active to inactive +export async function toggleAFK( + id: string, + status: UserStatus, + { signal }: FetchOptions = {} +) { + if (status == "suspended") { + toast.error("Cannot mark suspended user as AFK."); + return; + } + + const newStatus = status == "active" ? "inactive" : "active"; + + const res = await dashboard[":id"].status.$patch( + { + json: { status: newStatus }, + param: { id }, + }, + { init: { signal } } + ); + + await assertOk(res); +} + +// Change users status +export async function setUserStatus( + id: string, + status: UserStatus, + { signal }: FetchOptions = {} +) { + const res = await dashboard[":id"].status.$patch( + { + json: { status }, + param: { id }, + }, + { init: { signal } } + ); + + await assertOk(res); + const { data: newStatus } = await res.json(); + + return newStatus; +} + +// Add role to a user +export async function addRole( + id: string, + role: UserRole, + { signal }: FetchOptions = {} +) { + const res = await dashboard[":id"].role.$post( + { + json: { role }, + param: { id }, + }, + { init: { signal } } + ); + + await assertOk(res); +} + +// Remove role from a user +export async function removeRole( + id: string, + role: UserRole, + { signal }: FetchOptions = {} +) { + const res = await dashboard[":id"].role[":roleName"].$delete( + { + param: { id, roleName: role }, + }, + { init: { signal } } + ); + + await assertOk(res); +} + +// List role data for every user +export async function fetchRoleTable({ signal }: FetchOptions = {}) { + const res = await dashboard.roles.$get({ init: { signal } }); + + await assertOk(res); + const { data: roleTable } = await res.json(); + + return roleTable; +} + +// List member/profile data for every user +export async function fetchMembersTable({ signal }: FetchOptions = {}) { + const res = await dashboard.members.$get({ init: { signal } }); + + await assertOk(res); + const { data: memberTable } = await res.json(); + + return memberTable; +} + +// Get data for assignments table +export async function fetchAssignmentsTable({ signal }: FetchOptions = {}) { + const res = await dashboard.assignments.$get({ init: { signal } }); + + await assertOk(res); + const { data: assignmentTable } = await res.json(); + + return assignmentTable; +} + +// Mark user as suspended +export async function suspendUser(id: string, { signal }: FetchOptions = {}) { + const res = await dashboard[":id"].suspend.$patch( + { param: { id } }, + { init: { signal } } + ); + + await assertOk(res); +} + +// Mark user as unsuspended +export async function unsuspendUser(id: string, { signal }: FetchOptions = {}) { + const res = await dashboard[":id"].unsuspend.$patch( + { param: { id } }, + { init: { signal } } + ); + + await assertOk(res); +} + +// Reassign a panel member +export async function reassignPanelMember( + id: string, + panel_id: string, + { signal }: FetchOptions = {} +) { + const res = await dashboard[":id"].reassign[":panel_id"].$patch( + { param: { id, panel_id } }, + { init: { signal } } + ); + + await assertOk(res); +} diff --git a/app/src/routes/dashboard/assignments.tsx b/app/src/routes/dashboard/assignments.tsx index d03ac159..7be9ca76 100644 --- a/app/src/routes/dashboard/assignments.tsx +++ b/app/src/routes/dashboard/assignments.tsx @@ -1,12 +1,54 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; +import { createFileRoute, useRouter } from "@tanstack/react-router"; +import { toast } from "sonner"; import { UserRoundArrowLeft } from "@/components/icons/UserRoundArrowLeft"; import { Button } from "@/components/ui/button"; +import { AssignmentsTable } from "@/components/tables/AssignmentsTable"; +import { + fetchAssignmentsTable, + reassignPanelMember, +} from "@/lib/api/dashboard"; export const Route = createFileRoute("/dashboard/assignments")({ + loader: async ({ abortController }) => { + const data = await fetchAssignmentsTable({ + signal: abortController.signal, + }); + return { data }; + }, component: RouteComponent, }); function RouteComponent() { + const assignments = Route.useLoaderData(); + const router = useRouter(); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isReassigning, setIsReassigning] = useState(false); + + const handleReassign = async () => { + setIsReassigning(true); + try { + await Promise.all( + [...selectedIds] + .map((id) => ({ + id, + panelId: assignments.data.find((a) => a.id === id)?.panel_id, + })) + .filter( + (t): t is { id: string; panelId: string } => t.panelId != null + ) + .map(({ id, panelId }) => reassignPanelMember(id, panelId)) + ); + setSelectedIds(new Set()); + await router.invalidate(); + toast.info("Successfully reassigned user(s)!"); + } catch (err) { + toast.error("Could not reassign one or more user(s)."); + } finally { + setIsReassigning(false); + } + }; + return (
@@ -17,7 +59,12 @@ function RouteComponent() {
- @@ -27,6 +74,11 @@ function RouteComponent() {
{/* AssignmentsTable */} +
diff --git a/app/src/routes/dashboard/members.tsx b/app/src/routes/dashboard/members.tsx index 10635903..ff58a29a 100644 --- a/app/src/routes/dashboard/members.tsx +++ b/app/src/routes/dashboard/members.tsx @@ -1,13 +1,57 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { Ban } from "lucide-react"; +import { createFileRoute, useRouter } from "@tanstack/react-router"; +import { useState } from "react"; +import { Ban, UserRoundCheck } from "lucide-react"; +import { toast } from "sonner"; import { MembersTable } from "@/components/tables/MembersTable"; import { Button } from "@/components/ui/button"; +import { + fetchMembersTable, + suspendUser, + unsuspendUser, +} from "@/lib/api/dashboard"; export const Route = createFileRoute("/dashboard/members")({ + loader: async ({ abortController }) => { + const data = await fetchMembersTable({ signal: abortController.signal }); + return { data }; + }, component: RouteComponent, }); function RouteComponent() { + const members = Route.useLoaderData(); + const router = useRouter(); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [suspending, setSuspending] = useState(false); // used also for unsuspending + + const handleSuspend = async () => { + setSuspending(true); + try { + await Promise.all([...selectedIds].map((id) => suspendUser(id))); + setSelectedIds(new Set()); // reset selected ids after suspension + await router.invalidate(); + toast.info("Successfully suspended user(s)!"); + } catch (err) { + toast.error("Could not suspend one or more users."); + } finally { + setSuspending(false); + } + }; + + const handleUnsuspend = async () => { + setSuspending(true); + try { + await Promise.all([...selectedIds].map((id) => unsuspendUser(id))); + setSelectedIds(new Set()); // reset selected ids after suspension + await router.invalidate(); + toast.info("Successfully unsuspended user(s)!"); + } catch (err) { + toast.error("Could not unsuspend one or more users."); + } finally { + setSuspending(false); + } + }; + return (
@@ -18,9 +62,20 @@ function RouteComponent() {
+ +
diff --git a/app/src/routes/dashboard/roles.tsx b/app/src/routes/dashboard/roles.tsx index 29d0b819..8f54000a 100644 --- a/app/src/routes/dashboard/roles.tsx +++ b/app/src/routes/dashboard/roles.tsx @@ -1,13 +1,92 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; +import { createFileRoute, useRouter } from "@tanstack/react-router"; import { ShieldMinus, ShieldPlus, SquareArrowRightExit } from "lucide-react"; +import { toast } from "sonner"; +import type { UserRole, UserStatus } from "@/lib/api/dashboard"; import { Button } from "@/components/ui/button"; import { RolesTable } from "@/components/tables/RolesTable"; +import { + addRole, + fetchRoleTable, + removeRole, + toggleAFK, +} from "@/lib/api/dashboard"; export const Route = createFileRoute("/dashboard/roles")({ + loader: async ({ abortController }) => { + const data = await fetchRoleTable({ signal: abortController.signal }); + return { data }; + }, component: RouteComponent, }); function RouteComponent() { + const roles = Route.useLoaderData(); + const router = useRouter(); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [submittingChange, setSubmittingChange] = useState(false); + + // setChangeRole is unused right now but will be used for role dropdown + // @ts-expect-error + const [changeRole, setChangeRole] = useState("verifier"); + + const handleToggleAFK = async () => { + setSubmittingChange(true); + try { + await Promise.all( + [...selectedIds].map((id) => + toggleAFK( + id, + roles.data.find((r) => r.id === id)?.status as UserStatus + ) + ) + ); + setSelectedIds(new Set()); + await router.invalidate(); + toast.info("Successfully toggled AFK status for user(s)!"); + } catch (err) { + toast.error("Could not toggle AFK for one or more users."); + } finally { + setSubmittingChange(false); + } + }; + + const handleAddRole = async () => { + setSubmittingChange(true); + try { + await Promise.all([...selectedIds].map((id) => addRole(id, changeRole))); + setSelectedIds(new Set()); + await router.invalidate(); + toast.info('Successfully added role "' + changeRole + '" to user(s)!'); + } catch (err) { + toast.error( + 'Could not add role "' + changeRole + '" to one or more users.' + ); + } finally { + setSubmittingChange(false); + } + }; + + const handleRemoveRole = async () => { + setSubmittingChange(true); + try { + await Promise.all( + [...selectedIds].map((id) => removeRole(id, changeRole)) + ); + setSelectedIds(new Set()); + await router.invalidate(); + toast.info( + 'Successfully removed role "' + changeRole + '" from user(s)!' + ); + } catch (err) { + toast.error( + 'Could remove add role "' + changeRole + '" from one or more users.' + ); + } finally { + setSubmittingChange(false); + } + }; + return (
@@ -18,12 +97,22 @@ function RouteComponent() {
- - @@ -31,6 +120,8 @@ function RouteComponent() {
diff --git a/supabase/migrations/20260824230913_add_user_statuses.sql b/supabase/migrations/20260824230913_add_user_statuses.sql new file mode 100644 index 00000000..5e5f5b71 --- /dev/null +++ b/supabase/migrations/20260824230913_add_user_statuses.sql @@ -0,0 +1,83 @@ +create type "public"."user_status" as enum ('active', 'inactive', 'suspended'); + + + create table "public"."user_statuses" ( + "user_id" uuid not null, + "status" public.user_status not null default 'active'::public.user_status, + "updated_at" timestamp with time zone default now() + ); + + +alter table "public"."user_statuses" enable row level security; + +CREATE UNIQUE INDEX user_statuses_pkey ON public.user_statuses USING btree (user_id); + +alter table "public"."user_statuses" add constraint "user_statuses_pkey" PRIMARY KEY using index "user_statuses_pkey"; + +grant delete on table "public"."user_statuses" to "anon"; + +grant insert on table "public"."user_statuses" to "anon"; + +grant references on table "public"."user_statuses" to "anon"; + +grant select on table "public"."user_statuses" to "anon"; + +grant trigger on table "public"."user_statuses" to "anon"; + +grant truncate on table "public"."user_statuses" to "anon"; + +grant update on table "public"."user_statuses" to "anon"; + +grant delete on table "public"."user_statuses" to "authenticated"; + +grant insert on table "public"."user_statuses" to "authenticated"; + +grant references on table "public"."user_statuses" to "authenticated"; + +grant select on table "public"."user_statuses" to "authenticated"; + +grant trigger on table "public"."user_statuses" to "authenticated"; + +grant truncate on table "public"."user_statuses" to "authenticated"; + +grant update on table "public"."user_statuses" to "authenticated"; + +grant delete on table "public"."user_statuses" to "service_role"; + +grant insert on table "public"."user_statuses" to "service_role"; + +grant references on table "public"."user_statuses" to "service_role"; + +grant select on table "public"."user_statuses" to "service_role"; + +grant trigger on table "public"."user_statuses" to "service_role"; + +grant truncate on table "public"."user_statuses" to "service_role"; + +grant update on table "public"."user_statuses" to "service_role"; + +create or replace function public.handle_new_user() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +declare + requested_username text; +begin + requested_username := nullif(trim(new.raw_user_meta_data ->> 'username'), ''); + if requested_username is null then + requested_username := 'user-' || left(replace(new.id::text, '-', ''), 8); + end if; + begin + insert into public.profiles (id, username) + values (new.id, requested_username); + exception when unique_violation then + insert into public.profiles (id, username) + values (new.id, requested_username || '-' || left(replace(new.id::text, '-', ''), 6)); + end; + insert into public.user_statuses (user_id, status) + values (new.id, 'active'); + return new; +end; +$$; diff --git a/supabase/migrations/20260830160839_role_and_status_perms.sql b/supabase/migrations/20260830160839_role_and_status_perms.sql new file mode 100644 index 00000000..fb9201e8 --- /dev/null +++ b/supabase/migrations/20260830160839_role_and_status_perms.sql @@ -0,0 +1,54 @@ +-- Grants access for status access by users and needed access to admins for dashboard + +create policy "Admins can view all roles" +on "public"."user_roles" +as permissive +for select +to public +using (public.has_role('admin'::public.app_role)); + +create policy "Admins can add roles" +on "public"."user_roles" +as permissive +for insert +to public +with check (public.has_role('admin'::public.app_role)); + +create policy "Admins can delete roles" +on "public"."user_roles" +as permissive +for delete +to public +using (public.has_role('admin'::public.app_role)); + +create policy "Admins can view statuses" +on "public"."user_statuses" +as permissive +for select +to authenticated +using (public.has_role('admin'::public.app_role)); + +create policy "Users can view their own status" +on "public"."user_statuses" +as permissive +for select +to public +using ((user_id = ( SELECT auth.uid() AS uid))); + +create policy "Admins can update profiles" +on "public"."profiles" +as permissive +for update +to authenticated +using (public.has_role('admin'::public.app_role)) +with check (public.has_role('admin'::public.app_role)); + +create policy "Admins can change statuses" +on "public"."user_statuses" +as permissive +for update +to authenticated +using (public.has_role('admin'::public.app_role)) +with check (public.has_role('admin'::public.app_role)); + +grant update on public.profiles to authenticated; diff --git a/supabase/migrations/20260902210237_reassign_panel_member_fn.sql b/supabase/migrations/20260902210237_reassign_panel_member_fn.sql new file mode 100644 index 00000000..5922963e --- /dev/null +++ b/supabase/migrations/20260902210237_reassign_panel_member_fn.sql @@ -0,0 +1,68 @@ +create or replace function public.reassign_panel_member( + p_panel_id uuid, + p_member_id uuid +) +returns uuid +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case_id uuid; + v_created_by uuid; + v_closed_at timestamptz; + v_seat_id uuid; + v_new_member uuid; +begin + -- Lock panel so overlapping reassignments can't conflict. + select case_id, closed_at + into v_case_id, v_closed_at + from public.review_panels + where id = p_panel_id + for update; + if not found then + raise exception 'Review panel not found' using errcode = 'no_data_found'; + end if; + if v_closed_at is not null then + raise exception 'Cannot reassign a seat on a closed panel' using errcode = 'check_violation'; + end if; + select created_by into v_created_by + from public.review_cases + where id = v_case_id; + select id into v_seat_id + from public.panel_members + where panel_id = p_panel_id + and member_id = p_member_id + for update; + if not found then + raise exception 'Selected member is not on selected panel.' using errcode = 'no_data_found'; + end if; + -- replacement conditions: active, not author, not already on panel (which includes person being removed) + select ur.user_id into v_new_member + from public.user_roles ur + join public.user_statuses us on us.user_id = ur.user_id + where ur.role = 'verifier' + and us.status = 'active' + and ur.user_id is distinct from v_created_by + and not exists ( + select 1 from public.panel_members pm + where pm.panel_id = p_panel_id + and pm.member_id = ur.user_id + ) + order by random() + limit 1; + if v_new_member is null then + return null; + end if; + -- remove decision from person being removed from panel + delete from public.review_decisions + where panel_member_id = v_seat_id; + update public.panel_members + set member_id = v_new_member, + status = 'assigned', + assigned_at = now() + where id = v_seat_id; + return v_new_member; +end; +$$; +grant execute on function public.reassign_panel_member(uuid, uuid) to service_role; diff --git a/supabase/seed.sql b/supabase/seed.sql index 5f5f5841..5ee0fb79 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -47,7 +47,7 @@ declare v_id uuid; i int; begin - for i in 1..3 loop + for i in 1..5 loop v_id := ('00000000-0000-4000-8000-00000000001' || i)::uuid; insert into auth.users