Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: introduce temporary admin mode for site config #1033

Merged
merged 4 commits into from
Feb 3, 2025
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
232 changes: 232 additions & 0 deletions apps/studio/src/pages/sites/[siteId]/admin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
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 { setSiteConfigByAdminSchema } 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()

if (!isUserIsomerAdmin) {
toast({
title: "You do not have permission to access this page.",
status: "error",
...BRIEF_TOAST_SETTINGS,
})
void router.push(`/sites/${siteId}`)
}

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 setSiteConfigByAdminSchema here instead of in site.ts since omit does not work after refine
const {
reset,
watch,
register,
handleSubmit,
formState: { isDirty, errors },
} = useZodForm({
schema: setSiteConfigByAdminSchema
.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 { mutate, isLoading } = trpc.site.setSiteConfigByAdmin.useMutation({
onSuccess: async () => {
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 })
// Reset the form's isDirty but use the latest values provided by the user
reset(watch())
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 [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,
})
})

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 setSiteConfigByAdminSchema = 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,
setSiteConfigByAdminSchema,
} 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
}),
setSiteConfigByAdmin: protectedProcedure
.input(setSiteConfigByAdminSchema)
.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
Loading
Loading