Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e3dcb15
♻️ Migrate GitHub OAuth token management to encrypted HttpOnly cookies
NoritakaIkeda Oct 17, 2025
f433134
♻️ Refactor to use Result monad patterns consistently
NoritakaIkeda Oct 17, 2025
162a684
♻️(app): Remove login redirect on token failure in project creation
NoritakaIkeda Oct 17, 2025
437fb3c
📝(config): Improve KEYRING documentation in env template
NoritakaIkeda Oct 17, 2025
8f4ded6
♻️(security): Migrate to LIAM_GITHUB_OAUTH_KEYRING environment variable
NoritakaIkeda Oct 20, 2025
b3aedd9
♻️(security): Remove legacy keyring fallback variables
NoritakaIkeda Oct 20, 2025
183f7d5
♻️(security): Move security package to internal-packages
NoritakaIkeda Oct 20, 2025
9b7b704
🔧(security): Add missing config files to security package
NoritakaIkeda Oct 20, 2025
b9d4801
♻️(auth): Refactor token refresh to use Result monad patterns
NoritakaIkeda Oct 20, 2025
896dbbf
♻️(middleware): Improve API authentication handling
NoritakaIkeda Oct 20, 2025
13f2557
🐛(auth): Ensure cookie writes complete and propagate failures
NoritakaIkeda Oct 20, 2025
f9d22ce
Merge branch 'main' into feat/github-oauth-token-management
NoritakaIkeda Oct 20, 2025
0cf8ebe
♻️(middleware): Simplify authentication logic
NoritakaIkeda Oct 20, 2025
5b08f37
➕(deps): Add @octokit/oauth-app and remove @supabase/supabase-js
NoritakaIkeda Oct 20, 2025
cc1a9a2
♻️(auth): Refactor token refresh to use @octokit/oauth-app
NoritakaIkeda Oct 20, 2025
e4bc3e1
🐛(middleware): Use correct logout API path
NoritakaIkeda Oct 20, 2025
498d126
🎨(auth): Format token refresh route imports
NoritakaIkeda Oct 20, 2025
370ba03
🎨(deps): Reorder dependencies alphabetically in package.json
NoritakaIkeda Oct 20, 2025
07e4cd0
🎨(lint): Remove trailing newline from eslint-suppressions.json
NoritakaIkeda Oct 20, 2025
7fb689b
♻️(auth): Revert to manual GitHub OAuth token refresh
NoritakaIkeda Oct 20, 2025
0aeeaa0
✨(auth): Make refresh_token_expires_in required in token refresh
NoritakaIkeda Oct 21, 2025
61e3146
✨(organizations): Add clearOrganizationIdCookie server action
NoritakaIkeda Oct 21, 2025
aece9bd
✨(auth): Add comprehensive logout API endpoint
NoritakaIkeda Oct 21, 2025
164db73
♻️(ui): Refactor UserDropdown to use new logout API
NoritakaIkeda Oct 21, 2025
d80f93e
♻️(auth): Move logout endpoint from /api/auth/logout to /api/logout
NoritakaIkeda Oct 21, 2025
e804aab
➖(deps): Remove unused @octokit/oauth-app dependency
NoritakaIkeda Oct 21, 2025
69c832f
🐛(ui): Fix installation selector state update timing
NoritakaIkeda Oct 21, 2025
85635de
🎨(organizations): Remove trailing newline from clearOrganizationIdCookie
NoritakaIkeda Oct 21, 2025
dce67e7
➕(security): Add neverthrow dependency
NoritakaIkeda Oct 21, 2025
eb68438
♻️(security): Refactor cryptoBox to use Result monad patterns
NoritakaIkeda Oct 21, 2025
8541e7f
♻️(auth): Refactor token refresh to use neverthrow Result patterns
NoritakaIkeda Oct 21, 2025
4af40bd
🔧(app): Remove unused path aliases from tsconfig.json
NoritakaIkeda Oct 21, 2025
d3e80e7
📝(middleware): Remove outdated API routes comment
NoritakaIkeda Oct 21, 2025
972b2c9
♻️(projects): Simplify okAsync usage in new project page
NoritakaIkeda Oct 21, 2025
8793232
Merge remote-tracking branch 'origin/main' into feat/github-oauth-tok…
NoritakaIkeda Oct 21, 2025
663d433
🔧: Fix lockfile and sync package versions after merge
NoritakaIkeda Oct 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,15 @@ SENTRY_ORG=""
SENTRY_PROJECT=""
SUPABASE_SERVICE_ROLE_KEY=""
NEXT_PUBLIC_ALLOWED_DOMAINS=""

# GitHub OAuth token encryption keyring
# LIAM_GITHUB_OAUTH_KEYRING
# Usage: AES-256-GCM keyring for encrypting GitHub OAuth tokens in HttpOnly cookies.
# Format: comma-separated entries "<kid>:<base64key>" — first entry is the current key.
# - <kid>: recommended format is a readable version like "kYYYY-MM" (e.g., k2025-01)
# - <base64key>: 32-byte random key, base64-encoded
# Generate 32-byte key (Node.js):
# node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Example (current key first, with a rotated fallback):
# LIAM_GITHUB_OAUTH_KEYRING="k2025-01:BASE64_KEY_FOR_2025_01,k2024-12:BASE64_KEY_FOR_2024_12"
LIAM_GITHUB_OAUTH_KEYRING=""
11 changes: 11 additions & 0 deletions frontend/apps/app/app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { fromAsyncThrowable } from '@liam-hq/neverthrow'
import { NextResponse } from 'next/server'
import { clearTokens } from '../../../../libs/github/cookie'

export async function POST() {
await fromAsyncThrowable(clearTokens)().match(
() => undefined,
() => undefined,
)
return NextResponse.json({ ok: true })
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent error handling may hide logout failures.

Both success and error outcomes are discarded without logging. While always returning { ok: true } might be intentional for logout UX (client should proceed even if server fails), completely suppressing errors makes debugging difficult.

Consider logging errors for observability:

 export async function POST() {
-  await fromAsyncThrowable(clearTokens)().match(
-    () => undefined,
-    () => undefined,
-  )
+  await fromAsyncThrowable(clearTokens)()._unsafeUnwrap()
   return NextResponse.json({ ok: true })
 }

Alternatively, if you want to always succeed but still log errors:

 export async function POST() {
-  await fromAsyncThrowable(clearTokens)().match(
-    () => undefined,
-    () => undefined,
-  )
+  const result = await fromAsyncThrowable(clearTokens)()
+  if (result.isErr()) {
+    console.error('Failed to clear tokens during logout:', result.error)
+  }
   return NextResponse.json({ ok: true })
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function POST() {
await fromAsyncThrowable(clearTokens)().match(
() => undefined,
() => undefined,
)
return NextResponse.json({ ok: true })
}
export async function POST() {
const result = await fromAsyncThrowable(clearTokens)()
if (result.isErr()) {
console.error('Failed to clear tokens during logout:', result.error)
}
return NextResponse.json({ ok: true })
}
🤖 Prompt for AI Agents
In frontend/apps/app/app/api/auth/logout/route.ts around lines 5 to 11 the POST
handler swallows both success and error branches from
fromAsyncThrowable(clearTokens) without logging, which hides failures; modify
the error branch to capture and log the thrown error (using the project's logger
or console.error) while still returning NextResponse.json({ ok: true }) so the
UX is unchanged; implement this by handling the match's error case (or wrapping
clearTokens in try/catch) and logging a clear contextual message plus the error
details for observability.

Comment thread
MH4GF marked this conversation as resolved.
Outdated
143 changes: 143 additions & 0 deletions frontend/apps/app/app/api/github/token/refresh/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
fromPromise,
fromValibotSafeParse,
type Result,
} from '@liam-hq/neverthrow'
import * as Sentry from '@sentry/nextjs'
import type { ResultAsync as NeverthrowResultAsync } from 'neverthrow'
import { err, ok, ResultAsync } from 'neverthrow'
import { NextResponse } from 'next/server'
import * as v from 'valibot'
import type { TokenPayload } from '../../../../../libs/github/cookie'
import {
readRefreshToken,
writeAccessToken,
writeRefreshToken,
} from '../../../../../libs/github/cookie'

const getClientCredentials = (): Result<
{ clientId: string; clientSecret: string },
Error
> => {
const clientId = process.env.GITHUB_CLIENT_ID
const clientSecret = process.env.GITHUB_CLIENT_SECRET
if (!clientId || !clientSecret) {
return err(new Error('Missing GitHub OAuth client credentials'))
}
return ok({ clientId, clientSecret })
}

type RefreshAccessTokenResult = {
accessToken: string
refreshToken: string
refreshTokenExpiresIn: number
newExpiresAt: string
}

// GitHub OAuth token refresh response schema
const GitHubTokenRefreshResponseSchema = v.object({
access_token: v.string(),
token_type: v.string(),
scope: v.optional(v.string()),
expires_in: v.number(),
refresh_token: v.string(),
refresh_token_expires_in: v.optional(v.number()),
})

function refreshAccessToken(
refreshToken: string,
clientId: string,
clientSecret: string,
): ResultAsync<RefreshAccessTokenResult, Error> {
const body = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}).toString()

return ResultAsync.fromPromise(
fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
}),
(e) => (e instanceof Error ? e : new Error(String(e))),
)
.andThen((res) => {
if (!res.ok) {
return err(new Error(`GitHub token refresh failed: ${res.status}`))
}
return ResultAsync.fromPromise(res.json() as Promise<unknown>, (e) =>
e instanceof Error ? e : new Error(String(e)),
)
})
.andThen((data) =>
fromValibotSafeParse(GitHubTokenRefreshResponseSchema, data),
)
.map((data) => {
const now = Date.now()
const newExpiresAt = new Date(
now + Math.max(0, data.expires_in) * 1000,
).toISOString()
const refreshTokenExpiresIn = data.refresh_token_expires_in ?? 0

return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
refreshTokenExpiresIn,
newExpiresAt,
}
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Critical: Defaulting refresh_token_expires_in to 0 causes immediate cookie expiry.

Line 86 sets refreshTokenExpiresIn to 0 when GitHub omits refresh_token_expires_in. This propagates to line 118 in the handler, resulting in refreshTtlMs = 0, which makes the refresh token cookie expire immediately and breaks subsequent authentication.

GitHub's documentation indicates refresh_token_expires_in should typically be provided. Consider treating its absence as an error or using a safe default:

     .map((data) => {
       const now = Date.now()
       const newExpiresAt = new Date(
         now + Math.max(0, data.expires_in) * 1000,
       ).toISOString()
-      const refreshTokenExpiresIn = data.refresh_token_expires_in ?? 0
+      
+      // GitHub should provide refresh_token_expires_in; if missing, treat as error
+      // or use a safe fallback (e.g., 6 months = 15552000 seconds)
+      if (!data.refresh_token_expires_in) {
+        throw new Error('GitHub did not provide refresh_token_expires_in')
+      }
+      const refreshTokenExpiresIn = data.refresh_token_expires_in

       return {
         accessToken: data.access_token,
         refreshToken: data.refresh_token,
         refreshTokenExpiresIn,
         newExpiresAt,
       }
     })

Alternatively, use a safe default like 6 months (15552000 seconds) if you want to handle GitHub's omission gracefully.

Run this script to check GitHub's documentation on the expected behavior:


🌐 Web query:

Does GitHub OAuth token refresh always return refresh_token_expires_in field? What is the typical expiration time for GitHub refresh tokens?

💡 Result:

Short answer: No — GitHub only returns refresh_token_expires_in when your app is using expiring user access tokens. When returned its value is 15897600 seconds (GitHub describes this as 6 months). [1][2]

Details:

  • If you have token expiration disabled, responses omit expires_in and refresh_token_expires_in. [1]
  • When expiration is enabled, access tokens expire in 28800 seconds (8 hours) and refresh tokens expire in 15897600 seconds (~6 months). [1][2]

Sources: GitHub docs — "Generating a user access token for a GitHub App" and "Refreshing user access tokens". [1][2]


Now I need to examine the actual route.ts file to understand how refreshTokenExpiresIn is used and whether the GitHub App has token expiration enabled.


---

🏁 Script executed:

```shell
#!/bin/bash
# Search for the route file and examine context around lines 81-94 and 117-118
fd -e route.ts -path "*github/token/refresh*" -x cat {}

Length of output: 168



---

🏁 Script executed:

```shell
#!/bin/bash
# Find and display the route.ts file
find . -name "route.ts" -path "*github/token/refresh*" -exec cat {} \;

Length of output: 4240


Critical: Defaulting refresh_token_expires_in to 0 causes the refresh token cookie to expire immediately.

Line 86 sets refreshTokenExpiresIn to 0 when the field is missing. This flows to lines 117-119, where it calculates refreshTtlMs = 0 * 1000, making refreshExpiresAt equal the current timestamp. The refresh token cookie then expires instantly, breaking all subsequent authentication attempts.

GitHub enables token expiration by default and when enabled, refresh tokens expire after six months (15897600 seconds). The schema marks refresh_token_expires_in as optional to handle both expiration-enabled and disabled cases. However, defaulting to 0 is incorrect in both scenarios—it should either treat the absence as an error or use a safe fallback.

Consider this fix:

     .map((data) => {
       const now = Date.now()
       const newExpiresAt = new Date(
         now + Math.max(0, data.expires_in) * 1000,
       ).toISOString()
-      const refreshTokenExpiresIn = data.refresh_token_expires_in ?? 0
+      // If refresh_token_expires_in is missing, use GitHub's default of 6 months (15897600 seconds)
+      const refreshTokenExpiresIn = data.refresh_token_expires_in ?? 15897600

       return {
         accessToken: data.access_token,
         refreshToken: data.refresh_token,
         refreshTokenExpiresIn,
         newExpiresAt,
       }
     })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.map((data) => {
const now = Date.now()
const newExpiresAt = new Date(
now + Math.max(0, data.expires_in) * 1000,
).toISOString()
const refreshTokenExpiresIn = data.refresh_token_expires_in ?? 0
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
refreshTokenExpiresIn,
newExpiresAt,
}
})
.map((data) => {
const now = Date.now()
const newExpiresAt = new Date(
now + Math.max(0, data.expires_in) * 1000,
).toISOString()
// If refresh_token_expires_in is missing, use GitHub's default of 6 months (15897600 seconds)
const refreshTokenExpiresIn = data.refresh_token_expires_in ?? 15897600
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
refreshTokenExpiresIn,
newExpiresAt,
}
})

}

async function handler(): Promise<NextResponse> {
// 1) Load client credentials
const creds = getClientCredentials()
if (creds.isErr()) {
Sentry.captureException(creds.error, { tags: { area: 'refresh_api' } })
return NextResponse.json(
{ ok: false, error: 'server_misconfigured' },
{ status: 500 },
)
}
const { clientId, clientSecret } = creds.value

// 2) Read refresh token and refresh via GitHub
const refreshTokenResult: NeverthrowResultAsync<TokenPayload, Error> =
readRefreshToken()
const result = await refreshTokenResult
.andThen(({ token }: TokenPayload) =>
refreshAccessToken(token, clientId, clientSecret),
)
.andThen((refreshed: RefreshAccessTokenResult) => {
const rtei = refreshed.refreshTokenExpiresIn
const refreshTtlMs = rtei * 1000
const refreshExpiresAt = new Date(Date.now() + refreshTtlMs).toISOString()

// Ensure cookie writes complete and propagate failures
return fromPromise(
Promise.all([
writeAccessToken(refreshed.accessToken, refreshed.newExpiresAt),
writeRefreshToken(refreshed.refreshToken, refreshExpiresAt),
]),
).map(() => NextResponse.json({ ok: true }))
})

if (result.isErr()) {
Sentry.captureException(result.error, { tags: { area: 'refresh_api' } })
return NextResponse.json(
{ ok: false, error: 'refresh_failed' },
{ status: 401 },
)
}

return result.value
}

export async function POST() {
return handler()
}
43 changes: 42 additions & 1 deletion frontend/apps/app/app/auth/callback/[provider]/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { fromAsyncThrowable } from '@liam-hq/neverthrow'
import * as Sentry from '@sentry/nextjs'
import { NextResponse } from 'next/server'
import { ensureUserHasOrganization } from '../../../../components/LoginPage/services/ensureUserHasOrganization'
import { sanitizeReturnPath } from '../../../../components/LoginPage/services/validateReturnPath'
import { createClient } from '../../../../libs/db/server'
import {
writeAccessToken,
writeRefreshToken,
} from '../../../../libs/github/cookie'

export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
Expand All @@ -11,9 +17,44 @@ export async function GET(request: Request) {

if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
const { data, error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
await ensureUserHasOrganization()
// Persist provider tokens into encrypted HttpOnly cookies (best-effort)
const { session } = data
const access = session?.provider_token ?? ''
const refresh = session?.provider_refresh_token ?? ''
// received provider tokens for cookie write
// Access token expiration: 8 hours.
// GitHub's OAuth access tokens typically expire after 8 hours. See: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#step-3-github-redirects-back-to-your-site
const EIGHT_HOURS_MS = 8 * 60 * 60 * 1000
Comment on lines +27 to +30

Copilot AI Oct 16, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These hardcoded expiration values should be documented explaining why 8 hours for access tokens and 30 days for refresh tokens were chosen, or ideally sourced from GitHub API documentation if these align with GitHub's actual token lifetimes.

Suggested change
// received provider tokens for cookie write
const EIGHT_HOURS_MS = 8 * 60 * 60 * 1000
// received provider tokens for cookie write
// Access token expiration: 8 hours.
// GitHub's OAuth access tokens typically expire after 8 hours. See: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#step-3-github-redirects-back-to-your-site
const EIGHT_HOURS_MS = 8 * 60 * 60 * 1000
// Refresh token expiration: 30 days.
// GitHub's OAuth refresh tokens typically expire after 30 days. See: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#step-3-github-redirects-back-to-your-site

Copilot uses AI. Check for mistakes.
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHub refresh tokens are valid for 6 months (180 days), not 30 days. This hardcoded value causes refresh tokens to be considered expired after 30 days, even though they're still valid. Why?
https://docs.github.com/ja/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this point earlier.
I intended to align with any standard HttpOnly cookie storage duration, but since web standards and common security guidelines don't specifically mention it, I'll match the lifetime of GitHub tokens instead. I'll address this in the next PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intended to align with any standard HttpOnly cookie storage duration.

I don't think there's a problem with that itself, but using magic numbers is not good.


await fromAsyncThrowable(async () => {
if (access) {
const accessExpiresAt = new Date(
Date.now() + EIGHT_HOURS_MS,
).toISOString()
await writeAccessToken(access, accessExpiresAt)
}
if (refresh) {
const refreshExpiresAt = new Date(
Date.now() + THIRTY_DAYS_MS,
).toISOString()
await writeRefreshToken(refresh, refreshExpiresAt)
}
})().match(
() => undefined,
(e) => {
const err = e instanceof Error ? e : new Error(String(e))
Sentry.captureException(err, {
tags: {
area: 'auth_cookie_write',
},
})
return undefined
},
Comment on lines +50 to +56

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Access tokens and refresh tokens must be obtained during login. Since this is a critical path, we want to monitor it with Sentry without stopping the app due to errors.

)

const forwardedHost = request.headers.get('x-forwarded-host')
const isLocalEnv = process.env.NODE_ENV === 'development'
Expand Down
34 changes: 27 additions & 7 deletions frontend/apps/app/app/projects/new/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { getInstallations } from '@liam-hq/github'
import { fromPromise } from '@liam-hq/neverthrow'
import { redirect } from 'next/navigation'
import { ProjectNewPage } from '../../../components/ProjectNewPage'
import { getOrganizationId } from '../../../features/organizations/services/getOrganizationId'
import { createClient } from '../../../libs/db/server'
import { getUserAccessToken } from '../../../libs/github/token'
import { urlgen } from '../../../libs/routes'

export default async function NewProjectPage() {
Expand All @@ -25,18 +27,36 @@ export default async function NewProjectPage() {
redirect(urlgen('login'))
}

const { data } = await supabase.auth.getSession()

if (data.session === null) {
redirect(urlgen('login'))
}

const { installations } = await getInstallations(data.session)
const tokenResult = await getUserAccessToken()

const { installations, needsRefresh } = await tokenResult
.asyncAndThen((token) => {
if (!token) {
return fromPromise(
Promise.resolve({
installations: [],
needsRefresh: true,
}),
)
Comment thread
MH4GF marked this conversation as resolved.
Outdated
}
return getInstallations(token).map((result) => ({
installations: result.installations,
needsRefresh: false,
}))
})
.match(
(v) => v,
(e) => {
console.error('Failed to get token or installations:', e)
return { installations: [], needsRefresh: true }
},
)

return (
<ProjectNewPage
installations={installations}
organizationId={organizationId}
needsRefresh={needsRefresh}
/>
)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client'

import { fromPromise } from '@liam-hq/neverthrow'
import {
Avatar,
AvatarWithImage,
Expand Down Expand Up @@ -63,6 +64,19 @@ export const UserDropdown: FC<Props> = ({ avatarUrl, userName, userEmail }) => {
// Delete organizationId cookie
deleteCookie('organizationId')

// Best-effort call to logout endpoint to clear OAuth cookies
type HttpResponse = {
ok: boolean
headers: { get(name: string): string | null }
json: () => Promise<unknown>
}
await fromPromise<HttpResponse>(
fetch('/api/auth/logout', { method: 'POST', cache: 'no-store' }),
).match(
() => {},
() => {},
)
Comment thread
MH4GF marked this conversation as resolved.
Outdated

// Redirect with success parameter
router.push('/login?logout=success')
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
import type { Installation } from '@liam-hq/github'
import type { FC } from 'react'
import { TokenRefreshKick } from '../TokenRefreshKick'
import { InstallationSelector } from './components/InstallationSelector'
import styles from './ProjectNewPage.module.css'

type Props = {
installations: Installation[]
organizationId: string
needsRefresh?: boolean
}

export const ProjectNewPage: FC<Props> = ({
installations,
organizationId,
needsRefresh,
}) => {
return (
<div className={styles.container}>
<TokenRefreshKick trigger={needsRefresh} />
<h1 className={styles.title}>Add a Project</h1>
<InstallationSelector
installations={installations}
organizationId={organizationId}
disabled={Boolean(needsRefresh)}
/>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,25 +24,27 @@ import styles from './InstallationSelector.module.css'
type Props = {
installations: Installation[]
organizationId: string
disabled?: boolean
}

export const InstallationSelector: FC<Props> = ({
installations,
organizationId,
disabled = false,
}) => {
const [selectedInstallation, setSelectedInstallation] =
useState<Installation | null>(null)
const [isAddingProject, startAddingProjectTransition] = useTransition()
const [, startTransition] = useTransition()

const [repositoriesState, repositoriesAction, isRepositoriesLoading] =
useActionState(getRepositories, { repositories: [], loading: false })

const githubAppUrl = process.env.NEXT_PUBLIC_GITHUB_APP_URL

const handleSelectInstallation = (installation: Installation) => {
setSelectedInstallation(installation)
startTransition(() => {
if (disabled) return
startAddingProjectTransition(() => {
setSelectedInstallation(installation)
const formData = new FormData()
formData.append('installationId', installation.id.toString())
repositoriesAction(formData)
Expand Down Expand Up @@ -84,8 +86,8 @@ export const InstallationSelector: FC<Props> = ({
</div>
<div className={styles.installationSelector}>
<DropdownMenuRoot>
<DropdownMenuTrigger asChild>
<Button size="lg" variant="ghost-secondary">
<DropdownMenuTrigger asChild disabled={disabled}>
<Button size="lg" variant="ghost-secondary" disabled={disabled}>
{selectedInstallation
? match(selectedInstallation.account)
.with({ login: P.string }, (item) => item.login)
Expand All @@ -104,7 +106,7 @@ export const InstallationSelector: FC<Props> = ({
return (
<DropdownMenuItem
key={item.id}
onSelect={() => handleSelectInstallation(item)}
onSelect={() => !disabled && handleSelectInstallation(item)}
>
{login}
</DropdownMenuItem>
Expand Down
Loading
Loading