From 40e83571f6d777d058d355a27344c587d89fd921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 24 Oct 2023 16:25:07 +0200 Subject: [PATCH] Deploy to production (#55) * Fixes issues after merge to main (#50) * Caches projects on server-side (#52) * Caches projects for user * No longer selects first project if there is only one * Removes debug log * Shows projects cached on the server * Fixes initial loading state * Removes debug log * Adds missing awaits * Adds tests for logoutHandler * Fixes unit tests * Uses decorator pattern to cache projects * Adds tests for * Fixes typos * Fixes linting error * Fixes compile error * Logs out user when receiving HTTP 401 (#53) * Caches projects for user * No longer selects first project if there is only one * Removes debug log * Shows projects cached on the server * Fixes initial loading state * Removes debug log * Adds missing awaits * Adds tests for logoutHandler * Fixes unit tests * Uses decorator pattern to cache projects * Adds tests for * Fixes typos * Fixes linting error * Fixes compile error * Logs out user when receiving HTTP 401 * Adds missing files * Fixes linting errors * Update dependabot.yml (#54) * Revert "Merge branch 'main' into develop" This reverts commit 37c7158b38a1fd9874eaf396f8b5cbba4f14a352, reversing changes made to ffc60711af6b0378f9e9b8c90c77ebd2ffca4f4f. --- .github/dependabot.yml | 2 +- ...okenCoder.test.ts => ZodJSONCoder.test.ts} | 28 +++++++++---- .../common/authHandler/logoutHandler.test.ts | 41 ++++++++++++++++++ .../projects/CachingProjectDataSource.test.ts | 42 +++++++++++++++++++ __test__/projects/ProjectPageState.test.ts | 22 ---------- src/app/[...slug]/page.tsx | 4 +- src/app/api/auth/[auth0]/route.ts | 10 ++++- src/app/api/user/projects/route.ts | 26 ++++++++++-- src/app/layout.tsx | 13 +++--- src/app/page.tsx | 7 +++- src/common/authHandler/logout.ts | 12 ++++++ .../errorHandling/client/ErrorHandler.tsx | 23 ++++++++++ .../keyValueStore/RedisKeyValueStore.ts | 4 +- .../userData/Auth0SessionDataRepository.ts | 7 ++-- src/common/utils/ZodJSONCoder.ts | 19 +++++++++ src/common/utils/fetcher.ts | 12 ++++++ src/composition.ts | 29 ++++++++++--- .../auth/data/Auth0RefreshTokenReader.ts | 3 +- .../auth/data/GitHubOAuthTokenRefresher.ts | 9 ++-- .../auth/domain/AccessTokenService.ts | 7 ++-- src/features/auth/domain/AuthError.ts | 1 + src/features/auth/domain/OAuthTokenCoder.ts | 18 -------- .../domain/SessionOAuthTokenRepository.ts | 13 +++--- .../domain/UserDataOAuthTokenRepository.ts | 13 +++--- ...pository.ts => GitHubProjectDataSource.ts} | 24 +++++------ src/features/projects/data/useProjects.ts | 4 +- .../domain/CachingProjectDataSource.ts | 22 ++++++++++ .../projects/domain/IOpenApiSpecification.ts | 6 --- src/features/projects/domain/IProject.ts | 9 ---- .../projects/domain/IProjectDataSource.ts | 5 +++ .../projects/domain/IProjectRepository.ts | 7 ++-- .../domain/ISessionProjectRepository.ts | 7 ++++ src/features/projects/domain/IVersion.ts | 8 ---- .../projects/domain/OpenApiSpecification.ts | 12 ++++++ src/features/projects/domain/Project.ts | 14 +++++++ .../projects/domain/ProjectPageSelection.ts | 12 +++--- .../projects/domain/ProjectPageState.ts | 16 +++---- .../domain/SessionProjectRepository.ts | 29 +++++++++++++ src/features/projects/domain/Version.ts | 13 ++++++ src/features/projects/view/ProjectAvatar.tsx | 4 +- src/features/projects/view/ProjectList.tsx | 6 +-- .../projects/view/ProjectListItem.tsx | 6 +-- src/features/projects/view/ProjectsPage.tsx | 24 +++++++++++ .../view/ProjectsPageSecondaryContent.tsx | 2 +- .../projects/view/client/ProjectsPage.tsx | 28 +++++++------ .../view/docs/SpecificationSelector.tsx | 4 +- .../projects/view/docs/VersionSelector.tsx | 4 +- src/middleware.ts | 2 +- 48 files changed, 458 insertions(+), 175 deletions(-) rename __test__/auth/{OAuthTokenCoder.test.ts => ZodJSONCoder.test.ts} (77%) create mode 100644 __test__/common/authHandler/logoutHandler.test.ts create mode 100644 __test__/projects/CachingProjectDataSource.test.ts create mode 100644 src/common/authHandler/logout.ts create mode 100644 src/common/errorHandling/client/ErrorHandler.tsx create mode 100644 src/common/utils/ZodJSONCoder.ts create mode 100644 src/features/auth/domain/AuthError.ts delete mode 100644 src/features/auth/domain/OAuthTokenCoder.ts rename src/features/projects/data/{GitHubProjectRepository.ts => GitHubProjectDataSource.ts} (91%) create mode 100644 src/features/projects/domain/CachingProjectDataSource.ts delete mode 100644 src/features/projects/domain/IOpenApiSpecification.ts delete mode 100644 src/features/projects/domain/IProject.ts create mode 100644 src/features/projects/domain/IProjectDataSource.ts create mode 100644 src/features/projects/domain/ISessionProjectRepository.ts delete mode 100644 src/features/projects/domain/IVersion.ts create mode 100644 src/features/projects/domain/OpenApiSpecification.ts create mode 100644 src/features/projects/domain/Project.ts create mode 100644 src/features/projects/domain/SessionProjectRepository.ts create mode 100644 src/features/projects/domain/Version.ts create mode 100644 src/features/projects/view/ProjectsPage.tsx diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d1f0d085..aff82a10 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,4 +3,4 @@ updates: - package-ecosystem: "npm" directory: "/" schedule: - interval: "daily" + interval: "weekly" diff --git a/__test__/auth/OAuthTokenCoder.test.ts b/__test__/auth/ZodJSONCoder.test.ts similarity index 77% rename from __test__/auth/OAuthTokenCoder.test.ts rename to __test__/auth/ZodJSONCoder.test.ts index 19fbb871..d7d36086 100644 --- a/__test__/auth/OAuthTokenCoder.test.ts +++ b/__test__/auth/ZodJSONCoder.test.ts @@ -1,4 +1,14 @@ -import OAuthTokenCoder from "../../src/features/auth/domain/OAuthTokenCoder" +import { z } from "zod" +import ZodJSONCoder from "../../src/common/utils/ZodJSONCoder" + +const SampleAuthTokenSchema = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + accessTokenExpiryDate: z.coerce.date(), + refreshTokenExpiryDate: z.coerce.date() +}) + +type SampleAuthToken = z.infer test("It encodes a valid token", async () => { const token = { @@ -7,7 +17,7 @@ test("It encodes a valid token", async () => { accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000), refreshTokenExpiryDate: new Date(new Date().getTime() + 24 * 3600 * 1000) } - const str = OAuthTokenCoder.encode(token) + const str = ZodJSONCoder.encode(SampleAuthTokenSchema, token) const decodedToken = JSON.parse(str) expect(decodedToken.accessToken).toBe(token.accessToken) expect(decodedToken.refreshToken).toBe(token.refreshToken) @@ -24,7 +34,7 @@ test("It decodes a valid token", async () => { accessTokenExpiryDate: accessTokenExpiryDate, refreshTokenExpiryDate: refreshTokenExpiryDate }) - const token = OAuthTokenCoder.decode(str) + const token: SampleAuthToken = ZodJSONCoder.decode(SampleAuthTokenSchema, str) expect(token.accessToken).toBe("foo") expect(token.refreshToken).toBe("bar") expect(token.accessTokenExpiryDate).toEqual(accessTokenExpiryDate) @@ -37,7 +47,7 @@ test("It throws an error when the returned OAuth token does not contain an acces accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000), refreshTokenExpiryDate: new Date(new Date().getTime() + 24 * 3600 * 1000) }) - expect(() => OAuthTokenCoder.decode(str)).toThrow() + expect(() => ZodJSONCoder.decode(SampleAuthTokenSchema, str)).toThrow() }) test("It throws an error when the returned OAuth token does not contain an refresh token", async () => { @@ -46,7 +56,7 @@ test("It throws an error when the returned OAuth token does not contain an refre accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000), refreshTokenExpiryDate: new Date(new Date().getTime() + 24 * 3600 * 1000) }) - expect(() => OAuthTokenCoder.decode(str)).toThrow() + expect(() => ZodJSONCoder.decode(SampleAuthTokenSchema, str)).toThrow() }) test("It throws an error when the returned OAuth token does not contain an expiry date for the access token", async () => { @@ -55,7 +65,7 @@ test("It throws an error when the returned OAuth token does not contain an expir refreshToken: "bar", refreshTokenExpiryDate: new Date(new Date().getTime() + 24 * 3600 * 1000) }) - expect(() => OAuthTokenCoder.decode(str)).toThrow() + expect(() => ZodJSONCoder.decode(SampleAuthTokenSchema, str)).toThrow() }) test("It throws an error when the returned OAuth token does not contain an expiry date for the refresh token", async () => { @@ -64,7 +74,7 @@ test("It throws an error when the returned OAuth token does not contain an expir refreshToken: "bar", accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000) }) - expect(() => OAuthTokenCoder.decode(str)).toThrow() + expect(() => ZodJSONCoder.decode(SampleAuthTokenSchema, str)).toThrow() }) test("It throws an error when the returned OAuth token does not contain a valid expiry date for the access token", async () => { @@ -74,7 +84,7 @@ test("It throws an error when the returned OAuth token does not contain a valid accessTokenExpiryDate: "baz", refreshTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000) }) - expect(() => OAuthTokenCoder.decode(str)).toThrow() + expect(() => ZodJSONCoder.decode(SampleAuthTokenSchema, str)).toThrow() }) test("It throws an error when the returned OAuth token does not contain a valid expiry date for the refresh token", async () => { @@ -84,5 +94,5 @@ test("It throws an error when the returned OAuth token does not contain a valid accessTokenExpiryDate: new Date(new Date().getTime() + 3600 * 1000), refreshTokenExpiryDate: "baz" }) - expect(() => OAuthTokenCoder.decode(str)).toThrow() + expect(() => ZodJSONCoder.decode(SampleAuthTokenSchema, str)).toThrow() }) diff --git a/__test__/common/authHandler/logoutHandler.test.ts b/__test__/common/authHandler/logoutHandler.test.ts new file mode 100644 index 00000000..49bd48e4 --- /dev/null +++ b/__test__/common/authHandler/logoutHandler.test.ts @@ -0,0 +1,41 @@ +import logoutHandler from "../../../src/common/authHandler/logout" + +test("It deletes the user's auth token", async () => { + let didDeleteAuthToken = false + logoutHandler({ + async getOAuthToken() { + throw new Error("Not implemented") + }, + async storeOAuthToken() {}, + async deleteOAuthToken() { + didDeleteAuthToken = true + } + }, { + async getProjects() { + return [] + }, + async storeProjects() {}, + async deleteProjects() {} + }) + expect(didDeleteAuthToken).toBeTruthy() +}) + +test("It deletes the cached projects", async () => { + let didDeleteProjects = false + logoutHandler({ + async getOAuthToken() { + throw new Error("Not implemented") + }, + async storeOAuthToken() {}, + async deleteOAuthToken() {} + }, { + async getProjects() { + return [] + }, + async storeProjects() {}, + async deleteProjects() { + didDeleteProjects = true + } + }) + expect(didDeleteProjects).toBeTruthy() +}) diff --git a/__test__/projects/CachingProjectDataSource.test.ts b/__test__/projects/CachingProjectDataSource.test.ts new file mode 100644 index 00000000..8e458c3c --- /dev/null +++ b/__test__/projects/CachingProjectDataSource.test.ts @@ -0,0 +1,42 @@ +import Project from "../../src/features/projects/domain/Project" +import CachingProjectDataSource from "../../src/features/projects/domain/CachingProjectDataSource" + +test("It caches projects read from the data source", async () => { + const projects = [{ + id: "foo", + name: "foo", + versions: [{ + id: "bar", + name: "bar", + specifications: [{ + id: "baz.yml", + name: "baz.yml", + url: "https://example.com/baz.yml" + }] + }, { + id: "hello", + name: "hello", + specifications: [{ + id: "world.yml", + name: "world.yml", + url: "https://example.com/world.yml" + }] + }] + }] + let cachedProjects: Project[] | undefined + const sut = new CachingProjectDataSource({ + async getProjects() { + return projects + } + }, { + async getProjects() { + return [] + }, + async storeProjects(projects) { + cachedProjects = projects + }, + async deleteProjects() {} + }) + await sut.getProjects() + expect(cachedProjects).toEqual(projects) +}) diff --git a/__test__/projects/ProjectPageState.test.ts b/__test__/projects/ProjectPageState.test.ts index a67bf3dd..3e54da10 100644 --- a/__test__/projects/ProjectPageState.test.ts +++ b/__test__/projects/ProjectPageState.test.ts @@ -32,28 +32,6 @@ test("It gracefully errors when no project has been selected", async () => { expect(sut.state).toEqual(ProjectPageState.NO_PROJECT_SELECTED) }) -test("It selects the first project when there is only one project", async () => { - const sut = getProjectPageState({ - projects: [{ - id: "foo", - name: "foo", - versions: [{ - id: "bar", - name: "bar", - specifications: [{ - id: "hello", - name: "hello.yml", - url: "https://example.com/hello.yml" - }] - }] - }] - }) - expect(sut.state).toEqual(ProjectPageState.HAS_SELECTION) - expect(sut.selection!.project.id).toEqual("foo") - expect(sut.selection!.version.id).toEqual("bar") - expect(sut.selection!.specification.id).toEqual("hello") -}) - test("It selects the first version and specification of the specified project", async () => { const sut = getProjectPageState({ selectedProjectId: "bar", diff --git a/src/app/[...slug]/page.tsx b/src/app/[...slug]/page.tsx index f75935bd..ba172ad2 100644 --- a/src/app/[...slug]/page.tsx +++ b/src/app/[...slug]/page.tsx @@ -1,6 +1,7 @@ import { getProjectId, getSpecificationId, getVersionId } from "@/common/utils/url" import SessionOAuthTokenBarrier from "@/features/auth/view/SessionOAuthTokenBarrier" -import ProjectsPage from "@/features/projects/view/client/ProjectsPage" +import ProjectsPage from "@/features/projects/view/ProjectsPage" +import { sessionProjectRepository } from "@/composition" type PageParams = { slug: string | string[] } @@ -9,6 +10,7 @@ export default async function Page({ params }: { params: PageParams }) { return ( { } const onLogout: NextAppRouterHandler = async (req: NextRequest, ctx: AppRouteHandlerFnContext) => { - await sessionOAuthTokenRepository.deleteOAuthToken().catch(() => null) + await Promise.all([ + sessionOAuthTokenRepository.deleteOAuthToken().catch(() => null), + sessionProjectRepository.deleteProjects().catch(() => null) + ]) + await logoutHandler() return await handleLogout(req, ctx) } diff --git a/src/app/api/user/projects/route.ts b/src/app/api/user/projects/route.ts index a94b16be..a0ac3816 100644 --- a/src/app/api/user/projects/route.ts +++ b/src/app/api/user/projects/route.ts @@ -1,7 +1,27 @@ import { NextResponse } from "next/server" -import { projectRepository } from "@/composition" +import { projectDataSource } from "@/composition" +import { UnauthorizedError } from "@/features/auth/domain/AuthError" export async function GET() { - const projects = await projectRepository.getProjects() - return NextResponse.json({projects}) + try { + const projects = await projectDataSource.getProjects() + return NextResponse.json({projects}) + } catch (error) { + if (error instanceof UnauthorizedError) { + return NextResponse.json({ + status: 401, + message: error.message + }, { status: 401 }) + } else if (error instanceof Error) { + return NextResponse.json({ + status: 500, + message: error.message + }, { status: 500 }) + } else { + return NextResponse.json({ + status: 500, + message: "Unknown error" + }, { status: 500 }) + } + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e0dbc8ab..b7b50ea6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,10 @@ import "./globals.css" import type { Metadata } from "next" +import { UserProvider } from "@auth0/nextjs-auth0/client" import { Inter } from "next/font/google" import { CssBaseline } from "@mui/material" import ThemeRegistry from "@/common/theme/ThemeRegistry" -import { UserProvider } from "@auth0/nextjs-auth0/client" +import ErrorHandler from "@/common/errorHandling/client/ErrorHandler" const inter = Inter({ subsets: ["latin"] }) @@ -17,10 +18,12 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - - - {children} - + + + + {children} + + diff --git a/src/app/page.tsx b/src/app/page.tsx index 4d2bce07..a72a8f99 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,10 +1,13 @@ import SessionOAuthTokenBarrier from "@/features/auth/view/SessionOAuthTokenBarrier" -import ProjectsPage from "@/features/projects/view/client/ProjectsPage" +import ProjectsPage from "@/features/projects/view/ProjectsPage" +import { sessionProjectRepository } from "@/composition" export default async function Page() { return ( - + ) } diff --git a/src/common/authHandler/logout.ts b/src/common/authHandler/logout.ts new file mode 100644 index 00000000..9bc80634 --- /dev/null +++ b/src/common/authHandler/logout.ts @@ -0,0 +1,12 @@ +import ISessionOAuthTokenRepository from "@/features/auth/domain/ISessionOAuthTokenRepository" +import ISessionProjectRepository from "@/features/projects/domain/ISessionProjectRepository" + +export default async function logoutHandler( + sessionOAuthTokenRepository: ISessionOAuthTokenRepository, + sessionProjectRepository: ISessionProjectRepository +) { + await Promise.all([ + sessionOAuthTokenRepository.deleteOAuthToken().catch(() => null), + sessionProjectRepository.deleteProjects().catch(() => null) + ]) +} diff --git a/src/common/errorHandling/client/ErrorHandler.tsx b/src/common/errorHandling/client/ErrorHandler.tsx new file mode 100644 index 00000000..83b68d45 --- /dev/null +++ b/src/common/errorHandling/client/ErrorHandler.tsx @@ -0,0 +1,23 @@ +"use client" + +import { SWRConfig } from "swr" +import { FetcherError } from "@/common/utils/fetcher" + +export default function ErrorHandler({ + children +}: { + children: React.ReactNode +}) { + const onSWRError = (error: FetcherError) => { + if (error.status == 401) { + if (typeof window !== "undefined") { + window.location.href = "/api/auth/logout" + } + } + } + return ( + + {children} + + ) +} diff --git a/src/common/keyValueStore/RedisKeyValueStore.ts b/src/common/keyValueStore/RedisKeyValueStore.ts index b3a484d3..4a552154 100644 --- a/src/common/keyValueStore/RedisKeyValueStore.ts +++ b/src/common/keyValueStore/RedisKeyValueStore.ts @@ -13,10 +13,10 @@ export default class RedisKeyValueStore implements IKeyValueStore { } async set(key: string, data: string | number | Buffer): Promise { - this.redis.set(key, data) + await this.redis.set(key, data) } async delete(key: string): Promise { - this.redis.del(key) + await this.redis.del(key) } } diff --git a/src/common/userData/Auth0SessionDataRepository.ts b/src/common/userData/Auth0SessionDataRepository.ts index a8c8b1c3..d0380ef3 100644 --- a/src/common/userData/Auth0SessionDataRepository.ts +++ b/src/common/userData/Auth0SessionDataRepository.ts @@ -1,6 +1,7 @@ import { getSession } from "@auth0/nextjs-auth0" import ISessionDataRepository from "@/common/userData/ISessionDataRepository" import IUserDataRepository from "@/common/userData/IUserDataRepository" +import { UnauthorizedError } from "@/features/auth/domain/AuthError" export default class Auth0SessionDataRepository implements ISessionDataRepository { private readonly repository: IUserDataRepository @@ -12,7 +13,7 @@ export default class Auth0SessionDataRepository implements ISessionDataReposi async get(): Promise { const session = await getSession() if (!session) { - throw new Error(`User data could not be read because the user is not authenticated."`) + throw new UnauthorizedError(`User data could not be read because the user is not authenticated.`) } return await this.repository.get(session.user.sub) } @@ -20,7 +21,7 @@ export default class Auth0SessionDataRepository implements ISessionDataReposi async set(value: T): Promise { const session = await getSession() if (!session) { - throw new Error(`User data could not be persisted because the user is not authenticated."`) + throw new UnauthorizedError(`User data could not be persisted because the user is not authenticated.`) } return await this.repository.set(session.user.sub, value) } @@ -28,7 +29,7 @@ export default class Auth0SessionDataRepository implements ISessionDataReposi async delete(): Promise { const session = await getSession() if (!session) { - throw new Error(`User data could not be deleted because the user is not authenticated."`) + throw new UnauthorizedError(`User data could not be deleted because the user is not authenticated.`) } return await this.repository.delete(session.user.sub) } diff --git a/src/common/utils/ZodJSONCoder.ts b/src/common/utils/ZodJSONCoder.ts new file mode 100644 index 00000000..bb9a1e5f --- /dev/null +++ b/src/common/utils/ZodJSONCoder.ts @@ -0,0 +1,19 @@ +import { ZodType } from "zod" + +export default class ZodJSONCoder { + static encode(schema: Schema, value: T): string { + const validatedValue = schema.parse(value) + return JSON.stringify(validatedValue) + } + + static decode(schema: Schema, string: string): T { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + let obj: any | undefined + try { + obj = JSON.parse(string) + } catch(error) { + throw new Error("Could not parse JSON.") + } + return schema.parse(obj) + } +} diff --git a/src/common/utils/fetcher.ts b/src/common/utils/fetcher.ts index 594fa841..4d296810 100644 --- a/src/common/utils/fetcher.ts +++ b/src/common/utils/fetcher.ts @@ -1,8 +1,20 @@ + export class FetcherError extends Error { + readonly status: number + + constructor(status: number, message: string) { + super(message) + this.status = status + } + } + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ export default async function fetcher( input: RequestInfo, init?: RequestInit ): Promise { const res = await fetch(input, init) + if (!res.ok) { + throw new FetcherError(res.status, "An error occurred while fetching the data.") + } return res.json() } diff --git a/src/composition.ts b/src/composition.ts index f9ee3806..2772265b 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -1,14 +1,17 @@ import AccessTokenService from "@/features/auth/domain/AccessTokenService" import Auth0RefreshTokenReader from "@/features/auth/data/Auth0RefreshTokenReader" -import Auth0SessionDataRepository from "@/common//userData/Auth0SessionDataRepository" +import Auth0SessionDataRepository from "@/common/userData/Auth0SessionDataRepository" +import CachingProjectDataSource from "@/features/projects/domain/CachingProjectDataSource" import GitHubClient from "@/common/github/GitHubClient" import GitHubOAuthTokenRefresher from "@/features/auth/data/GitHubOAuthTokenRefresher" -import GitHubProjectRepository from "@/features/projects/data/GitHubProjectRepository" +import GitHubProjectDataSource from "@/features/projects/data/GitHubProjectDataSource" import InitialOAuthTokenService from "@/features/auth/domain/InitialOAuthTokenService" import KeyValueUserDataRepository from "@/common//userData/KeyValueUserDataRepository" import RedisKeyValueStore from "@/common/keyValueStore/RedisKeyValueStore" import SessionOAuthTokenRepository from "@/features/auth/domain/SessionOAuthTokenRepository" +import SessionProjectRepository from "./features/projects/domain/SessionProjectRepository" import UserDataOAuthTokenRepository from "@/features/auth/domain/UserDataOAuthTokenRepository" +import authLogoutHandler from "@/common/authHandler/logout" const { AUTH0_MANAGEMENT_DOMAIN, @@ -51,9 +54,21 @@ export const gitHubClient = new GitHubClient({ accessTokenReader: accessTokenService }) -export const projectRepository = new GitHubProjectRepository( - gitHubClient, - GITHUB_ORGANIZATION_NAME +export const sessionProjectRepository = new SessionProjectRepository( + new Auth0SessionDataRepository( + new KeyValueUserDataRepository( + new RedisKeyValueStore(REDIS_URL), + "projects" + ) + ) +) + +export const projectDataSource = new CachingProjectDataSource( + new GitHubProjectDataSource( + gitHubClient, + GITHUB_ORGANIZATION_NAME + ), + sessionProjectRepository ) export const initialOAuthTokenService = new InitialOAuthTokenService({ @@ -66,3 +81,7 @@ export const initialOAuthTokenService = new InitialOAuthTokenService({ oAuthTokenRefresher: gitHubOAuthTokenRefresher, oAuthTokenRepository: new UserDataOAuthTokenRepository(oAuthTokenRepository) }) + +export const logoutHandler = async () => { + await authLogoutHandler(sessionOAuthTokenRepository, sessionProjectRepository) +} diff --git a/src/features/auth/data/Auth0RefreshTokenReader.ts b/src/features/auth/data/Auth0RefreshTokenReader.ts index de160152..57bf2cde 100644 --- a/src/features/auth/data/Auth0RefreshTokenReader.ts +++ b/src/features/auth/data/Auth0RefreshTokenReader.ts @@ -1,5 +1,6 @@ import { ManagementClient } from "auth0" import IRefreshTokenReader from "../domain/IRefreshTokenReader" +import { UnauthorizedError } from "../domain/AuthError" interface Auth0RefreshTokenReaderConfig { domain: string @@ -27,7 +28,7 @@ export default class Auth0RefreshTokenReader implements IRefreshTokenReader { return identity.connection.toLowerCase() == this.connection.toLowerCase() }) if (!identity) { - throw new Error(`No identity found for connection "${this.connection}"`) + throw new UnauthorizedError(`No identity found for connection "${this.connection}"`) } return identity.refresh_token } diff --git a/src/features/auth/data/GitHubOAuthTokenRefresher.ts b/src/features/auth/data/GitHubOAuthTokenRefresher.ts index 8b5a6e1e..4fe3b4ab 100644 --- a/src/features/auth/data/GitHubOAuthTokenRefresher.ts +++ b/src/features/auth/data/GitHubOAuthTokenRefresher.ts @@ -1,5 +1,6 @@ import OAuthToken from "../domain/OAuthToken" import IOAuthTokenRefresher from "../domain/IOAuthTokenRefresher" +import { UnauthorizedError } from "../domain/AuthError" export interface GitHubOAuthTokenRefresherConfig { readonly clientId: string @@ -17,7 +18,7 @@ export default class GitHubOAuthTokenRefresher implements IOAuthTokenRefresher { const url = this.getAccessTokenURL(refreshToken) const response = await fetch(url, { method: "POST" }) if (response.status != 200) { - throw new Error( + throw new UnauthorizedError( `Failed refreshing access token with HTTP status ${response.status}: ${response.statusText}` ) } @@ -27,9 +28,9 @@ export default class GitHubOAuthTokenRefresher implements IOAuthTokenRefresher { const errorDescription = params.get("error_description") if (error && error.length > 0) { if (errorDescription && errorDescription.length > 0) { - throw new Error(errorDescription) + throw new UnauthorizedError(errorDescription) } else { - throw new Error(error) + throw new UnauthorizedError(error) } } const newAccessToken = params.get("access_token") @@ -42,7 +43,7 @@ export default class GitHubOAuthTokenRefresher implements IOAuthTokenRefresher { !newRawAccessTokenExpiryDate || newRawAccessTokenExpiryDate.length <= 0 || !newRawRefreshTokenExpiryDate || newRawRefreshTokenExpiryDate.length <= 0 ) { - throw new Error("Refreshing access token did not produce a valid access token") + throw new UnauthorizedError("Refreshing access token did not produce a valid access token") } const accessTokenExpiryDate = new Date( new Date().getTime() + parseInt(newRawAccessTokenExpiryDate) * 1000 diff --git a/src/features/auth/domain/AccessTokenService.ts b/src/features/auth/domain/AccessTokenService.ts index 6f2355d6..719f317d 100644 --- a/src/features/auth/domain/AccessTokenService.ts +++ b/src/features/auth/domain/AccessTokenService.ts @@ -1,5 +1,6 @@ -import ISessionOAuthTokenRepository from "../domain/ISessionOAuthTokenRepository" -import IOAuthTokenRefresher from "../domain/IOAuthTokenRefresher" +import ISessionOAuthTokenRepository from "./ISessionOAuthTokenRepository" +import IOAuthTokenRefresher from "./IOAuthTokenRefresher" +import { UnauthorizedError } from "./AuthError" export default class AccessTokenService { private readonly tokenRepository: ISessionOAuthTokenRepository @@ -28,7 +29,7 @@ export default class AccessTokenService { } else if (refreshTokenExpiryDate.getTime() > now.getTime()) { return await this.refreshSpecifiedAccessToken(authToken.refreshToken) } else { - throw new Error("Both the access token and refresh token have expired.") + throw new UnauthorizedError("Both the access token and refresh token have expired.") } } diff --git a/src/features/auth/domain/AuthError.ts b/src/features/auth/domain/AuthError.ts new file mode 100644 index 00000000..2e482071 --- /dev/null +++ b/src/features/auth/domain/AuthError.ts @@ -0,0 +1 @@ +export class UnauthorizedError extends Error {} diff --git a/src/features/auth/domain/OAuthTokenCoder.ts b/src/features/auth/domain/OAuthTokenCoder.ts deleted file mode 100644 index b0d081ba..00000000 --- a/src/features/auth/domain/OAuthTokenCoder.ts +++ /dev/null @@ -1,18 +0,0 @@ -import OAuthToken, { OAuthTokenSchema } from "./OAuthToken" - -export default class OAuthTokenCoder { - static encode(token: OAuthToken): string { - return JSON.stringify(token) - } - - static decode(string: string): OAuthToken { - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - let obj: any | undefined - try { - obj = JSON.parse(string) - } catch(error) { - throw new Error(`Could not decode OAuthToken read from store.`) - } - return OAuthTokenSchema.parse(obj) - } -} diff --git a/src/features/auth/domain/SessionOAuthTokenRepository.ts b/src/features/auth/domain/SessionOAuthTokenRepository.ts index e98150cc..c7942598 100644 --- a/src/features/auth/domain/SessionOAuthTokenRepository.ts +++ b/src/features/auth/domain/SessionOAuthTokenRepository.ts @@ -1,7 +1,8 @@ +import ZodJSONCoder from "../../../common/utils/ZodJSONCoder" import ISessionDataRepository from "@/common/userData/ISessionDataRepository" -import ISessionOAuthTokenRepository from "../domain/SessionOAuthTokenRepository" -import OAuthToken from "../domain/OAuthToken" -import OAuthTokenCoder from "../domain/OAuthTokenCoder" +import ISessionOAuthTokenRepository from "./SessionOAuthTokenRepository" +import { UnauthorizedError } from "./AuthError" +import OAuthToken, { OAuthTokenSchema } from "./OAuthToken" export default class SessionOAuthTokenRepository implements ISessionOAuthTokenRepository { private readonly repository: ISessionDataRepository @@ -13,13 +14,13 @@ export default class SessionOAuthTokenRepository implements ISessionOAuthTokenRe async getOAuthToken(): Promise { const string = await this.repository.get() if (!string) { - throw new Error(`No OAuthToken stored for user.`) + throw new UnauthorizedError(`No OAuthToken stored for user.`) } - return OAuthTokenCoder.decode(string) + return ZodJSONCoder.decode(OAuthTokenSchema, string) } async storeOAuthToken(token: OAuthToken): Promise { - const string = OAuthTokenCoder.encode(token) + const string = ZodJSONCoder.encode(OAuthTokenSchema, token) await this.repository.set(string) } diff --git a/src/features/auth/domain/UserDataOAuthTokenRepository.ts b/src/features/auth/domain/UserDataOAuthTokenRepository.ts index 16d7fdde..d2428a7a 100644 --- a/src/features/auth/domain/UserDataOAuthTokenRepository.ts +++ b/src/features/auth/domain/UserDataOAuthTokenRepository.ts @@ -1,7 +1,8 @@ +import ZodJSONCoder from "@/common/utils/ZodJSONCoder" import IUserDataRepository from "@/common/userData/IUserDataRepository" -import IUserDataOAuthTokenRepository from "../domain/UserDataOAuthTokenRepository" -import OAuthToken from "../domain/OAuthToken" -import OAuthTokenCoder from "../domain/OAuthTokenCoder" +import IUserDataOAuthTokenRepository from ".//UserDataOAuthTokenRepository" +import { UnauthorizedError } from "./AuthError" +import OAuthToken, { OAuthTokenSchema } from "./OAuthToken" export default class UserDataOAuthTokenRepository implements IUserDataOAuthTokenRepository { private readonly repository: IUserDataRepository @@ -13,13 +14,13 @@ export default class UserDataOAuthTokenRepository implements IUserDataOAuthToken async getOAuthToken(userId: string): Promise { const string = await this.repository.get(userId) if (!string) { - throw new Error(`No OAuthToken stored for user with ID ${userId}.`) + throw new UnauthorizedError(`No OAuthToken stored for user with ID ${userId}.`) } - return OAuthTokenCoder.decode(string) + return ZodJSONCoder.decode(OAuthTokenSchema, string) } async storeOAuthToken(userId: string, token: OAuthToken): Promise { - const string = OAuthTokenCoder.encode(token) + const string = ZodJSONCoder.encode(OAuthTokenSchema, token) await this.repository.set(userId, string) } diff --git a/src/features/projects/data/GitHubProjectRepository.ts b/src/features/projects/data/GitHubProjectDataSource.ts similarity index 91% rename from src/features/projects/data/GitHubProjectRepository.ts rename to src/features/projects/data/GitHubProjectDataSource.ts index bb27f98e..e21cf27b 100644 --- a/src/features/projects/data/GitHubProjectRepository.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -1,8 +1,8 @@ import IGitHubClient from "@/common/github/IGitHubClient" -import IProject from "../domain/IProject" +import Project from "../domain/Project" import IProjectConfig from "../domain/IProjectConfig" -import IProjectRepository from "../domain/IProjectRepository" -import IVersion from "../domain/IVersion" +import IProjectDataSource from "../domain/IProjectDataSource" +import Version from "../domain/Version" import ProjectConfigParser from "../domain/ProjectConfigParser" type SearchResult = { @@ -40,7 +40,7 @@ type File = { readonly name: string } -export default class GitHubProjectRepository implements IProjectRepository { +export default class GitHubProjectDataSource implements IProjectDataSource { private gitHubClient: IGitHubClient private organizationName: string @@ -49,7 +49,7 @@ export default class GitHubProjectRepository implements IProjectRepository { + async getProjects(): Promise { const response = await this.gitHubClient.graphql(` query Repositories($searchQuery: String!) { search(query: $searchQuery, type: REPOSITORY, first: 100) { @@ -107,15 +107,15 @@ export default class GitHubProjectRepository implements IProjectRepository { return this.mapProject(searchResult) }) - .filter((project: IProject) => { + .filter((project: Project) => { return project.versions.length > 0 }) - .sort((a: IProject, b: IProject) => { + .sort((a: Project, b: Project) => { return a.name.localeCompare(b.name) }) } - private mapProject(searchResult: SearchResult): IProject { + private mapProject(searchResult: SearchResult): Project { const config = this.getConfig(searchResult) let imageURL: string | undefined if (config && config.image) { @@ -147,7 +147,7 @@ export default class GitHubProjectRepository implements IProjectRepository { return this.mapVersionFromRef(searchResult.owner.login, searchResult.name, ref) }) @@ -160,12 +160,12 @@ export default class GitHubProjectRepository implements IProjectRepository { + const allVersions = branchVersions.concat(tagVersions).sort((a: Version, b: Version) => { return a.name.localeCompare(b.name) }) // Move the top-priority branches to the top of the list. for (const candidateDefaultBranch of candidateDefaultBranches) { - const defaultBranchIndex = allVersions.findIndex((version: IVersion) => { + const defaultBranchIndex = allVersions.findIndex((version: Version) => { return version.name === candidateDefaultBranch }) if (defaultBranchIndex !== -1) { @@ -177,7 +177,7 @@ export default class GitHubProjectRepository implements IProjectRepository { return this.isOpenAPISpecification(file.name) }).map(file => { diff --git a/src/features/projects/data/useProjects.ts b/src/features/projects/data/useProjects.ts index 16e2a5b2..5e1455cb 100644 --- a/src/features/projects/data/useProjects.ts +++ b/src/features/projects/data/useProjects.ts @@ -1,8 +1,8 @@ import useSWR from "swr" import fetcher from "@/common/utils/fetcher" -import IProject from "../domain/IProject" +import Project from "../domain/Project" -type ProjectContainer = { projects: IProject[] } +type ProjectContainer = { projects: Project[] } export default function useProjects() { const { data, error, isLoading } = useSWR( diff --git a/src/features/projects/domain/CachingProjectDataSource.ts b/src/features/projects/domain/CachingProjectDataSource.ts new file mode 100644 index 00000000..f076ae7d --- /dev/null +++ b/src/features/projects/domain/CachingProjectDataSource.ts @@ -0,0 +1,22 @@ +import Project from "./Project" +import IProjectDataSource from "./IProjectDataSource" +import ISessionProjectRepository from "./ISessionProjectRepository" + +export default class CachingProjectDataSource implements IProjectDataSource { + private dataSource: IProjectDataSource + private sessionProjectRepository: ISessionProjectRepository + + constructor( + dataSource: IProjectDataSource, + sessionProjectRepository: ISessionProjectRepository + ) { + this.dataSource = dataSource + this.sessionProjectRepository = sessionProjectRepository + } + + async getProjects(): Promise { + const projects = await this.dataSource.getProjects() + await this.sessionProjectRepository.storeProjects(projects) + return projects + } +} \ No newline at end of file diff --git a/src/features/projects/domain/IOpenApiSpecification.ts b/src/features/projects/domain/IOpenApiSpecification.ts deleted file mode 100644 index 30a52a35..00000000 --- a/src/features/projects/domain/IOpenApiSpecification.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default interface IOpenApiSpecification { - readonly id: string - readonly name: string - readonly url: string - readonly editURL?: string -} diff --git a/src/features/projects/domain/IProject.ts b/src/features/projects/domain/IProject.ts deleted file mode 100644 index 662f3112..00000000 --- a/src/features/projects/domain/IProject.ts +++ /dev/null @@ -1,9 +0,0 @@ -import IVersion from "./IVersion" - -export default interface IProject { - readonly id: string - readonly name: string - readonly displayName?: string - readonly versions: IVersion[] - readonly imageURL?: string -} diff --git a/src/features/projects/domain/IProjectDataSource.ts b/src/features/projects/domain/IProjectDataSource.ts new file mode 100644 index 00000000..d89b9ff5 --- /dev/null +++ b/src/features/projects/domain/IProjectDataSource.ts @@ -0,0 +1,5 @@ +import Project from "./Project" + +export default interface IProjectDataSource { + getProjects(): Promise +} diff --git a/src/features/projects/domain/IProjectRepository.ts b/src/features/projects/domain/IProjectRepository.ts index 1ff50ccf..a123d92e 100644 --- a/src/features/projects/domain/IProjectRepository.ts +++ b/src/features/projects/domain/IProjectRepository.ts @@ -1,5 +1,6 @@ -import IProject from "./IProject" +import Project from "./Project" -export default interface IProjectRepository { - getProjects(): Promise +export default interface IProjectRepository { + getProjects(): Promise + storeProjects(projects: Project[]): Promise } diff --git a/src/features/projects/domain/ISessionProjectRepository.ts b/src/features/projects/domain/ISessionProjectRepository.ts new file mode 100644 index 00000000..055b78e7 --- /dev/null +++ b/src/features/projects/domain/ISessionProjectRepository.ts @@ -0,0 +1,7 @@ +import Project from "./Project" + +export default interface ISessionProjectRepository { + getProjects(): Promise + storeProjects(projects: Project[]): Promise + deleteProjects(): Promise +} diff --git a/src/features/projects/domain/IVersion.ts b/src/features/projects/domain/IVersion.ts deleted file mode 100644 index bf780ca1..00000000 --- a/src/features/projects/domain/IVersion.ts +++ /dev/null @@ -1,8 +0,0 @@ -import IOpenApiSpecification from "./IOpenApiSpecification" - -export default interface IVersion { - readonly id: string - readonly name: string - readonly specifications: IOpenApiSpecification[] - readonly url?: string -} diff --git a/src/features/projects/domain/OpenApiSpecification.ts b/src/features/projects/domain/OpenApiSpecification.ts new file mode 100644 index 00000000..32ed1012 --- /dev/null +++ b/src/features/projects/domain/OpenApiSpecification.ts @@ -0,0 +1,12 @@ +import { z } from "zod" + +export const OpenApiSpecificationSchema = z.object({ + id: z.string(), + name: z.string(), + url: z.string(), + editURL: z.string().optional() +}) + +type OpenApiSpecification = z.infer + +export default OpenApiSpecification diff --git a/src/features/projects/domain/Project.ts b/src/features/projects/domain/Project.ts new file mode 100644 index 00000000..7430a897 --- /dev/null +++ b/src/features/projects/domain/Project.ts @@ -0,0 +1,14 @@ +import { z } from "zod" +import { VersionSchema } from "./Version" + +export const ProjectSchema = z.object({ + id: z.string(), + name: z.string(), + displayName: z.string().optional(), + versions: VersionSchema.array(), + imageURL: z.string().optional() +}) + +type Project = z.infer + +export default Project diff --git a/src/features/projects/domain/ProjectPageSelection.ts b/src/features/projects/domain/ProjectPageSelection.ts index 1af9ffe1..0da2e3f3 100644 --- a/src/features/projects/domain/ProjectPageSelection.ts +++ b/src/features/projects/domain/ProjectPageSelection.ts @@ -1,11 +1,11 @@ -import IProject from "../domain/IProject" -import IVersion from "../domain/IVersion" -import IOpenApiSpecification from "../domain/IOpenApiSpecification" +import Project from "../domain/Project" +import Version from "../domain/Version" +import OpenApiSpecification from "../domain/OpenApiSpecification" type ProjectPageSelection = { - readonly project: IProject - readonly version: IVersion - readonly specification: IOpenApiSpecification + readonly project: Project + readonly version: Version + readonly specification: OpenApiSpecification } export default ProjectPageSelection diff --git a/src/features/projects/domain/ProjectPageState.ts b/src/features/projects/domain/ProjectPageState.ts index 9cefd814..58a0852a 100644 --- a/src/features/projects/domain/ProjectPageState.ts +++ b/src/features/projects/domain/ProjectPageState.ts @@ -1,6 +1,6 @@ -import IProject from "./IProject" -import IVersion from "./IVersion" -import IOpenApiSpecification from "./IOpenApiSpecification" +import Project from "./Project" +import Version from "./Version" +import OpenApiSpecification from "./OpenApiSpecification" import ProjectPageSelection from "./ProjectPageSelection" export enum ProjectPageState { @@ -22,7 +22,7 @@ export type ProjectPageStateContainer = { type GetProjectPageStateProps = { isLoading?: boolean error?: Error - projects?: IProject[] + projects?: Project[] selectedProjectId?: string selectedVersionId?: string selectedSpecificationId?: string @@ -43,10 +43,6 @@ export function getProjectPageState({ return { state: ProjectPageState.ERROR, error } } projects = projects || [] - if (!selectedProjectId && projects.length == 1) { - // If no project is selected and the user only has a single project then we select that. - selectedProjectId = projects[0].id - } if (!selectedProjectId) { return { state: ProjectPageState.NO_PROJECT_SELECTED } } @@ -55,7 +51,7 @@ export function getProjectPageState({ return { state: ProjectPageState.PROJECT_NOT_FOUND } } // Find selected version or default to first version if none is selected. - let version: IVersion + let version: Version if (selectedVersionId) { const selectedVersion = project.versions.find(e => e.id == selectedVersionId) if (selectedVersion) { @@ -69,7 +65,7 @@ export function getProjectPageState({ return { state: ProjectPageState.VERSION_NOT_FOUND } } // Find selected specification or default to first specification if none is selected. - let specification: IOpenApiSpecification + let specification: OpenApiSpecification if (selectedSpecificationId) { const selectedSpecification = version.specifications.find(e => e.id == selectedSpecificationId) if (selectedSpecification) { diff --git a/src/features/projects/domain/SessionProjectRepository.ts b/src/features/projects/domain/SessionProjectRepository.ts new file mode 100644 index 00000000..5387a3c5 --- /dev/null +++ b/src/features/projects/domain/SessionProjectRepository.ts @@ -0,0 +1,29 @@ +import ZodJSONCoder from "@/common/utils/ZodJSONCoder" +import ISessionDataRepository from "@/common/userData/ISessionDataRepository" +import ISessionProjectRepository from "./ISessionProjectRepository" +import Project, { ProjectSchema } from "./Project" + +export default class SessionProjectRepository implements ISessionProjectRepository { + private readonly repository: ISessionDataRepository + + constructor(repository: ISessionDataRepository) { + this.repository = repository + } + + async getProjects(): Promise { + const string = await this.repository.get() + if (!string) { + return undefined + } + return ZodJSONCoder.decode(ProjectSchema.array(), string) + } + + async storeProjects(projects: Project[]): Promise { + const string = ZodJSONCoder.encode(ProjectSchema.array(), projects) + await this.repository.set(string) + } + + async deleteProjects(): Promise { + await this.repository.delete() + } +} diff --git a/src/features/projects/domain/Version.ts b/src/features/projects/domain/Version.ts new file mode 100644 index 00000000..4abde158 --- /dev/null +++ b/src/features/projects/domain/Version.ts @@ -0,0 +1,13 @@ +import { z } from "zod" +import { OpenApiSpecificationSchema } from "./OpenApiSpecification" + +export const VersionSchema = z.object({ + id: z.string(), + name: z.string(), + specifications: OpenApiSpecificationSchema.array(), + url: z.string().optional() +}) + +type Version = z.infer + +export default Version diff --git a/src/features/projects/view/ProjectAvatar.tsx b/src/features/projects/view/ProjectAvatar.tsx index 37847cb2..481ed74c 100644 --- a/src/features/projects/view/ProjectAvatar.tsx +++ b/src/features/projects/view/ProjectAvatar.tsx @@ -1,13 +1,13 @@ import { alpha, useTheme } from "@mui/material/styles" import { SxProps } from "@mui/system" import { Avatar } from "@mui/material" -import IProject from "../domain/IProject" +import Project from "../domain/Project" function ProjectAvatar({ project, sx }: { - project: IProject, + project: Project, sx?: SxProps }) { const theme = useTheme() diff --git a/src/features/projects/view/ProjectList.tsx b/src/features/projects/view/ProjectList.tsx index 929ab042..5aa9db5f 100644 --- a/src/features/projects/view/ProjectList.tsx +++ b/src/features/projects/view/ProjectList.tsx @@ -1,13 +1,13 @@ import { List, Box, Typography } from "@mui/material" import ProjectListItem from "./ProjectListItem" import ProjectListItemPlaceholder from "./ProjectListItemPlaceholder" -import IProject from "../domain/IProject" +import Project from "../domain/Project" interface ProjectListProps { readonly isLoading: boolean - readonly projects: IProject[] + readonly projects: Project[] readonly selectedProjectId?: string - readonly onSelectProject: (project: IProject) => void + readonly onSelectProject: (project: Project) => void } const ProjectList = ( diff --git a/src/features/projects/view/ProjectListItem.tsx b/src/features/projects/view/ProjectListItem.tsx index 94b53c36..35887e67 100644 --- a/src/features/projects/view/ProjectListItem.tsx +++ b/src/features/projects/view/ProjectListItem.tsx @@ -1,5 +1,5 @@ import { ListItem, ListItemButton, ListItemText, Typography } from "@mui/material" -import IProject from "../domain/IProject" +import Project from "../domain/Project" import ProjectAvatar from "./ProjectAvatar" const ProjectListItem = ( @@ -8,9 +8,9 @@ const ProjectListItem = ( isSelected, onSelectProject }: { - project: IProject + project: Project isSelected: boolean - onSelectProject: (project: IProject) => void + onSelectProject: (project: Project) => void } ) => { return ( diff --git a/src/features/projects/view/ProjectsPage.tsx b/src/features/projects/view/ProjectsPage.tsx new file mode 100644 index 00000000..37c41cfe --- /dev/null +++ b/src/features/projects/view/ProjectsPage.tsx @@ -0,0 +1,24 @@ +import SessionProjectRepository from "../domain/SessionProjectRepository" +import ClientProjectsPage from "./client/ProjectsPage" + +export default async function ProjectsPage({ + sessionProjectRepository, + projectId, + versionId, + specificationId +}: { + sessionProjectRepository: SessionProjectRepository + projectId?: string + versionId?: string + specificationId?: string +}) { + const projects = await sessionProjectRepository.getProjects() + return ( + + ) +} diff --git a/src/features/projects/view/ProjectsPageSecondaryContent.tsx b/src/features/projects/view/ProjectsPageSecondaryContent.tsx index 5cb8b095..360e00f5 100644 --- a/src/features/projects/view/ProjectsPageSecondaryContent.tsx +++ b/src/features/projects/view/ProjectsPageSecondaryContent.tsx @@ -12,7 +12,7 @@ const ProjectsPageSecondaryContent = ({ case ProjectPageState.NO_PROJECT_SELECTED: return <> case ProjectPageState.ERROR: - return + return case ProjectPageState.HAS_SELECTION: return case ProjectPageState.PROJECT_NOT_FOUND: diff --git a/src/features/projects/view/client/ProjectsPage.tsx b/src/features/projects/view/client/ProjectsPage.tsx index 6181f713..4cce8a7b 100644 --- a/src/features/projects/view/client/ProjectsPage.tsx +++ b/src/features/projects/view/client/ProjectsPage.tsx @@ -2,25 +2,29 @@ import { useRouter } from "next/navigation" import SidebarContainer from "@/features/sidebar/view/client/SidebarContainer" +import Project from "../../domain/Project" import ProjectList from "../ProjectList" import ProjectsPageSecondaryContent from "../ProjectsPageSecondaryContent" import ProjectsPageTrailingToolbarItem from "../ProjectsPageTrailingToolbarItem" -import IProject from "../../domain/IProject" import { getProjectPageState } from "../../domain/ProjectPageState" import projectNavigator from "../../domain/projectNavigator" import useProjects from "../../data/useProjects" -interface ProjectsPageProps { - readonly projectId?: string - readonly versionId?: string - readonly specificationId?: string -} - -export default function ProjectsPage( - { projectId, versionId, specificationId }: ProjectsPageProps -) { +export default function ProjectsPage({ + projects: serverProjects, + projectId, + versionId, + specificationId +}: { + projects?: Project[] + projectId?: string + versionId?: string + specificationId?: string +}) { const router = useRouter() - const { projects, error, isLoading } = useProjects() + const { projects: clientProjects, error, isLoading: isClientLoading } = useProjects() + const projects = isClientLoading ? (serverProjects || []) : clientProjects + const isLoading = serverProjects === undefined && isClientLoading const stateContainer = getProjectPageState({ isLoading, error, @@ -29,7 +33,7 @@ export default function ProjectsPage( selectedVersionId: versionId, selectedSpecificationId: specificationId }) - const handleProjectSelected = (project: IProject) => { + const handleProjectSelected = (project: Project) => { const version = project.versions[0] const specification = version.specifications[0] router.push(`/${project.id}/${version.id}/${specification.id}`) diff --git a/src/features/projects/view/docs/SpecificationSelector.tsx b/src/features/projects/view/docs/SpecificationSelector.tsx index 2684456f..f0723652 100644 --- a/src/features/projects/view/docs/SpecificationSelector.tsx +++ b/src/features/projects/view/docs/SpecificationSelector.tsx @@ -1,8 +1,8 @@ import { SelectChangeEvent, Select, MenuItem, FormControl } from "@mui/material" -import IOpenApiSpecification from "../../domain/IOpenApiSpecification" +import OpenApiSpecification from "../../domain/OpenApiSpecification" interface SpecificationSelectorProps { - specifications: IOpenApiSpecification[] + specifications: OpenApiSpecification[] selection: string onSelect: (specificationId: string) => void } diff --git a/src/features/projects/view/docs/VersionSelector.tsx b/src/features/projects/view/docs/VersionSelector.tsx index d5197eee..a4f3305b 100644 --- a/src/features/projects/view/docs/VersionSelector.tsx +++ b/src/features/projects/view/docs/VersionSelector.tsx @@ -1,8 +1,8 @@ import { Select, MenuItem, SelectChangeEvent, FormControl } from "@mui/material" -import IVersion from "../../domain/IVersion" +import Version from "../../domain/Version" interface VersionSelectorProps { - versions: IVersion[] + versions: Version[] selection: string onSelect: (versionId: string) => void } diff --git a/src/middleware.ts b/src/middleware.ts index 21ffc2cd..9242f401 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,7 @@ import { withMiddlewareAuthRequired } from "@auth0/nextjs-auth0/edge" export const config = { - matcher: "/((?!api/hooks|api/auth/forceLogout).*)" + matcher: "/((?!api/hooks|api/auth/logout|api/auth/forceLogout).*)" } export default withMiddlewareAuthRequired()