diff --git a/__test__/common/github/AccessTokenRefreshingGitHubClient.test.ts b/__test__/common/github/AccessTokenRefreshingGitHubClient.test.ts
index c8c7d21a..7913e1c3 100644
--- a/__test__/common/github/AccessTokenRefreshingGitHubClient.test.ts
+++ b/__test__/common/github/AccessTokenRefreshingGitHubClient.test.ts
@@ -27,7 +27,10 @@ test("It forwards a GraphQL request", async () => {
async getPullRequestComments() {
return []
},
- async addCommentToPullRequest() {}
+ async addCommentToPullRequest() {},
+ async getOrganizationMembershipStatus() {
+ return { state: "active" }
+ }
})
const request: GraphQLQueryRequest = {
query: "foo",
@@ -58,7 +61,10 @@ test("It forwards a request to get the repository content", async () => {
async getPullRequestComments() {
return []
},
- async addCommentToPullRequest() {}
+ async addCommentToPullRequest() {},
+ async getOrganizationMembershipStatus() {
+ return { state: "active" }
+ }
})
const request: GetRepositoryContentRequest = {
repositoryOwner: "foo",
@@ -91,7 +97,10 @@ test("It forwards a request to get comments to a pull request", async () => {
forwardedRequest = request
return []
},
- async addCommentToPullRequest() {}
+ async addCommentToPullRequest() {},
+ async getOrganizationMembershipStatus() {
+ return { state: "active" }
+ }
})
const request: GetPullRequestCommentsRequest = {
appInstallationId: 1234,
@@ -125,6 +134,9 @@ test("It forwards a request to add a comment to a pull request", async () => {
},
async addCommentToPullRequest(request: AddCommentToPullRequestRequest) {
forwardedRequest = request
+ },
+ async getOrganizationMembershipStatus() {
+ return { state: "active" }
}
})
const request: AddCommentToPullRequestRequest = {
@@ -164,7 +176,10 @@ test("It retries with a refreshed access token when receiving HTTP 401", async (
async getPullRequestComments() {
return []
},
- async addCommentToPullRequest() {}
+ async addCommentToPullRequest() {},
+ async getOrganizationMembershipStatus() {
+ return { state: "active" }
+ }
})
const request: GraphQLQueryRequest = {
query: "foo",
@@ -195,7 +210,10 @@ test("It only retries a request once when receiving HTTP 401", async () => {
async getPullRequestComments() {
return []
},
- async addCommentToPullRequest() {}
+ async addCommentToPullRequest() {},
+ async getOrganizationMembershipStatus() {
+ return { state: "active" }
+ }
})
const request: GraphQLQueryRequest = {
query: "foo",
@@ -229,7 +247,10 @@ test("It does not refresh an access token when the initial request was successfu
async getPullRequestComments() {
return []
},
- async addCommentToPullRequest() {}
+ async addCommentToPullRequest() {},
+ async getOrganizationMembershipStatus() {
+ return { state: "active" }
+ }
})
const request: GraphQLQueryRequest = {
query: "foo",
diff --git a/__test__/common/session/GitHubOrganizationSessionValidator.test.ts b/__test__/common/session/GitHubOrganizationSessionValidator.test.ts
new file mode 100644
index 00000000..105039de
--- /dev/null
+++ b/__test__/common/session/GitHubOrganizationSessionValidator.test.ts
@@ -0,0 +1,143 @@
+import {
+ GetOrganizationMembershipStatusRequest
+} from "../../../src/common/github/IGitHubClient"
+import GitHubOrganizationSessionValidator from "../../../src/common/session/GitHubOrganizationSessionValidator"
+
+test("It requests organization membership status for the specified organization", async () => {
+ let queriedOrganizationName: string | undefined
+ const sut = new GitHubOrganizationSessionValidator(
+ {
+ async graphql() {
+ return {}
+ },
+ async getRepositoryContent() {
+ return { downloadURL: "https://example.com" }
+ },
+ async getPullRequestComments() {
+ return []
+ },
+ async addCommentToPullRequest() {},
+ async getOrganizationMembershipStatus(request: GetOrganizationMembershipStatusRequest) {
+ queriedOrganizationName = request.organizationName
+ return { state: "active" }
+ }
+ },
+ "foo"
+ )
+ await sut.validateSession()
+ expect(queriedOrganizationName).toBe("foo")
+})
+
+test("It considers session valid when membership state is \"active\"", async () => {
+ const sut = new GitHubOrganizationSessionValidator(
+ {
+ async graphql() {
+ return {}
+ },
+ async getRepositoryContent() {
+ return { downloadURL: "https://example.com" }
+ },
+ async getPullRequestComments() {
+ return []
+ },
+ async addCommentToPullRequest() {},
+ async getOrganizationMembershipStatus() {
+ return { state: "active" }
+ }
+ },
+ "foo"
+ )
+ const isSessionValid = await sut.validateSession()
+ expect(isSessionValid).toBeTruthy()
+})
+
+test("It considers session invalid when membership state is \"pending\"", async () => {
+ const sut = new GitHubOrganizationSessionValidator(
+ {
+ async graphql() {
+ return {}
+ },
+ async getRepositoryContent() {
+ return { downloadURL: "https://example.com" }
+ },
+ async getPullRequestComments() {
+ return []
+ },
+ async addCommentToPullRequest() {},
+ async getOrganizationMembershipStatus() {
+ return { state: "pending" }
+ }
+ },
+ "foo"
+ )
+ const isSessionValid = await sut.validateSession()
+ expect(isSessionValid).toBeFalsy()
+})
+
+test("It considers session invalid when receiving HTTP 404, indicating user is not member of the organization", async () => {
+ const sut = new GitHubOrganizationSessionValidator(
+ {
+ async graphql() {
+ return {}
+ },
+ async getRepositoryContent() {
+ return { downloadURL: "https://example.com" }
+ },
+ async getPullRequestComments() {
+ return []
+ },
+ async addCommentToPullRequest() {},
+ async getOrganizationMembershipStatus() {
+ throw { status: 404, message: "User is not member of organization"}
+ }
+ },
+ "foo"
+ )
+ const isSessionValid = await sut.validateSession()
+ expect(isSessionValid).toBeFalsy()
+})
+
+test("It considers session invalid when receiving HTTP 404, indicating that the organization has blocked the GitHub app", async () => {
+ const sut = new GitHubOrganizationSessionValidator(
+ {
+ async graphql() {
+ return {}
+ },
+ async getRepositoryContent() {
+ return { downloadURL: "https://example.com" }
+ },
+ async getPullRequestComments() {
+ return []
+ },
+ async addCommentToPullRequest() {},
+ async getOrganizationMembershipStatus() {
+ throw { status: 403, message: "Organization has blocked GitHub app"}
+ }
+ },
+ "foo"
+ )
+ const isSessionValid = await sut.validateSession()
+ expect(isSessionValid).toBeFalsy()
+})
+
+test("It forwards error when getting membership status throws unknown error", async () => {
+ const sut = new GitHubOrganizationSessionValidator(
+ {
+ async graphql() {
+ return {}
+ },
+ async getRepositoryContent() {
+ return { downloadURL: "https://example.com" }
+ },
+ async getPullRequestComments() {
+ return []
+ },
+ async addCommentToPullRequest() {},
+ async getOrganizationMembershipStatus() {
+ throw { status: 500 }
+ }
+ },
+ "foo"
+ )
+ await expect(sut.validateSession()).rejects.toEqual({ status: 500 })
+})
\ No newline at end of file
diff --git a/__test__/projects/SessionValidatingProjectDataSource.test.ts b/__test__/projects/SessionValidatingProjectDataSource.test.ts
new file mode 100644
index 00000000..f0f96dc7
--- /dev/null
+++ b/__test__/projects/SessionValidatingProjectDataSource.test.ts
@@ -0,0 +1,46 @@
+import SessionValidatingProjectDataSource from "../../src/features/projects/domain/SessionValidatingProjectDataSource"
+
+test("It validates the session", async () => {
+ let didValidateSession = false
+ const sut = new SessionValidatingProjectDataSource({
+ async validateSession() {
+ didValidateSession = true
+ return true
+ },
+ }, {
+ async getProjects() {
+ return []
+ }
+ })
+ await sut.getProjects()
+ expect(didValidateSession).toBeTruthy()
+})
+
+test("It fetches projects when session is valid", async () => {
+ let didFetchProjects = false
+ const sut = new SessionValidatingProjectDataSource({
+ async validateSession() {
+ return true
+ },
+ }, {
+ async getProjects() {
+ didFetchProjects = true
+ return []
+ }
+ })
+ await sut.getProjects()
+ expect(didFetchProjects).toBeTruthy()
+})
+
+test("It throws error when session is invalid", async () => {
+ const sut = new SessionValidatingProjectDataSource({
+ async validateSession() {
+ return false
+ },
+ }, {
+ async getProjects() {
+ return []
+ }
+ })
+ expect(sut.getProjects()).rejects.toThrowError()
+})
diff --git a/src/app/api/user/projects/route.ts b/src/app/api/user/projects/route.ts
index a0ac3816..799f13f6 100644
--- a/src/app/api/user/projects/route.ts
+++ b/src/app/api/user/projects/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"
import { projectDataSource } from "@/composition"
-import { UnauthorizedError } from "@/features/auth/domain/AuthError"
+import { UnauthorizedError, InvalidSessionError } from "@/common/errors"
export async function GET() {
try {
@@ -8,20 +8,17 @@ export async function GET() {
return NextResponse.json({projects})
} catch (error) {
if (error instanceof UnauthorizedError) {
- return NextResponse.json({
- status: 401,
- message: error.message
- }, { status: 401 })
+ return errorResponse(401, error.message)
+ } else if (error instanceof InvalidSessionError) {
+ return errorResponse(403, error.message)
} else if (error instanceof Error) {
- return NextResponse.json({
- status: 500,
- message: error.message
- }, { status: 500 })
+ return errorResponse(500, error.message)
} else {
- return NextResponse.json({
- status: 500,
- message: "Unknown error"
- }, { status: 500 })
+ return errorResponse(500, "Unknown error")
}
}
}
+
+function errorResponse(status: number, message: string): NextResponse {
+ return NextResponse.json({ status, message }, { status })
+}
diff --git a/src/app/invalid-session/page.tsx b/src/app/invalid-session/page.tsx
new file mode 100644
index 00000000..ee33fec5
--- /dev/null
+++ b/src/app/invalid-session/page.tsx
@@ -0,0 +1,5 @@
+import InvalidSessionPage from "@/features/auth/view/InvalidSessionPage"
+
+export default async function Page() {
+ return
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 6d89e3d4..1760b426 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -4,7 +4,7 @@ import { UserProvider } from "@auth0/nextjs-auth0/client"
import { config as fontAwesomeConfig } from "@fortawesome/fontawesome-svg-core"
import { CssBaseline } from "@mui/material"
import ThemeRegistry from "@/common/theme/ThemeRegistry"
-import ErrorHandler from "@/common/errorHandling/client/ErrorHandler"
+import ErrorHandler from "@/common/errors/client/ErrorHandler"
import "@fortawesome/fontawesome-svg-core/styles.css"
fontAwesomeConfig.autoAddCss = false
diff --git a/src/common/errorHandling/client/ErrorHandler.tsx b/src/common/errors/client/ErrorHandler.tsx
similarity index 66%
rename from src/common/errorHandling/client/ErrorHandler.tsx
rename to src/common/errors/client/ErrorHandler.tsx
index 83b68d45..20ba19a3 100644
--- a/src/common/errorHandling/client/ErrorHandler.tsx
+++ b/src/common/errors/client/ErrorHandler.tsx
@@ -9,10 +9,13 @@ export default function ErrorHandler({
children: React.ReactNode
}) {
const onSWRError = (error: FetcherError) => {
+ if (typeof window === "undefined") {
+ return
+ }
if (error.status == 401) {
- if (typeof window !== "undefined") {
- window.location.href = "/api/auth/logout"
- }
+ window.location.href = "/api/auth/logout"
+ } else if (error.status == 403) {
+ window.location.href = "/invalid-session"
}
}
return (
diff --git a/src/common/errors/index.ts b/src/common/errors/index.ts
new file mode 100644
index 00000000..74acb8d0
--- /dev/null
+++ b/src/common/errors/index.ts
@@ -0,0 +1,2 @@
+export class UnauthorizedError extends Error {}
+export class InvalidSessionError extends Error {}
diff --git a/src/common/github/AccessTokenRefreshingGitHubClient.ts b/src/common/github/AccessTokenRefreshingGitHubClient.ts
index e9a3a29a..e5e4a9e7 100644
--- a/src/common/github/AccessTokenRefreshingGitHubClient.ts
+++ b/src/common/github/AccessTokenRefreshingGitHubClient.ts
@@ -4,6 +4,8 @@ import IGitHubClient, {
GraphQlQueryResponse,
GetRepositoryContentRequest,
GetPullRequestCommentsRequest,
+ GetOrganizationMembershipStatusRequest,
+ GetOrganizationMembershipStatusRequestResponse,
AddCommentToPullRequestRequest,
RepositoryContent,
PullRequestComment
@@ -60,6 +62,14 @@ export default class AccessTokenRefreshingGitHubClient implements IGitHubClient
})
}
+ async getOrganizationMembershipStatus(
+ request: GetOrganizationMembershipStatusRequest
+ ): Promise {
+ return await this.send(async () => {
+ return await this.gitHubClient.getOrganizationMembershipStatus(request)
+ })
+ }
+
private async send(fn: () => Promise): Promise {
const accessToken = await this.accessTokenReader.getAccessToken()
try {
diff --git a/src/common/github/GitHubClient.ts b/src/common/github/GitHubClient.ts
index 2b03cbe2..cec899e9 100644
--- a/src/common/github/GitHubClient.ts
+++ b/src/common/github/GitHubClient.ts
@@ -6,6 +6,8 @@ import IGitHubClient, {
GetRepositoryContentRequest,
GetPullRequestCommentsRequest,
AddCommentToPullRequestRequest,
+ GetOrganizationMembershipStatusRequest,
+ GetOrganizationMembershipStatusRequestResponse,
RepositoryContent,
PullRequestComment
} from "./IGitHubClient"
@@ -90,4 +92,15 @@ export default class GitHubClient implements IGitHubClient {
body: request.body
})
}
+
+ async getOrganizationMembershipStatus(
+ request: GetOrganizationMembershipStatusRequest
+ ): Promise {
+ const accessToken = await this.accessTokenReader.getAccessToken()
+ const octokit = new Octokit({ auth: accessToken })
+ const response = await octokit.rest.orgs.getMembershipForAuthenticatedUser({
+ org: request.organizationName
+ })
+ return { state: response.data.state }
+ }
}
diff --git a/src/common/github/IGitHubClient.ts b/src/common/github/IGitHubClient.ts
index 9537786e..1b349fc6 100644
--- a/src/common/github/IGitHubClient.ts
+++ b/src/common/github/IGitHubClient.ts
@@ -40,9 +40,18 @@ export type AddCommentToPullRequestRequest = {
readonly body: string
}
+export type GetOrganizationMembershipStatusRequest = {
+ readonly organizationName: string
+}
+
+export type GetOrganizationMembershipStatusRequestResponse = {
+ readonly state: "active" | "pending"
+}
+
export default interface IGitHubClient {
graphql(request: GraphQLQueryRequest): Promise
getRepositoryContent(request: GetRepositoryContentRequest): Promise
getPullRequestComments(request: GetPullRequestCommentsRequest): Promise
addCommentToPullRequest(request: AddCommentToPullRequestRequest): Promise
+ getOrganizationMembershipStatus(request: GetOrganizationMembershipStatusRequest): Promise
}
diff --git a/src/common/session/Auth0Session.ts b/src/common/session/Auth0Session.ts
index 4131b040..0971235b 100644
--- a/src/common/session/Auth0Session.ts
+++ b/src/common/session/Auth0Session.ts
@@ -1,6 +1,6 @@
import { getSession } from "@auth0/nextjs-auth0"
+import { UnauthorizedError } from "@/common/errors"
import ISession from "./ISession"
-import { UnauthorizedError } from "@/features/auth/domain/AuthError"
export default class Auth0Session implements ISession {
async getUserId(): Promise {
diff --git a/src/common/session/GitHubOrganizationSessionValidator.ts b/src/common/session/GitHubOrganizationSessionValidator.ts
new file mode 100644
index 00000000..308ff043
--- /dev/null
+++ b/src/common/session/GitHubOrganizationSessionValidator.ts
@@ -0,0 +1,34 @@
+import IGitHubClient from "../github/IGitHubClient"
+import ISessionValidator from "./ISessionValidator"
+
+export default class GitHubOrganizationSessionValidator implements ISessionValidator {
+ private readonly gitHubClient: IGitHubClient
+ private readonly acceptedOrganization: string
+
+ constructor(gitHubClient: IGitHubClient, acceptedOrganization: string) {
+ this.gitHubClient = gitHubClient
+ this.acceptedOrganization = acceptedOrganization
+ }
+
+ async validateSession(): Promise {
+ try {
+ const response = await this.gitHubClient.getOrganizationMembershipStatus({
+ organizationName: this.acceptedOrganization
+ })
+ return response.state == "active"
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+ } catch (error: any) {
+ if (error.status) {
+ if (error.status == 404) {
+ return false
+ } else if (error.status == 403) {
+ return false
+ } else {
+ throw error
+ }
+ } else {
+ throw error
+ }
+ }
+ }
+}
diff --git a/src/common/session/ISessionValidator.ts b/src/common/session/ISessionValidator.ts
new file mode 100644
index 00000000..b51cd5e6
--- /dev/null
+++ b/src/common/session/ISessionValidator.ts
@@ -0,0 +1,3 @@
+export default interface ISessionValidator {
+ validateSession(): Promise
+}
diff --git a/src/composition.ts b/src/composition.ts
index 37a60313..dcfac50c 100644
--- a/src/composition.ts
+++ b/src/composition.ts
@@ -4,6 +4,7 @@ import Auth0Session from "@/common/session/Auth0Session"
import CachingProjectDataSource from "@/features/projects/domain/CachingProjectDataSource"
import GitHubClient from "@/common/github/GitHubClient"
import GitHubOAuthTokenRefresher from "@/features/auth/data/GitHubOAuthTokenRefresher"
+import GitHubOrganizationSessionValidator from "@/common/session/GitHubOrganizationSessionValidator"
import GitHubProjectDataSource from "@/features/projects/data/GitHubProjectDataSource"
import InitialOAuthTokenService from "@/features/auth/domain/InitialOAuthTokenService"
import KeyValueUserDataRepository from "@/common/userData/KeyValueUserDataRepository"
@@ -15,6 +16,7 @@ import SessionDataRepository from "@/common/userData/SessionDataRepository"
import SessionMutexFactory from "@/common/mutex/SessionMutexFactory"
import SessionOAuthTokenRepository from "@/features/auth/domain/SessionOAuthTokenRepository"
import SessionProjectRepository from "@/features/projects/domain/SessionProjectRepository"
+import SessionValidatingProjectDataSource from "@/features/projects/domain/SessionValidatingProjectDataSource"
import OAuthTokenRepository from "@/features/auth/domain/OAuthTokenRepository"
import authLogoutHandler from "@/common/authHandler/logout"
@@ -70,6 +72,11 @@ export const gitHubClient = new AccessTokenRefreshingGitHubClient(
})
)
+export const sessionValidator = new GitHubOrganizationSessionValidator(
+ gitHubClient,
+ GITHUB_ORGANIZATION_NAME
+)
+
export const sessionProjectRepository = new SessionProjectRepository(
new SessionDataRepository(
new Auth0Session(),
@@ -81,9 +88,12 @@ export const sessionProjectRepository = new SessionProjectRepository(
)
export const projectDataSource = new CachingProjectDataSource(
- new GitHubProjectDataSource(
- gitHubClient,
- GITHUB_ORGANIZATION_NAME
+ new SessionValidatingProjectDataSource(
+ sessionValidator,
+ new GitHubProjectDataSource(
+ gitHubClient,
+ GITHUB_ORGANIZATION_NAME
+ )
),
sessionProjectRepository
)
diff --git a/src/features/auth/data/Auth0RefreshTokenReader.ts b/src/features/auth/data/Auth0RefreshTokenReader.ts
index 57bf2cde..79fd6bfb 100644
--- a/src/features/auth/data/Auth0RefreshTokenReader.ts
+++ b/src/features/auth/data/Auth0RefreshTokenReader.ts
@@ -1,6 +1,6 @@
import { ManagementClient } from "auth0"
+import { UnauthorizedError } from "@/common/errors"
import IRefreshTokenReader from "../domain/IRefreshTokenReader"
-import { UnauthorizedError } from "../domain/AuthError"
interface Auth0RefreshTokenReaderConfig {
domain: string
diff --git a/src/features/auth/data/GitHubOAuthTokenRefresher.ts b/src/features/auth/data/GitHubOAuthTokenRefresher.ts
index 68dd12c4..d5e88607 100644
--- a/src/features/auth/data/GitHubOAuthTokenRefresher.ts
+++ b/src/features/auth/data/GitHubOAuthTokenRefresher.ts
@@ -1,6 +1,6 @@
+import { UnauthorizedError } from "@/common/errors"
import OAuthToken from "../domain/OAuthToken"
import IOAuthTokenRefresher from "../domain/IOAuthTokenRefresher"
-import { UnauthorizedError } from "../domain/AuthError"
export interface GitHubOAuthTokenRefresherConfig {
readonly clientId: string
diff --git a/src/features/auth/domain/AuthError.ts b/src/features/auth/domain/AuthError.ts
deleted file mode 100644
index 2e482071..00000000
--- a/src/features/auth/domain/AuthError.ts
+++ /dev/null
@@ -1 +0,0 @@
-export class UnauthorizedError extends Error {}
diff --git a/src/features/auth/domain/OAuthTokenRepository.ts b/src/features/auth/domain/OAuthTokenRepository.ts
index d8bdfe17..fd416386 100644
--- a/src/features/auth/domain/OAuthTokenRepository.ts
+++ b/src/features/auth/domain/OAuthTokenRepository.ts
@@ -1,7 +1,7 @@
import ZodJSONCoder from "@/common/utils/ZodJSONCoder"
import IUserDataRepository from "@/common/userData/IUserDataRepository"
+import { UnauthorizedError } from "@/common/errors"
import IOAuthTokenRepository from "./IOAuthTokenRepository"
-import { UnauthorizedError } from "./AuthError"
import OAuthToken, { OAuthTokenSchema } from "./OAuthToken"
export default class OAuthTokenRepository implements IOAuthTokenRepository {
diff --git a/src/features/auth/domain/SessionOAuthTokenRepository.ts b/src/features/auth/domain/SessionOAuthTokenRepository.ts
index c7942598..2f5d0905 100644
--- a/src/features/auth/domain/SessionOAuthTokenRepository.ts
+++ b/src/features/auth/domain/SessionOAuthTokenRepository.ts
@@ -1,7 +1,7 @@
+import { UnauthorizedError } from "../../../common/errors"
import ZodJSONCoder from "../../../common/utils/ZodJSONCoder"
import ISessionDataRepository from "@/common/userData/ISessionDataRepository"
import ISessionOAuthTokenRepository from "./SessionOAuthTokenRepository"
-import { UnauthorizedError } from "./AuthError"
import OAuthToken, { OAuthTokenSchema } from "./OAuthToken"
export default class SessionOAuthTokenRepository implements ISessionOAuthTokenRepository {
diff --git a/src/features/auth/view/InvalidSessionPage.tsx b/src/features/auth/view/InvalidSessionPage.tsx
new file mode 100644
index 00000000..99292a13
--- /dev/null
+++ b/src/features/auth/view/InvalidSessionPage.tsx
@@ -0,0 +1,22 @@
+import { redirect } from "next/navigation"
+import { sessionValidator } from "@/composition"
+import InvalidSession from "./client/InvalidSession"
+
+const {
+ NEXT_PUBLIC_SHAPE_DOCS_TITLE,
+ GITHUB_ORGANIZATION_NAME
+} = process.env
+
+export default async function InvalidSessionPage() {
+ const isSessionValid = await sessionValidator.validateSession()
+ if (isSessionValid) {
+ // User ended up here by mistake so lets send them to the front page.
+ redirect("/")
+ }
+ return (
+
+ )
+}
diff --git a/src/features/auth/view/client/InvalidSession.tsx b/src/features/auth/view/client/InvalidSession.tsx
new file mode 100644
index 00000000..76cf8416
--- /dev/null
+++ b/src/features/auth/view/client/InvalidSession.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+import { Button, Stack, Typography } from "@mui/material"
+
+export default function InvalidSession({
+ siteName,
+ organizationName
+}: {
+ siteName: string
+ organizationName: string
+}) {
+ const navigateToFrontPage = () => {
+ if (typeof window !== "undefined") {
+ window.location.href = "/api/auth/logout"
+ }
+ }
+ return (
+
+
+
+ Your account does not have access to {siteName}
+
+
+ Access to {siteName} requires that your account is an active member of the {organizationName} organization on GitHub.
+
+
+
+
+ )
+}
diff --git a/src/features/projects/domain/SessionValidatingProjectDataSource.ts b/src/features/projects/domain/SessionValidatingProjectDataSource.ts
new file mode 100644
index 00000000..942d0859
--- /dev/null
+++ b/src/features/projects/domain/SessionValidatingProjectDataSource.ts
@@ -0,0 +1,25 @@
+import { InvalidSessionError } from "../../../common/errors"
+import ISessionValidator from "@/common/session/ISessionValidator"
+import IProjectDataSource from "../domain/IProjectDataSource"
+import Project from "../domain/Project"
+
+export default class SessionValidatingProjectDataSource implements IProjectDataSource {
+ private readonly sessionValidator: ISessionValidator
+ private readonly projectDataSource: IProjectDataSource
+
+ constructor(
+ sessionValidator: ISessionValidator,
+ projectDataSource: IProjectDataSource
+ ) {
+ this.sessionValidator = sessionValidator
+ this.projectDataSource = projectDataSource
+ }
+
+ async getProjects(): Promise {
+ const isValid = await this.sessionValidator.validateSession()
+ if (!isValid) {
+ throw new InvalidSessionError()
+ }
+ return await this.projectDataSource.getProjects()
+ }
+}