Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/(dashboard)/adminguard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useIdentity } from './identity-context'
* The check is synchronous: the server already resolved this user's identity and
* shipped it with the document, so there is no round trip to wait on and no
* skeleton to show. (This previously called getAuthedUser() on mount, which meant
* a JWKS fetch against the Supabase origin before /administrator could paint.)
* a JWKS fetch against the Supabase origin before /bookings could paint.)
*
* The redirect still has to happen in an effect -- router.push() during render is
* not allowed -- but rendering nothing while it runs is correct here: a non-admin
Expand Down
40 changes: 0 additions & 40 deletions app/(dashboard)/administrator/advanced-settings-tab.tsx

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export default function OneTimeForm({ bodies, semesters, onClose, onSuccess }: O
<div>
<label className={labelCls}>Semester *</label>
{semesters.length === 0 ? (
<p className="text-sm text-[#f87171]">No semesters available. Create one in Advanced Settings.</p>
<p className="text-sm text-[#f87171]">No semesters available. Create one in Management → Other Settings.</p>
) : (
<select
value={semesterId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,21 @@ import { usePendingActionsWatch } from '../pending-actions-watch'
import RequestsTab from './requests-tab'
import CancellationsTab from './cancellations-tab'
import BookingsTab from './bookings-tab'
import AdvancedSettingsTab from './advanced-settings-tab'
import SGASpacesTab from './sga-spaces-tab'

type Tab = 'Requests' | 'Cancellations' | 'Bookings' | 'SGA Spaces' | 'Advanced Settings'
/**
* The former Administrator page, now Bookings, at /bookings (issue #64).
*
* Its Advanced Settings tab -- users, bodies, audit, archive, other settings --
* has become the Management page, which is gated to high-access admins. What is
* left here is the booking work itself, open to every admin.
*
* /administrator redirects here, so links and bookmarks that predate the rename
* still land in the right place -- see the redirects() in next.config.ts.
*/
type Tab = 'Requests' | 'Cancellations' | 'Bookings' | 'SGA Spaces'

export default function AdministratorPage() {
export default function BookingsPage() {
const [activeTab, setActiveTab] = useState<Tab>('Bookings')
// Shared with the layout's sidebar badge instead of refetching the same
// endpoint on every Administrator page load.
Expand All @@ -30,10 +39,10 @@ export default function AdministratorPage() {
return (
<AdminGuard>
<div className="space-y-6">
<h1 className="text-2xl font-bold text-[#f0f6ff]">Administrator</h1>
<h1 className="text-2xl font-bold text-[#f0f6ff]">Bookings</h1>

<div className="flex gap-1 border-b border-[#1e5080] overflow-x-auto overflow-y-hidden">
{(['Bookings', 'SGA Spaces', 'Cancellations', 'Requests', 'Advanced Settings'] as Tab[]).map(tab => (
{(['Bookings', 'SGA Spaces', 'Cancellations', 'Requests'] as Tab[]).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
Expand Down Expand Up @@ -67,7 +76,6 @@ export default function AdministratorPage() {
{activeTab === 'Cancellations' && <CancellationsTab onCountChange={refreshCounts} />}
{activeTab === 'Bookings' && <BookingsTab />}
{activeTab === 'SGA Spaces' && <SGASpacesTab />}
{activeTab === 'Advanced Settings' && <AdvancedSettingsTab />}
</div>
</div>
</AdminGuard>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export default function TablingForm({ bodies, semesters, onClose, onSuccess }: T
<div>
<label className={labelCls}>Semester *</label>
{semesters.length === 0 ? (
<p className="text-sm text-[#f87171]">No semesters available. Create one in Advanced Settings.</p>
<p className="text-sm text-[#f87171]">No semesters available. Create one in Management → Other Settings.</p>
) : (
<select
value={semesterId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export default function WeeklyForm({ bodies, semesters, onClose, onSuccess }: We
<div>
<label className={labelCls}>Semester *</label>
{semesters.length === 0 ? (
<p className="text-sm text-[#f87171]">No semesters available. Create one in Advanced Settings.</p>
<p className="text-sm text-[#f87171]">No semesters available. Create one in Management → Other Settings.</p>
) : (
<select
value={semesterId}
Expand Down
8 changes: 5 additions & 3 deletions app/(dashboard)/dashboard-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { OriginTab } from '@/lib/pending-actions'
import { IdentityContext } from './identity-context'
import type { ShellIdentity } from '@/lib/shell-identity'
import type { AlertRow } from '@/lib/dashboard-data'
import { isManagementRole } from '@/lib/admin-roles'

function getGreeting() {
const hour = new Date().getHours()
Expand All @@ -31,7 +32,7 @@ export default function DashboardShell({
// lib/shell-identity.ts. These used to be state filled in by a client-side
// getClaims() + users/board_memberships round trip after hydration, which held
// the whole content area behind AuthGuard's skeleton while it ran.
const { isAdmin, isIEMS, isLeadership, fullName: userName } = identity
const { isAdmin, isIEMS, isLeadership, adminRole, fullName: userName } = identity
const [counts, setCounts] = useState<Counts>(EMPTY_COUNTS)
const [alerts, setAlerts] = useState<AlertRow[]>([])
const [showIdleWarning, setShowIdleWarning] = useState(false)
Expand Down Expand Up @@ -399,7 +400,7 @@ export default function DashboardShell({
<span className="text-[#c8102e] font-bold text-xl tracking-tight">Chambers</span>
</div>
<p className="text-slate-500 text-xs mt-0.5">NU Student Gov. Association</p>
<p className="text-slate-600 text-xs mt-1">v1.13.5</p>
<p className="text-slate-600 text-xs mt-1">v1.13.6</p>
{userName && (
<div className="flex items-start justify-between mt-2">
<p className="text-slate-500 text-xs italic">{getGreeting()},<br />{userName}</p>
Expand All @@ -423,7 +424,8 @@ export default function DashboardShell({
{navLink('/sga-spaces', 'SGA Spaces')}
{(isLeadership || isAdmin) && navLink('/request', 'Request a Booking')}
{(isAdmin || isIEMS) && navLink('/events', 'Events')}
{isAdmin && navLink('/administrator', 'Administrator')}
{isAdmin && navLink('/bookings', 'Bookings')}
{isAdmin && isManagementRole(adminRole) && navLink('/management', 'Management')}
</div>

{/* Total badge + Sign out */}
Expand Down
59 changes: 59 additions & 0 deletions app/(dashboard)/management/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client'

import { useState } from 'react'
import ManagementGuard from '../managementguard'
import UsersTab from './users-tab'
import BodiesTab from './bodies-tab'
import AuditTab from './audit-tab'
import ArchiveTab from './archive-tab'
import BookingSettingsTab from './booking-settings-tab'

/**
* Management -- the former Administrator > Advanced Settings sub-tabs, promoted
* to a root page of their own (issue #64).
*
* Administrator had outgrown a single tab strip: five tabs, one of which opened
* a second strip of five underneath it. These five have a different audience
* anyway -- they configure the system rather than run the week's bookings -- so
* they became their own page with their own guard, and Administrator was renamed
* to Bookings for what it actually is now.
*/
type Tab = 'Users' | 'Bodies' | 'Audit' | 'Archive' | 'Other Settings'

const TABS: Tab[] = ['Users', 'Bodies', 'Audit', 'Archive', 'Other Settings']

export default function ManagementPage() {
const [activeTab, setActiveTab] = useState<Tab>('Users')

return (
<ManagementGuard>
<div className="space-y-6">
<h1 className="text-2xl font-bold text-[#f0f6ff]">Management</h1>

<div className="flex gap-1 border-b border-[#1e5080] overflow-x-auto overflow-y-hidden">
{TABS.map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap flex-shrink-0 ${
activeTab === tab
? 'border-[#c8102e] text-[#f0f6ff] font-semibold'
: 'border-transparent text-[#93b8d8] hover:text-[#c8102e]'
}`}
>
{tab}
</button>
))}
</div>

<div>
{activeTab === 'Users' && <UsersTab />}
{activeTab === 'Bodies' && <BodiesTab />}
{activeTab === 'Audit' && <AuditTab />}
{activeTab === 'Archive' && <ArchiveTab />}
{activeTab === 'Other Settings' && <BookingSettingsTab />}
</div>
</div>
</ManagementGuard>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { Skeleton } from '@/app/_components/skeleton'
import { createClient } from '@/lib/supabase/client'
import { getAuthedUser } from '@/lib/auth'
import { ADMIN_ROLES, MANAGEMENT_ROLES } from '@/lib/admin-roles'

function UsersTabSkeleton() {
return (
Expand All @@ -27,26 +28,12 @@ function UsersTabSkeleton() {
}


const ADMIN_ROLES = [
'Executive Vice President',
'Vice President of Operational Affairs',
'Comptroller',
'Digital Innovation Manager',
'Digital Innovation Project Member',
'Information Manager',
]

const IEMS_ROLES = [
'Vice President of External Affairs',
'Director of Events',
]

const ROLE_EDITORS = [
'Executive Vice President',
'Vice President of Operational Affairs',
'Digital Innovation Manager',
'Information Manager',
]
const ROLE_EDITORS = MANAGEMENT_ROLES

interface Membership {
id: string
Expand Down
38 changes: 38 additions & 0 deletions app/(dashboard)/managementguard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use client'

import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useIdentity } from './identity-context'
import { isManagementRole } from '@/lib/admin-roles'

/**
* Gate for the Management page: admin *and* one of MANAGEMENT_ROLES (issue #64).
*
* Same shape as AdminGuard -- the identity was resolved on the server and shipped
* with the document, so the check is synchronous and nothing renders for the
* frame before the redirect runs. The fallback is /bookings rather than
* /my-rooms: everyone who fails this check is still an admin, so the useful place
* to land them is the admin page they do have.
*
* This governs what the dashboard *renders*, but it is no longer the only thing
* standing in the way: the endpoints behind these tabs now run the same
* isManagementRole() check against the live admin_role and answer 403, so a
* Comptroller who calls them directly gets nowhere.
*
* One deliberate exception -- GET /api/administrator/bodies stays open to any
* admin, because the Bookings page reads it for its body picker. Only POST and
* PATCH on that route are narrowed.
*/
export default function ManagementGuard({ children }: { children: React.ReactNode }) {
const { isAdmin, adminRole } = useIdentity()
const router = useRouter()
const allowed = isAdmin && isManagementRole(adminRole)

useEffect(() => {
if (!allowed) router.replace(isAdmin ? '/bookings' : '/my-rooms')
}, [allowed, isAdmin, router])

if (!allowed) return null

return <>{children}</>
}
2 changes: 1 addition & 1 deletion app/(dashboard)/my-rooms/booking-detail-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { useState } from 'react'
import BookingModal from '../administrator/booking-modal'
import BookingModal from '../bookings/booking-modal'
import { type FlatBooking, statusTextColors, senateTypeBadgeColors, DEFAULT_SENATE_BADGE } from './shared'

interface BookingDetailModalProps {
Expand Down
8 changes: 7 additions & 1 deletion app/(dashboard)/my-rooms/calendar-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useMemo, useState } from 'react'
import {
type FlatBooking,
bookingTitle,
statusBarColors,
statusTextColors,
formatTime,
Expand Down Expand Up @@ -161,7 +162,12 @@ export default function CalendarView({ bookings, onSelect, today }: CalendarView
<div key={b.id} onClick={() => onSelect(b)} className="flex items-center gap-4 px-5 py-3.5 hover:bg-[#1a4d8a] transition-colors cursor-pointer">
<div className={`w-1.5 h-8 rounded-full flex-shrink-0 ${statusBarColors[b.status] || 'bg-[#1e5080]'}`} />
<div className="flex-1 min-w-0">
<p className="font-semibold text-[#f0f6ff] truncate">{b.scopeLabel}</p>
<div className="flex items-baseline gap-2 min-w-0">
<p className="font-semibold text-[#f0f6ff] truncate">{bookingTitle(b)}</p>
{b.scopeLabel !== bookingTitle(b) && (
<span className="text-xs text-[#93b8d8] truncate">{b.scopeLabel}</span>
)}
</div>
<p className="text-sm text-[#6a96bb]">{b.location} · {formatTime(b.startTime)} – {formatTime(b.endTime)}</p>
</div>
<span className={`hidden md:inline text-xs font-semibold flex-shrink-0 ${statusTextColors[b.status] || 'text-[#93b8d8]'}`}>{b.status}</span>
Expand Down
5 changes: 3 additions & 2 deletions app/(dashboard)/my-rooms/my-rooms-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Skeleton } from '@/app/_components/skeleton'
import {
type FlatBooking,
type MyRoomsResponse,
bookingTitle,
flattenMyRooms,
isWithinDays,
statusColors,
Expand Down Expand Up @@ -313,12 +314,12 @@ export default function MyRoomsClient({
<div className={`w-1.5 h-8 rounded-full flex-shrink-0 ${statusBarColors[b.status] || 'bg-[#1e5080]'}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<p className="font-semibold text-[#f0f6ff] truncate">{b.location}</p>
<p className="font-semibold text-[#f0f6ff] truncate">{bookingTitle(b)}</p>
{b.senateType && (
<span className={`text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0 ${senateTypeBadgeColors[b.senateType] || DEFAULT_SENATE_BADGE}`}>{b.senateType}</span>
)}
</div>
<p className="text-sm text-[#6a96bb]">{formatDate(b.date)} · {formatTime(b.startTime)} – {formatTime(b.endTime)}</p>
<p className="text-sm text-[#6a96bb]">{b.location} · {formatDate(b.date)} · {formatTime(b.startTime)} – {formatTime(b.endTime)}</p>
</div>
<span className="hidden md:inline text-xs text-[#6a96bb] flex-shrink-0">{b.type === 'One-Time Room' ? 'One-Time/Multiple Room' : b.type}</span>
<span className={`hidden md:inline text-xs font-semibold flex-shrink-0 ${statusTextColors[b.status] || 'text-[#93b8d8]'}`}>{b.status}</span>
Expand Down
2 changes: 1 addition & 1 deletion app/(dashboard)/my-rooms/revision-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { useState } from 'react'
import TimePicker from '../administrator/time-picker'
import TimePicker from '../bookings/time-picker'

interface RevisionModalProps {
booking: {
Expand Down
14 changes: 14 additions & 0 deletions app/(dashboard)/my-rooms/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@ export function scopeFullOf(b: ScopedBookingRow): string[] {
).full
}

/**
* The headline for a booking row: what it is, not who runs it.
*
* Rows used to lead with the owning body (calendar day list) or the room (list
* view), which is the least distinguishing thing about them -- a body's rows all
* read the same, and so do a room's. The purpose is the title someone typed for
* this specific booking, so it leads and the rest drops to supporting text
* (issue #65). Falls back to the scope label for a booking saved without one, so
* a row is never headed by an empty string.
*/
export function bookingTitle(b: FlatBooking): string {
return b.purpose?.trim() || b.scopeLabel
}

export const SENATE_TYPES = ['Full Body', 'Weekly', 'Office Hours'] as const

export const statusColors: Record<string, string> = {
Expand Down
2 changes: 1 addition & 1 deletion app/(dashboard)/request/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { getAuthedUser } from '@/lib/auth'
import TimePicker from '../administrator/time-picker'
import TimePicker from '../bookings/time-picker'
import DateField from '@/app/_components/date-field'
import { Skeleton } from '@/app/_components/skeleton'
import BookingScopeSelector, { type BookingScopeValue } from '@/app/_components/booking-scope-selector'
Expand Down
2 changes: 1 addition & 1 deletion app/(dashboard)/sga-spaces/space-booking-modal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { useState, useEffect, useRef, useCallback } from 'react'
import TimePicker from '../administrator/time-picker'
import TimePicker from '../bookings/time-picker'
import DateField from '@/app/_components/date-field'

interface User {
Expand Down
Loading
Loading