diff --git a/__test__/auth/CachingRepositoryAccessReaderConfig.ts b/__test__/auth/CachingRepositoryAccessReaderConfig.test.ts
similarity index 85%
rename from __test__/auth/CachingRepositoryAccessReaderConfig.ts
rename to __test__/auth/CachingRepositoryAccessReaderConfig.test.ts
index 8833cc64..f4b4f2a1 100644
--- a/__test__/auth/CachingRepositoryAccessReaderConfig.ts
+++ b/__test__/auth/CachingRepositoryAccessReaderConfig.test.ts
@@ -1,9 +1,9 @@
-import { CachingRepositoryAccessReaderConfig } from "../../src/features/auth/domain"
+import { CachingRepositoryAccessReader } from "../../src/features/auth/domain"
test("It fetches repository names for user if they are not cached", async () => {
let didFetchRepositoryNames = false
let requestedUserId: string | undefined
- const sut = new CachingRepositoryAccessReaderConfig({
+ const sut = new CachingRepositoryAccessReader({
repository: {
async get() {
return null
@@ -27,7 +27,7 @@ test("It fetches repository names for user if they are not cached", async () =>
test("It does not fetch repository names if they are cached", async () => {
let didFetchRepositoryNames = false
- const sut = new CachingRepositoryAccessReaderConfig({
+ const sut = new CachingRepositoryAccessReader({
repository: {
async get() {
return "[\"foo\"]"
@@ -50,16 +50,16 @@ test("It does not fetch repository names if they are cached", async () => {
test("It caches fetched repository names for user", async () => {
let cachedUserId: string | undefined
let cachedRepositoryNames: string | undefined
- const sut = new CachingRepositoryAccessReaderConfig({
+ const sut = new CachingRepositoryAccessReader({
repository: {
async get() {
return null
},
- async set(userId, value) {
+ async set() {},
+ async setExpiring(userId: string, value: string) {
cachedUserId = userId
cachedRepositoryNames = value
},
- async setExpiring() {},
async delete() {}
},
repositoryAccessReader: {
@@ -74,7 +74,7 @@ test("It caches fetched repository names for user", async () => {
})
test("It decodes cached repository names", async () => {
- const sut = new CachingRepositoryAccessReaderConfig({
+ const sut = new CachingRepositoryAccessReader({
repository: {
async get() {
return "[\"foo\",\"bar\"]"
diff --git a/__test__/auth/GitHubOrganizationSessionValidator.test.ts b/__test__/auth/GitHubOrganizationSessionValidator.test.ts
new file mode 100644
index 00000000..9287ae6c
--- /dev/null
+++ b/__test__/auth/GitHubOrganizationSessionValidator.test.ts
@@ -0,0 +1,83 @@
+import {
+ GitHubOrganizationSessionValidator,
+ SessionValidity
+ } from "../../src/features/auth/domain"
+
+test("It requests organization membership status for the specified organization", async () => {
+ let queriedOrganizationName: string | undefined
+ const sut = new GitHubOrganizationSessionValidator({
+ acceptedOrganization: "foo",
+ organizationMembershipStatusReader: {
+ async getOrganizationMembershipStatus(request) {
+ queriedOrganizationName = request.organizationName
+ return { state: "active" }
+ }
+ }
+ })
+ await sut.validateSession()
+ expect(queriedOrganizationName).toBe("foo")
+})
+
+test("It considers session valid when membership state is \"active\"", async () => {
+ const sut = new GitHubOrganizationSessionValidator({
+ acceptedOrganization: "foo",
+ organizationMembershipStatusReader: {
+ async getOrganizationMembershipStatus() {
+ return { state: "active" }
+ }
+ }
+ })
+ const sessionValidity = await sut.validateSession()
+ expect(sessionValidity).toEqual(SessionValidity.VALID)
+})
+
+test("It considers user not to be part of the organization when membership state is \"pending\"", async () => {
+ const sut = new GitHubOrganizationSessionValidator({
+ acceptedOrganization: "foo",
+ organizationMembershipStatusReader: {
+ async getOrganizationMembershipStatus() {
+ return { state: "pending" }
+ }
+ }
+ })
+ const sessionValidity = await sut.validateSession()
+ expect(sessionValidity).toEqual(SessionValidity.OUTSIDE_GITHUB_ORGANIZATION)
+})
+
+test("It considers user not to be part of the organization when receiving HTTP 404", async () => {
+ const sut = new GitHubOrganizationSessionValidator({
+ acceptedOrganization: "foo",
+ organizationMembershipStatusReader: {
+ async getOrganizationMembershipStatus() {
+ throw { status: 404, message: "User is not member of organization"}
+ }
+ }
+ })
+ const sessionValidity = await sut.validateSession()
+ expect(sessionValidity).toEqual(SessionValidity.OUTSIDE_GITHUB_ORGANIZATION)
+})
+
+test("It considers organization to have blocked the GitHub app when receiving HTTP 403", async () => {
+ const sut = new GitHubOrganizationSessionValidator({
+ acceptedOrganization: "foo",
+ organizationMembershipStatusReader: {
+ async getOrganizationMembershipStatus() {
+ throw { status: 403, message: "Organization has blocked GitHub app"}
+ }
+ }
+ })
+ const sessionValidity = await sut.validateSession()
+ expect(sessionValidity).toEqual(SessionValidity.GITHUB_APP_BLOCKED)
+})
+
+test("It forwards error when getting membership status throws unknown error", async () => {
+ const sut = new GitHubOrganizationSessionValidator({
+ acceptedOrganization: "foo",
+ organizationMembershipStatusReader: {
+ async getOrganizationMembershipStatus() {
+ throw { status: 500 }
+ }
+ }
+ })
+ await expect(sut.validateSession()).rejects.toEqual({ status: 500 })
+})
diff --git a/__test__/auth/GuestAccessTokenRepository.test.ts b/__test__/auth/GuestAccessTokenRepository.test.ts
new file mode 100644
index 00000000..5c089cd3
--- /dev/null
+++ b/__test__/auth/GuestAccessTokenRepository.test.ts
@@ -0,0 +1,52 @@
+import { GuestAccessTokenRepository } from "../../src/features/auth/domain"
+
+test("It reads access token for user", async () => {
+ let readUserId: string | undefined
+ const sut = new GuestAccessTokenRepository({
+ async get(userId) {
+ readUserId = userId
+ return "foo"
+ },
+ async setExpiring() {},
+ async delete() {}
+ })
+ const accessToken = await sut.get("1234")
+ expect(readUserId).toBe("1234")
+ expect(accessToken).toBe("foo")
+})
+
+test("It stores access token for user", async () => {
+ let storedUserId: string | undefined
+ let storedToken: string | undefined
+ let storedTimeToLive: number | undefined
+ const sut = new GuestAccessTokenRepository({
+ async get() {
+ return "foo"
+ },
+ async setExpiring(userId, token, timeToLive) {
+ storedUserId = userId
+ storedToken = token
+ storedTimeToLive = timeToLive
+ },
+ async delete(userId) {}
+ })
+ await sut.set("1234", "bar")
+ expect(storedUserId).toBe("1234")
+ expect(storedToken).toBe("bar")
+ expect(storedTimeToLive).toBeGreaterThan(0)
+})
+
+test("It deletes access token for user", async () => {
+ let deletedUserId: string | undefined
+ const sut = new GuestAccessTokenRepository({
+ async get() {
+ return "foo"
+ },
+ async setExpiring() {},
+ async delete(userId) {
+ deletedUserId = userId
+ }
+ })
+ await sut.delete("1234")
+ expect(deletedUserId).toBe("1234")
+})
diff --git a/__test__/auth/GuestAccessTokenService.test.ts b/__test__/auth/GuestAccessTokenService.test.ts
index ceeceb4a..407ac26e 100644
--- a/__test__/auth/GuestAccessTokenService.test.ts
+++ b/__test__/auth/GuestAccessTokenService.test.ts
@@ -13,7 +13,7 @@ test("It gets the access token for the user", async () => {
readUserId = userId
return "foo"
},
- async setExpiring() {}
+ async set() {}
},
dataSource: {
async getAccessToken() {
@@ -25,28 +25,3 @@ test("It gets the access token for the user", async () => {
expect(readUserId).toBe("1234")
expect(accessToken).toBe("foo")
})
-
-test("It refreshes access token on demand when there is no cached access token", async () => {
- let didRefreshAccessToken = false
- const sut = new GuestAccessTokenService({
- userIdReader: {
- async getUserId() {
- return "1234"
- }
- },
- repository: {
- async get() {
- return null
- },
- async setExpiring() {}
- },
- dataSource: {
- async getAccessToken() {
- didRefreshAccessToken = true
- return "foo"
- }
- }
- })
- await sut.getAccessToken()
- expect(didRefreshAccessToken).toBeTruthy()
-})
diff --git a/__test__/auth/HostOnlySessionValidator.test.ts b/__test__/auth/HostOnlySessionValidator.test.ts
new file mode 100644
index 00000000..daf60611
--- /dev/null
+++ b/__test__/auth/HostOnlySessionValidator.test.ts
@@ -0,0 +1,43 @@
+import {
+ HostOnlySessionValidator,
+ SessionValidity
+} from "../../src/features/auth/domain"
+
+test("It validates session when user is host", async () => {
+ let didValidateSession = false
+ const sut = new HostOnlySessionValidator({
+ isGuestReader: {
+ async getIsGuest() {
+ return false
+ }
+ },
+ sessionValidator: {
+ async validateSession() {
+ didValidateSession = true
+ return SessionValidity.VALID
+ },
+ }
+ })
+ await sut.validateSession()
+ expect(didValidateSession).toBeTruthy()
+})
+
+
+test("It does not validate session when user is guest", async () => {
+ let didValidateSession = false
+ const sut = new HostOnlySessionValidator({
+ isGuestReader: {
+ async getIsGuest() {
+ return true
+ }
+ },
+ sessionValidator: {
+ async validateSession() {
+ didValidateSession = true
+ return SessionValidity.VALID
+ },
+ }
+ })
+ await sut.validateSession()
+ expect(didValidateSession).toBeFalsy()
+})
diff --git a/__test__/auth/RepositoryRestrictingAccessTokenDataSource.ts b/__test__/auth/RepositoryRestrictingAccessTokenDataSource.test.ts
similarity index 100%
rename from __test__/auth/RepositoryRestrictingAccessTokenDataSource.ts
rename to __test__/auth/RepositoryRestrictingAccessTokenDataSource.test.ts
diff --git a/__test__/auth/SessionValidity.test.ts b/__test__/auth/SessionValidity.test.ts
new file mode 100644
index 00000000..c411f973
--- /dev/null
+++ b/__test__/auth/SessionValidity.test.ts
@@ -0,0 +1,28 @@
+import {
+ mergeSessionValidity,
+ SessionValidity
+} from "../../src/features/auth/domain"
+
+test("It returns invalid validity when left-hand side validity indicates that the session is invalid", async () => {
+ const sut = mergeSessionValidity(
+ SessionValidity.INVALID_ACCESS_TOKEN,
+ SessionValidity.VALID
+ )
+ expect(sut).toEqual(SessionValidity.INVALID_ACCESS_TOKEN)
+})
+
+test("It returns invalid validity when right-hand side validity indicates that the session is invalid", async () => {
+ const sut = mergeSessionValidity(
+ SessionValidity.VALID,
+ SessionValidity.INVALID_ACCESS_TOKEN
+ )
+ expect(sut).toEqual(SessionValidity.INVALID_ACCESS_TOKEN)
+})
+
+test("It returns valid validity when both validities indicate that the session is valid", async () => {
+ const sut = mergeSessionValidity(
+ SessionValidity.VALID,
+ SessionValidity.VALID
+ )
+ expect(sut).toEqual(SessionValidity.VALID)
+})
diff --git a/__test__/common/session/GitHubOrganizationSessionValidator.test.ts b/__test__/common/session/GitHubOrganizationSessionValidator.test.ts
deleted file mode 100644
index 5e530047..00000000
--- a/__test__/common/session/GitHubOrganizationSessionValidator.test.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import {
- GetOrganizationMembershipStatusRequest
-} from "../../../src/common/github/IGitHubClient"
-import { GitHubOrganizationSessionValidator } from "../../../src/common"
-
-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__/common/session/SessionValidator.test.ts b/__test__/common/session/SessionValidator.test.ts
deleted file mode 100644
index 057bd6cd..00000000
--- a/__test__/common/session/SessionValidator.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { SessionValidator } from "../../../src/common"
-
-test("It validates a host user", async () => {
- let didValidateHostUser = false
- const sut = new SessionValidator({
- isGuestReader: {
- async getIsGuest() {
- return false
- }
- },
- guestSessionValidator: {
- async validateSession() {
- return true
- },
- },
- hostSessionValidator: {
- async validateSession() {
- didValidateHostUser = true
- return true
- },
- }
- })
- await sut.validateSession()
- expect(didValidateHostUser).toBeTruthy()
-})
-
-test("It validates a guest user", async () => {
- let didValidateGuestUser = false
- const sut = new SessionValidator({
- isGuestReader: {
- async getIsGuest() {
- return true
- }
- },
- guestSessionValidator: {
- async validateSession() {
- didValidateGuestUser = true
- return true
- },
- },
- hostSessionValidator: {
- async validateSession() {
- return true
- },
- }
- })
- await sut.validateSession()
- expect(didValidateGuestUser).toBeTruthy()
-})
diff --git a/__test__/projects/SessionValidatingProjectDataSource.test.ts b/__test__/projects/SessionValidatingProjectDataSource.test.ts
deleted file mode 100644
index 7050cb78..00000000
--- a/__test__/projects/SessionValidatingProjectDataSource.test.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { SessionValidatingProjectDataSource } from "../../src/features/projects/domain"
-
-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/[...slug]/page.tsx b/src/app/[...slug]/page.tsx
index cfcb585e..984a5cc9 100644
--- a/src/app/[...slug]/page.tsx
+++ b/src/app/[...slug]/page.tsx
@@ -1,5 +1,5 @@
import { getProjectId, getSpecificationId, getVersionId } from "../../common"
-import SessionAccessTokenBarrier from "@/features/auth/view/SessionAccessTokenBarrier"
+import SessionBarrier from "@/features/auth/view/SessionBarrier"
import ProjectsPage from "@/features/projects/view/ProjectsPage"
import { projectRepository } from "@/composition"
@@ -8,14 +8,14 @@ type PageParams = { slug: string | string[] }
export default async function Page({ params }: { params: PageParams }) {
const url = getURL(params)
return (
-
+
-
+
)
}
diff --git a/src/app/api/user/projects/route.ts b/src/app/api/user/projects/route.ts
index 64a96f68..8adfab66 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 { makeAPIErrorResponse, UnauthorizedError } from "../../../../common"
import { projectDataSource } from "@/composition"
-import { UnauthorizedError, InvalidSessionError } from "../../../../common"
export async function GET() {
try {
@@ -8,17 +8,11 @@ export async function GET() {
return NextResponse.json({projects})
} catch (error) {
if (error instanceof UnauthorizedError) {
- return errorResponse(401, error.message)
- } else if (error instanceof InvalidSessionError) {
- return errorResponse(403, error.message)
+ return makeAPIErrorResponse(401, error.message)
} else if (error instanceof Error) {
- return errorResponse(500, error.message)
+ return makeAPIErrorResponse(500, error.message)
} else {
- return errorResponse(500, "Unknown error")
+ return makeAPIErrorResponse(500, "Unknown error")
}
}
-}
-
-function errorResponse(status: number, message: string): NextResponse {
- return NextResponse.json({ status, message }, { status })
-}
+}
\ No newline at end of file
diff --git a/src/app/api/user/repository-access/route.ts b/src/app/api/user/repository-access/route.ts
new file mode 100644
index 00000000..697d4ee0
--- /dev/null
+++ b/src/app/api/user/repository-access/route.ts
@@ -0,0 +1,23 @@
+import { NextResponse } from "next/server"
+import { makeAPIErrorResponse } from "@/common"
+import { session, guestRepositoryAccessReader } from "@/composition"
+
+export async function GET() {
+ let userId: string
+ try {
+ userId = await session.getUserId()
+ } catch {
+ return makeAPIErrorResponse(401, "Unauthorized")
+ }
+ try {
+ const repositoryNames = await guestRepositoryAccessReader.getRepositoryNames(userId)
+ return NextResponse.json({repositories: repositoryNames})
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+ } catch (error: any) {
+ if (error.message) {
+ return makeAPIErrorResponse(500, error.message)
+ } else {
+ return makeAPIErrorResponse(500, "Unknown error")
+ }
+ }
+}
diff --git a/src/app/api/user/session-validity/route.ts b/src/app/api/user/session-validity/route.ts
new file mode 100644
index 00000000..aa5d2e97
--- /dev/null
+++ b/src/app/api/user/session-validity/route.ts
@@ -0,0 +1,22 @@
+import { NextResponse } from "next/server"
+import { makeAPIErrorResponse } from "@/common"
+import { session, delayedSessionValidator } from "@/composition"
+
+export async function GET() {
+ try {
+ await session.getUserId()
+ } catch {
+ return makeAPIErrorResponse(401, "Unauthorized")
+ }
+ try {
+ const sessionValidity = await delayedSessionValidator.validateSession()
+ return NextResponse.json({sessionValidity})
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+ } catch (error: any) {
+ if (error.message) {
+ return makeAPIErrorResponse(500, error.message)
+ } else {
+ return makeAPIErrorResponse(500, "Unknown error")
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/app/invalid-session/page.tsx b/src/app/invalid-session/page.tsx
deleted file mode 100644
index ee33fec5..00000000
--- a/src/app/invalid-session/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import InvalidSessionPage from "@/features/auth/view/InvalidSessionPage"
-
-export default async function Page() {
- return
-}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 55087f2f..01dfa211 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,11 +1,11 @@
-import SessionAccessTokenBarrier from "@/features/auth/view/SessionAccessTokenBarrier"
+import SessionBarrier from "@/features/auth/view/SessionBarrier"
import ProjectsPage from "@/features/projects/view/ProjectsPage"
import { projectRepository } from "@/composition"
export default async function Page() {
return (
-
+
-
+
)
}
diff --git a/src/common/errors/client/ErrorHandler.tsx b/src/common/errors/client/ErrorHandler.tsx
index 61260578..3626459a 100644
--- a/src/common/errors/client/ErrorHandler.tsx
+++ b/src/common/errors/client/ErrorHandler.tsx
@@ -14,8 +14,6 @@ export default function ErrorHandler({
}
if (error.status == 401) {
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
index 74acb8d0..8ad94893 100644
--- a/src/common/errors/index.ts
+++ b/src/common/errors/index.ts
@@ -1,2 +1,2 @@
-export class UnauthorizedError extends Error {}
-export class InvalidSessionError extends Error {}
+export { default as makeAPIErrorResponse } from "./makeAPIErrorResponse"
+export class UnauthorizedError extends Error {}
\ No newline at end of file
diff --git a/src/common/errors/makeAPIErrorResponse.ts b/src/common/errors/makeAPIErrorResponse.ts
new file mode 100644
index 00000000..af62969f
--- /dev/null
+++ b/src/common/errors/makeAPIErrorResponse.ts
@@ -0,0 +1,8 @@
+import { NextResponse } from "next/server"
+
+export default function makeAPIErrorResponse(
+ status: number,
+ message: string
+): NextResponse {
+ return NextResponse.json({ status, message }, { status })
+}
diff --git a/src/common/session/AlwaysValidSessionValidator.ts b/src/common/session/AlwaysValidSessionValidator.ts
deleted file mode 100644
index 3f55486a..00000000
--- a/src/common/session/AlwaysValidSessionValidator.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import ISessionValidator from "./ISessionValidator"
-
-export default class AlwaysValidSessionValidator implements ISessionValidator {
- async validateSession(): Promise {
- return true
- }
-}
\ No newline at end of file
diff --git a/src/common/session/GitHubOrganizationSessionValidator.ts b/src/common/session/GitHubOrganizationSessionValidator.ts
deleted file mode 100644
index 308ff043..00000000
--- a/src/common/session/GitHubOrganizationSessionValidator.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-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
deleted file mode 100644
index b51cd5e6..00000000
--- a/src/common/session/ISessionValidator.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export default interface ISessionValidator {
- validateSession(): Promise
-}
diff --git a/src/common/session/SessionValidator.ts b/src/common/session/SessionValidator.ts
deleted file mode 100644
index 4b3da8c9..00000000
--- a/src/common/session/SessionValidator.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import ISessionValidator from "./ISessionValidator"
-
-interface IIsGuestReader {
- getIsGuest(): Promise
-}
-
-type SessionValidatorConfig = {
- readonly isGuestReader: IIsGuestReader
- readonly guestSessionValidator: ISessionValidator
- readonly hostSessionValidator: ISessionValidator
-}
-
-export default class SessionValidator implements ISessionValidator {
- private readonly isGuestReader: IIsGuestReader
- private readonly guestSessionValidator: ISessionValidator
- private readonly hostSessionValidator: ISessionValidator
-
- constructor(config: SessionValidatorConfig) {
- this.isGuestReader = config.isGuestReader
- this.guestSessionValidator = config.guestSessionValidator
- this.hostSessionValidator = config.hostSessionValidator
- }
-
- async validateSession(): Promise {
- const isGuest = await this.isGuestReader.getIsGuest()
- if (isGuest) {
- return await this.guestSessionValidator.validateSession()
- } else {
- return await this.hostSessionValidator.validateSession()
- }
- }
-}
\ No newline at end of file
diff --git a/src/common/session/index.ts b/src/common/session/index.ts
index 4d19783f..bf010ed7 100644
--- a/src/common/session/index.ts
+++ b/src/common/session/index.ts
@@ -1,5 +1 @@
-export { default as AlwaysValidSessionValidator } from "./AlwaysValidSessionValidator"
-export { default as GitHubOrganizationSessionValidator } from "./GitHubOrganizationSessionValidator"
export type { default as ISession } from "./ISession"
-export type { default as ISessionValidator } from "./ISessionValidator"
-export { default as SessionValidator } from "./SessionValidator"
diff --git a/src/composition.ts b/src/composition.ts
index 46dce15b..52e5e75d 100644
--- a/src/composition.ts
+++ b/src/composition.ts
@@ -3,21 +3,16 @@ import RedisKeyedMutexFactory from "@/common/mutex/RedisKeyedMutexFactory"
import RedisKeyValueStore from "@/common/keyValueStore/RedisKeyValueStore"
import {
AccessTokenRefreshingGitHubClient,
- AlwaysValidSessionValidator,
GitHubClient,
- GitHubOrganizationSessionValidator,
KeyValueUserDataRepository,
- SessionMutexFactory,
- SessionValidator
+ SessionMutexFactory
} from "@/common"
import {
GitHubProjectDataSource
} from "@/features/projects/data"
import {
CachingProjectDataSource,
- ForgivingProjectDataSource,
- ProjectRepository,
- SessionValidatingProjectDataSource
+ ProjectRepository
} from "@/features/projects/domain"
import {
GitHubOAuthTokenRefresher,
@@ -29,16 +24,20 @@ import {
} from "@/features/auth/data"
import {
AccessTokenService,
- CachingRepositoryAccessReaderConfig,
+ AccessTokenSessionValidator,
+ CachingRepositoryAccessReader,
CachingUserIdentityProviderReader,
CompositeLogInHandler,
CompositeLogOutHandler,
CredentialsTransferringLogInHandler,
ErrorIgnoringLogOutHandler,
+ GitHubOrganizationSessionValidator,
+ GuestAccessTokenRepository,
GuestAccessTokenService,
- NullObjectCredentialsTransferrer,
+ GuestCredentialsTransferrer,
HostAccessTokenService,
HostCredentialsTransferrer,
+ HostOnlySessionValidator,
IsUserGuestReader,
LockingAccessTokenService,
OAuthTokenRepository,
@@ -101,9 +100,11 @@ const gitHubOAuthTokenRefresher = new GitHubOAuthTokenRefresher({
clientSecret: gitHubAppCredentials.clientSecret
})
-const accessTokenRepository = new KeyValueUserDataRepository(
- new RedisKeyValueStore(REDIS_URL),
- "accessToken"
+const guestAccessTokenRepository = new GuestAccessTokenRepository(
+ new KeyValueUserDataRepository(
+ new RedisKeyValueStore(REDIS_URL),
+ "accessToken"
+ )
)
const guestRepositoryAccessRepository = new KeyValueUserDataRepository(
@@ -111,13 +112,15 @@ const guestRepositoryAccessRepository = new KeyValueUserDataRepository(
"guestRepositoryAccess"
)
+export const guestRepositoryAccessReader = new CachingRepositoryAccessReader({
+ repository: guestRepositoryAccessRepository,
+ repositoryAccessReader: new Auth0RepositoryAccessReader({
+ ...auth0ManagementCredentials
+ })
+})
+
const guestAccessTokenDataSource = new RepositoryRestrictingAccessTokenDataSource({
- repositoryAccessReader: new CachingRepositoryAccessReaderConfig({
- repository: guestRepositoryAccessRepository,
- repositoryAccessReader: new Auth0RepositoryAccessReader({
- ...auth0ManagementCredentials
- })
- }),
+ repositoryAccessReader: guestRepositoryAccessReader,
dataSource: new GitHubInstallationAccessTokenDataSource({
...gitHubAppCredentials,
organization: GITHUB_ORGANIZATION_NAME
@@ -135,7 +138,7 @@ export const accessTokenService = new LockingAccessTokenService(
isGuestReader: session,
guestAccessTokenService: new GuestAccessTokenService({
userIdReader: session,
- repository: accessTokenRepository,
+ repository: guestAccessTokenRepository,
dataSource: guestAccessTokenDataSource
}),
hostAccessTokenService: new HostAccessTokenService({
@@ -157,13 +160,15 @@ export const userGitHubClient = new AccessTokenRefreshingGitHubClient(
gitHubClient
)
-export const sessionValidator = new SessionValidator({
+export const blockingSessionValidator = new AccessTokenSessionValidator({
+ accessTokenService: accessTokenService
+})
+export const delayedSessionValidator = new HostOnlySessionValidator({
isGuestReader: session,
- guestSessionValidator: new AlwaysValidSessionValidator(),
- hostSessionValidator: new GitHubOrganizationSessionValidator(
- userGitHubClient,
- GITHUB_ORGANIZATION_NAME
- )
+ sessionValidator: new GitHubOrganizationSessionValidator({
+ acceptedOrganization: GITHUB_ORGANIZATION_NAME,
+ organizationMembershipStatusReader: userGitHubClient
+ })
})
const projectUserDataRepository = new KeyValueUserDataRepository(
@@ -177,15 +182,9 @@ export const projectRepository = new ProjectRepository(
)
export const projectDataSource = new CachingProjectDataSource(
- new SessionValidatingProjectDataSource(
- sessionValidator,
- new ForgivingProjectDataSource({
- accessTokenReader: accessTokenService,
- projectDataSource: new GitHubProjectDataSource(
- userGitHubClient,
- GITHUB_ORGANIZATION_NAME
- )
- })
+ new GitHubProjectDataSource(
+ userGitHubClient,
+ GITHUB_ORGANIZATION_NAME
),
projectRepository
)
@@ -195,7 +194,10 @@ export const logInHandler = new CompositeLogInHandler([
isUserGuestReader: new IsUserGuestReader(
userIdentityProviderReader
),
- guestCredentialsTransferrer: new NullObjectCredentialsTransferrer(),
+ guestCredentialsTransferrer: new GuestCredentialsTransferrer({
+ dataSource: guestAccessTokenDataSource,
+ repository: guestAccessTokenRepository
+ }),
hostCredentialsTransferrer: new HostCredentialsTransferrer({
refreshTokenReader: new Auth0RefreshTokenReader({
...auth0ManagementCredentials,
@@ -216,6 +218,6 @@ export const logOutHandler = new ErrorIgnoringLogOutHandler(
new UserDataCleanUpLogOutHandler(session, userIdentityProviderRepository),
new UserDataCleanUpLogOutHandler(session, guestRepositoryAccessRepository),
new UserDataCleanUpLogOutHandler(session, oAuthTokenRepository),
- new UserDataCleanUpLogOutHandler(session, accessTokenRepository)
+ new UserDataCleanUpLogOutHandler(session, guestAccessTokenRepository)
])
)
diff --git a/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts b/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts
index 7a958619..b82c2112 100644
--- a/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts
+++ b/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts
@@ -17,6 +17,9 @@ export default class GitHubInstallationAccessTokenRefresher {
}
async getAccessToken(repositoryNames: string[]): Promise {
+ if (repositoryNames.length == 0) {
+ throw new Error("Must provide at least one repository name when creating a GitHub installation access token.")
+ }
const auth = createAppAuth({
appId: this.config.appId,
clientId: this.config.clientId,
diff --git a/src/features/auth/domain/accessToken/GuestAccessTokenRepository.ts b/src/features/auth/domain/accessToken/GuestAccessTokenRepository.ts
new file mode 100644
index 00000000..7a93cef0
--- /dev/null
+++ b/src/features/auth/domain/accessToken/GuestAccessTokenRepository.ts
@@ -0,0 +1,25 @@
+export interface IRepository {
+ get(userId: string): Promise
+ setExpiring(userId: string, token: string, timeToLive: number): Promise
+ delete(userId: string): Promise
+}
+
+export default class GuestAccessTokenRepository {
+ private readonly repository: IRepository
+
+ constructor(repository: IRepository) {
+ this.repository = repository
+ }
+
+ async get(userId: string): Promise {
+ return await this.repository.get(userId)
+ }
+
+ async set(userId: string, accessToken: string): Promise {
+ await this.repository.setExpiring(userId, accessToken, 7 * 24 * 3600)
+ }
+
+ async delete(userId: string): Promise {
+ await this.repository.delete(userId)
+ }
+}
\ No newline at end of file
diff --git a/src/features/auth/domain/accessToken/GuestAccessTokenService.ts b/src/features/auth/domain/accessToken/GuestAccessTokenService.ts
index d2e1a059..6ac68c0c 100644
--- a/src/features/auth/domain/accessToken/GuestAccessTokenService.ts
+++ b/src/features/auth/domain/accessToken/GuestAccessTokenService.ts
@@ -1,3 +1,4 @@
+import { UnauthorizedError } from "../../../../common"
import IAccessTokenService from "./IAccessTokenService"
export interface IUserIDReader {
@@ -6,7 +7,7 @@ export interface IUserIDReader {
export interface Repository {
get(userId: string): Promise
- setExpiring(userId: string, token: string, timeToLive: number): Promise
+ set(userId: string, token: string): Promise
}
export interface DataSource {
@@ -34,20 +35,15 @@ export default class GuestAccessTokenService implements IAccessTokenService {
const userId = await this.userIdReader.getUserId()
const accessToken = await this.repository.get(userId)
if (!accessToken) {
- // We fetch the access token for guests on demand.
- return await this.getNewAccessToken()
+ throw new UnauthorizedError(`No access token stored for user with ID ${userId}.`)
}
return accessToken
}
async refreshAccessToken(_accessToken: string): Promise {
- return await this.getNewAccessToken()
- }
-
- private async getNewAccessToken(): Promise {
const userId = await this.userIdReader.getUserId()
const newAccessToken = await this.dataSource.getAccessToken(userId)
- await this.repository.setExpiring(userId, newAccessToken, 7 * 24 * 3600)
+ await this.repository.set(userId, newAccessToken)
return newAccessToken
}
}
\ No newline at end of file
diff --git a/src/features/auth/domain/accessToken/index.ts b/src/features/auth/domain/accessToken/index.ts
index fd3cb104..b575dda2 100644
--- a/src/features/auth/domain/accessToken/index.ts
+++ b/src/features/auth/domain/accessToken/index.ts
@@ -1,3 +1,4 @@
+export { default as GuestAccessTokenRepository } from "./GuestAccessTokenRepository"
export { default as GuestAccessTokenService } from "./GuestAccessTokenService"
export { default as HostAccessTokenService } from "./HostAccessTokenService"
export { default as LockingAccessTokenService } from "./LockingAccessTokenService"
diff --git a/src/features/auth/domain/credentialsTransfer/GuestCredentialsTransferrer.ts b/src/features/auth/domain/credentialsTransfer/GuestCredentialsTransferrer.ts
new file mode 100644
index 00000000..05a39f7f
--- /dev/null
+++ b/src/features/auth/domain/credentialsTransfer/GuestCredentialsTransferrer.ts
@@ -0,0 +1,29 @@
+import ICredentialsTransferrer from "./ICredentialsTransferrer"
+
+export interface IDataSource {
+ getAccessToken(userId: string): Promise
+}
+
+export interface IRepository {
+ set(userId: string, token: string): Promise
+}
+
+export type GuestCredentialsTransferrerConfig = {
+ readonly dataSource: IDataSource
+ readonly repository: IRepository
+}
+
+export default class GuestCredentialsTransferrer implements ICredentialsTransferrer {
+ private readonly dataSource: IDataSource
+ private readonly repository: IRepository
+
+ constructor(config: GuestCredentialsTransferrerConfig) {
+ this.dataSource = config.dataSource
+ this.repository = config.repository
+ }
+
+ async transferCredentials(userId: string): Promise {
+ const newAccessToken = await this.dataSource.getAccessToken(userId)
+ await this.repository.set(userId, newAccessToken)
+ }
+}
diff --git a/src/features/auth/domain/credentialsTransfer/NullObjectCredentialsTransferrer.ts b/src/features/auth/domain/credentialsTransfer/NullObjectCredentialsTransferrer.ts
deleted file mode 100644
index 2b02e044..00000000
--- a/src/features/auth/domain/credentialsTransfer/NullObjectCredentialsTransferrer.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import ICredentialsTransferrer from "./ICredentialsTransferrer"
-
-export default class NullObjectCredentialsTransferrer implements ICredentialsTransferrer {
- async transferCredentials(_userId: string): Promise {}
-}
diff --git a/src/features/auth/domain/credentialsTransfer/index.ts b/src/features/auth/domain/credentialsTransfer/index.ts
index 3961d607..b07aa4af 100644
--- a/src/features/auth/domain/credentialsTransfer/index.ts
+++ b/src/features/auth/domain/credentialsTransfer/index.ts
@@ -1,2 +1,2 @@
+export { default as GuestCredentialsTransferrer } from "./GuestCredentialsTransferrer"
export { default as HostCredentialsTransferrer } from "./HostCredentialsTransferrer"
-export { default as NullObjectCredentialsTransferrer } from "./NullObjectCredentialsTransferrer"
diff --git a/src/features/auth/domain/index.ts b/src/features/auth/domain/index.ts
index 63cbe382..1c1961cb 100644
--- a/src/features/auth/domain/index.ts
+++ b/src/features/auth/domain/index.ts
@@ -4,4 +4,5 @@ export * from "./logIn"
export * from "./logOut"
export * from "./oAuthToken"
export * from "./repositoryAccess"
+export * from "./sessionValidity"
export * from "./userIdentityProvider"
diff --git a/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts b/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts
index ae967eb5..112171ee 100644
--- a/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts
+++ b/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts
@@ -24,11 +24,19 @@ export default class CredentialsTransferringLogInHandler implements ILogInHandle
}
async handleLogIn(userId: string): Promise {
- const isGuest = await this.isUserGuestReader.getIsUserGuest(userId)
- if (isGuest) {
- await this.guestCredentialsTransferrer.transferCredentials(userId)
- } else {
- await this.hostCredentialsTransferrer.transferCredentials(userId)
+ try {
+ const isGuest = await this.isUserGuestReader.getIsUserGuest(userId)
+ if (isGuest) {
+ await this.guestCredentialsTransferrer.transferCredentials(userId)
+ } else {
+ await this.hostCredentialsTransferrer.transferCredentials(userId)
+ }
+ } catch {
+ // It is safe to ignore the error. Transferring credentials is a
+ // "best-case scenario" that will always succeed unless the user
+ // is not a member of the GitHub organization or a guest user has
+ // been incorrectly configured. Either way, we allow the user to
+ // login an rely on the SessionBarrier to show an error later.
}
}
}
diff --git a/src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReaderConfig.ts b/src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReader.ts
similarity index 100%
rename from src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReaderConfig.ts
rename to src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReader.ts
diff --git a/src/features/auth/domain/repositoryAccess/index.ts b/src/features/auth/domain/repositoryAccess/index.ts
index 3f3e42b7..1224c7b8 100644
--- a/src/features/auth/domain/repositoryAccess/index.ts
+++ b/src/features/auth/domain/repositoryAccess/index.ts
@@ -1,2 +1,2 @@
-export { default as CachingRepositoryAccessReaderConfig } from "./CachingRepositoryAccessReaderConfig"
+export { default as CachingRepositoryAccessReader } from "./CachingRepositoryAccessReader"
export { default as RepositoryRestrictingAccessTokenDataSource } from "./RepositoryRestrictingAccessTokenDataSource"
diff --git a/src/features/auth/domain/sessionValidity/AccessTokenSessionValidator.ts b/src/features/auth/domain/sessionValidity/AccessTokenSessionValidator.ts
new file mode 100644
index 00000000..05ef4dc4
--- /dev/null
+++ b/src/features/auth/domain/sessionValidity/AccessTokenSessionValidator.ts
@@ -0,0 +1,26 @@
+import SessionValidity from "./SessionValidity"
+
+interface IAccessTokenService {
+ getAccessToken(): Promise
+}
+
+type AccessTokenSessionValidatorConfig = {
+ readonly accessTokenService: IAccessTokenService
+}
+
+export default class AccessTokenSessionValidator {
+ private readonly accessTokenService: IAccessTokenService
+
+ constructor(config: AccessTokenSessionValidatorConfig) {
+ this.accessTokenService = config.accessTokenService
+ }
+
+ async validateSession(): Promise {
+ try {
+ await this.accessTokenService.getAccessToken()
+ return SessionValidity.VALID
+ } catch {
+ return SessionValidity.INVALID_ACCESS_TOKEN
+ }
+ }
+}
diff --git a/src/features/auth/domain/sessionValidity/GitHubOrganizationSessionValidator.ts b/src/features/auth/domain/sessionValidity/GitHubOrganizationSessionValidator.ts
new file mode 100644
index 00000000..7f8aba6b
--- /dev/null
+++ b/src/features/auth/domain/sessionValidity/GitHubOrganizationSessionValidator.ts
@@ -0,0 +1,52 @@
+import SessionValidity from "./SessionValidity"
+
+type OrganizationMembershipStatus = {
+ readonly state: "active" | "pending"
+}
+
+interface IOrganizationMembershipStatusReader {
+ getOrganizationMembershipStatus(
+ request: { organizationName: string }
+ ): Promise
+}
+
+type GitHubOrganizationSessionValidatorConfig = {
+ readonly acceptedOrganization: string
+ readonly organizationMembershipStatusReader: IOrganizationMembershipStatusReader
+}
+
+export default class GitHubOrganizationSessionValidator {
+ private readonly acceptedOrganization: string
+ private readonly organizationMembershipStatusReader: IOrganizationMembershipStatusReader
+
+ constructor(config: GitHubOrganizationSessionValidatorConfig) {
+ this.acceptedOrganization = config.acceptedOrganization
+ this.organizationMembershipStatusReader = config.organizationMembershipStatusReader
+ }
+
+ async validateSession(): Promise {
+ try {
+ const response = await this.organizationMembershipStatusReader.getOrganizationMembershipStatus({
+ organizationName: this.acceptedOrganization
+ })
+ if (response.state == "active") {
+ return SessionValidity.VALID
+ } else {
+ return SessionValidity.OUTSIDE_GITHUB_ORGANIZATION
+ }
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+ } catch (error: any) {
+ if (error.status) {
+ if (error.status == 404) {
+ return SessionValidity.OUTSIDE_GITHUB_ORGANIZATION
+ } else if (error.status == 403) {
+ return SessionValidity.GITHUB_APP_BLOCKED
+ } else {
+ throw error
+ }
+ } else {
+ throw error
+ }
+ }
+ }
+}
diff --git a/src/features/auth/domain/sessionValidity/HostOnlySessionValidator.ts b/src/features/auth/domain/sessionValidity/HostOnlySessionValidator.ts
new file mode 100644
index 00000000..08220a67
--- /dev/null
+++ b/src/features/auth/domain/sessionValidity/HostOnlySessionValidator.ts
@@ -0,0 +1,33 @@
+import SessionValidity from "./SessionValidity"
+
+interface IIsGuestReader {
+ getIsGuest(): Promise
+}
+
+interface ISessionValidator {
+ validateSession(): Promise
+}
+
+type HostOnlySessionValidatorConfig = {
+ readonly isGuestReader: IIsGuestReader
+ readonly sessionValidator: ISessionValidator
+}
+
+export default class HostOnlySessionValidator {
+ private readonly isGuestReader: IIsGuestReader
+ private readonly sessionValidator: ISessionValidator
+
+ constructor(config: HostOnlySessionValidatorConfig) {
+ this.isGuestReader = config.isGuestReader
+ this.sessionValidator = config.sessionValidator
+ }
+
+ async validateSession(): Promise {
+ const isGuest = await this.isGuestReader.getIsGuest()
+ if (!isGuest) {
+ return await this.sessionValidator.validateSession()
+ } else {
+ return SessionValidity.VALID
+ }
+ }
+}
diff --git a/src/features/auth/domain/sessionValidity/SessionValidity.ts b/src/features/auth/domain/sessionValidity/SessionValidity.ts
new file mode 100644
index 00000000..ba461768
--- /dev/null
+++ b/src/features/auth/domain/sessionValidity/SessionValidity.ts
@@ -0,0 +1,21 @@
+enum SessionValidity {
+ VALID = "valid",
+ INVALID_ACCESS_TOKEN = "invalid_access_token",
+ OUTSIDE_GITHUB_ORGANIZATION = "outside_github_organization",
+ GITHUB_APP_BLOCKED = "github_app_blocked"
+}
+
+export default SessionValidity
+
+export function mergeSessionValidity(
+ lhs: SessionValidity,
+ rhs: SessionValidity
+): SessionValidity {
+ if (lhs != SessionValidity.VALID) {
+ return lhs
+ } else if (rhs != SessionValidity.VALID) {
+ return rhs
+ } else {
+ return SessionValidity.VALID
+ }
+}
diff --git a/src/features/auth/domain/sessionValidity/index.ts b/src/features/auth/domain/sessionValidity/index.ts
new file mode 100644
index 00000000..de4d2dde
--- /dev/null
+++ b/src/features/auth/domain/sessionValidity/index.ts
@@ -0,0 +1,7 @@
+export { default as AccessTokenSessionValidator } from "./AccessTokenSessionValidator"
+export { default as GitHubOrganizationSessionValidator } from "./GitHubOrganizationSessionValidator"
+export { default as HostOnlySessionValidator } from "./HostOnlySessionValidator"
+export { default as SessionValidity } from "./SessionValidity"
+export * from "./SessionValidity"
+export { default as useRepositoryAccess } from "./useRepositoryAccess"
+export { default as useSessionValidity } from "./useSessionValidity"
diff --git a/src/features/auth/domain/sessionValidity/useRepositoryAccess.ts b/src/features/auth/domain/sessionValidity/useRepositoryAccess.ts
new file mode 100644
index 00000000..c98d6b03
--- /dev/null
+++ b/src/features/auth/domain/sessionValidity/useRepositoryAccess.ts
@@ -0,0 +1,18 @@
+"use client"
+
+import useSWR from "swr"
+import { fetcher } from "../../../../common"
+
+type RepositoriesContainer = { repositories: string[] }
+
+export default function useRepositoryAccess() {
+ const { data, error, isLoading } = useSWR(
+ "/api/user/repository-access",
+ fetcher
+ )
+ return {
+ repositories: data?.repositories || [],
+ isLoading,
+ error
+ }
+}
diff --git a/src/features/auth/domain/sessionValidity/useSessionValidity.ts b/src/features/auth/domain/sessionValidity/useSessionValidity.ts
new file mode 100644
index 00000000..ce5d62ec
--- /dev/null
+++ b/src/features/auth/domain/sessionValidity/useSessionValidity.ts
@@ -0,0 +1,19 @@
+"use client"
+
+import useSWR from "swr"
+import { fetcher } from "../../../../common"
+import SessionValidity from "./SessionValidity"
+
+type SessionValidityContainer = { sessionValidity: SessionValidity }
+
+export default function useSessionValidity() {
+ const { data, error, isLoading } = useSWR(
+ "/api/user/session-validity",
+ fetcher
+ )
+ return {
+ sessionValidity: data?.sessionValidity || SessionValidity.VALID,
+ isLoading,
+ error
+ }
+}
diff --git a/src/features/auth/view/InvalidSessionPage.tsx b/src/features/auth/view/InvalidSessionPage.tsx
deleted file mode 100644
index 99292a13..00000000
--- a/src/features/auth/view/InvalidSessionPage.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-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/SessionAccessTokenBarrier.tsx b/src/features/auth/view/SessionAccessTokenBarrier.tsx
deleted file mode 100644
index 58996582..00000000
--- a/src/features/auth/view/SessionAccessTokenBarrier.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import { ReactNode } from "react"
-import { redirect } from "next/navigation"
-import { session, accessTokenService } from "@/composition"
-
-export default async function SessionAccessTokenBarrier({
- children
-}: {
- children: ReactNode
-}) {
- try {
- const isGuest = await session.getIsGuest()
- if (!isGuest) {
- await accessTokenService.getAccessToken()
- }
- return <>{children}>
- } catch {
- redirect("/api/auth/logout")
- }
-}
diff --git a/src/features/auth/view/SessionBarrier.tsx b/src/features/auth/view/SessionBarrier.tsx
new file mode 100644
index 00000000..15211ce9
--- /dev/null
+++ b/src/features/auth/view/SessionBarrier.tsx
@@ -0,0 +1,35 @@
+import { ReactNode } from "react"
+import { session, blockingSessionValidator } from "@/composition"
+import ClientSessionBarrier from "./client/SessionBarrier"
+
+const {
+ NEXT_PUBLIC_SHAPE_DOCS_TITLE,
+ GITHUB_ORGANIZATION_NAME
+} = process.env
+
+export default async function SessionBarrier({
+ children
+}: {
+ children: ReactNode
+}) {
+ const getIsGuest = async () => {
+ try {
+ return await session.getIsGuest()
+ } catch {
+ // We assume it's a guest.
+ return true
+ }
+ }
+ const isGuest = await getIsGuest()
+ const sessionValidity = await blockingSessionValidator.validateSession()
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/features/auth/view/client/GuestAccessTokenInvalidPage.tsx b/src/features/auth/view/client/GuestAccessTokenInvalidPage.tsx
new file mode 100644
index 00000000..65daa3a0
--- /dev/null
+++ b/src/features/auth/view/client/GuestAccessTokenInvalidPage.tsx
@@ -0,0 +1,54 @@
+"use client"
+
+import InvalidSessionPage from "./InvalidSessionPage"
+import LoadingIndicator from "@/common/loading/DelayedLoadingIndicator"
+import { useRepositoryAccess } from "../../domain"
+
+export default function GuestAccessTokenInvalidPage({
+ organizationName
+}: {
+ organizationName: string
+}) {
+ const {repositories, isLoading, error} = useRepositoryAccess()
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+ if (error) {
+ return (
+
+ It was not possible to obtain access to the projects on the {organizationName} organization on GitHub.
+
+ )
+ }
+ if (repositories.length == 0) {
+ return (
+
+ Your account does not have access to any projects.
+
+ )
+ }
+ const repositoryNamesHTML = makeRepositoryNamesHTML(repositories)
+ const html = `It was not possible to obtain access to ${repositoryNamesHTML}.`
+ return (
+
+
+
+ )
+}
+
+function makeRepositoryNamesHTML(repositories: string[]): string {
+ const copiedRepositories = [...repositories]
+ if (copiedRepositories.length == 1) {
+ return `${copiedRepositories[0]}`
+ } else {
+ const last = copiedRepositories.pop()
+ return copiedRepositories
+ .map(e => `${e}`)
+ .join(", ")
+ + `, and ${last}`
+ }
+}
\ No newline at end of file
diff --git a/src/features/auth/view/client/InvalidSession.tsx b/src/features/auth/view/client/InvalidSession.tsx
deleted file mode 100644
index 76cf8416..00000000
--- a/src/features/auth/view/client/InvalidSession.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-"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/auth/view/client/InvalidSessionPage.tsx b/src/features/auth/view/client/InvalidSessionPage.tsx
new file mode 100644
index 00000000..8a796a46
--- /dev/null
+++ b/src/features/auth/view/client/InvalidSessionPage.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import { ReactNode } from "react"
+import { Stack, Typography } from "@mui/material"
+import SidebarContainer from "@/features/sidebar/view/client/SidebarContainer"
+import useSidebarOpen from "@/common/state/useSidebarOpen"
+
+export default function InvalidSessionPage({
+ title,
+ children
+}: {
+ title?: ReactNode
+ children?: ReactNode
+}) {
+ const [isSidebarOpen, setSidebarOpen] = useSidebarOpen()
+ return (
+
+
+
+ {title &&
+
+ {title}
+
+ }
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/src/features/auth/view/client/SessionBarrier.tsx b/src/features/auth/view/client/SessionBarrier.tsx
new file mode 100644
index 00000000..3913e2fa
--- /dev/null
+++ b/src/features/auth/view/client/SessionBarrier.tsx
@@ -0,0 +1,51 @@
+"use client"
+
+import { ReactNode } from "react"
+import GuestAccessTokenInvalidPage from "./GuestAccessTokenInvalidPage"
+import InvalidSessionPage from "./InvalidSessionPage"
+import {
+ SessionValidity,
+ mergeSessionValidity,
+ useSessionValidity
+} from "../../domain"
+
+export default function SessionBarrier({
+ isGuest,
+ siteName,
+ organizationName,
+ sessionValidity: fastSessionValidity,
+ children
+}: {
+ isGuest: boolean
+ siteName: string
+ organizationName: string
+ sessionValidity: SessionValidity
+ children: ReactNode
+}) {
+ const { sessionValidity: delayedSessionValidity } = useSessionValidity()
+ const sessionValidity = mergeSessionValidity(
+ fastSessionValidity,
+ delayedSessionValidity
+ )
+ switch (sessionValidity) {
+ case SessionValidity.VALID:
+ return <>{children}>
+ case SessionValidity.INVALID_ACCESS_TOKEN:
+ if (isGuest) {
+ return
+ } else {
+ return (
+
+ It was not possible to obtain access to the projects on the {organizationName} organization on GitHub.
+
+ )
+ }
+ case SessionValidity.OUTSIDE_GITHUB_ORGANIZATION:
+ case SessionValidity.GITHUB_APP_BLOCKED:
+ return (
+
+ Access to {siteName} requires that your account is an active member of the {organizationName} organization on GitHub.
+
+ )
+ }
+}
diff --git a/src/features/projects/domain/ForgivingProjectDataSource.ts b/src/features/projects/domain/ForgivingProjectDataSource.ts
deleted file mode 100644
index 82f5c40f..00000000
--- a/src/features/projects/domain/ForgivingProjectDataSource.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import Project from "./Project"
-import IProjectDataSource from "./IProjectDataSource"
-
-interface IAccessTokenReader {
- getAccessToken(): Promise
-}
-
-type ForgivingProjectDataSourceConfig = {
- readonly accessTokenReader: IAccessTokenReader
- readonly projectDataSource: IProjectDataSource
-}
-
-export default class ForgivingProjectDataSource implements IProjectDataSource {
- private readonly accessTokenReader: IAccessTokenReader
- private readonly projectDataSource: IProjectDataSource
-
- constructor(config: ForgivingProjectDataSourceConfig) {
- this.accessTokenReader = config.accessTokenReader
- this.projectDataSource = config.projectDataSource
- }
-
- async getProjects(): Promise {
- try {
- await this.accessTokenReader.getAccessToken()
- } catch {
- // If we cannot get an access token, we show an empty list of projects.
- // It is common for guest users that we cannot get an access token because they
- // have been incorrectly configured to have access to non-existing repositories.
- return []
- }
- return this.projectDataSource.getProjects()
- }
-}
diff --git a/src/features/projects/domain/SessionValidatingProjectDataSource.ts b/src/features/projects/domain/SessionValidatingProjectDataSource.ts
deleted file mode 100644
index 0cb8c8c4..00000000
--- a/src/features/projects/domain/SessionValidatingProjectDataSource.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ISessionValidator, InvalidSessionError } from "../../../common"
-import { IProjectDataSource ,Project } from "../domain"
-
-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()
- }
-}
diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts
index f770e7c7..3d8006dc 100644
--- a/src/features/projects/domain/index.ts
+++ b/src/features/projects/domain/index.ts
@@ -1,5 +1,4 @@
export { default as CachingProjectDataSource } from "./CachingProjectDataSource"
-export { default as ForgivingProjectDataSource } from "./ForgivingProjectDataSource"
export { default as getSelection } from "./getSelection"
export type { default as IProjectConfig } from "./IProjectConfig"
export type { default as IProjectDataSource } from "./IProjectDataSource"
@@ -8,6 +7,5 @@ export type { default as Project } from "./Project"
export { default as ProjectConfigParser } from "./ProjectConfigParser"
export { default as projectNavigator } from "./projectNavigator"
export { default as ProjectRepository } from "./ProjectRepository"
-export { default as SessionValidatingProjectDataSource } from "./SessionValidatingProjectDataSource"
export { default as updateWindowTitle } from "./updateWindowTitle"
export type { default as Version } from "./Version"