From 381a4e3601bdb4b37823382a724af015657e36a8 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Thu, 21 Dec 2023 21:14:25 +0100 Subject: [PATCH] fix: pwa --- compose.yaml | 3 +- .../templates/pwa-deployment.yaml | 5 + pwa/app/api/auth/[...nextauth]/route.ts | 1 + pwa/app/auth.tsx | 29 +- pwa/app/bookmarks/page.tsx | 11 +- pwa/app/books/[id]/[slug]/page.tsx | 40 ++- pwa/app/books/page.tsx | 22 +- pwa/app/layout.tsx | 25 +- pwa/app/page.tsx | 281 +++++++++--------- pwa/app/providers.tsx | 28 ++ pwa/components/admin/book/BookInput.tsx | 17 +- pwa/components/book/Filters.tsx | 16 +- pwa/components/book/Item.tsx | 2 +- pwa/components/book/List.tsx | 26 +- pwa/components/book/Show.tsx | 13 +- pwa/components/bookmark/List.tsx | 4 - pwa/components/common/Header.tsx | 13 +- pwa/components/common/Layout.tsx | 27 +- pwa/components/review/Form.tsx | 2 +- pwa/components/review/Item.tsx | 15 +- pwa/components/review/List.tsx | 20 +- pwa/config/keycloak.ts | 1 + pwa/next.config.js | 1 + pwa/package.json | 4 +- pwa/pnpm-lock.yaml | 52 +++- pwa/types/Book.ts | 4 +- pwa/types/Bookmark.ts | 4 +- pwa/types/Review.ts | 6 +- pwa/types/User.ts | 2 +- pwa/utils/book.ts | 69 +++-- pwa/utils/dataAccess.ts | 7 +- 31 files changed, 431 insertions(+), 319 deletions(-) create mode 100644 pwa/app/api/auth/[...nextauth]/route.ts create mode 100644 pwa/app/providers.tsx diff --git a/compose.yaml b/compose.yaml index 25e573059..ce577782e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -42,7 +42,8 @@ services: NEXTAUTH_URL: ${NEXTAUTH_URL:-https://localhost/api/auth} NEXTAUTH_URL_INTERNAL: http://127.0.0.1:3000/api/auth OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-api-platform-pwa} - OIDC_SERVER_URL: ${OIDC_SERVER_URL_INTERNAL:-http://php/oidc/realms/demo} + OIDC_SERVER_URL: ${OIDC_SERVER_URL:-https://localhost/oidc/realms/demo} + OIDC_SERVER_URL_INTERNAL: ${OIDC_SERVER_URL_INTERNAL:-http://php/oidc/realms/demo} ###> doctrine/doctrine-bundle ### database: diff --git a/helm/api-platform/templates/pwa-deployment.yaml b/helm/api-platform/templates/pwa-deployment.yaml index 29bd31897..b21005fef 100644 --- a/helm/api-platform/templates/pwa-deployment.yaml +++ b/helm/api-platform/templates/pwa-deployment.yaml @@ -51,6 +51,11 @@ spec: configMapKeyRef: name: {{ include "api-platform.fullname" . }} key: oidc-server-url + - name: OIDC_SERVER_URL_INTERNAL + valueFrom: + configMapKeyRef: + name: {{ include "api-platform.fullname" . }} + key: oidc-server-url-internal - name: OIDC_CLIENT_ID valueFrom: configMapKeyRef: diff --git a/pwa/app/api/auth/[...nextauth]/route.ts b/pwa/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 000000000..4a118d98c --- /dev/null +++ b/pwa/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "../../../auth"; diff --git a/pwa/app/auth.tsx b/pwa/app/auth.tsx index 15e3532bc..dc7aefe1f 100644 --- a/pwa/app/auth.tsx +++ b/pwa/app/auth.tsx @@ -1,9 +1,9 @@ import { type TokenSet } from "@auth/core/types"; import { signOut as logout, type SignOutParams } from "next-auth/react"; -import NextAuth, { type Session as DefaultSession, type User as DefaultUser, type NextAuthResult as DefaultNextAuthResult } from "next-auth"; +import NextAuth, { type Session as DefaultSession, type User as DefaultUser } from "next-auth"; import KeycloakProvider from "next-auth/providers/keycloak"; -import { OIDC_CLIENT_ID, OIDC_SERVER_URL } from "../config/keycloak"; +import { OIDC_CLIENT_ID, OIDC_SERVER_URL, OIDC_SERVER_URL_INTERNAL } from "../config/keycloak"; export interface User extends DefaultUser { sub?: string | null @@ -41,7 +41,7 @@ export async function signOut( options?: SignOutParams ): Promise { // @ts-ignore - const url = `${OIDC_SERVER_URL}/protocol/openid-connect/logout?id_token_hint=${session.idToken}&post_logout_redirect_uri=${options?.callbackUrl ?? `${window.location.origin}/books`}`; + const url = `${OIDC_SERVER_URL_INTERNAL}/protocol/openid-connect/logout?id_token_hint=${session.idToken}&post_logout_redirect_uri=${options?.callbackUrl ?? `${window.location.origin}/books`}`; return await logout({ callbackUrl: url }); } @@ -66,7 +66,7 @@ export const { handlers: { GET, POST }, auth } = NextAuth({ // If the access token has expired, try to refresh it try { // todo use .well-known - const response = await fetch(`${OIDC_SERVER_URL}/protocol/openid-connect/token`, { + const response = await fetch(`${OIDC_SERVER_URL_INTERNAL}/protocol/openid-connect/token`, { headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: OIDC_CLIENT_ID, @@ -121,21 +121,26 @@ export const { handlers: { GET, POST }, auth } = NextAuth({ KeycloakProvider({ id: 'keycloak', clientId: OIDC_CLIENT_ID, - issuer: OIDC_SERVER_URL, + issuer: OIDC_SERVER_URL_INTERNAL, + // https://github.com/nextauthjs/next-auth/issues/685#issuecomment-785212676 + // protection: "pkce", + client: { + token_endpoint_auth_method: "none" + }, + // discovery not working: https://github.com/nextauthjs/next-auth/issues/8374 authorization: { + url: `${OIDC_SERVER_URL}/protocol/openid-connect/auth`, // https://authjs.dev/guides/basics/refresh-token-rotation#jwt-strategy params: { access_type: "offline", prompt: "consent", }, }, - // https://github.com/nextauthjs/next-auth/issues/685#issuecomment-785212676 - protection: "pkce", - // https://github.com/nextauthjs/next-auth/issues/4707 - // @ts-ignore - clientSecret: null, - client: { - token_endpoint_auth_method: "none" + token: { + url: `${OIDC_SERVER_URL}/protocol/openid-connect/token`, + }, + userinfo: { + url: `${OIDC_SERVER_URL}/protocol/openid-connect/token`, }, }), ], diff --git a/pwa/app/bookmarks/page.tsx b/pwa/app/bookmarks/page.tsx index b785ccbae..a7865b4d9 100644 --- a/pwa/app/bookmarks/page.tsx +++ b/pwa/app/bookmarks/page.tsx @@ -1,3 +1,4 @@ +import { type Metadata } from "next"; import { redirect } from "next/navigation"; import { List, type Props as ListProps } from "../../components/bookmark/List"; @@ -10,14 +11,12 @@ interface Query extends URLSearchParams { page?: number|string|null; } +export const metadata: Metadata = { + title: 'Bookmarks', +} async function getServerSideProps({ page = 1 }: Query, session: Session): Promise { try { - const response: FetchResponse> | undefined = await fetchApi(`/bookmarks?page=${Number(page)}`, { - headers: { - // @ts-ignore - Authorization: `Bearer ${session?.accessToken}`, - } - }); + const response: FetchResponse> | undefined = await fetchApi(`/bookmarks?page=${Number(page)}`, {}, session); if (!response?.data) { throw new Error('Unable to retrieve data from /bookmarks.'); } diff --git a/pwa/app/books/[id]/[slug]/page.tsx b/pwa/app/books/[id]/[slug]/page.tsx index a32c0ff5e..6c9812112 100644 --- a/pwa/app/books/[id]/[slug]/page.tsx +++ b/pwa/app/books/[id]/[slug]/page.tsx @@ -1,26 +1,48 @@ +import { type Metadata } from "next"; import { notFound } from "next/navigation"; import { Show, type Props as ShowProps } from "../../../../components/book/Show"; import { Book } from "../../../../types/Book"; import { type FetchResponse, fetchApi } from "../../../../utils/dataAccess"; +import { type Session, auth } from "../../../auth"; -interface Query { - id: number; - page?: number|undefined; +interface Props { + params: { id: string }; } -async function getServerSideProps({ id, page = 1 }: Query): Promise { +export async function generateMetadata({ params }: Props): Promise { + const id = params.id; + // @ts-ignore + const session: Session|null = await auth(); + try { + const response: FetchResponse | undefined = await fetchApi(`/books/${id}`, {}, session); + if (!response?.data) { + throw new Error(`Unable to retrieve data from /books/${id}.`); + } + const item = response.data; + + return { + title: `${item["title"]}${!!item["author"] && ` - ${item["author"]}`}`, + }; + } catch (error) { + console.error(error); + } + + return undefined; +} + +async function getServerSideProps(id: string, session: Session|null): Promise { try { const response: FetchResponse | undefined = await fetchApi(`/books/${id}`, { headers: { Preload: "/books/*/reviews", } - }); + }, session); if (!response?.data) { throw new Error(`Unable to retrieve data from /books/${id}.`); } - return { data: response.data, hubURL: response.hubURL, page: Number(page ?? 1) }; + return { data: response.data, hubURL: response.hubURL }; } catch (error) { console.error(error); } @@ -28,8 +50,10 @@ async function getServerSideProps({ id, page = 1 }: Query): Promise { +interface Props { + searchParams: Query; +} + +async function getServerSideProps(query: Query, session: Session|null): Promise { const page = Number(query.page ?? 1); const filters: FiltersProps = {}; if (query.page) { @@ -37,7 +44,9 @@ async function getServerSideProps(query: Query): Promise { } try { - const response: FetchResponse> | undefined = await fetchApi(buildUriFromFilters("/books", filters)); + const response: FetchResponse> | undefined = await fetchApi(buildUriFromFilters("/books", filters), { + cache: "force-cache", + }, session); if (!response?.data) { throw new Error('Unable to retrieve data from /books.'); } @@ -50,8 +59,13 @@ async function getServerSideProps(query: Query): Promise { return { data: null, hubURL: null, filters, page }; } -export default async function Page({ searchParams }: { searchParams: Query }) { - const props = await getServerSideProps(searchParams); +export const metadata: Metadata = { + title: 'Books Store', +} +export default async function Page({ searchParams }: Props) { + // @ts-ignore + const session: Session|null = await auth(); + const props = await getServerSideProps(searchParams, session); return ; } diff --git a/pwa/app/layout.tsx b/pwa/app/layout.tsx index 3b221f0ab..7d5c1debf 100644 --- a/pwa/app/layout.tsx +++ b/pwa/app/layout.tsx @@ -1,15 +1,32 @@ +import type { Metadata } from "next"; import { type ReactNode } from "react"; +import { SessionProvider } from "next-auth/react"; import "@fontsource/poppins"; import "@fontsource/poppins/600.css"; import "@fontsource/poppins/700.css"; import { Layout } from "../components/common/Layout"; import "../styles/globals.css"; +import { Providers } from "./providers"; +import { auth } from "./auth"; + +export const metadata: Metadata = { + title: 'Welcome to API Platform!', +} +export default async function RootLayout({ children }: { children: ReactNode }) { + const session = await auth(); -export default async function MyApp({ children }: { children: ReactNode }) { return ( - - {children} - + + + + + + {children} + + + + + ); }; diff --git a/pwa/app/page.tsx b/pwa/app/page.tsx index 542612ffc..38df87e9f 100644 --- a/pwa/app/page.tsx +++ b/pwa/app/page.tsx @@ -1,4 +1,3 @@ -import { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; import React from "react"; @@ -10,65 +9,92 @@ import mercurePicture from "../public/api-platform/mercure.svg"; import logoTilleuls from "../public/api-platform/logo_tilleuls.svg"; import apiPicture from "../public/api-platform/api.svg"; -const Welcome = () => ( -
-
- -
- Made with - - - - by -
- Les-Tilleuls.coop +export default function Page() { + return ( + - -
-
-
- API Platform + +
+
+
+ API Platform +
-
-
-

+
+

Welcome to - API Platform -

-

- This project host a generated{" "} + API Platform +

+

+ This project host a generated{" "} + + Next.js + {" "} + application: +

+
+ + Visit the Books Store +
+ + + +
+ +
+

+ Learn how to create your first API and generate a PWA: +

- Next.js - {" "} - application: -

-
- - Visit the Books Store + Get started
( />
- +
-

- Learn how to create your first API and generate a PWA: -

- - Get started -
- - - -
-
-
-
-
-
-
-

- Available services: -

-
- - - +
+
+
+
+

+ Available services: +

+
+ + + +
-
- -
-

- Follow us -

- -
+

+ Follow us +

+ - - - - - + + - - - - - - - - + + + + - - - + + + + + + + +
- -); -export default Welcome; -export const metadata: Metadata = { - title: 'Welcome to API Platform!', + ); } const Card = ({ diff --git a/pwa/app/providers.tsx b/pwa/app/providers.tsx new file mode 100644 index 000000000..d8341f69c --- /dev/null +++ b/pwa/app/providers.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { type ReactNode, useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; + +export function Providers(props: { children: ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 1000, + }, + }, + }), + ) + + return ( + + + {props.children} + + {} + + ) +} diff --git a/pwa/components/admin/book/BookInput.tsx b/pwa/components/admin/book/BookInput.tsx index c3b48ce77..b0905edb5 100644 --- a/pwa/components/admin/book/BookInput.tsx +++ b/pwa/components/admin/book/BookInput.tsx @@ -2,7 +2,7 @@ import { SyntheticEvent, useMemo, useRef, useState } from "react"; import Autocomplete from "@mui/material/Autocomplete"; import { debounce } from "@mui/material"; import { TextInput, type TextInputProps, useInput } from "react-admin"; -import { useQuery } from "react-query"; +import { useQuery } from "@tanstack/react-query"; import { useWatch } from "react-hook-form"; import { Search } from "../../../types/OpenLibrary/Search"; @@ -23,7 +23,8 @@ const fetchOpenLibrarySearch = async (query: string, signal?: AbortSignal | unde try { const response = await fetch(`https://openlibrary.org/search.json?q=${query.replace(/ - /, ' ')}&limit=10`, { signal, - method: "GET" + method: "GET", + cache: "force-cache", }); const results: Search = await response.json(); @@ -62,9 +63,9 @@ export const BookInput = (props: BookInputProps) => { const [value, setValue] = useState( !!title && !!author && !!field.value ? { title: title, author: author, value: field.value } : undefined ); - const { isLoading, data, isFetched } = useQuery( - ["search", searchQuery], - async () => { + const { isLoading, data, isFetched } = useQuery({ + queryKey: ["search", searchQuery], + queryFn: async () => { if (controller.current) { controller.current.abort(); } @@ -72,10 +73,8 @@ export const BookInput = (props: BookInputProps) => { return await fetchOpenLibrarySearch(searchQuery, controller.current.signal); }, - { - enabled: !!searchQuery, - } - ); + enabled: !!searchQuery, + }); const onInputChange = useMemo(() => debounce((event: SyntheticEvent, value: string) => setSearchQuery(value), 400), [] diff --git a/pwa/components/book/Filters.tsx b/pwa/components/book/Filters.tsx index e9c538af4..59d6700f3 100644 --- a/pwa/components/book/Filters.tsx +++ b/pwa/components/book/Filters.tsx @@ -1,12 +1,12 @@ -import {Formik} from "formik"; -import {type FunctionComponent} from "react"; -import {type UseMutationResult} from "react-query"; -import {Checkbox, debounce, FormControlLabel, FormGroup, TextField, Typography} from "@mui/material"; +import { Formik } from "formik"; +import { type FunctionComponent } from "react"; +import { type UseMutationResult } from "@tanstack/react-query"; +import { Checkbox, debounce, FormControlLabel, FormGroup, TextField, Typography } from "@mui/material"; -import {type FiltersProps} from "../../utils/book"; -import {type FetchError, type FetchResponse} from "../../utils/dataAccess"; -import {type PagedCollection} from "../../types/collection"; -import {type Book} from "../../types/Book"; +import { type FiltersProps } from "../../utils/book"; +import { type FetchError, type FetchResponse } from "../../utils/dataAccess"; +import { type PagedCollection } from "../../types/collection"; +import { type Book } from "../../types/Book"; interface Props { filters: FiltersProps | undefined; diff --git a/pwa/components/book/Item.tsx b/pwa/components/book/Item.tsx index d7ba6a208..4fa3a3370 100644 --- a/pwa/components/book/Item.tsx +++ b/pwa/components/book/Item.tsx @@ -6,7 +6,7 @@ import Rating from "@mui/material/Rating"; import { type Book } from "../../types/Book"; import { getItemPath } from "../../utils/dataAccess"; import { useOpenLibraryBook } from "../../utils/book"; -import { Loading } from "../../components/common/Loading"; +import { Loading } from "../common/Loading"; interface Props { book: Book; diff --git a/pwa/components/book/List.tsx b/pwa/components/book/List.tsx index ca3829a83..cebb30ae5 100644 --- a/pwa/components/book/List.tsx +++ b/pwa/components/book/List.tsx @@ -1,19 +1,17 @@ "use client"; import { type NextPage } from "next"; -import Head from "next/head"; import { useRouter } from "next/navigation"; -import { useMutation } from "react-query"; +import { useMutation } from "@tanstack/react-query"; import FilterListOutlinedIcon from "@mui/icons-material/FilterListOutlined"; import { MenuItem, Select } from "@mui/material"; -import { Item } from "../../components/book/Item"; -import { Filters } from "../../components/book/Filters"; -import { Pagination } from "../../components/common/Pagination"; +import { Item } from "./Item"; +import { Filters } from "./Filters"; +import { Pagination } from "../common/Pagination"; import { type Book } from "../../types/Book"; import { type PagedCollection } from "../../types/collection"; import { type FiltersProps, buildUriFromFilters } from "../../utils/book"; -import { type FetchError, type FetchResponse } from "../../utils/dataAccess"; import { useMercure } from "../../utils/mercure"; export interface Props { @@ -25,24 +23,18 @@ export interface Props { const getPagePath = (page: number): string => `/books?page=${page}`; -export const List: NextPage = ({ data, hubURL, filters, page }) => { +export const List: NextPage = ({ data, hubURL, filters, page }: Props) => { const collection = useMercure(data, hubURL); const router = useRouter(); - const filtersMutation = useMutation< - FetchResponse> | undefined, - Error | FetchError, - FiltersProps - // @ts-ignore - >(async (filters) => { - router.push(buildUriFromFilters("/books", filters)); + const filtersMutation = useMutation({ + mutationFn: async (filters: FiltersProps) => { + router.push(buildUriFromFilters("/books", filters)); + } }); return (
- - Books Store -