From 253b257bd8063ec4b60b810e2cff7c9b0e039ed1 Mon Sep 17 00:00:00 2001
From: dcshzj <27919917+dcshzj@users.noreply.github.com>
Date: Fri, 24 Jan 2025 10:21:44 +0800
Subject: [PATCH 1/4] feat: introduce temporary admin mode for site config
---
.../studio/src/pages/sites/[siteId]/admin.tsx | 228 ++++++++++++++++++
apps/studio/src/schemas/site.ts | 10 +
.../src/server/modules/site/site.router.ts | 57 ++++-
.../layouts/AdminCmsSidebarLayout.tsx | 13 +-
.../layouts/AdminSidebarOnlyLayout.tsx | 13 +-
5 files changed, 318 insertions(+), 3 deletions(-)
create mode 100644 apps/studio/src/pages/sites/[siteId]/admin.tsx
diff --git a/apps/studio/src/pages/sites/[siteId]/admin.tsx b/apps/studio/src/pages/sites/[siteId]/admin.tsx
new file mode 100644
index 0000000000..e169760fdb
--- /dev/null
+++ b/apps/studio/src/pages/sites/[siteId]/admin.tsx
@@ -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 support@isomer.gov.sg",
+ 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 (
+
+ You do not have permission to access this page.
+
+ )
+ }
+
+ return (
+ <>
+ setNextUrl("")}
+ nextUrl={nextUrl}
+ />
+
+
+
+
+ Manage site configurations
+
+
+ No validation is done on the JSON input. Please ensure that they
+ are valid before saving.
+
+
+ {SUPPORTED_SITE_CONFIG_TYPES.map((type) => (
+
+
+
+ Site {type}
+
+
+
+
+ {errors[type]?.message}
+
+
+ ))}
+
+
+
+ Changes will be reflected on your site immediately.
+
+
+
+ Save settings
+
+
+
+
+
+ >
+ )
+}
+
+SiteAdminPage.getLayout = (page) => {
+ return (
+
+ )
+}
+
+export default SiteAdminPage
diff --git a/apps/studio/src/schemas/site.ts b/apps/studio/src/schemas/site.ts
index 4de39ef0fb..6508c09f6c 100644
--- a/apps/studio/src/schemas/site.ts
+++ b/apps/studio/src/schemas/site.ts
@@ -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(),
+})
diff --git a/apps/studio/src/server/modules/site/site.router.ts b/apps/studio/src/server/modules/site/site.router.ts
index 7aa462443e..5e79ffcc68 100644
--- a/apps/studio/src/server/modules/site/site.router.ts
+++ b/apps/studio/src/server/modules/site/site.router.ts
@@ -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,
@@ -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)
+ }),
})
diff --git a/apps/studio/src/templates/layouts/AdminCmsSidebarLayout.tsx b/apps/studio/src/templates/layouts/AdminCmsSidebarLayout.tsx
index 3cc0db212c..a0a2fb7390 100644
--- a/apps/studio/src/templates/layouts/AdminCmsSidebarLayout.tsx
+++ b/apps/studio/src/templates/layouts/AdminCmsSidebarLayout.tsx
@@ -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"
@@ -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"
@@ -41,6 +42,7 @@ const CmsSidebarWrapper = ({ children }: PropsWithChildren) => {
const { siteId } = useQueryParse(siteSchema)
const { logout } = useMe()
+ const isUserIsomerAdmin = useIsUserIsomerAdmin()
const pageNavItems: CmsSidebarItem[] = [
{
@@ -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[] = [
diff --git a/apps/studio/src/templates/layouts/AdminSidebarOnlyLayout.tsx b/apps/studio/src/templates/layouts/AdminSidebarOnlyLayout.tsx
index 96efa91159..511e76c51b 100644
--- a/apps/studio/src/templates/layouts/AdminSidebarOnlyLayout.tsx
+++ b/apps/studio/src/templates/layouts/AdminSidebarOnlyLayout.tsx
@@ -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"
@@ -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"
@@ -40,6 +41,7 @@ const CmsSidebarWrapper = ({ children }: PropsWithChildren) => {
const { siteId } = useQueryParse(siteSchema)
const { logout } = useMe()
+ const isUserIsomerAdmin = useIsUserIsomerAdmin()
const pageNavItems: CmsSidebarItem[] = [
{
@@ -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[] = [
From d549351e72d5802c0e65a91969627e67cbb0d080 Mon Sep 17 00:00:00 2001
From: dcshzj <27919917+dcshzj@users.noreply.github.com>
Date: Tue, 28 Jan 2025 08:33:04 +0800
Subject: [PATCH 2/4] chore: use redirect and toast instead of error message
---
apps/studio/src/pages/sites/[siteId]/admin.tsx | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/apps/studio/src/pages/sites/[siteId]/admin.tsx b/apps/studio/src/pages/sites/[siteId]/admin.tsx
index e169760fdb..f2ebaff36b 100644
--- a/apps/studio/src/pages/sites/[siteId]/admin.tsx
+++ b/apps/studio/src/pages/sites/[siteId]/admin.tsx
@@ -47,6 +47,15 @@ const SiteAdminPage: NextPageWithLayout = () => {
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 { mutate, isLoading } = trpc.site.setSiteConfigAdmin.useMutation({
onSuccess: async () => {
// reset({ config, theme, navbar, footer })
@@ -146,14 +155,6 @@ const SiteAdminPage: NextPageWithLayout = () => {
})
})
- if (!isUserIsomerAdmin) {
- return (
-
- You do not have permission to access this page.
-
- )
- }
-
return (
<>
Date: Mon, 3 Feb 2025 11:01:30 +0800
Subject: [PATCH 3/4] fix: block access for non-admins
---
apps/studio/src/pages/sites/[siteId]/admin.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/studio/src/pages/sites/[siteId]/admin.tsx b/apps/studio/src/pages/sites/[siteId]/admin.tsx
index f2ebaff36b..5947906c3a 100644
--- a/apps/studio/src/pages/sites/[siteId]/admin.tsx
+++ b/apps/studio/src/pages/sites/[siteId]/admin.tsx
@@ -47,7 +47,7 @@ const SiteAdminPage: NextPageWithLayout = () => {
const { siteId } = useQueryParse(siteAdminSchema)
const isUserIsomerAdmin = useIsUserIsomerAdmin()
- if (isUserIsomerAdmin) {
+ if (!isUserIsomerAdmin) {
toast({
title: "You do not have permission to access this page.",
status: "error",
From 31dcf08f43695cdc78c70983cff0df8d9a24e9dd Mon Sep 17 00:00:00 2001
From: dcshzj <27919917+dcshzj@users.noreply.github.com>
Date: Mon, 3 Feb 2025 11:11:46 +0800
Subject: [PATCH 4/4] chore: rename setSiteConfigAdmin to setSiteConfigByAdmin
---
.../studio/src/pages/sites/[siteId]/admin.tsx | 59 ++++++++++---------
apps/studio/src/schemas/site.ts | 2 +-
.../src/server/modules/site/site.router.ts | 6 +-
3 files changed, 35 insertions(+), 32 deletions(-)
diff --git a/apps/studio/src/pages/sites/[siteId]/admin.tsx b/apps/studio/src/pages/sites/[siteId]/admin.tsx
index 5947906c3a..5ce966fd30 100644
--- a/apps/studio/src/pages/sites/[siteId]/admin.tsx
+++ b/apps/studio/src/pages/sites/[siteId]/admin.tsx
@@ -25,7 +25,7 @@ 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 { setSiteConfigByAdminSchema } from "~/schemas/site"
import { AdminSidebarOnlyLayout } from "~/templates/layouts/AdminSidebarOnlyLayout"
import { trpc } from "~/utils/trpc"
@@ -56,31 +56,6 @@ const SiteAdminPage: NextPageWithLayout = () => {
void router.push(`/sites/${siteId}`)
}
- 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 support@isomer.gov.sg",
- status: "error",
- ...BRIEF_TOAST_SETTINGS,
- })
- },
- })
-
const [previousConfig] = trpc.site.getConfig.useSuspenseQuery({
id: siteId,
})
@@ -94,13 +69,15 @@ const SiteAdminPage: NextPageWithLayout = () => {
id: siteId,
})
- // NOTE: Refining the setNotificationSchema here instead of in site.ts since omit does not work after refine
+ // 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: setSiteConfigAdminSchema
+ schema: setSiteConfigByAdminSchema
.omit({ siteId: true })
.refine((data) => !!data.config, {
message: "Site config must be present",
@@ -126,6 +103,32 @@ const SiteAdminPage: NextPageWithLayout = () => {
},
})
+ 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 support@isomer.gov.sg",
+ status: "error",
+ ...BRIEF_TOAST_SETTINGS,
+ })
+ },
+ })
+
const [nextUrl, setNextUrl] = useState("")
const isOpen = !!nextUrl
diff --git a/apps/studio/src/schemas/site.ts b/apps/studio/src/schemas/site.ts
index 6508c09f6c..87a210bba6 100644
--- a/apps/studio/src/schemas/site.ts
+++ b/apps/studio/src/schemas/site.ts
@@ -27,7 +27,7 @@ export const getNameSchema = z.object({
// NOTE: This is a temporary schema for editing the JSON content directly,
// until the proper editing experience is implemented
-export const setSiteConfigAdminSchema = z.object({
+export const setSiteConfigByAdminSchema = z.object({
siteId: z.number().min(1),
config: z.string(),
theme: z.string(),
diff --git a/apps/studio/src/server/modules/site/site.router.ts b/apps/studio/src/server/modules/site/site.router.ts
index 5e79ffcc68..e66037508e 100644
--- a/apps/studio/src/server/modules/site/site.router.ts
+++ b/apps/studio/src/server/modules/site/site.router.ts
@@ -10,7 +10,7 @@ import {
getNameSchema,
getNotificationSchema,
setNotificationSchema,
- setSiteConfigAdminSchema,
+ setSiteConfigByAdminSchema,
} from "~/schemas/site"
import { protectedProcedure, router } from "~/server/trpc"
import { safeJsonParse } from "~/utils/safeJsonParse"
@@ -144,8 +144,8 @@ export const siteRouter = router({
return input
}),
- setSiteConfigAdmin: protectedProcedure
- .input(setSiteConfigAdminSchema)
+ setSiteConfigByAdmin: protectedProcedure
+ .input(setSiteConfigByAdminSchema)
.mutation(async ({ ctx, input }) => {
const { siteId, config, theme, navbar, footer } = input
await validateUserPermissionsForSite({