From d24b123c92f126470061876587c59356d84d788f Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Mon, 18 Mar 2024 10:37:35 +0100 Subject: [PATCH] feat: use Next.js app router + upgrade to next-auth@5 (beta) (#366) --- .github/workflows/ci.yml | 2 +- api/.env | 2 +- api/config/packages/api_platform.yaml | 2 - compose.override.yaml | 2 +- compose.prod.yaml | 2 +- compose.yaml | 12 +- .../templates/pwa-deployment.yaml | 9 +- pwa/Dockerfile | 2 +- .../admin/index.tsx => app/admin/page.tsx} | 4 +- pwa/app/api/auth/[...nextauth]/route.ts | 1 + .../auth/[...nextauth].tsx => app/auth.tsx} | 73 ++-- pwa/app/bookmarks/page.tsx | 46 +++ pwa/app/books/[id]/[slug]/page.tsx | 65 +++ pwa/app/books/page.tsx | 72 ++++ pwa/app/layout.tsx | 32 ++ pwa/app/page.tsx | 225 +++++++++++ pwa/app/providers.tsx | 28 ++ pwa/components/admin/Admin.tsx | 31 +- pwa/components/admin/AppBar.tsx | 10 +- pwa/components/admin/Menu.tsx | 2 + pwa/components/admin/authProvider.tsx | 14 +- pwa/components/admin/book/BookInput.tsx | 21 +- pwa/components/admin/book/Create.tsx | 2 +- pwa/components/admin/book/Edit.tsx | 4 +- pwa/components/admin/book/Form.tsx | 4 +- pwa/components/admin/book/List.tsx | 6 +- pwa/components/admin/book/ShowButton.tsx | 3 +- pwa/components/admin/review/BookField.tsx | 3 +- pwa/components/admin/review/Edit.tsx | 10 +- pwa/components/admin/review/List.tsx | 10 +- pwa/components/admin/review/Show.tsx | 4 +- pwa/components/book/Filters.tsx | 19 +- pwa/components/book/Item.tsx | 8 +- pwa/components/book/List.tsx | 46 ++- pwa/components/book/Show.tsx | 92 +++-- pwa/components/bookmark/List.tsx | 18 +- pwa/components/common/Header.tsx | 16 +- pwa/components/common/Layout.tsx | 32 +- pwa/components/common/Pagination.tsx | 4 +- pwa/components/review/Form.tsx | 27 +- pwa/components/review/Item.tsx | 21 +- pwa/components/review/List.tsx | 33 +- pwa/config/keycloak.ts | 1 + pwa/next.config.js | 1 + pwa/package.json | 21 +- pwa/pages/_app.tsx | 22 -- pwa/pages/bookmarks/index.tsx | 48 --- pwa/pages/books/[id]/[slug]/index.tsx | 30 -- pwa/pages/books/index.tsx | 55 --- pwa/pages/index.tsx | 227 ----------- pwa/pnpm-lock.yaml | 372 +++++++++--------- pwa/tailwind.config.ts | 20 + pwa/tests/BookView.spec.ts | 2 +- pwa/tests/BookmarksList.spec.ts | 2 +- pwa/tests/pages/BookPage.ts | 2 +- pwa/tsconfig.json | 13 +- pwa/types/Book.ts | 4 +- pwa/types/Bookmark.ts | 4 +- pwa/types/OpenLibrary/Book.ts | 4 +- pwa/types/OpenLibrary/Search.ts | 2 +- pwa/types/OpenLibrary/Work.ts | 2 +- pwa/types/Review.ts | 6 +- pwa/types/User.ts | 2 +- pwa/utils/book.ts | 77 ++-- pwa/utils/dataAccess.ts | 71 +--- pwa/utils/mercure.ts | 8 +- pwa/utils/security.ts | 14 - 67 files changed, 1059 insertions(+), 970 deletions(-) rename pwa/{pages/admin/index.tsx => app/admin/page.tsx} (76%) create mode 100644 pwa/app/api/auth/[...nextauth]/route.ts rename pwa/{pages/api/auth/[...nextauth].tsx => app/auth.tsx} (63%) create mode 100644 pwa/app/bookmarks/page.tsx create mode 100644 pwa/app/books/[id]/[slug]/page.tsx create mode 100644 pwa/app/books/page.tsx create mode 100644 pwa/app/layout.tsx create mode 100644 pwa/app/page.tsx create mode 100644 pwa/app/providers.tsx delete mode 100644 pwa/pages/_app.tsx delete mode 100644 pwa/pages/bookmarks/index.tsx delete mode 100644 pwa/pages/books/[id]/[slug]/index.tsx delete mode 100644 pwa/pages/books/index.tsx delete mode 100644 pwa/pages/index.tsx create mode 100644 pwa/tailwind.config.ts delete mode 100644 pwa/utils/security.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da7e35850..3710e9912 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,7 @@ jobs: KEYCLOAK_DOCKER_IMAGE: europe-west1-docker.pkg.dev/${{ secrets.GKE_PROJECT }}/${{ secrets.GKE_PROJECT }}/keycloak:latest APP_SECRET: ba63418865d58089f7f070e0a437b6d16b1fb970 CADDY_MERCURE_JWT_SECRET: 33b04d361e437e0d7d715600fc24fdefba317154 - NEXTAUTH_SECRET: 77e4c3f5a6fb652b6245a5df8a704e04ad90bc7e + AUTH_SECRET: 77e4c3f5a6fb652b6245a5df8a704e04ad90bc7e POSTGRES_PASSWORD: aae5bf316ef5fe87ad806c6a9240fff68bcfdaf7 KEYCLOAK_POSTGRES_PASSWORD: 26d7f630f1524eb210bbf496443f2038a9316e9e KEYCLOAK_ADMIN_PASSWORD: 2f31e2fad93941b818449fd8d57fd019b6ce7fa5 diff --git a/api/.env b/api/.env index bc27bbcd7..4091bba46 100644 --- a/api/.env +++ b/api/.env @@ -18,7 +18,7 @@ TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 TRUSTED_HOSTS=^(localhost|php)$ OIDC_SERVER_URL=https://localhost/oidc/realms/demo -OIDC_SERVER_URL_INTERNAL=http://php/oidc/realms/demo +OIDC_SERVER_URL_INTERNAL=http://keycloak:8080/oidc/realms/demo OIDC_SWAGGER_CLIENT_ID=api-platform-swagger ###> symfony/framework-bundle ### diff --git a/api/config/packages/api_platform.yaml b/api/config/packages/api_platform.yaml index 1b8464976..608c759fb 100644 --- a/api/config/packages/api_platform.yaml +++ b/api/config/packages/api_platform.yaml @@ -35,9 +35,7 @@ api_platform: pkce: true type: oauth2 flow: authorizationCode - # todo retrieve url from .well-known tokenUrl: '%env(OIDC_SERVER_URL)%/protocol/openid-connect/token' - # todo retrieve url from .well-known authorizationUrl: '%env(OIDC_SERVER_URL)%/protocol/openid-connect/auth' scopes: openid: (required) Indicates that the application intends to use OIDC to verify the user's identity diff --git a/compose.override.yaml b/compose.override.yaml index 26c5cc99e..f07decc7a 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -58,7 +58,7 @@ services: keycloak-config-cli: image: bitnami/keycloak-config-cli:5-debian-11 environment: - KEYCLOAK_URL: http://php/oidc/ + KEYCLOAK_URL: http://keycloak:8080/oidc/ KEYCLOAK_USER: ${KEYCLOAK_ADMIN_USER:-admin} KEYCLOAK_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-!ChangeMe!} KEYCLOAK_AVAILABILITYCHECK_ENABLED: "true" diff --git a/compose.prod.yaml b/compose.prod.yaml index abf5614fc..f77172349 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -19,7 +19,7 @@ services: context: ./pwa target: prod environment: - NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + AUTH_SECRET: ${AUTH_SECRET} database: environment: diff --git a/compose.yaml b/compose.yaml index 25e573059..2ad56b665 100644 --- a/compose.yaml +++ b/compose.yaml @@ -19,7 +19,7 @@ services: MERCURE_PUBLIC_URL: https://${SERVER_NAME:-localhost}/.well-known/mercure MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!} OIDC_SERVER_URL: ${OIDC_SERVER_URL:-https://localhost/oidc/realms/demo} - OIDC_SERVER_URL_INTERNAL: ${OIDC_SERVER_URL_INTERNAL:-http://php/oidc/realms/demo} + OIDC_SERVER_URL_INTERNAL: ${OIDC_SERVER_URL_INTERNAL:-http://keycloak:8080/oidc/realms/demo} ports: # HTTP - target: 80 @@ -38,11 +38,13 @@ services: image: ${IMAGES_PREFIX:-}app-pwa environment: NEXT_PUBLIC_ENTRYPOINT: http://php - NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-!ChangeThisNextAuthSecret!} - NEXTAUTH_URL: ${NEXTAUTH_URL:-https://localhost/api/auth} - NEXTAUTH_URL_INTERNAL: http://127.0.0.1:3000/api/auth + AUTH_SECRET: ${AUTH_SECRET:-!ChangeThisNextAuthSecret!} + AUTH_URL: ${AUTH_URL:-https://localhost/api/auth} + AUTH_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://keycloak:8080/oidc/realms/demo} + NEXT_SHARP_PATH: /srv/app/node_modules/sharp ###> doctrine/doctrine-bundle ### database: diff --git a/helm/api-platform/templates/pwa-deployment.yaml b/helm/api-platform/templates/pwa-deployment.yaml index 29bd31897..c46755b5a 100644 --- a/helm/api-platform/templates/pwa-deployment.yaml +++ b/helm/api-platform/templates/pwa-deployment.yaml @@ -36,12 +36,12 @@ spec: env: - name: NEXT_PUBLIC_ENTRYPOINT value: http://{{ include "api-platform.fullname" . }} - - name: NEXTAUTH_URL + - name: AUTH_URL valueFrom: configMapKeyRef: name: {{ include "api-platform.fullname" . }} key: next-auth-url - - name: NEXTAUTH_SECRET + - name: AUTH_SECRET valueFrom: secretKeyRef: name: {{ include "api-platform.fullname" . }} @@ -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/Dockerfile b/pwa/Dockerfile index a7ebabff4..845b82ac3 100644 --- a/pwa/Dockerfile +++ b/pwa/Dockerfile @@ -2,7 +2,7 @@ # Versions -FROM node:20-alpine AS node_upstream +FROM node:21-alpine AS node_upstream # Base stage for dev and build diff --git a/pwa/pages/admin/index.tsx b/pwa/app/admin/page.tsx similarity index 76% rename from pwa/pages/admin/index.tsx rename to pwa/app/admin/page.tsx index 4998c43dc..3f11b005e 100644 --- a/pwa/pages/admin/index.tsx +++ b/pwa/app/admin/page.tsx @@ -1,6 +1,8 @@ +"use client"; + import dynamic from "next/dynamic"; -const Admin = dynamic(() => import("@/components/admin/Admin"), { +const Admin = dynamic(() => import("../../components/admin/Admin"), { ssr: false, }); 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/pages/api/auth/[...nextauth].tsx b/pwa/app/auth.tsx similarity index 63% rename from pwa/pages/api/auth/[...nextauth].tsx rename to pwa/app/auth.tsx index ff916f244..f6c5dc947 100644 --- a/pwa/pages/api/auth/[...nextauth].tsx +++ b/pwa/app/auth.tsx @@ -1,19 +1,21 @@ -import NextAuth, { type AuthOptions, type SessionOptions, type DefaultUser, type TokenSet } from "next-auth"; +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 } 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"; -interface Session extends SessionOptions { +export interface User extends DefaultUser { + sub?: string | null +} + +export interface Session extends DefaultSession { + error?: "RefreshAccessTokenError" accessToken: string idToken: string - error?: "RefreshAccessTokenError" user?: User } -interface User extends DefaultUser { - sub?: string | null -} - interface JWT { accessToken: string idToken: string @@ -30,10 +32,28 @@ interface Account { refresh_token: string } -export const authOptions: AuthOptions = { +interface Profile { + sub: string +} + +interface SignOutResponse { + url: string +} + +export async function signOut( + session: DefaultSession, + options?: SignOutParams +): Promise { + return await logout({ + // @ts-ignore + callbackUrl: `${OIDC_SERVER_URL}/protocol/openid-connect/logout?id_token_hint=${session.idToken}&post_logout_redirect_uri=${options?.callbackUrl ?? window.location.origin}`, + }); +} + +export const { handlers: { GET, POST }, auth } = NextAuth({ callbacks: { // @ts-ignore - async jwt({ token, account }: { token: JWT, account: Account }): Promise { + async jwt({ token, account, profile }: { token: JWT, account: Account, profile: Profile }): Promise { if (account) { // Save the access token and refresh token in the JWT on the initial login return { @@ -42,6 +62,7 @@ export const authOptions: AuthOptions = { idToken: account.id_token, expiresAt: Math.floor(Date.now() / 1000 + account.expires_in), refreshToken: account.refresh_token, + sub: profile.sub, }; } else if (Date.now() < token.expiresAt * 1000) { // If the access token has not expired yet, return it @@ -49,7 +70,6 @@ export const authOptions: AuthOptions = { } else { // 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`, { headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ @@ -106,23 +126,32 @@ export const authOptions: AuthOptions = { id: 'keycloak', clientId: OIDC_CLIENT_ID, issuer: OIDC_SERVER_URL, + + // user information will be extracted from the `id_token` claims, instead of making a request to the `userinfo` endpoint + // https://next-auth.js.org/configuration/providers/oauth + // @ts-ignore + idToken: true, + + // https://github.com/nextauthjs/next-auth/issues/685#issuecomment-785212676 + protection: "pkce", + client: { + token_endpoint_auth_method: "none", + }, + + // would love to use discovery, but can't because since next-auth:v5 token endpoint is called internally + // also, discovery doesn't seem to work properly: https://github.com/nextauthjs/next-auth/pull/9718 + // wellKnown: `${OIDC_SERVER_URL}/.well-known/openid-configuration`, + token: `${OIDC_SERVER_URL_INTERNAL}/protocol/openid-connect/token`, + userinfo: `${OIDC_SERVER_URL}/protocol/openid-connect/token`, authorization: { + url: `${OIDC_SERVER_URL}/protocol/openid-connect/auth`, // https://authjs.dev/guides/basics/refresh-token-rotation#jwt-strategy params: { access_type: "offline", + scope: "openid profile email", 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" - }, }), ], -}; - -export default NextAuth(authOptions); +}); diff --git a/pwa/app/bookmarks/page.tsx b/pwa/app/bookmarks/page.tsx new file mode 100644 index 000000000..bc5fc9f44 --- /dev/null +++ b/pwa/app/bookmarks/page.tsx @@ -0,0 +1,46 @@ +import { type Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { List, type Props as ListProps } from "../../components/bookmark/List"; +import { type Bookmark } from "../../types/Bookmark"; +import { type PagedCollection } from "../../types/collection"; +import { type FetchResponse, fetchApi } from "../../utils/dataAccess"; +import { type Session, auth } from "../auth"; + +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)}`, { + next: { revalidate: 3600 }, + }, session); + if (!response?.data) { + throw new Error('Unable to retrieve data from /bookmarks.'); + } + + return { data: response.data, hubURL: response.hubURL, page: Number(page) }; + } catch (error) { + console.error(error); + } + + return { data: null, hubURL: null, page: Number(page) }; +} + +export default async function Page({ searchParams }: { searchParams: Query }) { + // @ts-ignore + const session: Session|null = await auth(); + if (!session || session?.error === "RefreshAccessTokenError") { + // todo find a way to redirect directly to keycloak from here + // Can't use next-auth/middleware because of https://github.com/nextauthjs/next-auth/discussions/7488 + redirect("/api/auth/signin?callbackUrl=/bookmarks"); + } + + const props = await getServerSideProps(searchParams, session); + + return ; +} diff --git a/pwa/app/books/[id]/[slug]/page.tsx b/pwa/app/books/[id]/[slug]/page.tsx new file mode 100644 index 000000000..455bcb442 --- /dev/null +++ b/pwa/app/books/[id]/[slug]/page.tsx @@ -0,0 +1,65 @@ +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 Props { + params: { id: string }; +} + +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}`, { + next: { revalidate: 3600 }, + }, 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", + }, + next: { revalidate: 3600 }, + }, session); + if (!response?.data) { + throw new Error(`Unable to retrieve data from /books/${id}.`); + } + + return { data: response.data, hubURL: response.hubURL }; + } catch (error) { + console.error(error); + } + + return undefined; +} + +export default async function Page({ params }: Props) { + // @ts-ignore + const session: Session|null = await auth(); + const props = await getServerSideProps(params.id, session); + if (!props) { + notFound(); + } + + return ; +} diff --git a/pwa/app/books/page.tsx b/pwa/app/books/page.tsx new file mode 100644 index 000000000..fec08be98 --- /dev/null +++ b/pwa/app/books/page.tsx @@ -0,0 +1,72 @@ +import { type Metadata } from "next"; + +import {auth, type Session} from "../auth"; +import { List, type Props as ListProps } from "../../components/book/List"; +import { type Book } from "../../types/Book"; +import { type PagedCollection } from "../../types/collection"; +import { type FetchResponse, fetchApi } from "../../utils/dataAccess"; +import { type FiltersProps, buildUriFromFilters } from "../../utils/book"; + +interface Query extends URLSearchParams { + page?: number|string|undefined; + author?: string|undefined; + title?: string|undefined; + condition?: string|undefined; + "condition[]"?: string|string[]|undefined; + "order[title]"?: string|undefined; +} + +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) { + filters.page = page; + } + if (query.author) { + filters.author = query.author; + } + if (query.title) { + filters.title = query.title; + } + if (query.condition) { + filters.condition = [query.condition]; + } else if (typeof query["condition[]"] === "string") { + filters.condition = [query["condition[]"]]; + } else if (typeof query["condition[]"] === "object") { + filters.condition = query["condition[]"]; + } + if (query["order[title]"]) { + filters.order = { title: query["order[title]"] }; + } + + try { + const response: FetchResponse> | undefined = await fetchApi(buildUriFromFilters("/books", filters), { + cache: "force-cache", + next: { revalidate: 3600 }, + }, session); + if (!response?.data) { + throw new Error('Unable to retrieve data from /books.'); + } + + return { data: response.data, hubURL: response.hubURL, filters, page }; + } catch (error) { + console.error(error); + } + + return { data: null, hubURL: null, filters, page }; +} + +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 new file mode 100644 index 000000000..7d5c1debf --- /dev/null +++ b/pwa/app/layout.tsx @@ -0,0 +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(); + + return ( + + + + + + {children} + + + + + + ); +}; diff --git a/pwa/app/page.tsx b/pwa/app/page.tsx new file mode 100644 index 000000000..38df87e9f --- /dev/null +++ b/pwa/app/page.tsx @@ -0,0 +1,225 @@ +import Image from "next/image"; +import Link from "next/link"; +import React from "react"; + +import adminPicture from "../public/api-platform/admin.svg"; +import rocketPicture from "../public/api-platform/rocket.svg"; +import logo from "../public/api-platform/logo_api-platform.svg"; +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"; + +export default function Page() { + return ( +
+
+ +
+ Made with + + + + by +
+ Les-Tilleuls.coop +
+
+
+
+
+
+ API Platform +
+
+
+

+ + Welcome to + + 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: +

+ + Get started +
+ + + +
+
+
+
+
+
+
+
+

+ Available services: +

+
+ + + +
+
+
+
+
+

+ Follow us +

+ + + + + + + + + + + + + + + + +
+
+ ); +} + +const Card = ({ + image, + url, + title, +}: { + image: string + url: string + title: string +}) => ( + +); + +const HelpButton = ({ + children, + url, + title, +}: { + url: string + title: string + children: React.ReactNode +}) => ( + ( + + {children} + + ) +); 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/Admin.tsx b/pwa/components/admin/Admin.tsx index 79a7f8ac3..4b6aac3da 100644 --- a/pwa/components/admin/Admin.tsx +++ b/pwa/components/admin/Admin.tsx @@ -1,5 +1,4 @@ import Head from "next/head"; -import { type Session } from "next-auth"; import { useContext, useRef, useState } from "react"; import { type DataProvider, Layout, type LayoutProps, localStorageStore, resolveBrowserLocale } from "react-admin"; import { signIn, useSession } from "next-auth/react"; @@ -10,25 +9,25 @@ import frenchMessages from "ra-language-french"; import { fetchHydra, HydraAdmin, hydraDataProvider, OpenApiAdmin, ResourceGuesser } from "@api-platform/admin"; import { parseHydraDocumentation } from "@api-platform/api-doc-parser"; -import DocContext from "@/components/admin/DocContext"; -import authProvider from "@/components/admin/authProvider"; -import AppBar from "@/components/admin/AppBar"; -import Menu from "@/components/admin/Menu"; -import { ENTRYPOINT } from "@/config/entrypoint"; -import { List as BooksList } from "@/components/admin/book/List"; -import { Create as BooksCreate } from "@/components/admin/book/Create"; -import { Edit as BooksEdit } from "@/components/admin/book/Edit"; -import { List as ReviewsList } from "@/components/admin/review/List"; -import { Show as ReviewsShow } from "@/components/admin/review/Show"; -import { Edit as ReviewsEdit } from "@/components/admin/review/Edit"; -import { type Book } from "@/types/Book"; -import { type Review } from "@/types/Review"; +import { type Session } from "../../app/auth"; +import DocContext from "../../components/admin/DocContext"; +import authProvider from "../../components/admin/authProvider"; +import AppBar from "../../components/admin/AppBar"; +import Menu from "../../components/admin/Menu"; +import { ENTRYPOINT } from "../../config/entrypoint"; +import { List as BooksList } from "../../components/admin/book/List"; +import { Create as BooksCreate } from "../../components/admin/book/Create"; +import { Edit as BooksEdit } from "../../components/admin/book/Edit"; +import { List as ReviewsList } from "../../components/admin/review/List"; +import { Show as ReviewsShow } from "../../components/admin/review/Show"; +import { Edit as ReviewsEdit } from "../../components/admin/review/Edit"; +import { type Book } from "../../types/Book"; +import { type Review } from "../../types/Review"; const apiDocumentationParser = (session: Session) => async () => { try { return await parseHydraDocumentation(ENTRYPOINT, { headers: { - // @ts-ignore Authorization: `Bearer ${session?.accessToken}`, }, }); @@ -69,7 +68,6 @@ const AdminUI = ({ session, children }: { session: Session, children?: React.Rea httpClient: (url: URL, options = {}) => fetchHydra(url, { ...options, headers: { - // @ts-ignore Authorization: `Bearer ${session?.accessToken}`, }, }), @@ -141,6 +139,7 @@ const AdminWithOIDC = () => { return; } + // @ts-ignore return ; }; diff --git a/pwa/components/admin/AppBar.tsx b/pwa/components/admin/AppBar.tsx index e72d1ca7d..5e8385a05 100644 --- a/pwa/components/admin/AppBar.tsx +++ b/pwa/components/admin/AppBar.tsx @@ -2,12 +2,12 @@ import { useContext, useState } from "react"; import { AppBar, AppBarClasses, UserMenu, Logout, useStore } from "react-admin"; import { type AppBarProps } from "react-admin"; import { Button, Menu, MenuItem, Typography } from "@mui/material"; - import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import DocContext from "@/components/admin/DocContext"; -import HydraLogo from "@/components/admin/HydraLogo"; -import OpenApiLogo from "@/components/admin/OpenApiLogo"; -import Logo from "@/components/admin/Logo"; + +import DocContext from "../../components/admin/DocContext"; +import HydraLogo from "../../components/admin/HydraLogo"; +import OpenApiLogo from "../../components/admin/OpenApiLogo"; +import Logo from "../../components/admin/Logo"; const DocTypeMenuButton = () => { const [anchorEl, setAnchorEl] = useState(null); diff --git a/pwa/components/admin/Menu.tsx b/pwa/components/admin/Menu.tsx index ea5b205a7..a9c7f0276 100644 --- a/pwa/components/admin/Menu.tsx +++ b/pwa/components/admin/Menu.tsx @@ -4,7 +4,9 @@ import CommentIcon from "@mui/icons-material/Comment"; const Menu = () => ( + {/*@ts-ignore*/} }/> + {/*@ts-ignore*/} }/> ); diff --git a/pwa/components/admin/authProvider.tsx b/pwa/components/admin/authProvider.tsx index 209c8ca02..63822f24e 100644 --- a/pwa/components/admin/authProvider.tsx +++ b/pwa/components/admin/authProvider.tsx @@ -1,21 +1,21 @@ import { AuthProvider } from "react-admin"; -import { getSession, signIn } from "next-auth/react"; +import { signIn } from "next-auth/react"; -import { signOut } from "@/utils/security"; +import { auth, signOut } from "../../app/auth"; const authProvider: AuthProvider = { // Nothing to do here, this function will never be called login: async () => Promise.resolve(), logout: async () => { - const session = await getSession(); + const session = await auth(); if (!session) { return; } - await signOut(session); + await signOut(session, {callbackUrl: window.location.origin}); }, checkError: async (error) => { - const session = await getSession(); + const session = await auth(); const status = error.status; // @ts-ignore if (!session || session?.error === "RefreshAccessTokenError" || status === 401) { @@ -29,7 +29,7 @@ const authProvider: AuthProvider = { } }, checkAuth: async () => { - const session = await getSession(); + const session = await auth(); // @ts-ignore if (!session || session?.error === "RefreshAccessTokenError") { await signIn("keycloak"); @@ -42,7 +42,7 @@ const authProvider: AuthProvider = { getPermissions: () => Promise.resolve(), // @ts-ignore getIdentity: async () => { - const session = await getSession(); + const session = await auth(); return session ? Promise.resolve(session.user) : Promise.reject(); }, diff --git a/pwa/components/admin/book/BookInput.tsx b/pwa/components/admin/book/BookInput.tsx index e3f1a099d..b0905edb5 100644 --- a/pwa/components/admin/book/BookInput.tsx +++ b/pwa/components/admin/book/BookInput.tsx @@ -2,11 +2,11 @@ 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"; -import { SearchDoc } from "@/types/OpenLibrary/SearchDoc"; +import { Search } from "../../../types/OpenLibrary/Search"; +import { SearchDoc } from "../../../types/OpenLibrary/SearchDoc"; interface Result { title: string; @@ -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/admin/book/Create.tsx b/pwa/components/admin/book/Create.tsx index fd385b2d8..f1950e8dc 100644 --- a/pwa/components/admin/book/Create.tsx +++ b/pwa/components/admin/book/Create.tsx @@ -1,6 +1,6 @@ import { CreateGuesser, type CreateGuesserProps } from "@api-platform/admin"; -import { Form } from "@/components/admin/book/Form"; +import { Form } from "../../../components/admin/book/Form"; export const Create = (props: CreateGuesserProps) => ( diff --git a/pwa/components/admin/book/Edit.tsx b/pwa/components/admin/book/Edit.tsx index 4199489b5..6c7333250 100644 --- a/pwa/components/admin/book/Edit.tsx +++ b/pwa/components/admin/book/Edit.tsx @@ -1,8 +1,8 @@ import { EditGuesser, type EditGuesserProps } from "@api-platform/admin"; import { TopToolbar } from 'react-admin'; -import { Form } from "@/components/admin/book/Form"; -import { ShowButton } from "@/components/admin/book/ShowButton"; +import { Form } from "../../../components/admin/book/Form"; +import { ShowButton } from "../../../components/admin/book/ShowButton"; // @ts-ignore const Actions = ({ data }) => ( diff --git a/pwa/components/admin/book/Form.tsx b/pwa/components/admin/book/Form.tsx index 835f23dd5..bed9b4e35 100644 --- a/pwa/components/admin/book/Form.tsx +++ b/pwa/components/admin/book/Form.tsx @@ -1,7 +1,7 @@ import { required } from "react-admin"; -import { ConditionInput } from "@/components/admin/book/ConditionInput"; -import { BookInput } from "@/components/admin/book/BookInput"; +import { ConditionInput } from "../../../components/admin/book/ConditionInput"; +import { BookInput } from "../../../components/admin/book/BookInput"; export const Form = () => ( <> diff --git a/pwa/components/admin/book/List.tsx b/pwa/components/admin/book/List.tsx index c7a398b1e..544e67c22 100644 --- a/pwa/components/admin/book/List.tsx +++ b/pwa/components/admin/book/List.tsx @@ -8,9 +8,9 @@ import { EditButton, } from "react-admin"; -import { ShowButton } from "@/components/admin/book/ShowButton"; -import { RatingField } from "@/components/admin/review/RatingField"; -import { ConditionInput } from "@/components/admin/book/ConditionInput"; +import { ShowButton } from "../../../components/admin/book/ShowButton"; +import { RatingField } from "../../../components/admin/review/RatingField"; +import { ConditionInput } from "../../../components/admin/book/ConditionInput"; const ConditionField = (props: UseRecordContextParams) => { const record = useRecordContext(props); diff --git a/pwa/components/admin/book/ShowButton.tsx b/pwa/components/admin/book/ShowButton.tsx index c23760654..c35a611b4 100644 --- a/pwa/components/admin/book/ShowButton.tsx +++ b/pwa/components/admin/book/ShowButton.tsx @@ -1,8 +1,9 @@ import { Button, ShowButtonProps, useRecordContext } from "react-admin"; -import { getItemPath } from "@/utils/dataAccess"; import slugify from "slugify"; import VisibilityIcon from "@mui/icons-material/Visibility"; +import { getItemPath } from "../../../utils/dataAccess"; + export const ShowButton = (props: ShowButtonProps) => { const record = useRecordContext(props); diff --git a/pwa/components/admin/review/BookField.tsx b/pwa/components/admin/review/BookField.tsx index 7a8c5f9fe..23280ee9f 100644 --- a/pwa/components/admin/review/BookField.tsx +++ b/pwa/components/admin/review/BookField.tsx @@ -1,8 +1,9 @@ import { useRecordContext, type UseRecordContextParams } from "react-admin"; import Link from "next/link"; -import { getItemPath } from "@/utils/dataAccess"; import slugify from "slugify"; +import { getItemPath } from "../../../utils/dataAccess"; + export const BookField = (props: UseRecordContextParams) => { const record = useRecordContext(props); diff --git a/pwa/components/admin/review/Edit.tsx b/pwa/components/admin/review/Edit.tsx index bbbd55be3..6a9e8a441 100644 --- a/pwa/components/admin/review/Edit.tsx +++ b/pwa/components/admin/review/Edit.tsx @@ -1,9 +1,9 @@ import { EditGuesser, type EditGuesserProps } from "@api-platform/admin"; import { AutocompleteInput, ReferenceInput, required, TextInput } from "react-admin"; -import { type Book } from "@/types/Book"; -import { type Review } from "@/types/Review"; -import { RatingInput } from "@/components/admin/review/RatingInput"; +import { type Book } from "../../../types/Book"; +import { type Review } from "../../../types/Review"; +import { RatingInput } from "./RatingInput"; const transform = (data: Review) => ({ ...data, @@ -13,10 +13,10 @@ const transform = (data: Review) => ({ export const Edit = (props: EditGuesserProps) => ( - + ({ title: searchText })} optionText={(choice: Book): string => `${choice.title} - ${choice.author}`} - label="Book" style={{ width: 500 }}/> + label="Book" style={{ width: 500 }} validate={required()}/> diff --git a/pwa/components/admin/review/List.tsx b/pwa/components/admin/review/List.tsx index ccbf30d00..b1f390f70 100644 --- a/pwa/components/admin/review/List.tsx +++ b/pwa/components/admin/review/List.tsx @@ -10,11 +10,11 @@ import { AutocompleteInput, } from "react-admin"; -import { BookField } from "@/components/admin/review/BookField"; -import { RatingField } from "@/components/admin/review/RatingField"; -import { RatingInput } from "@/components/admin/review/RatingInput"; -import { type Book } from "@/types/Book"; -import { User } from "@/types/User"; +import { BookField } from "../../../components/admin/review/BookField"; +import { RatingField } from "../../../components/admin/review/RatingField"; +import { RatingInput } from "../../../components/admin/review/RatingInput"; +import { type Book } from "../../../types/Book"; +import { User } from "../../../types/User"; const bookQuery = (searchText: string) => { const values = searchText.split(" - ").map(n => n.trim()).filter(n => n); diff --git a/pwa/components/admin/review/Show.tsx b/pwa/components/admin/review/Show.tsx index 5108184d1..4e7efca2f 100644 --- a/pwa/components/admin/review/Show.tsx +++ b/pwa/components/admin/review/Show.tsx @@ -1,8 +1,8 @@ import { FieldGuesser, ShowGuesser, type ShowGuesserProps } from "@api-platform/admin"; import { TextField } from "react-admin"; -import { RatingField } from "@/components/admin/review/RatingField"; -import { BookField } from "@/components/admin/review/BookField"; +import { RatingField } from "../../../components/admin/review/RatingField"; +import { BookField } from "../../../components/admin/review/BookField"; export const Show = (props: ShowGuesserProps) => ( diff --git a/pwa/components/book/Filters.tsx b/pwa/components/book/Filters.tsx index c4954c916..fac38dde3 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; @@ -18,8 +18,9 @@ export const Filters: FunctionComponent = ({ filters, mutation }) => ( initialValues={filters ?? {}} enableReinitialize={true} onSubmit={(values, { setSubmitting, setStatus, setErrors }) => { + delete values.page; mutation.mutate( - { ...values, page: 1 }, + values, { onSuccess: () => { setStatus({ diff --git a/pwa/components/book/Item.tsx b/pwa/components/book/Item.tsx index 003ecc005..4fa3a3370 100644 --- a/pwa/components/book/Item.tsx +++ b/pwa/components/book/Item.tsx @@ -3,10 +3,10 @@ import Link from "next/link"; import { type FunctionComponent } from "react"; 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 { type Book } from "../../types/Book"; +import { getItemPath } from "../../utils/dataAccess"; +import { useOpenLibraryBook } from "../../utils/book"; +import { Loading } from "../common/Loading"; interface Props { book: Book; diff --git a/pwa/components/book/List.tsx b/pwa/components/book/List.tsx index ca37d7a0c..9c5fb97b9 100644 --- a/pwa/components/book/List.tsx +++ b/pwa/components/book/List.tsx @@ -1,20 +1,20 @@ +"use client"; + import { type NextPage } from "next"; -import Head from "next/head"; -import { useRouter } from "next/router"; -import { useMutation } from "react-query"; +import { useRouter } from "next/navigation"; +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 { 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"; +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 { useMercure } from "../../utils/mercure"; -interface Props { +export interface Props { data: PagedCollection | null; hubURL: string | null; filters: FiltersProps; @@ -23,24 +23,21 @@ 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)); + }, + onError: (error) => { + console.error(error); + }, }); return (
- - Books Store -