diff --git a/Dockerfile b/Dockerfile index f095c0d..2fbdcd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,9 @@ RUN npm install -g pnpm WORKDIR /app +# Production behavior: enables the Secure cookie flag and the JWT_SECRET boot check +ENV NODE_ENV=production + # Copy workspace files COPY package.json pnpm-workspace.yaml ./ COPY server/package.json ./server/ diff --git a/client/package.json b/client/package.json index 4fc1253..066f94a 100644 --- a/client/package.json +++ b/client/package.json @@ -12,11 +12,17 @@ "prepare": "husky" }, "dependencies": { + "@hookform/resolvers": "^5.4.0", + "@tanstack/react-query": "^5.101.2", + "axios": "^1.18.1", "motion": "^12.38.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-hook-form": "^7.80.0", "react-icons": "^5.6.0", - "react-router-hash-link": "^2.4.3" + "react-router-dom": "^7.13.2", + "react-router-hash-link": "^2.4.3", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -34,7 +40,6 @@ "husky": "^9.1.7", "only-allow": "^1.2.2", "postcss": "^8.5.8", - "react-router-dom": "^7.13.2", "tailwindcss": "^4.2.2", "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index c8ff1b6..a1a09dd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,11 +1,21 @@ +import { useHomeContent } from "./hooks/useHomeContent"; +import type { HomeContentValues } from "./schemas/content"; + +// exemplar CMS default data -> shown when data does not exist / API is down +const DEFAULTS: HomeContentValues = { + heading: "Welcome to Project UMSA!", + subtitle: "to get started, go to the project team section and have a look :)", +}; + function App() { + const { data } = useHomeContent(); + const content = data ?? DEFAULTS; + return (
-

- Welcome to Project UMSA! -

+

{content.heading}

-

to get started, go to the project team section and have a look :)

+

{content.subtitle}

); diff --git a/client/src/hooks/useAuth.ts b/client/src/hooks/useAuth.ts new file mode 100644 index 0000000..4d80611 --- /dev/null +++ b/client/src/hooks/useAuth.ts @@ -0,0 +1,50 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; +import { api } from "../lib/api"; +import type { LoginValues } from "../schemas/auth"; + +// The ["auth", "me"] cache entry IS our auth state — every component that calls +// useMe() reads the same entry, and the login/logout mutations write to it. +// No AuthContext needed: react-query is already an app-wide store. +export const ME_KEY = ["auth", "me"] as const; + +export interface Me { + email: string; +} + +export function useMe() { + return useQuery({ + queryKey: ME_KEY, + queryFn: async (): Promise => { + try { + const { data } = await api.get("/admin/auth/me"); + return data; + } catch (err) { + if (isAxiosError(err) && err.response?.status === 401) { + return null; // logged-out is a normal STATE, not an error + } + throw err; + } + }, + retry: false, + staleTime: 5 * 60 * 1000, + }); +} + +export function useLogin() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (values: LoginValues) => + api.post("/admin/auth/login", values).then((res) => res.data), + onSuccess: (me) => queryClient.setQueryData(ME_KEY, me), + }); +} + +export function useLogout() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => api.post("/admin/auth/logout"), + // Nulling the cache entry makes AdminLayout redirect declaratively + onSuccess: () => queryClient.setQueryData(ME_KEY, null), + }); +} diff --git a/client/src/hooks/useHomeContent.ts b/client/src/hooks/useHomeContent.ts new file mode 100644 index 0000000..aad25b6 --- /dev/null +++ b/client/src/hooks/useHomeContent.ts @@ -0,0 +1,30 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; +import { api } from "../lib/api"; +import { ME_KEY } from "./useAuth"; +import type { HomeContentValues } from "../schemas/content"; + +export const HOME_CONTENT_KEY = ["content", "home"] as const; + +export function useHomeContent() { + return useQuery({ + queryKey: HOME_CONTENT_KEY, + queryFn: () => api.get("/content/home").then((res) => res.data), + }); +} + +export function useSaveHomeContent() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (values: HomeContentValues) => + api.put("/admin/content/home", values).then((res) => res.data), + // forces refetch so every render sees the new values + onSuccess: () => queryClient.invalidateQueries({ queryKey: HOME_CONTENT_KEY }), + onError: (err) => { + if (isAxiosError(err) && err.response?.status === 401) { + // session died mid-edit -> nulls auth cache and AdminLayout redirects + queryClient.setQueryData(ME_KEY, null); + } + }, + }); +} diff --git a/client/src/layouts/AdminLayout.tsx b/client/src/layouts/AdminLayout.tsx new file mode 100644 index 0000000..5f4668a --- /dev/null +++ b/client/src/layouts/AdminLayout.tsx @@ -0,0 +1,58 @@ +import { Navigate, NavLink, Outlet } from "react-router-dom"; +import { useLogout, useMe } from "../hooks/useAuth"; + +// Adding an admin page = one entry here + one route in main.tsx. +const MENU = [{ to: "/admin/home-content", label: "Homepage text" }]; + +export default function AdminLayout() { + const { data: me, isPending } = useMe(); + const logout = useLogout(); + + if (isPending) { + return ( +
+ Checking login… +
+ ); + } + + // if not logged in -> redirect + if (!me) { + return ; + } + + return ( +
+ +
+ +
+
+ ); +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts new file mode 100644 index 0000000..2d92e0e --- /dev/null +++ b/client/src/lib/api.ts @@ -0,0 +1,15 @@ +import axios, { isAxiosError } from "axios"; + +// one shared client for public AND admin requests +export const api = axios.create({ + baseURL: "/api", + withCredentials: true, // send/receive the umsa_admin session cookie +}); + +export function apiErrorMessage(err: unknown): string { + if (isAxiosError(err)) { + // our server wraps errors as { error: { code, message } } + return err.response?.data?.error?.message ?? err.message; + } + return "Something went wrong"; +} diff --git a/client/src/main.tsx b/client/src/main.tsx index 48749e6..e2a14fd 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import * as ReactDOM from "react-dom/client"; -import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import { createBrowserRouter, Navigate, RouterProvider } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import RootLayout from "./layouts/RootLayout"; import App from "./App"; import "./index.css"; @@ -27,6 +28,13 @@ import Tadiwa from "./pages/project-team/Tadiwa"; import Terrence from "./pages/project-team/Terrence"; import Alex from "./pages/project-team/Alex"; +{ + /* admin (CMS) imports */ +} +import AdminLayout from "./layouts/AdminLayout"; +import AdminLogin from "./pages/admin/Login"; +import HomeContentEditor from "./pages/admin/HomeContentEditor"; + { /* here's where we set up all our routing */ } @@ -52,10 +60,29 @@ const router = createBrowserRouter([ { path: "about", element: }, ], }, + /* admin routes live OUTSIDE RootLayout so they don't get the public navbar/footer */ + { + path: "/admin/login", + element: , + }, + { + path: "/admin", + element: , + children: [ + { index: true, element: }, + { path: "home-content", element: }, + ], + }, ]); +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 1 } }, +}); + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + + + , ); diff --git a/client/src/pages/admin/HomeContentEditor.tsx b/client/src/pages/admin/HomeContentEditor.tsx new file mode 100644 index 0000000..bbc60a7 --- /dev/null +++ b/client/src/pages/admin/HomeContentEditor.tsx @@ -0,0 +1,73 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { homeContentSchema, type HomeContentValues } from "../../schemas/content"; +import { useHomeContent, useSaveHomeContent } from "../../hooks/useHomeContent"; +import { apiErrorMessage } from "../../lib/api"; + +// template for future CMS pages: +// -> read with a query, prefill the form using react-hook-form, write with mutation +// can copy format for events / gallery / team editors +export default function HomeContentEditor() { + const { data, isPending, isError } = useHomeContent(); + const save = useSaveHomeContent(); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(homeContentSchema), + values: data, // syncs form whenever data changes + resetOptions: { keepDirtyValues: true }, + }); + + if (isPending) { + return

Loading current content…

; + } + if (isError) { + return

Could not load content — is the server running?

; + } + + const onSubmit = handleSubmit((values) => save.mutate(values)); + + return ( +
+

Homepage text

+

+ This is the welcome message on the public homepage. Changes go live as soon as + you save. +

+ +
+ + {errors.heading &&

{errors.heading.message}

} + +