Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 49 additions & 0 deletions src/app/api/auth/jira/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { type NextRequest } from "next/server";
import { getServerAuthSession } from "~/server/auth";
import { env } from "~/env";
import { randomBytes } from "crypto";

export async function GET(req: NextRequest) {
const session = await getServerAuthSession();

if (!session?.user) {
return new Response("Unauthorized", { status: 401 });
}

const state = randomBytes(32).toString("hex");
const codeVerifier = randomBytes(32).toString("hex");

// Store state and code verifier in session/cookie
const cookieStore = req.cookies;
cookieStore.set("jira_oauth_state", state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 10, // 10 minutes
});
cookieStore.set("jira_code_verifier", codeVerifier, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 10, // 10 minutes
});

const authUrl = new URL("https://auth.atlassian.com/authorize");
authUrl.searchParams.set("audience", "api.atlassian.com");
authUrl.searchParams.set("client_id", env.JIRA_CLIENT_ID);
authUrl.searchParams.set(
"scope",
"read:jira-user read:jira-work write:jira-work",
);
authUrl.searchParams.set(
"redirect_uri",
`${env.NEXTAUTH_URL}/api/auth/jira/callback`,
);
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("prompt", "consent");

return Response.redirect(authUrl.toString());
}
69 changes: 69 additions & 0 deletions src/app/api/auth/jira/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { type NextRequest } from "next/server";
import { getServerAuthSession } from "~/server/auth";
import { env } from "~/env";
import { db } from "~/server/db/db";

export async function GET(req: NextRequest) {
const session = await getServerAuthSession();

if (!session?.user) {
return new Response("Unauthorized", { status: 401 });
}

const searchParams = req.nextUrl.searchParams;
const code = searchParams.get("code");
const state = searchParams.get("state");
const error = searchParams.get("error");

if (error) {
return new Response(`Authentication error: ${error}`, { status: 400 });
}

const cookieStore = req.cookies;
const storedState = cookieStore.get("jira_oauth_state")?.value;
const codeVerifier = cookieStore.get("jira_code_verifier")?.value;

if (!storedState || !codeVerifier || state !== storedState) {
return new Response("Invalid state", { status: 400 });
}

try {
const tokenResponse = await fetch(
"https://auth.atlassian.com/oauth/token",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
client_id: env.JIRA_CLIENT_ID,
client_secret: env.JIRA_CLIENT_SECRET,
code,
redirect_uri: `${env.NEXTAUTH_URL}/api/auth/jira/callback`,
code_verifier: codeVerifier,
}),
},
);

if (!tokenResponse.ok) {
throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
}

const { access_token } = await tokenResponse.json();

// Store the token in the database
await db.users.find(parseInt(session.user.id)).update({
jiraToken: access_token,
});

// Clear OAuth cookies
cookieStore.delete("jira_oauth_state");
cookieStore.delete("jira_code_verifier");

return Response.redirect(`${env.NEXTAUTH_URL}/dashboard`);
} catch (error) {
console.error("Error exchanging code for token:", error);
return new Response("Failed to exchange code for token", { status: 500 });
}
}
55 changes: 55 additions & 0 deletions src/app/api/webhooks/jira/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { type NextRequest } from "next/server";
import { env } from "~/env";
import { db } from "~/server/db/db";
import { TodoStatus } from "~/server/db/enums";

export async function POST(req: NextRequest) {
// Verify webhook secret
const webhookSecret = req.headers.get("x-atlassian-webhook-token");
if (webhookSecret !== env.JIRA_WEBHOOK_SECRET) {
return new Response("Invalid webhook secret", { status: 401 });
}

try {
const payload = await req.json();

// Only process issue creation events
if (payload.webhookEvent !== "jira:issue_created") {
return new Response("Event type not supported", { status: 200 });
}

const issue = payload.issue;
const project = await db.projects.findBy({
repoFullName: issue.fields.project.key,
});

if (!project) {
return new Response("Project not found", { status: 404 });
}

// Check if todo already exists for this issue
const existingTodo = await db.todos.findByOptional({
projectId: project.id,
issueId: issue.id,
});

if (existingTodo) {
return new Response("Todo already exists", { status: 200 });
}

// Create new todo
await db.todos.create({
projectId: project.id,
description: `${issue.fields.summary}\n\n${issue.fields.description || ""}`,
name: issue.fields.summary,
status: TodoStatus.TODO,
issueId: issue.id,
position: issue.id,
});

return new Response("Todo created successfully", { status: 200 });
} catch (error) {
console.error("Error processing Jira webhook:", error);
return new Response("Internal server error", { status: 500 });
}
}
38 changes: 38 additions & 0 deletions src/app/dashboard/[org]/[repo]/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { getServerAuthSession } from "~/server/auth";
import { Button } from "~/components/ui/button";

export default async function SettingsPage() {
const session = await getServerAuthSession();

return (
<div className="container mx-auto px-4 py-8">
<h1 className="mb-8 text-3xl font-bold">Repository Settings</h1>

<div className="bg-card rounded-lg border p-6 shadow-sm">
<h2 className="mb-4 text-xl font-semibold">Integrations</h2>

<div className="space-y-4">
<div>
<h3 className="mb-2 text-lg font-medium">Jira Integration</h3>
<p className="text-muted-foreground mb-4 text-sm">
Connect your Jira account to automatically create todos from Jira
issues.
</p>
{session?.user?.jiraToken ? (
<p className="text-sm text-green-600">✓ Connected to Jira</p>
) : (
<Button
onClick={() => {
window.location.href = "/api/auth/jira/authorize";
}}
className="bg-blue-600 hover:bg-blue-700"
>
Log in with Jira
</Button>
)}
</div>
</div>
</div>
</div>
);
}
5 changes: 5 additions & 0 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ declare module "next-auth/adapters" {
interface AdapterUser {
login?: string;
role?: UserRole;
jiraToken?: string;
}
}

Expand All @@ -35,6 +36,7 @@ declare module "next-auth" {
login: string;
role?: UserRole;
expires?: string; // ISO DateString
jiraToken?: string;
// ...other properties
} & DefaultSession["user"];
accessToken: string;
Expand All @@ -49,6 +51,7 @@ declare module "next-auth" {
role: UserRole;
login: string;
expires?: string;
jiraToken?: string;
}
}

Expand Down Expand Up @@ -110,6 +113,7 @@ export const authOptions: NextAuthOptions = {
const { session, user } = params;
const userId = parseInt(user.id, 10);
const account = await db.accounts.findBy({ userId });
const dbUser = await db.users.find(userId);
return {
...session,
accessToken: account.access_token,
Expand All @@ -119,6 +123,7 @@ export const authOptions: NextAuthOptions = {
role: user.role,
login: user.login,
expires: session.expires,
jiraToken: dbUser.jiraToken,
},
};
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { change } from "../dbScript";

change(async (db) => {
await db.changeTable("users", (t) => ({
jiraToken: t.text().nullable(),
}));
});
1 change: 1 addition & 0 deletions src/server/db/tables/users.table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
}