diff --git a/src/app/api/auth/jira/authorize/route.ts b/src/app/api/auth/jira/authorize/route.ts new file mode 100644 index 0000000..2ad52d3 --- /dev/null +++ b/src/app/api/auth/jira/authorize/route.ts @@ -0,0 +1,38 @@ +import { type NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; + +export async function GET(request: NextRequest) { + const clientId = process.env.JIRA_CLIENT_ID; + const redirectUri = process.env.JIRA_REDIRECT_URI; + + if (!clientId || !redirectUri) { + return new NextResponse("Jira client ID or redirect URI is not set", { + status: 500, + }); + } + + const state = crypto.randomBytes(16).toString("hex"); + const codeVerifier = crypto.randomBytes(64).toString("hex"); + const codeChallenge = crypto + .createHash("sha256") + .update(codeVerifier) + .digest("base64url"); + + const authorizationUrl = + `https://auth.atlassian.com/authorize` + + `?audience=api.atlassian.com` + + `&client_id=${encodeURIComponent(clientId)}` + + `&scope=${encodeURIComponent("read:jira-user read:jira-work")}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&state=${encodeURIComponent(state)}` + + `&response_type=code` + + `&prompt=consent` + + `&code_challenge=${encodeURIComponent(codeChallenge)}` + + `&code_challenge_method=S256`; + + const response = NextResponse.redirect(authorizationUrl); + response.cookies.set("jira_code_verifier", codeVerifier, { httpOnly: true }); + response.cookies.set("jira_state", state, { httpOnly: true }); + + return response; +} diff --git a/src/app/api/auth/jira/callback/route.ts b/src/app/api/auth/jira/callback/route.ts new file mode 100644 index 0000000..66073ea --- /dev/null +++ b/src/app/api/auth/jira/callback/route.ts @@ -0,0 +1,70 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { db } from "@/server/db/db"; +import { getServerAuthSession } from "@/server/auth"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const code = searchParams.get("code"); + const state = searchParams.get("state"); + + const jiraState = request.cookies.get("jira_state")?.value; + const codeVerifier = request.cookies.get("jira_code_verifier")?.value; + + if (!code || !state || !jiraState || !codeVerifier) { + return new NextResponse("Invalid request", { status: 400 }); + } + + if (state !== jiraState) { + return new NextResponse("State mismatch", { status: 400 }); + } + + const clientId = process.env.JIRA_CLIENT_ID ?? ""; + const clientSecret = process.env.JIRA_CLIENT_SECRET ?? ""; + const redirectUri = process.env.JIRA_REDIRECT_URI ?? ""; + + const body = { + grant_type: "authorization_code", + client_id: clientId, + client_secret: clientSecret, + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }; + + const tokenResponse = await fetch("https://auth.atlassian.com/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!tokenResponse.ok) { + return new NextResponse("Failed to exchange code for token", { + status: 500, + }); + } + + const tokenData = await tokenResponse.json(); + const accessToken = tokenData.access_token; + + if (!accessToken) { + return new NextResponse("Invalid token response", { status: 500 }); + } + + const session = await getServerAuthSession(); + + if (!session?.user) { + return new NextResponse("Not authenticated", { status: 401 }); + } + + const userId = parseInt(session.user.id, 10); + + await db.users.find(userId).update({ + jiraToken: accessToken, + }); + + const response = NextResponse.redirect("/dashboard"); + response.cookies.delete("jira_state"); + response.cookies.delete("jira_code_verifier"); + + return response; +} diff --git a/src/app/dashboard/[org]/[repo]/settings/page.tsx b/src/app/dashboard/[org]/[repo]/settings/page.tsx new file mode 100644 index 0000000..0316ce5 --- /dev/null +++ b/src/app/dashboard/[org]/[repo]/settings/page.tsx @@ -0,0 +1,34 @@ +"use client"; + +import React from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; + +interface SettingsPageProps { + params: { + org: string; + repo: string; + }; +} + +export default function SettingsPage({ params }: SettingsPageProps) { + const router = useRouter(); + const { data: session } = useSession(); + + const handleLoginWithJira = () => { + router.push("/api/auth/jira/authorize"); + }; + + return ( +
+

+ Settings for {params.org}/{params.repo} +

+ {!session?.user?.jiraToken ? ( + + ) : ( +

Connected to Jira

+ )} +
+ ); +} diff --git a/src/server/auth.ts b/src/server/auth.ts index 79a7950..fcf0bf5 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -25,6 +25,7 @@ declare module "next-auth/adapters" { interface AdapterUser { login?: string; role?: UserRole; + jiraToken?: string; } } @@ -34,8 +35,8 @@ declare module "next-auth" { id: string; login: string; role?: UserRole; + jiraToken?: string; expires?: string; // ISO DateString - // ...other properties } & DefaultSession["user"]; accessToken: string; } @@ -45,9 +46,9 @@ declare module "next-auth" { } interface User { - // ...other properties role: UserRole; login: string; + jiraToken?: string; expires?: string; } } @@ -118,6 +119,7 @@ export const authOptions: NextAuthOptions = { id: userId, role: user.role, login: user.login, + jiraToken: user.jiraToken, expires: session.expires, }, }; @@ -150,15 +152,7 @@ export const authOptions: NextAuthOptions = { }; }, }), - /** - * ...add more providers here. - * - * Most other providers require a bit more work than the Discord provider. For example, the - * GitHub provider requires you to add the `refresh_token_expires_in` field to the Account - * model. Refer to the NextAuth.js docs for the provider you want to use. Example: - * - * @see https://next-auth.js.org/providers/github - */ + // ...add more providers here. ], }; diff --git a/src/server/db/migrations/20241101000000_addJiraTokenToUsers.ts b/src/server/db/migrations/20241101000000_addJiraTokenToUsers.ts new file mode 100644 index 0000000..289f78b --- /dev/null +++ b/src/server/db/migrations/20241101000000_addJiraTokenToUsers.ts @@ -0,0 +1,9 @@ +import { change } from "../dbScript"; + +change(async (db) => { + await db.changeTable("users", (t) => ({ + add: { + jiraToken: t.text().nullable(), + }, + })); +}); diff --git a/src/server/db/tables/users.table.ts b/src/server/db/tables/users.table.ts index 45db303..e85a5e5 100644 --- a/src/server/db/tables/users.table.ts +++ b/src/server/db/tables/users.table.ts @@ -24,6 +24,7 @@ export class UsersTable extends BaseTable { onboardingStatus: t .enum("onboarding_status", ONBOARDING_STATUS_VALUES) .default(OnboardingStatus.NONE), + jiraToken: t.text().nullable(), ...t.timestamps(), })); }