From c79ffd4303942ee071fa8f6e9514696d9aa2f701 Mon Sep 17 00:00:00 2001
From: Che <30403707+Che-Zhu@users.noreply.github.com>
Date: Fri, 5 Dec 2025 15:20:52 +0800
Subject: [PATCH 01/10] feat: add GitHub account binding for logged-in users
Implement GitHub OAuth binding functionality that allows users
(e.g., password-authenticated users) to link their GitHub accounts
to their existing sessions.
Changes:
- Add GitHub binding status API (GET /api/user/github)
- Add GitHub unbind API (DELETE /api/user/github)
- Add OAuth initiation endpoint (GET /api/user/github/bind)
- Add OAuth callback handler (GET /api/auth/github/callback)
- Add GitHub tab to Settings Dialog with binding UI
- Implement popup-based OAuth flow with postMessage communication
Technical details:
- Use CSRF protection with state parameter stored in httpOnly cookie
- Store GitHub credentials in UserIdentity.metadata (token, login, avatar)
- Prevent unbinding if GitHub is the only login method
- Set isPrimary=false for binding (not primary authentication)
- State expires after 10 minutes for security
The binding flow uses a popup window to avoid disrupting the main
application, with automatic status refresh upon successful binding.
---
app/api/auth/github/callback/route.ts | 219 ++++++++++++++++++++++++++
app/api/user/github/bind/route.ts | 62 ++++++++
app/api/user/github/route.ts | 104 ++++++++++++
components/dialog/settings-dialog.tsx | 168 +++++++++++++++++++-
4 files changed, 549 insertions(+), 4 deletions(-)
create mode 100644 app/api/auth/github/callback/route.ts
create mode 100644 app/api/user/github/bind/route.ts
create mode 100644 app/api/user/github/route.ts
diff --git a/app/api/auth/github/callback/route.ts b/app/api/auth/github/callback/route.ts
new file mode 100644
index 0000000..e01c0d7
--- /dev/null
+++ b/app/api/auth/github/callback/route.ts
@@ -0,0 +1,219 @@
+import { NextRequest, NextResponse } from 'next/server'
+
+import { env } from '@/lib/env'
+import { logger as baseLogger } from '@/lib/logger'
+import { prisma } from '@/lib/db'
+
+const logger = baseLogger.child({ module: 'api/auth/github/callback' })
+
+/**
+ * GET /api/auth/github/callback
+ * Handles the OAuth callback from GitHub for account binding
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const searchParams = request.nextUrl.searchParams
+ const code = searchParams.get('code')
+ const state = searchParams.get('state')
+
+ if (!code || !state) {
+ return NextResponse.json({ error: 'Missing code or state parameter' }, { status: 400 })
+ }
+
+ // Verify state parameter
+ const stateCookie = request.cookies.get('github_oauth_state')?.value
+
+ if (!stateCookie || stateCookie !== state) {
+ logger.warn('State mismatch in GitHub OAuth callback')
+ return NextResponse.json({ error: 'Invalid state parameter' }, { status: 400 })
+ }
+
+ // Decode state to get userId
+ let userId: string
+ try {
+ const decodedState = Buffer.from(state, 'base64').toString('utf-8')
+ const [, extractedUserId, timestamp] = decodedState.split('|')
+
+ // Check if state is expired (10 minutes)
+ const stateAge = Date.now() - parseInt(timestamp, 10)
+ if (stateAge > 10 * 60 * 1000) {
+ return NextResponse.json({ error: 'State expired' }, { status: 400 })
+ }
+
+ userId = extractedUserId
+ } catch {
+ return NextResponse.json({ error: 'Invalid state format' }, { status: 400 })
+ }
+
+ // Exchange code for access token
+ const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ },
+ body: JSON.stringify({
+ client_id: env.GITHUB_CLIENT_ID,
+ client_secret: env.GITHUB_CLIENT_SECRET,
+ code,
+ }),
+ })
+
+ const tokenData = await tokenResponse.json()
+
+ if (!tokenData.access_token) {
+ logger.error('Failed to get access token from GitHub')
+ return NextResponse.json({ error: 'Failed to get access token' }, { status: 500 })
+ }
+
+ const accessToken = tokenData.access_token
+ const scope = tokenData.scope || 'repo read:user'
+
+ // Get GitHub user info
+ const userResponse = await fetch('https://api.github.com/user', {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: 'application/vnd.github.v3+json',
+ },
+ })
+
+ const githubUser = await userResponse.json()
+
+ if (!githubUser.id) {
+ logger.error('Failed to get GitHub user info')
+ return NextResponse.json({ error: 'Failed to get user info' }, { status: 500 })
+ }
+
+ const githubUserId = githubUser.id.toString()
+ const githubLogin = githubUser.login
+ const githubAvatarUrl = githubUser.avatar_url
+
+ // Check if this GitHub account is already bound to another user
+ const existingIdentity = await prisma.userIdentity.findUnique({
+ where: {
+ unique_provider_user: {
+ provider: 'GITHUB',
+ providerUserId: githubUserId,
+ },
+ },
+ })
+
+ if (existingIdentity && existingIdentity.userId !== userId) {
+ logger.warn(`GitHub account ${githubLogin} is already bound to another user`)
+ return createCallbackPage(
+ false,
+ 'This GitHub account is already bound to another user account.'
+ )
+ }
+
+ // Upsert GitHub identity
+ await prisma.userIdentity.upsert({
+ where: {
+ unique_provider_user: {
+ provider: 'GITHUB',
+ providerUserId: githubUserId,
+ },
+ },
+ update: {
+ metadata: {
+ token: accessToken,
+ scope,
+ login: githubLogin,
+ avatar_url: githubAvatarUrl,
+ },
+ },
+ create: {
+ userId,
+ provider: 'GITHUB',
+ providerUserId: githubUserId,
+ metadata: {
+ token: accessToken,
+ scope,
+ login: githubLogin,
+ avatar_url: githubAvatarUrl,
+ },
+ isPrimary: false, // This is a binding, not primary login
+ },
+ })
+
+ logger.info(`GitHub account ${githubLogin} bound successfully for user ${userId}`)
+
+ // Return success page that notifies parent window
+ return createCallbackPage(true, 'GitHub account connected successfully!')
+ } catch (error) {
+ logger.error(`Error in GitHub OAuth callback: ${error}`)
+ return createCallbackPage(false, 'An error occurred during GitHub authentication.')
+ }
+}
+
+/**
+ * Create an HTML page that sends a message to the parent window (popup opener)
+ * and closes itself
+ */
+function createCallbackPage(success: boolean, message: string): NextResponse {
+ const html = `
+
+
+
+
+ GitHub Authentication
+
+
+
+
+
${success ? '✅' : '❌'}
+
${message}
+
This window will close automatically...
+
+
+
+
+ `
+
+ return new NextResponse(html, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'text/html',
+ },
+ })
+}
diff --git a/app/api/user/github/bind/route.ts b/app/api/user/github/bind/route.ts
new file mode 100644
index 0000000..abead91
--- /dev/null
+++ b/app/api/user/github/bind/route.ts
@@ -0,0 +1,62 @@
+import { randomBytes } from 'crypto'
+import { NextResponse } from 'next/server'
+
+import { auth } from '@/lib/auth'
+import { env } from '@/lib/env'
+import { logger as baseLogger } from '@/lib/logger'
+
+const logger = baseLogger.child({ module: 'api/user/github/bind' })
+
+/**
+ * GET /api/user/github/bind
+ * Initiates the GitHub OAuth flow for binding
+ * Redirects to GitHub authorization page with a secure state parameter
+ */
+export async function GET() {
+ try {
+ const session = await auth()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET) {
+ logger.error('GitHub OAuth is not configured')
+ return NextResponse.json({ error: 'GitHub OAuth is not configured' }, { status: 500 })
+ }
+
+ // Generate a secure random state parameter
+ const state = randomBytes(32).toString('hex')
+
+ // Store state in a cookie for verification in callback
+ // Format: state|userId|timestamp
+ const stateData = `${state}|${session.user.id}|${Date.now()}`
+ const encodedState = Buffer.from(stateData).toString('base64')
+
+ // Build GitHub OAuth URL
+ const githubAuthUrl = new URL('https://github.com/login/oauth/authorize')
+ githubAuthUrl.searchParams.set('client_id', env.GITHUB_CLIENT_ID)
+ githubAuthUrl.searchParams.set('redirect_uri', `${process.env.NEXTAUTH_URL}/api/auth/github/callback`)
+ githubAuthUrl.searchParams.set('scope', 'repo read:user')
+ githubAuthUrl.searchParams.set('state', encodedState)
+
+ logger.info(`GitHub OAuth bind initiated for user ${session.user.id}`)
+
+ // Create response with redirect
+ const response = NextResponse.redirect(githubAuthUrl.toString())
+
+ // Set state cookie (expires in 10 minutes)
+ response.cookies.set('github_oauth_state', encodedState, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: 600, // 10 minutes
+ path: '/',
+ })
+
+ return response
+ } catch (error) {
+ logger.error(`Error initiating GitHub OAuth bind: ${error}`)
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
+ }
+}
diff --git a/app/api/user/github/route.ts b/app/api/user/github/route.ts
new file mode 100644
index 0000000..0f37212
--- /dev/null
+++ b/app/api/user/github/route.ts
@@ -0,0 +1,104 @@
+import { NextResponse } from 'next/server'
+
+import { auth } from '@/lib/auth'
+import { prisma } from '@/lib/db'
+import { logger as baseLogger } from '@/lib/logger'
+
+const logger = baseLogger.child({ module: 'api/user/github' })
+
+/**
+ * GET /api/user/github
+ * Returns the GitHub binding status for the current user
+ */
+export async function GET() {
+ try {
+ const session = await auth()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ // Find GitHub identity for this user
+ const githubIdentity = await prisma.userIdentity.findFirst({
+ where: {
+ userId: session.user.id,
+ provider: 'GITHUB',
+ },
+ })
+
+ if (!githubIdentity) {
+ return NextResponse.json({ connected: false })
+ }
+
+ // Extract GitHub info from metadata
+ const metadata = githubIdentity.metadata as {
+ login?: string
+ avatar_url?: string
+ }
+
+ return NextResponse.json({
+ connected: true,
+ login: metadata.login,
+ avatar_url: metadata.avatar_url,
+ })
+ } catch (error) {
+ logger.error(`Error fetching GitHub status: ${error}`)
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
+ }
+}
+
+/**
+ * DELETE /api/user/github
+ * Unbinds the GitHub account from the current user
+ */
+export async function DELETE() {
+ try {
+ const session = await auth()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ // Find and delete GitHub identity
+ const githubIdentity = await prisma.userIdentity.findFirst({
+ where: {
+ userId: session.user.id,
+ provider: 'GITHUB',
+ },
+ })
+
+ if (!githubIdentity) {
+ return NextResponse.json({ error: 'No GitHub account connected' }, { status: 404 })
+ }
+
+ // Check if this is the primary (and only) identity
+ const identityCount = await prisma.userIdentity.count({
+ where: {
+ userId: session.user.id,
+ },
+ })
+
+ if (identityCount === 1 && githubIdentity.isPrimary) {
+ return NextResponse.json(
+ {
+ error: 'Cannot unbind the only login method. Please add another login method first.',
+ },
+ { status: 400 }
+ )
+ }
+
+ // Delete the GitHub identity
+ await prisma.userIdentity.delete({
+ where: {
+ id: githubIdentity.id,
+ },
+ })
+
+ logger.info(`GitHub account unbound for user ${session.user.id}`)
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ logger.error(`Error unbinding GitHub account: ${error}`)
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
+ }
+}
diff --git a/components/dialog/settings-dialog.tsx b/components/dialog/settings-dialog.tsx
index 568c355..58abadd 100644
--- a/components/dialog/settings-dialog.tsx
+++ b/components/dialog/settings-dialog.tsx
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
-import { Code, Database, Save, Terminal } from 'lucide-react';
+import { Code, Database, Github, Save, Terminal } from 'lucide-react';
import { toast } from 'sonner';
import {
@@ -26,7 +26,7 @@ import { useSealos } from '@/provider/sealos';
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
- defaultTab?: 'system-prompt' | 'kubeconfig' | 'anthropic';
+ defaultTab?: 'system-prompt' | 'kubeconfig' | 'anthropic' | 'github';
}
const DEFAULT_SYSTEM_PROMPT = `You are an AI full-stack developer working in a Next.js environment.
@@ -57,7 +57,13 @@ const DEFAULT_SYSTEM_PROMPT = `You are an AI full-stack developer working in a N
- Optimize for performance and SEO
- Follow modern React patterns and best practices`;
-type TabType = 'system-prompt' | 'kubeconfig' | 'anthropic';
+type TabType = 'system-prompt' | 'kubeconfig' | 'anthropic' | 'github';
+
+interface GitHubStatus {
+ connected: boolean;
+ login?: string;
+ avatar_url?: string;
+}
export default function SettingsDialog({
open,
@@ -86,6 +92,11 @@ export default function SettingsDialog({
const [isAnthropicLoading, setIsAnthropicLoading] = useState(false);
const [isAnthropicInitialLoading, setIsAnthropicInitialLoading] = useState(true);
+ // GitHub state
+ const [githubStatus, setGithubStatus] = useState({ connected: false });
+ const [isGithubLoading, setIsGithubLoading] = useState(false);
+ const [isGithubInitialLoading, setIsGithubInitialLoading] = useState(true);
+
// Confirmation dialog state
const [showSystemPromptConfirm, setShowSystemPromptConfirm] = useState(false);
const [showSystemPromptResetConfirm, setShowSystemPromptResetConfirm] = useState(false);
@@ -99,6 +110,7 @@ export default function SettingsDialog({
loadKubeconfig();
}
loadAnthropicConfig();
+ loadGithubStatus();
}
}, [open, isSealos]);
@@ -160,6 +172,18 @@ export default function SettingsDialog({
}
};
+ const loadGithubStatus = async () => {
+ try {
+ const data = await fetchClient.GET('/api/user/github');
+ setGithubStatus(data);
+ } catch (error) {
+ console.error('Failed to load GitHub status:', error);
+ setGithubStatus({ connected: false });
+ } finally {
+ setIsGithubInitialLoading(false);
+ }
+ };
+
const handleSaveSystemPrompt = () => {
setShowSystemPromptConfirm(true);
};
@@ -253,6 +277,66 @@ export default function SettingsDialog({
toast.success('Reset to default system prompt');
};
+ const handleConnectGithub = () => {
+ setIsGithubLoading(true);
+
+ // Open popup window for GitHub OAuth
+ const width = 600;
+ const height = 700;
+ const left = window.screen.width / 2 - width / 2;
+ const top = window.screen.height / 2 - height / 2;
+
+ const popup = window.open(
+ '/api/user/github/bind',
+ 'github-oauth',
+ `width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
+ );
+
+ if (!popup) {
+ toast.error('Failed to open popup window. Please allow popups for this site.');
+ setIsGithubLoading(false);
+ return;
+ }
+
+ // Listen for message from popup
+ const handleMessage = (event: MessageEvent) => {
+ if (event.origin !== window.location.origin) return;
+ if (event.data.type !== 'github-oauth-callback') return;
+
+ if (event.data.success) {
+ toast.success('GitHub account connected successfully!');
+ loadGithubStatus();
+ } else {
+ toast.error(event.data.message || 'Failed to connect GitHub account');
+ }
+
+ setIsGithubLoading(false);
+ window.removeEventListener('message', handleMessage);
+ };
+
+ window.addEventListener('message', handleMessage);
+
+ // Fallback: stop loading after timeout
+ setTimeout(() => {
+ setIsGithubLoading(false);
+ window.removeEventListener('message', handleMessage);
+ }, 60000); // 1 minute timeout
+ };
+
+ const handleDisconnectGithub = async () => {
+ setIsGithubLoading(true);
+ try {
+ await fetchClient.DELETE('/api/user/github');
+ toast.success('GitHub account disconnected successfully');
+ setGithubStatus({ connected: false });
+ } catch (error: unknown) {
+ console.error('Failed to disconnect GitHub:', error);
+ toast.error(error instanceof Error ? error.message : 'Failed to disconnect GitHub account');
+ } finally {
+ setIsGithubLoading(false);
+ }
+ };
+
return (