Skip to content
Draft
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: 2 additions & 0 deletions prisma/migrations/20260318174510_is_developer/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "app"."staff_user" ADD COLUMN "is_developer" BOOLEAN NOT NULL DEFAULT false;
1 change: 1 addition & 0 deletions prisma/models/app/staff_user.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ model StaffUser {
isPesticideDesignate Boolean @default(false) @map("is_pesticide_designate")
isFinanceDesignate Boolean @default(false) @map("is_finance_designate")
isIta2Designate Boolean @default(false) @map("is_ita2_designate")
isDeveloper Boolean @default(false) @map("is_developer")

@@map("staff_user")
@@schema("app")
Expand Down
43 changes: 43 additions & 0 deletions src/app/protected/settings/developer/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { revalidatePath } from "next/cache"
import { headers } from "next/headers"
import { RoleSwitcher } from "@/components/settings/developer/RoleSwitcher"
import { getStaffUserBySub } from "@/lib/prisma/staff_user/getStaffUserBySub"
import { updateStaffUser } from "@/lib/prisma/staff_user/updateStaffUser"
import { getAuthContext } from "@/utils/auth/getAuthContext"

// This page should always be rendered dynamically to ensure fresh data
export const dynamic = "force-dynamic"

const revalidateDeveloperPage = async () => {
"use server"
revalidatePath("/protected/settings/developer")
}

export default async function Page() {
const headersList = await headers()
const authContext = getAuthContext(headersList)
const user = authContext?.user

const currentUser = await getStaffUserBySub(user?.sub ?? "")

if (!currentUser?.isDeveloper) {
return (
<div className="space-y-sm">
<h2>You do not have access to this page.</h2>
</div>
)
}

return (
<div className="space-y-sm">
<h2>Developer Settings</h2>
<div className="p-2">
<RoleSwitcher
currentUser={currentUser}
updateStaffUser={updateStaffUser}
revalidatePage={revalidateDeveloperPage}
/>
</div>
</div>
)
}
25 changes: 24 additions & 1 deletion src/app/protected/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,35 @@
import { ShieldCheckIcon } from "@heroicons/react/24/outline"
import { headers } from "next/headers"
import { NAV_ITEMS } from "@/components/settings/NAV_ITEMS"
import { NavItem } from "@/components/settings/NavItem"
import { getStaffUserBySub } from "@/lib/prisma/staff_user/getStaffUserBySub"
import { getAuthContext } from "@/utils/auth/getAuthContext"

export default async function Page() {
const headersList = await headers()
const authContext = getAuthContext(headersList)
const user = authContext?.user

const currentUser = await getStaffUserBySub(user?.sub ?? "")

const navigationItems = [
...NAV_ITEMS,
...(currentUser?.isDeveloper
? [
{
label: "Developer Settings",
href: "/protected/settings/developer",
icon: <ShieldCheckIcon className="h-7 w-7 text-blue" />,
},
]
: []),
]

return (
<div className="space-y-sm">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Settings</h1>
<div className="space-y-1">
{NAV_ITEMS.map((item) => (
{navigationItems.map((item) => (
<NavItem key={item.href} href={item.href} label={item.label} icon={item.icon} />
))}
</div>
Expand Down
9 changes: 9 additions & 0 deletions src/app/protected/settings/service-categories/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { revalidatePath } from "next/cache"
import { headers } from "next/headers"
import { ServiceCategoryTable } from "@/components/settings/service_categories/ServiceCategoryTable"
import { getAllServices } from "@/lib/prisma/service/getAllServices"
import { getAllServiceCategories } from "@/lib/prisma/service_category/getAllServiceCategories"
import { getStaffUserBySub } from "@/lib/prisma/staff_user/getStaffUserBySub"
import { getAuthContext } from "@/utils/auth/getAuthContext"

// This page should always be rendered dynamically to ensure fresh data
export const dynamic = "force-dynamic"

export default async function Page() {
const headersList = await headers()
const authContext = getAuthContext(headersList)
const user = authContext?.user

const currentUser = await getStaffUserBySub(user?.sub ?? "")
const serviceCategories = await getAllServiceCategories()
const services = await getAllServices()

Expand All @@ -19,6 +27,7 @@ export default async function Page() {
<div className="space-y-sm">
<h2>Service Categories</h2>
<ServiceCategoryTable
currentUser={currentUser}
serviceCategories={serviceCategories}
services={services}
revalidateTable={revalidateTable}
Expand Down
66 changes: 66 additions & 0 deletions src/components/settings/developer/RoleSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client"

import { useState } from "react"
import { SelectInput } from "@/components/common/select"
import type { Role, StaffUser } from "@/generated/prisma/client"

type RoleSwitcherProps = {
currentUser: StaffUser
updateStaffUser: (
user: Partial<StaffUser>,
prevUser: Partial<StaffUser>,
availableRoles?: Role[]
) => Promise<StaffUser | null>
revalidatePage: () => Promise<void>
}

const AVAILABLE_ROLES: Role[] = ["Authenticated", "CSR", "SCSR", "SDM", "Administrator"]

export const RoleSwitcher = ({
currentUser,
updateStaffUser,
revalidatePage,
}: RoleSwitcherProps) => {
const [isUpdating, setIsUpdating] = useState(false)
const [error, setError] = useState<string | null>(null)

const handleRoleChange = async (newRole: Role) => {
if (newRole === currentUser.role) return

try {
setIsUpdating(true)
setError(null)
await updateStaffUser({ ...currentUser, role: newRole }, currentUser, AVAILABLE_ROLES)
await revalidatePage()
setIsUpdating(false)
window.location.href = "/protected/settings/developer"
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to update role")
setIsUpdating(false)
}
}

return (
<div className="space-y-3">
<div className="w-[33%]">
<SelectInput
id="role-switcher"
label="Current Role"
value={currentUser.role}
onChange={(value) => handleRoleChange(value as Role)}
disabled={isUpdating}
options={AVAILABLE_ROLES.map((role) => ({
value: role,
label: role,
}))}
/>
{isUpdating && <p className="mt-2 text-sm text-gray-600">Updating role...</p>}
</div>
{error && (
<div className="flex flex-col gap-1 rounded-md border-l-4 border-l-red-600 bg-red-50 p-3">
<p className="text-sm font-medium text-red-800">{error}</p>
</div>
)}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ type EditServiceCategoryModalProps = {
onClose: () => void
serviceCategory: ServiceCategoryWithRelations | null
services: Service[]
canEdit: boolean
canArchive: boolean
updateServiceCategory: (
serviceCategory: Partial<ServiceCategoryWithRelations>
) => Promise<ServiceCategoryWithRelations | null>
Expand All @@ -31,6 +33,8 @@ export const EditServiceCategoryModal = ({
onClose,
serviceCategory,
services,
canEdit,
canArchive,
updateServiceCategory,
revalidateTable,
openConfirmArchiveServiceCategoryModal,
Expand Down Expand Up @@ -86,7 +90,7 @@ export const EditServiceCategoryModal = ({
if (!serviceCategory || !formData) return null

const isArchived = serviceCategory.deletedAt !== null
const isReadonly = isArchived
const isReadonly = isArchived || !canEdit

const hasMadeChanges = JSON.stringify(formData) !== JSON.stringify(serviceCategory)

Expand Down Expand Up @@ -123,13 +127,19 @@ export const EditServiceCategoryModal = ({

<DialogBody>
<form className="space-y-5">
{isReadonly && (
{!canEdit && (
<div className="flex flex-col gap-1 rounded-md border-l-4 border-l-red-600 bg-red-50 p-4">
{isArchived && (
<p className="text-sm font-medium text-red-800">
This service category is archived and cannot be edited.
</p>
)}
<p className="text-sm font-medium text-red-800">
You do not have permission to edit this service category.
</p>
</div>
)}

{isArchived && (
<div className="flex flex-col gap-1 rounded-md border-l-4 border-l-red-600 bg-red-50 p-4">
<p className="text-sm font-medium text-red-800">
This service category is archived and cannot be edited.
</p>
</div>
)}

Expand All @@ -152,9 +162,11 @@ export const EditServiceCategoryModal = ({
<button type="button" className="tertiary" onClick={onClose}>
Cancel
</button>
<button type="button" className="secondary danger" onClick={handleOpenArchive}>
{isArchived ? "Unarchive" : "Archive"}
</button>
{canArchive && (
<button type="button" className="secondary danger" onClick={handleOpenArchive}>
{isArchived ? "Unarchive" : "Archive"}
</button>
)}
<button
type="button"
className="primary"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
"use client"

import { useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { DataTable } from "@/components/common/datatable"
import { Switch } from "@/components/common/switch"
import type { Service } from "@/generated/prisma/client"
import { useAuth } from "@/hooks/useAuth"
import { useDialog } from "@/hooks/useDialog"
import { insertServiceCategory } from "@/lib/prisma/service_category/insertServiceCategory"
import type { ServiceCategoryWithRelations } from "@/lib/prisma/service_category/types"
import { updateServiceCategory } from "@/lib/prisma/service_category/updateServiceCategory"
import type { StaffUserWithRelations } from "@/lib/prisma/staff_user/types"
import { resolvePolicy } from "@/utils/policies/resolvePolicy"
import type { UserContext } from "@/utils/policies/types"
import { ConfirmArchiveServiceCategoryModal } from "../ConfirmArchiveServiceCategoryModal"
import { CreateServiceCategoryModal } from "../CreateServiceCategoryModal"
import { EditServiceCategoryModal } from "../EditServiceCategoryModal"
import { columns } from "./columns"

export type ServiceCategoryTableProps = {
currentUser: StaffUserWithRelations | null
serviceCategories: ServiceCategoryWithRelations[]
services: Service[]
revalidateTable: () => Promise<void>
}

export const ServiceCategoryTable = ({
currentUser,
serviceCategories,
services,
revalidateTable,
}: ServiceCategoryTableProps) => {
const { role, idir_user_guid } = useAuth()
const {
open: editServiceCategoryModalOpen,
openDialog: openEditServiceCategoryModal,
Expand All @@ -40,9 +47,36 @@ export const ServiceCategoryTable = ({
closeDialog: closeConfirmArchiveServiceCategoryModal,
} = useDialog()

const userContext = useMemo<UserContext>(
() => ({
staff_user_id: idir_user_guid ?? null,
role,
location_code: currentUser?.locationCode ?? null,
}),
[idir_user_guid, role, currentUser?.locationCode]
)

const actions = resolvePolicy("service_category", userContext)

const [showArchived, setShowArchived] = useState<boolean>(false)
const [selectedServiceCategory, setSelectedServiceCategory] =
useState<ServiceCategoryWithRelations | null>(null)
const [canEditSelectedServiceCategory, setCanEditSelectedServiceCategory] =
useState<boolean>(false)
const [canArchiveSelectedServiceCategory, setCanArchiveSelectedServiceCategory] =
useState<boolean>(false)

// Determine if the current user can edit/archive the selected service category whenever either changes
useEffect(() => {
if (selectedServiceCategory) {
const actions = resolvePolicy("service_category", userContext, selectedServiceCategory)
setCanEditSelectedServiceCategory(actions.includes("edit"))
setCanArchiveSelectedServiceCategory(actions.includes("archive"))
} else {
setCanEditSelectedServiceCategory(false)
setCanArchiveSelectedServiceCategory(false)
}
}, [selectedServiceCategory, userContext])

const handleRowClick = (serviceCategory: ServiceCategoryWithRelations) => {
setSelectedServiceCategory(serviceCategory)
Expand All @@ -52,12 +86,20 @@ export const ServiceCategoryTable = ({
const serviceCategoriesToShow = showArchived
? serviceCategories
: serviceCategories.filter((serviceCategory) => serviceCategory.deletedAt === null)

const canCreate = actions.includes("create")

return (
<>
<div className="flex items-center justify-end mb-3 gap-4">
<h3 className="self-center text-sm font-medium text-gray-700">Show Archived</h3>
<Switch checked={showArchived} onChange={setShowArchived} />
<button type="button" onClick={openCreateServiceCategoryModal} className="primary">
<button
type="button"
onClick={openCreateServiceCategoryModal}
disabled={!canCreate}
className="primary"
>
+ Create
</button>
</div>
Expand All @@ -84,6 +126,8 @@ export const ServiceCategoryTable = ({
updateServiceCategory={updateServiceCategory}
revalidateTable={revalidateTable}
openConfirmArchiveServiceCategoryModal={openConfirmArchiveServiceCategoryModal}
canEdit={canEditSelectedServiceCategory}
canArchive={canArchiveSelectedServiceCategory}
/>
<CreateServiceCategoryModal
open={createServiceCategoryModalOpen}
Expand Down
2 changes: 2 additions & 0 deletions src/utils/policies/policies.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LocationPolicy } from "./resources/location"
import { ServiceCategoryPolicy } from "./resources/service_category"
import { StaffUserPolicy } from "./resources/staff_user"
import type { Policies } from "./types"

Expand All @@ -20,4 +21,5 @@ import type { Policies } from "./types"
export const policies: Policies = {
staff_user: StaffUserPolicy,
location: LocationPolicy,
service_category: ServiceCategoryPolicy,
}
20 changes: 20 additions & 0 deletions src/utils/policies/resources/service_category.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Policy } from "../types"

export const ServiceCategoryPolicy: Policy = (user_context, _data) => {
const { role } = user_context
const actions = new Set<string>()

// View permissions
actions.add("view") // Anyone can view service categories

// Create permissions
if (role === "Administrator") actions.add("create") // Administrators can create service categories

// Edit permissions
if (role === "Administrator") actions.add("edit") // Administrators can edit all records

// Archive permissions
if (role === "Administrator") actions.add("archive") // Administrators can archive all records

return Array.from(actions)
}
Loading