Skip to content

Commit

Permalink
feat: introduce temporary admin mode for site config
Browse files Browse the repository at this point in the history
  • Loading branch information
dcshzj committed Jan 24, 2025
1 parent 972cde9 commit 253b257
Show file tree
Hide file tree
Showing 5 changed files with 318 additions and 3 deletions.
228 changes: 228 additions & 0 deletions apps/studio/src/pages/sites/[siteId]/admin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/router"
import {
Button,
Center,
chakra,
FormControl,
HStack,
Text,
VStack,
} from "@chakra-ui/react"
import {
FormErrorMessage,
Infobox,
Textarea,
useToast,
} from "@opengovsg/design-system-react"
import { ResourceType } from "~prisma/generated/generatedEnums"
import { z } from "zod"

import { PermissionsBoundary } from "~/components/AuthWrappers"
import { BRIEF_TOAST_SETTINGS } from "~/constants/toast"
import { UnsavedSettingModal } from "~/features/editing-experience/components/UnsavedSettingModal"
import { useIsUserIsomerAdmin } from "~/hooks/useIsUserIsomerAdmin"
import { useQueryParse } from "~/hooks/useQueryParse"
import { useZodForm } from "~/lib/form"
import { type NextPageWithLayout } from "~/lib/types"
import { setSiteConfigAdminSchema } from "~/schemas/site"
import { AdminSidebarOnlyLayout } from "~/templates/layouts/AdminSidebarOnlyLayout"
import { trpc } from "~/utils/trpc"

const siteAdminSchema = z.object({
siteId: z.coerce.number(),
})

const SUPPORTED_SITE_CONFIG_TYPES = [
"config",
"theme",
"navbar",
"footer",
] as const

const SiteAdminPage: NextPageWithLayout = () => {
const toast = useToast()
const router = useRouter()
const trpcUtils = trpc.useUtils()
const { siteId } = useQueryParse(siteAdminSchema)
const isUserIsomerAdmin = useIsUserIsomerAdmin()

const { mutate, isLoading } = trpc.site.setSiteConfigAdmin.useMutation({
onSuccess: async () => {
// reset({ config, theme, navbar, footer })
await trpcUtils.site.getConfig.invalidate({ id: siteId })
await trpcUtils.site.getTheme.invalidate({ id: siteId })
await trpcUtils.site.getNavbar.invalidate({ id: siteId })
await trpcUtils.site.getFooter.invalidate({ id: siteId })
toast({
title: "Saved site config!",
description: "Check your site in 5-10 minutes to view it live.",
status: "success",
...BRIEF_TOAST_SETTINGS,
})
},
onError: () => {
toast({
title: "Error saving site config!",
description:
"If this persists, please report this issue at [email protected]",
status: "error",
...BRIEF_TOAST_SETTINGS,
})
},
})

const [previousConfig] = trpc.site.getConfig.useSuspenseQuery({
id: siteId,
})
const [previousTheme] = trpc.site.getTheme.useSuspenseQuery({
id: siteId,
})
const [previousNavbar] = trpc.site.getNavbar.useSuspenseQuery({
id: siteId,
})
const [previousFooter] = trpc.site.getFooter.useSuspenseQuery({
id: siteId,
})

// NOTE: Refining the setNotificationSchema here instead of in site.ts since omit does not work after refine
const {
register,
handleSubmit,
formState: { isDirty, errors },
} = useZodForm({
schema: setSiteConfigAdminSchema
.omit({ siteId: true })
.refine((data) => !!data.config, {
message: "Site config must be present",
path: ["config"],
})
.refine((data) => !!data.theme, {
message: "Site theme must be present",
path: ["theme"],
})
.refine((data) => !!data.navbar, {
message: "Site navbar must be present",
path: ["navbar"],
})
.refine((data) => !!data.footer, {
message: "Site footer must be present",
path: ["footer"],
}),
defaultValues: {
config: JSON.stringify(previousConfig, null, 2),
theme: JSON.stringify(previousTheme, null, 2),
navbar: JSON.stringify(previousNavbar.content, null, 2),
footer: JSON.stringify(previousFooter.content, null, 2),
},
})

const [nextUrl, setNextUrl] = useState("")
const isOpen = !!nextUrl

useEffect(() => {
const handleRouteChange = (url: string) => {
if (isDirty) {
router.events.off("routeChangeStart", handleRouteChange)
setNextUrl(url)
router.events.emit("routeChangeError")
// eslint-disable-next-line @typescript-eslint/only-throw-error
throw "Error to abort router route change. Ignore this!"
}
}

if (!isOpen) {
router.events.on("routeChangeStart", handleRouteChange)
}
return () => {
router.events.off("routeChangeStart", handleRouteChange)
}
}, [isOpen, router.events, isDirty])

const onClickUpdate = handleSubmit((input) => {
mutate({
siteId,
...input,
})
})

if (!isUserIsomerAdmin) {
return (
<Center h="full">
<Text>You do not have permission to access this page.</Text>
</Center>
)
}

return (
<>
<UnsavedSettingModal
isOpen={isOpen}
onClose={() => setNextUrl("")}
nextUrl={nextUrl}
/>
<chakra.form
onSubmit={onClickUpdate}
overflow="auto"
height={0}
minH="100%"
>
<Center py="5.5rem" px="2rem">
<VStack w="48rem" alignItems="flex-start" spacing="1.5rem">
<Text w="full" textStyle="h3-semibold">
Manage site configurations
</Text>
<Infobox variant="warning" textStyle="body-2" size="sm">
No validation is done on the JSON input. Please ensure that they
are valid before saving.
</Infobox>

{SUPPORTED_SITE_CONFIG_TYPES.map((type) => (
<FormControl key={type} isInvalid={!!errors[type]}>
<VStack w="full" alignItems="flex-start" spacing="0.75rem">
<Text
textColor="base.content.strong"
textStyle="subhead-1"
pt="0.5rem"
>
Site {type}
</Text>

<Textarea
fontFamily="monospace"
boxSizing="border-box"
minH="18rem"
{...register(type)}
/>

<FormErrorMessage>{errors[type]?.message}</FormErrorMessage>
</VStack>
</FormControl>
))}

<HStack justifyContent="flex-end" w="full" gap="1.5rem">
<Text textColor="base.content.medium" textStyle="caption-2">
Changes will be reflected on your site immediately.
</Text>

<Button type="submit" isLoading={isLoading} isDisabled={!isDirty}>
Save settings
</Button>
</HStack>
</VStack>
</Center>
</chakra.form>
</>
)
}

SiteAdminPage.getLayout = (page) => {
return (
<PermissionsBoundary
resourceType={ResourceType.RootPage}
page={AdminSidebarOnlyLayout(page)}
/>
)
}

export default SiteAdminPage
10 changes: 10 additions & 0 deletions apps/studio/src/schemas/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,13 @@ export const setNotificationSchema = z.object({
export const getNameSchema = z.object({
siteId: z.number().min(1),
})

// NOTE: This is a temporary schema for editing the JSON content directly,
// until the proper editing experience is implemented
export const setSiteConfigAdminSchema = z.object({
siteId: z.number().min(1),
config: z.string(),
theme: z.string(),
navbar: z.string(),
footer: z.string(),
})
57 changes: 56 additions & 1 deletion apps/studio/src/server/modules/site/site.router.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import type {
IsomerSiteConfigProps,
IsomerSiteThemeProps,
IsomerSiteWideComponentsProps,
} from "@opengovsg/isomer-components"

import {
getConfigSchema,
getLocalisedSitemapSchema,
getNameSchema,
getNotificationSchema,
setNotificationSchema,
setSiteConfigAdminSchema,
} from "~/schemas/site"
import { protectedProcedure, router } from "~/server/trpc"
import { safeJsonParse } from "~/utils/safeJsonParse"
import { publishSite } from "../aws/codebuild.service"
import { db } from "../database"
import { db, jsonb } from "../database"
import {
getFooter,
getLocalisedSitemap,
Expand Down Expand Up @@ -136,4 +144,51 @@ export const siteRouter = router({

return input
}),
setSiteConfigAdmin: protectedProcedure
.input(setSiteConfigAdminSchema)
.mutation(async ({ ctx, input }) => {
const { siteId, config, theme, navbar, footer } = input
await validateUserPermissionsForSite({
siteId,
userId: ctx.user.id,
action: "update",
})

await db.transaction().execute(async (tx) => {
await tx
.updateTable("Site")
.set({
config: jsonb(safeJsonParse(config) as IsomerSiteConfigProps),
theme: jsonb(safeJsonParse(theme) as IsomerSiteThemeProps),
})
.where("id", "=", siteId)
.execute()

await tx
.updateTable("Navbar")
.set({
content: jsonb(
safeJsonParse(
navbar,
) as IsomerSiteWideComponentsProps["navBarItems"],
),
})
.where("siteId", "=", siteId)
.execute()

await tx
.updateTable("Footer")
.set({
content: jsonb(
safeJsonParse(
footer,
) as IsomerSiteWideComponentsProps["footerItems"],
),
})
.where("siteId", "=", siteId)
.execute()
})

await publishSite(ctx.logger, siteId)
}),
})
13 changes: 12 additions & 1 deletion apps/studio/src/templates/layouts/AdminCmsSidebarLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { PropsWithChildren } from "react"
import { useRouter } from "next/router"
import { Flex } from "@chakra-ui/react"
import { BiCog, BiFolder, BiLogOut } from "react-icons/bi"
import { BiCog, BiFolder, BiLogOut, BiStar } from "react-icons/bi"
import { z } from "zod"

import type { CmsSidebarItem } from "~/components/CmsSidebar/CmsSidebarItems"
Expand All @@ -11,6 +11,7 @@ import { LayoutHead } from "~/components/LayoutHead"
import { SearchableHeader } from "~/components/SearchableHeader"
import { DirectorySidebar } from "~/features/dashboard/components/DirectorySidebar"
import { useMe } from "~/features/me/api"
import { useIsUserIsomerAdmin } from "~/hooks/useIsUserIsomerAdmin"
import { useQueryParse } from "~/hooks/useQueryParse"
import { type GetLayout } from "~/lib/types"

Expand Down Expand Up @@ -41,6 +42,7 @@ const CmsSidebarWrapper = ({ children }: PropsWithChildren) => {
const { siteId } = useQueryParse(siteSchema)

const { logout } = useMe()
const isUserIsomerAdmin = useIsUserIsomerAdmin()

const pageNavItems: CmsSidebarItem[] = [
{
Expand All @@ -54,6 +56,15 @@ const CmsSidebarWrapper = ({ children }: PropsWithChildren) => {

// TODO(ISOM-1552): Add back manage users functionality when implemented
{ icon: BiCog, label: "Settings", href: `/sites/${siteId}/settings` },
...(isUserIsomerAdmin
? [
{
icon: BiStar,
label: "Isomer Admin Settings",
href: `/sites/${siteId}/admin`,
},
]
: []),
]

const userNavItems: CmsSidebarItem[] = [
Expand Down
13 changes: 12 additions & 1 deletion apps/studio/src/templates/layouts/AdminSidebarOnlyLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { PropsWithChildren } from "react"
import { useRouter } from "next/router"
import { Flex } from "@chakra-ui/react"
import { BiCog, BiFolder, BiLogOut } from "react-icons/bi"
import { BiCog, BiFolder, BiLogOut, BiStar } from "react-icons/bi"
import { z } from "zod"

import type { CmsSidebarItem } from "~/components/CmsSidebar/CmsSidebarItems"
Expand All @@ -10,6 +10,7 @@ import { CmsSidebar, CmsSidebarContainer } from "~/components/CmsSidebar"
import { LayoutHead } from "~/components/LayoutHead"
import { SearchableHeader } from "~/components/SearchableHeader"
import { useMe } from "~/features/me/api"
import { useIsUserIsomerAdmin } from "~/hooks/useIsUserIsomerAdmin"
import { useQueryParse } from "~/hooks/useQueryParse"
import { type GetLayout } from "~/lib/types"

Expand Down Expand Up @@ -40,6 +41,7 @@ const CmsSidebarWrapper = ({ children }: PropsWithChildren) => {
const { siteId } = useQueryParse(siteSchema)

const { logout } = useMe()
const isUserIsomerAdmin = useIsUserIsomerAdmin()

const pageNavItems: CmsSidebarItem[] = [
{
Expand All @@ -52,6 +54,15 @@ const CmsSidebarWrapper = ({ children }: PropsWithChildren) => {
},
// TODO(ISOM-1552): Add back manage users functionality when implemented
{ icon: BiCog, label: "Settings", href: `/sites/${siteId}/settings` },
...(isUserIsomerAdmin
? [
{
icon: BiStar,
label: "Isomer Admin Settings",
href: `/sites/${siteId}/admin`,
},
]
: []),
]

const userNavItems: CmsSidebarItem[] = [
Expand Down

0 comments on commit 253b257

Please sign in to comment.