diff --git a/.env.example b/.env.example index d471b8e2..8077dc3d 100644 --- a/.env.example +++ b/.env.example @@ -7,9 +7,8 @@ NEXT_PUBLIC_LIBRARY_TOKEN=XXX UNILOGIN_API_URL=https://et-broker.unilogin.dk UNILOGIN_WELKNOWN_URL=https://et-broker.unilogin.dk/auth/realms/broker/.well-known/openid-configuration -UNILOGIN_REFRESH_TOKEN_URL=https://et-broker.unilogin.dk/auth/realms/broker/protocol/openid-connect/token -UNILOGIN_CLIENT_ID=https://stg.ereolengo.itkdev.dk/ -UNILOGIN_CLIENT_SECRET=XXX UNILOGIN_SESSION_SECRET=ZIKj+BiUbpitnDO8TeptXnQKsZmh2uGHMR1Wd4GplhE= +UNILOGIN_CLIENT_ID=https://stg.ereolengo.itkdev.dk/ +UNILOGIN_CLIENT_SECRET=XXXX NEXT_PUBLIC_APP_URL=https://localhost:3000 diff --git a/.github/workflows/accessibility-test.yml b/.github/workflows/accessibility-test.yml new file mode 100644 index 00000000..64941f18 --- /dev/null +++ b/.github/workflows/accessibility-test.yml @@ -0,0 +1,42 @@ +# Workflow name +name: "Accessibility Test" + +# Event for the workflow +on: push + +# List of jobs +jobs: + # Run interaction and accessibility tests + accessibility: + name: Accessibility tests + + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + # Make sure the actual branch is checked out when running on pull requests. + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 # 👈 Required to retrieve git history + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + + - name: Install dependencies + run: yarn + + - name: Install Playwright + run: npx playwright install --with-deps + + - name: Build Storybook + run: yarn build-storybook --quiet + + - name: Serve Storybook and run tests + run: | + export FORCE_COLOR=1 + npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \ + "npx http-server storybook-static --port 6006 --silent" \ + "npx wait-on tcp:6006 && yarn test-storybook" diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 59ec76f0..78a7c804 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -31,4 +31,4 @@ jobs: run: yarn install --frozen-lockfile - name: Run unit tests - run: yarn test:unit + run: yarn test:unit:once diff --git a/.storybook/main.ts b/.storybook/main.ts index 858f0ef7..05529ff4 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -7,6 +7,7 @@ const config: StorybookConfig = { "@storybook/addon-essentials", "@chromatic-com/storybook", "@storybook/addon-interactions", + "@storybook/addon-a11y", ], framework: { name: "@storybook/nextjs", diff --git a/.storybook/test-runner.ts b/.storybook/test-runner.ts new file mode 100644 index 00000000..59a38ab5 --- /dev/null +++ b/.storybook/test-runner.ts @@ -0,0 +1,17 @@ +import { checkA11y, injectAxe } from "axe-playwright" + +module.exports = { + async preVisit(page) { + await injectAxe(page) + }, + async postVisit(page) { + await checkA11y(page, "#storybook-root", { + axeOptions: {}, + detailedReport: true, + detailedReportOptions: { + html: true, + }, + verbose: true, + }) + }, +} diff --git a/__tests__/refresh-token.test.ts b/__tests__/refresh-token.test.ts index 87a930af..0dd83ea3 100644 --- a/__tests__/refresh-token.test.ts +++ b/__tests__/refresh-token.test.ts @@ -2,7 +2,7 @@ import { add } from "date-fns" import { IronSession, getIronSession } from "iron-session" import { testApiHandler } from "next-test-api-route-handler" -import { afterAll, beforeAll, beforeEach, expect, test, vi } from "vitest" +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest" // @ts-ignore import * as tokenRefreshHandler from "@/app/auth/token/refresh/route" @@ -41,93 +41,94 @@ const sessionThatShouldBeRefreshed = () => ({ id_token: "id", }) -// TODO: Enable this when the refresh endpoint is fixed after latest API changes. -test.skip("That the refresh endpoint redirects to the frontpage if there is no active session", async () => { - // Simulate an anonymous session. - // @ts-ignore - getIronSession.mockResolvedValue({ - isLoggedIn: false, - }) +// TODO: This should be activated when the refresh token logic is fully implemented. +describe.skip("Refresh token test suite", () => { + test("That the refresh endpoint redirects to the frontpage if there is no active session", async () => { + // Simulate an anonymous session. + // @ts-ignore + getIronSession.mockResolvedValue({ + isLoggedIn: false, + }) - await testApiHandler({ - appHandler: tokenRefreshHandler, - url: `/?redirect=http://john.johnson.com/john`, - async test({ fetch }) { - const res = await fetch({ method: "GET" }) - expect(res.headers.get("location")).toEqual("https://hellboy.the-movie.com/") - }, + await testApiHandler({ + appHandler: tokenRefreshHandler, + url: `/?redirect=http://john.johnson.com/john`, + async test({ fetch }) { + const res = await fetch({ method: "GET" }) + expect(res.headers.get("location")).toEqual("https://hellboy.the-movie.com/") + }, + }) }) -}) -// TODO: Enable this when the refresh endpoint is fixed after latest API changes. -test.skip("That the refresh endpoint redirects to the given endpoint after refreshing token", async () => { - // This is an authorized session that should be refreshed. - // @ts-ignore - getIronSession.mockResolvedValue(sessionThatShouldBeRefreshed()) + test("That the refresh endpoint redirects to the given endpoint after refreshing token", async () => { + // This is an authorized session that should be refreshed. + // @ts-ignore + getIronSession.mockResolvedValue(sessionThatShouldBeRefreshed()) - await testApiHandler({ - appHandler: tokenRefreshHandler, - url: `/?redirect=http://john.johnson.com/john`, - async test({ fetch }) { - const res = await fetch({ method: "GET" }) - expect(res.headers.get("location")).toEqual("http://john.johnson.com/john") - }, - }) + await testApiHandler({ + appHandler: tokenRefreshHandler, + url: `/?redirect=http://john.johnson.com/john`, + async test({ fetch }) { + const res = await fetch({ method: "GET" }) + expect(res.headers.get("location")).toEqual("http://john.johnson.com/john") + }, + }) - // This is an authorized session that should NOT be refreshed. - // @ts-ignore - getIronSession.mockResolvedValue({ - isLoggedIn: true, - expires: add(new Date(), { seconds: 300 }), - refresh_expires: add(new Date(), { seconds: 1800 }), - access_token: "access_token", - refresh_token: "refresh", + // This is an authorized session that should NOT be refreshed. + // @ts-ignore + getIronSession.mockResolvedValue({ + isLoggedIn: true, + expires: add(new Date(), { seconds: 300 }), + refresh_expires: add(new Date(), { seconds: 1800 }), + access_token: "access_token", + refresh_token: "refresh", + }) }) -}) -// TODO: Write tests that proves that the session object is updated correctly after a successful refresh. -// TODO: Enable this when the refresh endpoint is fixed after latest API changes. -test.skip("That the refreshValidation validates if the access token should be refreshed correctly", async () => { - // Since there is a buffer of 1 minute added to the refresh time, - // the access token should be refreshed 1 minute before it expires. - expect( - accessTokenShouldBeRefreshed({ - type: "unilogin", - expires: add(new Date(), { seconds: 59 }), - refresh_expires: add(new Date(), { seconds: 59 }), - isLoggedIn: true, - } as IronSession) - ).toBe(true) + // TODO: Write tests that proves that the session object is updated correctly after a successful refresh. - // Since there is a buffer of 1 minute added to the refresh time, - // the access token should be refreshed 1 minute before it expires. - // The tipping point in this case is the 60th second. - expect( - accessTokenShouldBeRefreshed({ - type: "unilogin", - expires: add(new Date(), { seconds: 60 }), - refresh_expires: add(new Date(), { seconds: 60 }), - isLoggedIn: true, - } as IronSession) - ).toBe(false) + test("That the refreshValidation validates if the access token should be refreshed correctly", async () => { + // Since there is a buffer of 1 minute added to the refresh time, + // the access token should be refreshed 1 minute before it expires. + expect( + accessTokenShouldBeRefreshed({ + type: "unilogin", + expires: add(new Date(), { seconds: 59 }), + refresh_expires: add(new Date(), { seconds: 59 }), + isLoggedIn: true, + } as IronSession) + ).toBe(true) - // The refresh logic looks at both expires and refresh_expires. - // Here the expires is the tipping point. - expect( - accessTokenShouldBeRefreshed({ - type: "unilogin", - expires: add(new Date(), { seconds: 59 }), - refresh_expires: add(new Date(), { seconds: 1800 }), - isLoggedIn: true, - } as IronSession) - ).toBe(true) - // Here the refresh_expires is the tipping point. - expect( - accessTokenShouldBeRefreshed({ - type: "unilogin", - expires: add(new Date(), { seconds: 300 }), - refresh_expires: add(new Date(), { seconds: 59 }), - isLoggedIn: true, - } as IronSession) - ).toBe(true) + // Since there is a buffer of 1 minute added to the refresh time, + // the access token should be refreshed 1 minute before it expires. + // The tipping point in this case is the 60th second. + expect( + accessTokenShouldBeRefreshed({ + type: "unilogin", + expires: add(new Date(), { seconds: 60 }), + refresh_expires: add(new Date(), { seconds: 60 }), + isLoggedIn: true, + } as IronSession) + ).toBe(false) + + // The refresh logic looks at both expires and refresh_expires. + // Here the expires is the tipping point. + expect( + accessTokenShouldBeRefreshed({ + type: "unilogin", + expires: add(new Date(), { seconds: 59 }), + refresh_expires: add(new Date(), { seconds: 1800 }), + isLoggedIn: true, + } as IronSession) + ).toBe(true) + // Here the refresh_expires is the tipping point. + expect( + accessTokenShouldBeRefreshed({ + type: "unilogin", + expires: add(new Date(), { seconds: 300 }), + refresh_expires: add(new Date(), { seconds: 59 }), + isLoggedIn: true, + } as IronSession) + ).toBe(true) + }) }) diff --git a/__tests__/url.test.ts b/__tests__/url.test.ts index c12c5ef8..eb160586 100644 --- a/__tests__/url.test.ts +++ b/__tests__/url.test.ts @@ -1,16 +1,16 @@ -import { expect, test, vi } from "vitest" +import { expect, test } from "vitest" import { resolveUrl } from "../lib/helpers/helper.routes" test("That resolveUrl can return a work url", async () => { - const workUrl = resolveUrl({ type: "work", routeParams: { id: 123 } }) + const workUrl = resolveUrl({ type: "work", routeParams: { wid: 123 } }) expect(workUrl).toBe("/work/123") }) test("That resolveUrl can return a work url with a manifestation type", async () => { const workUrl = resolveUrl({ type: "work", - routeParams: { id: 123 }, + routeParams: { wid: 123 }, queryParams: { audio: "true" }, }) expect(workUrl).toBe("/work/123?audio=true") diff --git a/app/auth/callback/unilogin/route.ts b/app/auth/callback/unilogin/route.ts index e47463d3..cbd14f91 100644 --- a/app/auth/callback/unilogin/route.ts +++ b/app/auth/callback/unilogin/route.ts @@ -1,49 +1,62 @@ -import { NextRequest } from "next/server" -import { IntrospectionResponse } from "openid-client" +import { sealData } from "iron-session" +import { NextRequest, NextResponse } from "next/server" +import * as client from "openid-client" import goConfig from "@/lib/config/goConfig" -import { - getOpenIdClientUniloginClientConfig, - getUniloginClient, -} from "@/lib/session/oauth/uniloginClient" -import { getSession, setTokensOnSession } from "@/lib/session/session" +import { getUniloginClientConfig } from "@/lib/session/oauth/uniloginClient" +import { getSession, sessionOptions, setTokensOnSession } from "@/lib/session/session" import { TTokenSet } from "@/lib/types/session" import schemas from "./schemas" -export interface TIntrospectionResponse extends IntrospectionResponse { +export interface TIntrospectionResponse extends client.IntrospectionResponse { uniid: string institutionIds: string } export async function GET(request: NextRequest) { - const session = await getSession() - const openIdClientConfig = await getOpenIdClientUniloginClientConfig() - if ( - !openIdClientConfig || - !openIdClientConfig.redirect_uri || - !openIdClientConfig.post_login_route - ) { - throw new Error("Unilogin client config is invalid.") - } + const session = await getSession({ request, response: NextResponse.next() }) + const config = await getUniloginClientConfig() + const currentSearchParams = request.nextUrl.searchParams + const appUrl = goConfig("app.url") + const redirectUri = new URL(`${appUrl}/auth/callback/unilogin`) + currentSearchParams.forEach((value, key) => { + redirectUri.searchParams.append(key, value) + }) + + // TODO: When we consider the callback being stable we can remove this. + // Maybe we can organize openid client logging in some way: + // + // config[client.customFetch] = async (url: string, options: RequestInit) => { + // // eslint-disable-next-line no-console + // console.log("Request URL: ", url.toString()) + // // eslint-disable-next-line no-console + // console.log("Request Options: ", options) - const client = await getUniloginClient() - const params = client.callbackParams(request.nextUrl.toString()) + // const request = new Request(url, options) + // return fetch(request) + // } // Fetch all user/token info. try { - const tokenSetResponse = await client.callback(openIdClientConfig.redirect_uri, params, { - code_verifier: session.code_verifier, + const tokenSetResponse = await client.authorizationCodeGrant(config, redirectUri, { + pkceCodeVerifier: session.code_verifier, + idTokenExpected: true, }) + const tokenSet = schemas.tokenSet.parse(tokenSetResponse) as TTokenSet - const introspectResponse = (await client.introspect( + const introspectResponse = (await client.tokenIntrospection( + config, tokenSet.access_token! )) as TIntrospectionResponse const introspect = schemas.introspect.parse(introspectResponse) - const userinfoResponse = await client.userinfo(tokenSetResponse) - const userinfo = schemas.userInfo.parse(userinfoResponse) + const claims = tokenSetResponse.claims()! + + // UserInfo Request + const userInfoResponse = await client.fetchUserInfo(config, tokenSet.access_token, claims.sub) + const userinfo = schemas.userInfo.parse(userInfoResponse) // Set basic session info. session.isLoggedIn = true @@ -59,12 +72,32 @@ export async function GET(request: NextRequest) { institutionIds: introspect.institutionIds, } - await session.save() - - return Response.redirect(openIdClientConfig.post_login_route) + // TODO: When we have verified that it works in Lagoon + // then see if we can reintroduce this, instead of the "handmade" cookie in the end. + // await session.save() } catch (error) { console.error(error) // TODO: Error page or redirect to login page. - return Response.redirect(goConfig("app.url")) + // return NextResponse.redirect(goConfig("app.url")) } + + const sealed = await sealData( + { + ...session, + }, + sessionOptions + ) + + // TODO: When we have verified that it works in Lagoon + // then see if we can use the session.save() instead of the "handmade" cookie here. + // Also we probably would like to go to different URL's depending on the try/catch above. + const headers = new Headers(request.headers) + headers.set( + "Set-Cookie", + `${sessionOptions.cookieName}=${sealed}; Max-Age=${sessionOptions.ttl}; Path=/; HttpOnly; ${sessionOptions.cookieOptions?.secure && "Secure"}` + ) + + return NextResponse.redirect(`${goConfig("app.url")}/user/profile`, { + headers, + }) } diff --git a/app/auth/login/unilogin/route.ts b/app/auth/login/unilogin/route.ts index 7cadab9d..c5ba7671 100644 --- a/app/auth/login/unilogin/route.ts +++ b/app/auth/login/unilogin/route.ts @@ -1,39 +1,28 @@ -import { generators } from "openid-client" +import * as client from "openid-client" -import { - getOpenIdClientUniloginClientConfig, - getUniloginClient, -} from "@/lib/session/oauth/uniloginClient" +import goConfig from "@/lib/config/goConfig" +import { getUniloginClientConfig } from "@/lib/session/oauth/uniloginClient" import { getSession } from "@/lib/session/session" export async function GET() { const session = await getSession() + const config = await getUniloginClientConfig() + const appUrl = goConfig("app.url") + const redirect_uri = `${appUrl}/auth/callback/unilogin` - session.code_verifier = generators.codeVerifier() + const code_verifier = client.randomPKCECodeVerifier() + const code_challenge = await client.calculatePKCECodeChallenge(code_verifier) + const code_challenge_method = "S256" - const code_challenge = generators.codeChallenge(session.code_verifier) + session.code_verifier = code_verifier - const openIdClientConfig = await getOpenIdClientUniloginClientConfig() - - if ( - !openIdClientConfig || - !openIdClientConfig.scope || - !openIdClientConfig.redirect_uri || - !openIdClientConfig.audience - ) { - throw new Error("Unilogin client config is invalid.") - } - - const client = await getUniloginClient() - - const url = client.authorizationUrl({ - scope: openIdClientConfig.scope, - audience: openIdClientConfig.audience, - redirect_uri: openIdClientConfig.redirect_uri, + const redirectTo = client.buildAuthorizationUrl(config, { + redirect_uri, + scope: "openid", code_challenge, - code_challenge_method: "S256", + code_challenge_method, }) await session.save() - return Response.redirect(url) + return Response.redirect(redirectTo) } diff --git a/app/auth/logout/route.ts b/app/auth/logout/route.ts index 68159ade..e49f3fc3 100644 --- a/app/auth/logout/route.ts +++ b/app/auth/logout/route.ts @@ -1,36 +1,35 @@ import { cookies } from "next/headers" -import { generators } from "openid-client" +import * as client from "openid-client" import goConfig from "@/lib/config/goConfig" -import { - getOpenIdClientUniloginClientConfig, - getUniloginClient, -} from "@/lib/session/oauth/uniloginClient" +import { getUniloginClientConfig } from "@/lib/session/oauth/uniloginClient" import { getSession } from "@/lib/session/session" export async function GET() { const session = await getSession() + const config = await getUniloginClientConfig() + const appUrl = new URL(String(goConfig("app.url"))) + + session.destroy() + // TODO: Distinguish between session types here. const id_token = cookies().get("go-session:id_token")?.value // TODO: Is this where we want to redirect to if id token cannot be resolved? if (!id_token) { - return Response.redirect(goConfig("app.url")) + return Response.redirect(appUrl) } + const endSessionEndpoint = config.serverMetadata().end_session_endpoint - const openIdClientConfig = await getOpenIdClientUniloginClientConfig() - - if (!openIdClientConfig || !openIdClientConfig.post_logout_redirect_uri) { - throw new Error("Unilogin client config is invalid.") + if (!endSessionEndpoint) { + return Response.redirect(appUrl) } - const client = await getUniloginClient() - - const endSession = client.endSessionUrl({ - post_logout_redirect_uri: openIdClientConfig.post_logout_redirect_uri, + const endSessionUrl = client.buildEndSessionUrl(config, { id_token_hint: id_token, - state: generators.state(), }) - session.destroy() - return Response.redirect(endSession) + // End session in Unilogin SSO. + await fetch(endSessionUrl) + + return Response.redirect(`${appUrl.toString()}?reload-session=true`) } diff --git a/app/auth/session/route.ts b/app/auth/session/route.ts index 1463f489..a6ba2bd9 100644 --- a/app/auth/session/route.ts +++ b/app/auth/session/route.ts @@ -14,3 +14,5 @@ export async function GET() { return Response.json({ error: e }, { status: 500 }) } } + +export const dynamic = "force-dynamic" diff --git a/app/auth/token/refresh/route.ts b/app/auth/token/refresh/route.ts index d4989fca..26d57980 100644 --- a/app/auth/token/refresh/route.ts +++ b/app/auth/token/refresh/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from "next/server" +import * as client from "openid-client" import { z } from "zod" import goConfig from "@/lib/config/goConfig" -import { getUniloginClient } from "@/lib/session/oauth/uniloginClient" +import { getUniloginClientConfig } from "@/lib/session/oauth/uniloginClient" import { getSession, setTokensOnSession } from "@/lib/session/session" import { TTokenSet } from "@/lib/types/session" @@ -13,7 +14,10 @@ const sessionTokenSchema = z.object({ }) export async function GET(request: NextRequest, response: NextResponse) { - const appUrl = goConfig("app.url") + const appUrl = String(goConfig("app.url")) + const config = await getUniloginClientConfig() + // TODO: Fix refresh token flow with new openid-client. + const session = await getSession() const frontpage = `${appUrl}/` @@ -30,9 +34,7 @@ export async function GET(request: NextRequest, response: NextResponse) { try { // TODO: Consider if we want to handle different types of sessions than unilogin. const tokens = sessionTokenSchema.parse(session) - const client = await getUniloginClient() - - const newTokens = await (client.refresh(tokens.refresh_token) as Promise) + const newTokens = client.refreshTokenGrant(config, tokens.refresh_token) as unknown as TTokenSet setTokensOnSession(session, newTokens) await session.save() } catch (error) { diff --git a/app/user/profile/LogoutButton.tsx b/app/user/profile/LogoutButton.tsx index 6849638e..284ade19 100644 --- a/app/user/profile/LogoutButton.tsx +++ b/app/user/profile/LogoutButton.tsx @@ -11,7 +11,9 @@ const LogoutButton = () => { return ( <> - +

Debugging:

{JSON.stringify(session, null, 2)}
diff --git a/app/user/profile/page.tsx b/app/user/profile/page.tsx index d0b76f70..da1939f2 100644 --- a/app/user/profile/page.tsx +++ b/app/user/profile/page.tsx @@ -1,10 +1,14 @@ +import { Suspense } from "react" + import LogoutButton from "./LogoutButton" const Page = () => { return (

Profile

- + Loading...

}> + +
) } diff --git a/app/work/[id]/page.tsx b/app/work/[id]/page.tsx new file mode 100644 index 00000000..c20704ca --- /dev/null +++ b/app/work/[id]/page.tsx @@ -0,0 +1,29 @@ +import { HydrationBoundary, dehydrate } from "@tanstack/react-query" +import React from "react" + +import WorkPageLayout from "@/components/pages/workPageLayout/WorkPageLayout" +import getQueryClient from "@/lib/getQueryClient" +import { useGetMaterialQuery } from "@/lib/graphql/generated/fbi/graphql" + +function Page({ params: { id } }: { params: { id: string } }) { + const queryClient = getQueryClient() + + const decodedWid = decodeURIComponent(id) + + queryClient.prefetchQuery({ + queryKey: useGetMaterialQuery.getKey({ wid: decodedWid }), + queryFn: useGetMaterialQuery.fetcher({ wid: decodedWid }), + }) + + return ( + +
+ +
{JSON.stringify(id, null, 2)}
+ Page +
+
+ ) +} + +export default Page diff --git a/components/global/header/Header.tsx b/components/global/header/Header.tsx index d7b3f7b7..7c753400 100644 --- a/components/global/header/Header.tsx +++ b/components/global/header/Header.tsx @@ -21,10 +21,12 @@ function Header() {
- - + Loading...

}> + +
diff --git a/components/global/header/ProfileButton.tsx b/components/global/header/ProfileButton.tsx index 63fc4bad..5d609243 100644 --- a/components/global/header/ProfileButton.tsx +++ b/components/global/header/ProfileButton.tsx @@ -24,7 +24,7 @@ const HeaderButton = ({ asChild?: boolean // TODO: Dynamic aria-label. }) => ( - ) @@ -56,7 +56,9 @@ function ProfileButton() { Log ind med UNI•Login
- +
diff --git a/components/pages/workPageLayout/WorkPageLayout.tsx b/components/pages/workPageLayout/WorkPageLayout.tsx new file mode 100644 index 00000000..6867f43b --- /dev/null +++ b/components/pages/workPageLayout/WorkPageLayout.tsx @@ -0,0 +1,21 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import React from "react" + +import { useGetMaterialQuery } from "@/lib/graphql/generated/fbi/graphql" + +function WorkPageLayout({ wid }: { wid: string }) { + const data = useQuery({ + queryKey: useGetMaterialQuery.getKey({ wid }), + queryFn: useGetMaterialQuery.fetcher({ wid }), + }) + + return ( +
+
{JSON.stringify({ data }, null, 2)}
+
+ ) +} + +export default WorkPageLayout diff --git a/components/shared/badge/BadgeButton.tsx b/components/shared/badge/BadgeButton.tsx index 0985065f..76bbbb4c 100644 --- a/components/shared/badge/BadgeButton.tsx +++ b/components/shared/badge/BadgeButton.tsx @@ -7,9 +7,16 @@ type BadgeButtonProps = { isActive?: boolean classNames?: string children: React.ReactNode + ariaLabel: string } -const BadgeButton = ({ onClick, isActive = false, classNames, children }: BadgeButtonProps) => { +const BadgeButton = ({ + onClick, + isActive = false, + classNames, + children, + ariaLabel, +}: BadgeButtonProps) => { return ( diff --git a/components/shared/button/Button.stories.tsx b/components/shared/button/Button.stories.tsx index 37d7a507..06ddb5ab 100644 --- a/components/shared/button/Button.stories.tsx +++ b/components/shared/button/Button.stories.tsx @@ -28,6 +28,7 @@ export const Default: Story = { }, }, args: { + ariaLabel: "Prøv Lydbogen", variant: "default", size: "default", }, @@ -48,6 +49,7 @@ export const Small: Story = { }, }, args: { + ariaLabel: "Prøv Lydbogen", variant: "default", size: "sm", }, @@ -66,6 +68,7 @@ export const SmallDark: Story = { }, }, args: { + ariaLabel: "Prøv Lydbogen", variant: "default", size: "sm", }, @@ -85,6 +88,7 @@ export const Medium: Story = { }, }, args: { + ariaLabel: "Prøv Lydbogen", variant: "default", size: "md", }, @@ -103,6 +107,7 @@ export const MediumDark: Story = { }, }, args: { + ariaLabel: "Prøv Lydbogen", variant: "default", size: "md", }, @@ -122,6 +127,7 @@ export const Large: Story = { }, }, args: { + ariaLabel: "Prøv Lydbogen", variant: "default", size: "lg", }, @@ -140,6 +146,7 @@ export const LargeDark: Story = { }, }, args: { + ariaLabel: "Prøv Lydbogen", variant: "default", size: "lg", }, @@ -154,6 +161,7 @@ export const LargeDark: Story = { export const IconStory: Story = { name: "Icon", args: { + ariaLabel: "Tilgå hjælpesiden", variant: "icon", }, render: args => ( @@ -166,6 +174,7 @@ export const IconStory: Story = { export const IconStoryDark: Story = { name: "Icon dark", args: { + ariaLabel: "Tilgå hjælpesiden", variant: "icon", }, decorators: [darkModeDecorator], diff --git a/components/shared/button/Button.tsx b/components/shared/button/Button.tsx index c701dd6b..c864653d 100644 --- a/components/shared/button/Button.tsx +++ b/components/shared/button/Button.tsx @@ -59,13 +59,19 @@ export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean + ariaLabel: string } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, ariaLabel, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button" return ( - + ) } ) diff --git a/components/shared/publizonReader/PublizonReader.tsx b/components/shared/publizonReader/PublizonReader.tsx new file mode 100644 index 00000000..05c07d9a --- /dev/null +++ b/components/shared/publizonReader/PublizonReader.tsx @@ -0,0 +1,87 @@ +import React, { CSSProperties, useEffect } from "react" + +import { appendAsset, readerAssets } from "./helper" + +// type ReaderType = { identifier?: string; orderId?: string }; + +// Define mutually exclusive types for identifier and orderId +type ReaderType = + | { + identifier: string + orderId?: never + } + | { + identifier?: never + orderId: string + } + +const Reader = ({ identifier, orderId }: ReaderType) => { + useEffect(() => { + readerAssets.forEach(appendAsset) + }, [identifier, orderId]) + + const readerStyles: CSSProperties = { + position: "absolute", + top: "0", // Padding from the top + left: "0", // Padding from the left + right: "0", // Padding from the right + bottom: "0", // Padding from the bottom + padding: "20px", // Padding for the reader + width: "100%", + maxWidth: "unset", + zIndex: 1000, + // border: "1px dotted black", // Should be removed in production + margin: "0", + } + + const handleBack = () => { + window.history.back() + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + handleBack() + } + } + + if (orderId) { + return ( +
+

orderId: {orderId}

+
+
+ ) + } + + if (identifier) { + return ( +
+ ) + } + + return null +} + +export default Reader diff --git a/components/shared/publizonReader/helper.ts b/components/shared/publizonReader/helper.ts new file mode 100644 index 00000000..feb64fa7 --- /dev/null +++ b/components/shared/publizonReader/helper.ts @@ -0,0 +1,41 @@ +type AssetType = { + src: string + type: "script" | "link" +} + +export const readerAssets: AssetType[] = [ + { + src: "https://reader.pubhub.dk/2.2.0/js/chunk-vendors.js", + type: "script", + }, + { + src: "https://reader.pubhub.dk/2.2.0/js/app.js", + type: "script", + }, + { + src: "https://reader.pubhub.dk/2.2.0/css/chunk-vendors.css", + type: "link", + }, + { + src: "https://reader.pubhub.dk/2.2.0/css/app.css", + type: "link", + }, +] + +export const appendAsset = ({ src, type }: AssetType) => { + if (type === "script") { + const scriptElement = document.createElement("script") + scriptElement.src = src + scriptElement.defer = true + scriptElement.async = false + scriptElement.type = "module" + document.head.appendChild(scriptElement) + } + + if (type === "link") { + const linkElement = document.createElement("link") + linkElement.href = src + linkElement.rel = "stylesheet" + document.head.appendChild(linkElement) + } +} diff --git a/components/shared/searchFilters/SearchFiltersColumn.tsx b/components/shared/searchFilters/SearchFiltersColumn.tsx index 0f152174..340da491 100644 --- a/components/shared/searchFilters/SearchFiltersColumn.tsx +++ b/components/shared/searchFilters/SearchFiltersColumn.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from "react" import { useSearchDataAndLoadingStates } from "@/components/pages/searchPageLayout/helper" +import { AnimateChangeInHeight } from "@/components/shared/animateChangeInHeight/AnimateChangeInHeight" import BadgeButton from "@/components/shared/badge/BadgeButton" import Icon from "@/components/shared/icon/Icon" import { @@ -13,8 +14,6 @@ import { cn } from "@/lib/helpers/helper.cn" import { TFilters } from "@/lib/machines/search/types" import useSearchMachineActor from "@/lib/machines/search/useSearchMachineActor" -import { AnimateChangeInHeight } from "../animateChangeInHeight/AnimateChangeInHeight" - type SearchFiltersColumnProps = { facet: SearchFacetFragment isLast: boolean @@ -73,6 +72,7 @@ const SearchFiltersColumn = ({ {facet.values.map((value, index) => ( actor.send({ type: "TOGGLE_FILTER", name: facet.name, value: value.term }) } @@ -87,14 +87,13 @@ const SearchFiltersColumn = ({
{hasOverflow && ( { setIsExpanded(prev => !prev) }}> -

- {!isExpanded && "Flere"} {isExpanded && "Skjul"} -

+

{isExpanded ? "Skjul" : "Flere"}

)} diff --git a/components/shared/searchFilters/SearchFiltersMobile.tsx b/components/shared/searchFilters/SearchFiltersMobile.tsx index e6fd7487..d157a255 100644 --- a/components/shared/searchFilters/SearchFiltersMobile.tsx +++ b/components/shared/searchFilters/SearchFiltersMobile.tsx @@ -43,7 +43,7 @@ const SearchFiltersMobile = ({ facets }: SearchFiltersMobileProps) => { aria-label="Vis filtreringsmuligheder" onClick={() => setIsSheetOpen(!isSheetOpen)} className="flex flex-row items-center gap-1 text-typo-link"> -