diff --git a/app/[lang]/webview/about-scandic-friends/page.tsx b/app/[lang]/webview/about-scandic-friends/page.tsx deleted file mode 100644 index 6f7755401..000000000 --- a/app/[lang]/webview/about-scandic-friends/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import "@/app/globals.css" -import "@scandic-hotels/design-system/style.css" - -import { serverClient } from "@/lib/trpc/server" - -import MaxWidth from "@/components/MaxWidth" -import Content from "@/components/MyPages/AccountPage/Content" - -import styles from "./page.module.css" - -import type { LangParams, PageArgs, UriParams } from "@/types/params" - -export default async function AboutScandicFriends({ - params, -}: PageArgs) { - const accountPage = await serverClient().contentstack.accountPage.get({ - url: "/my-pages/overview", - lang: params.lang, - }) - - return ( - - - - ) -} diff --git a/app/[lang]/webview/layout.module.css b/app/[lang]/webview/layout.module.css index 4351f00fc..b03073834 100644 --- a/app/[lang]/webview/layout.module.css +++ b/app/[lang]/webview/layout.module.css @@ -1,6 +1,6 @@ .layout { padding-bottom: 7.7rem; font-family: var(--ff-fira-sans); - + background-color: var(--Brand-Coffee-Subtle); min-height: 100dvh; } diff --git a/app/[lang]/webview/layout.tsx b/app/[lang]/webview/layout.tsx index b6eef042d..77b081954 100644 --- a/app/[lang]/webview/layout.tsx +++ b/app/[lang]/webview/layout.tsx @@ -1,3 +1,6 @@ +import "@/app/globals.css" +import "@scandic-hotels/design-system/style.css" + import { firaMono, firaSans } from "@/app/[lang]/(live)/fonts" import styles from "./layout.module.css" diff --git a/app/[lang]/webview/about-scandic-friends/page.module.css b/app/[lang]/webview/loyalty-page/page.module.css similarity index 100% rename from app/[lang]/webview/about-scandic-friends/page.module.css rename to app/[lang]/webview/loyalty-page/page.module.css diff --git a/app/[lang]/webview/loyalty-page/page.tsx b/app/[lang]/webview/loyalty-page/page.tsx new file mode 100644 index 000000000..b173ff4c2 --- /dev/null +++ b/app/[lang]/webview/loyalty-page/page.tsx @@ -0,0 +1,44 @@ +import { notFound, redirect } from "next/navigation" + +import { serverClient } from "@/lib/trpc/server" + +import { Blocks } from "@/components/Loyalty/Blocks" +import Sidebar from "@/components/Loyalty/Sidebar" +import MaxWidth from "@/components/MaxWidth" + +import styles from "./page.module.css" + +import type { LangParams, PageArgs, UriParams } from "@/types/params" + +export default async function AboutScandicFriends({ + params, + searchParams, +}: PageArgs) { + if (!searchParams.uri) { + return notFound() + } + + const loyaltyPage = await serverClient({ + onError() { + const returnUrl = new URLSearchParams({ + returnurl: `${params.lang}/webview/${searchParams.uri}`, + }) + + const refreshUrl = `/${params.lang}/webview/refresh?${returnUrl.toString()}` + redirect(refreshUrl) + }, + }).contentstack.loyaltyPage.get({ + href: searchParams.uri, + locale: params.lang, + }) + + return ( +
+ {loyaltyPage.sidebar ? : null} + + + + +
+ ) +} diff --git a/app/[lang]/webview/my-pages/benefits/page.tsx b/app/[lang]/webview/my-pages/benefits/page.tsx deleted file mode 100644 index 194f9c7b8..000000000 --- a/app/[lang]/webview/my-pages/benefits/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import "@/app/globals.css" -import "@scandic-hotels/design-system/style.css" - -import { serverClient } from "@/lib/trpc/server" - -import MaxWidth from "@/components/MaxWidth" -import Content from "@/components/MyPages/AccountPage/Content" - -import styles from "./benefits.module.css" - -import type { LangParams, PageArgs, UriParams } from "@/types/params" - -export default async function MyPages({ - params, -}: PageArgs) { - const accountPage = await serverClient().contentstack.accountPage.get({ - url: "/my-pages/benefits", - lang: params.lang, - }) - - return ( - - - - ) -} diff --git a/app/[lang]/webview/my-pages/overview/overview.module.css b/app/[lang]/webview/my-pages/overview/overview.module.css deleted file mode 100644 index 9ff21ac9f..000000000 --- a/app/[lang]/webview/my-pages/overview/overview.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.blocks { - display: grid; - gap: 4.2rem; - padding-left: 2rem; - padding-right: 2rem; -} diff --git a/app/[lang]/webview/my-pages/overview/page.tsx b/app/[lang]/webview/my-pages/overview/page.tsx deleted file mode 100644 index da4b03cfe..000000000 --- a/app/[lang]/webview/my-pages/overview/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import "@/app/globals.css" -import "@scandic-hotels/design-system/style.css" - -import { serverClient } from "@/lib/trpc/server" - -import MaxWidth from "@/components/MaxWidth" -import Content from "@/components/MyPages/AccountPage/Content" - -import styles from "./overview.module.css" - -import type { LangParams, PageArgs, UriParams } from "@/types/params" - -export default async function MyPages({ - params, -}: PageArgs) { - const accountPage = await serverClient().contentstack.accountPage.get({ - url: "/my-pages/overview", - lang: params.lang, - }) - - return ( - - - - ) -} diff --git a/app/[lang]/webview/my-pages/benefits/benefits.module.css b/app/[lang]/webview/my-pages/page.module.css similarity index 100% rename from app/[lang]/webview/my-pages/benefits/benefits.module.css rename to app/[lang]/webview/my-pages/page.module.css diff --git a/app/[lang]/webview/my-pages/page.tsx b/app/[lang]/webview/my-pages/page.tsx new file mode 100644 index 000000000..934320bb6 --- /dev/null +++ b/app/[lang]/webview/my-pages/page.tsx @@ -0,0 +1,67 @@ +import "@/app/globals.css" +import "@scandic-hotels/design-system/style.css" + +import { notFound, redirect } from "next/navigation" + +import { Lang } from "@/constants/languages" +import { overview } from "@/constants/routes/webviews" +import { _ } from "@/lib/translation" +import { serverClient } from "@/lib/trpc/server" + +import MaxWidth from "@/components/MaxWidth" +import Content from "@/components/MyPages/AccountPage/Webview/Content" +import Link from "@/components/TempDesignSystem/Link" +import Title from "@/components/Title" + +import styles from "./page.module.css" + +import type { LangParams, PageArgs, UriParams } from "@/types/params" + +function getLink(lang: Lang, uri: string) { + if (uri === overview[lang]) { + return { + title: _("Go to points"), + href: `/${lang}/webview/my-pages/points`, + } + } else { + return { + title: _("Go to membership overview"), + href: `/${lang}/webview/my-pages/overview`, + } + } +} + +export default async function MyPages({ + params, + searchParams, +}: PageArgs) { + if (!searchParams.uri) { + return notFound() + } + + const accountPage = await serverClient({ + onError() { + const returnUrl = new URLSearchParams({ + returnurl: `${params.lang}/webview/${searchParams.uri}`, + }) + + const refreshUrl = `/${params.lang}/webview/refresh?${returnUrl.toString()}` + redirect(refreshUrl) + }, + }).contentstack.accountPage.get({ + url: searchParams.uri, + lang: params.lang, + }) + + const link = getLink(params.lang, searchParams.uri) + + return ( + +
+ {_("Welcome")} + {link.title} +
+ +
+ ) +} diff --git a/app/[lang]/webview/my-pages/points/page.tsx b/app/[lang]/webview/my-pages/points/page.tsx deleted file mode 100644 index b1ed1b463..000000000 --- a/app/[lang]/webview/my-pages/points/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import "@/app/globals.css" -import "@scandic-hotels/design-system/style.css" - -import { serverClient } from "@/lib/trpc/server" - -import MaxWidth from "@/components/MaxWidth" -import Content from "@/components/MyPages/AccountPage/Content" - -import styles from "./points.module.css" - -import type { LangParams, PageArgs, UriParams } from "@/types/params" - -export default async function Points({ - params, -}: PageArgs) { - // const accountPage = await serverClient().contentstack.accountPage.get({ - // url: "/my-pages/points", - // lang: params.lang, - // }) - - return ( - -

POINTS

- {/* */} -
- ) -} diff --git a/app/[lang]/webview/my-pages/points/points.module.css b/app/[lang]/webview/my-pages/points/points.module.css deleted file mode 100644 index 9ff21ac9f..000000000 --- a/app/[lang]/webview/my-pages/points/points.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.blocks { - display: grid; - gap: 4.2rem; - padding-left: 2rem; - padding-right: 2rem; -} diff --git a/components/MyPages/AccountPage/Webview/Content.tsx b/components/MyPages/AccountPage/Webview/Content.tsx new file mode 100644 index 000000000..97fd548d9 --- /dev/null +++ b/components/MyPages/AccountPage/Webview/Content.tsx @@ -0,0 +1,77 @@ +import JsonToHtml from "@/components/JsonToHtml" +import Overview from "@/components/MyPages/Blocks/Overview" +import Shortcuts from "@/components/MyPages/Blocks/Shortcuts" + +import { + AccountPageContentProps, + ContentProps, +} from "@/types/components/myPages/myPage/accountPage" +import { + ContentEntries, + DynamicContentComponents, +} from "@/types/components/myPages/myPage/enums" + +function DynamicComponent({ component, props }: AccountPageContentProps) { + switch (component) { + case DynamicContentComponents.membership_overview: + return + default: + return null + } +} + +export default function Content({ lang, content }: ContentProps) { + return ( + <> + {content.map((item) => { + switch (item.__typename) { + case ContentEntries.AccountPageContentDynamicContent: + const link = item.dynamic_content.link.linkConnection.edges.length + ? { + href: + item.dynamic_content.link.linkConnection.edges[0].node + .original_url || + `/${lang}${item.dynamic_content.link.linkConnection.edges[0].node.url}`, + text: item.dynamic_content.link.link_text, + } + : null + + const componentProps = { + lang, + title: item.dynamic_content.title, + // TODO: rename preamble to subtitle in Contentstack? + subtitle: item.dynamic_content.preamble, + ...(link && { link }), + } + return ( + + ) + case ContentEntries.AccountPageContentShortcuts: + return ( + + ) + case ContentEntries.AccountPageContentTextContent: + return ( +
+ +
+ ) + default: + return null + } + })} + + ) +} diff --git a/constants/routes/webviews.ts b/constants/routes/webviews.ts new file mode 100644 index 000000000..f4ec8ef58 --- /dev/null +++ b/constants/routes/webviews.ts @@ -0,0 +1,73 @@ +/** + * @file Due to these records being used in next.config.js, and that is required + * to be a js file, we use jsdoc to type these. + */ + +/** + * These are routes that define code entries for My pages + */ + +/** @type {import('@/types/routes').LangRoute} */ +const myPages = { + da: "/da/webview/mine-sider", + de: "/de/webview/mein-profil", + en: "/en/webview/my-pages", + fi: "/fi/webview/minun-sivujani", + no: "/no/webview/mine-sider", + sv: "/sv/webview/mina-sidor", +} + +/** @type {import('@/types/routes').LangRoute} */ +export const overview = { + da: `${myPages.da}/oversigt`, + de: `${myPages.de}/uberblick`, + en: `${myPages.en}/overview`, + fi: `${myPages.fi}/yleiskatsaus`, + no: `${myPages.no}/oversikt`, + sv: `${myPages.sv}/oversikt`, +} + +/** @type {import('@/types/routes').LangRoute} */ +export const benefits = { + da: `${myPages.da}/fordele`, + de: `${myPages.de}/vorteile`, + en: `${myPages.en}/benefits`, + fi: `${myPages.fi}/etuja`, + no: `${myPages.no}/fordeler`, + sv: `${myPages.sv}/formaner`, +} + +/** @type {import('@/types/routes').LangRoute} */ +export const points = { + da: `${myPages.da}/point`, + de: `${myPages.de}/punkte`, + en: `${myPages.en}/points`, + fi: `${myPages.fi}/pisteitä`, + no: `${myPages.no}/poeng`, + sv: `${myPages.sv}/poang`, +} + +/** @type {import('@/types/routes').LangRoute} */ +export const programOverview = { + da: `/da/webview/ophold`, + de: `/de/webview/aufenthalte`, + en: `/en/webview/stays`, + fi: `/fi/webview/oleskeluni`, + no: `/no/webview/opphold`, + sv: `/sv/webview/vistelser`, +} + +export const webviews = [ + ...Object.values(benefits), + ...Object.values(overview), + ...Object.values(points), + ...Object.values(programOverview), +] + +export const myPagesWebviews = [ + ...Object.values(benefits), + ...Object.values(overview), + ...Object.values(points), +] + +export const loyaltyPagesWebviews = [...Object.values(programOverview)] diff --git a/middlewares/webView.ts b/middlewares/webView.ts index 7d52e2ae9..3db536475 100644 --- a/middlewares/webView.ts +++ b/middlewares/webView.ts @@ -1,19 +1,42 @@ import { type NextMiddleware, NextResponse } from "next/server" import { findLang } from "@/constants/languages" +import { + loyaltyPagesWebviews, + myPagesWebviews, + webviews, +} from "@/constants/routes/webviews" import { env } from "@/env/server" -import { badRequest, internalServerError } from "@/server/errors/next" +import { badRequest } from "@/server/errors/next" import { decryptData } from "@/utils/aes" import type { MiddlewareMatcher } from "@/types/middleware" export const middleware: NextMiddleware = async (request) => { + const { nextUrl } = request + const lang = findLang(nextUrl.pathname) + + const pathNameWithoutLang = nextUrl.pathname.replace(`/${lang}/webview`, "") + const searchParams = new URLSearchParams(request.nextUrl.searchParams) + searchParams.set("uri", pathNameWithoutLang) + const webviewToken = request.cookies.get("webviewToken") if (webviewToken) { // since the token exists, this is a subsequent visit // we're done, allow it - return NextResponse.next() + if (myPagesWebviews.includes(nextUrl.pathname)) { + return NextResponse.rewrite( + new URL(`/${lang}/webview/my-pages?${searchParams.toString()}`, nextUrl) + ) + } else if (loyaltyPagesWebviews.includes(nextUrl.pathname)) { + return NextResponse.rewrite( + new URL( + `/${lang}/webview/loyalty-page?${searchParams.toString()}`, + nextUrl + ) + ) + } } // Authorization header is required for webviews @@ -37,13 +60,31 @@ export const middleware: NextMiddleware = async (request) => { authorization ) - const response = NextResponse.next() - response.cookies.set("webviewToken", decryptedData, { - httpOnly: true, - secure: true, - }) - - return response + if (myPagesWebviews.includes(nextUrl.pathname)) { + return NextResponse.rewrite( + new URL( + `/${lang}/webview/my-pages?${searchParams.toString()}`, + nextUrl + ), + { + headers: { + "Set-Cookie": `webviewToken=${decryptedData}; Secure; HttpOnly; Path=/; SameSite=Strict;`, + }, + } + ) + } else if (loyaltyPagesWebviews.includes(nextUrl.pathname)) { + return NextResponse.rewrite( + new URL( + `/${lang}/webview/loyalty-page?${searchParams.toString()}`, + nextUrl + ), + { + headers: { + "Set-Cookie": `webviewToken=${decryptedData}; Secure; HttpOnly; Path=/; SameSite=Strict;`, + }, + } + ) + } } catch (e) { if (e instanceof Error) { console.error(`${e.name}: ${e.message}`) @@ -55,7 +96,6 @@ export const middleware: NextMiddleware = async (request) => { export const matcher: MiddlewareMatcher = (request) => { const { nextUrl } = request - const lang = findLang(nextUrl.pathname) - const pathNameWithoutLang = nextUrl.pathname.replace(`/${lang}`, "") - return pathNameWithoutLang.startsWith("/webview/") + + return webviews.includes(nextUrl.pathname) } diff --git a/server/trpc.ts b/server/trpc.ts index eb0c5bef6..8facef2ef 100644 --- a/server/trpc.ts +++ b/server/trpc.ts @@ -29,7 +29,8 @@ export const contentstackProcedure = t.procedure.use(async function (opts) { }) export const protectedProcedure = t.procedure.use(async function (opts) { const authRequired = opts.meta?.authRequired ?? true - const session = await (await opts.ctx).session + const ctx = await opts.ctx + const session = ctx.session if (!authRequired && env.NODE_ENV === "development") { console.info( `❌❌❌❌ You are opting out of authorization, if its done on purpose maybe you should use the publicProcedure instead. ❌❌❌❌` diff --git a/test.js b/test.js new file mode 100644 index 000000000..6bb65f394 --- /dev/null +++ b/test.js @@ -0,0 +1,84 @@ +function base64ToUint8Array(base64String) { + const binaryString = atob(base64String) + const byteArray = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + byteArray[i] = binaryString.charCodeAt(i) + } + return byteArray +} + +function utf8ToUint8Array(utf8String) { + return new TextEncoder().encode(utf8String) +} + +function uint8ArrayToBase64(uint8Array) { + let binaryString = "" + const len = uint8Array.byteLength + for (let i = 0; i < len; i++) { + binaryString += String.fromCharCode(uint8Array[i]) + } + return btoa(binaryString) +} + +function uint8ArrayToUtf8(uint8Array) { + return new TextDecoder().decode(uint8Array) +} + +async function decryptData(keyBase64, ivBase64, encryptedDataBase64) { + const keyBuffer = await crypto.subtle.importKey( + "raw", + base64ToUint8Array(keyBase64), + "AES-CBC", + false, + ["decrypt"] + ) + + const encryptedDataBuffer = base64ToUint8Array(encryptedDataBase64) + const ivBuffer = base64ToUint8Array(ivBase64) + const decryptedDataBuffer = await crypto.subtle.decrypt( + { name: "AES-CBC", iv: ivBuffer }, + keyBuffer, + encryptedDataBuffer + ) + + const decryptedData = uint8ArrayToUtf8(new Uint8Array(decryptedDataBuffer)) + return decryptedData +} +async function encryptData(keyBase64, ivBase64, data) { + const keyBuffer = await crypto.subtle.importKey( + "raw", + base64ToUint8Array(keyBase64), + "AES-CBC", + false, + ["encrypt"] + ) + + const dataBuffer = utf8ToUint8Array(data) + const ivBuffer = base64ToUint8Array(ivBase64) + const encryptedDataBuffer = await crypto.subtle.encrypt( + { name: "AES-CBC", iv: ivBuffer }, + keyBuffer, + dataBuffer + ) + + const encryptedData = uint8ArrayToBase64(new Uint8Array(encryptedDataBuffer)) + return encryptedData +} + +const keyBase64 = "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=" +const ivBase64 = btoa("abcdefghijklmnop") +const data = "_0XBPWQQ_60d7f996-04e1-4e2f-b7e9-1d1139b808ea" + +// Encrypt the data +const encryptedData = await encryptData(keyBase64, ivBase64, data) + +// Decrypt the data +const decryptedData = await decryptData(keyBase64, ivBase64, encryptedData) + +// const tegwpjke = await encryptData( +// "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", +// btoa("abcdefghijklmnop"), +// "_0XBPWQQ_60d7f996-04e1-4e2f-b7e9-1d1139b808ea" +// ) + +// console.log(tegwpjke)