Skip to content

Commit 7a0d08e

Browse files
authored
fix(integrations): configure Jira OAuth per-integration via admin UI (#462)
Jira OAuth previously relied on global JIRA_CLIENT_ID/SECRET/REDIRECT_URI env vars and Jira-specific auth/callback routes, which prevented each instance from registering its own Atlassian OAuth app from the admin UI. - JiraAdapter now prefers per-integration credentials (clientId/secret/ redirectUri) decrypted by IntegrationManager, falling back to the legacy JIRA_* env vars only when an integration provides none. - Remove the Jira-specific /api/integrations/jira/auth and /callback routes in favor of the generic /api/integrations/oauth/<provider>/* flow. - test-connection: the OAuth2 path no longer attempts a non-existent client-credentials probe; it confirms client config is present and returns requiresUserAuth so the integration is not prematurely marked ACTIVE. - Admin UI: surface the canonical callback URL (with copy), add Authorize/ Reauthorize actions and an "Awaiting authorization" status for OAuth integrations not yet connected. - Document the per-integration setup and legacy env fallback; add i18n keys.
1 parent 36e6503 commit 7a0d08e

27 files changed

Lines changed: 523 additions & 284 deletions

File tree

docs/docs/user-guide/integrations.md

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -221,22 +221,19 @@ When using API key authentication, and assuming the account has the required per
221221

222222
#### Jira with OAuth 2.0
223223

224-
OAuth 2.0 is Atlassian's preferred authentication path for Jira Cloud and is recommended for any team where the per-user reporter attribution and granular scoping matter.
224+
OAuth 2.0 is Atlassian's preferred authentication path for Jira Cloud and is recommended for any team where per-user reporter attribution and granular scoping matter. Each user authorizes individually and issues are created as that user.
225225

226-
1. Create OAuth app in [Atlassian Developer Console](https://developer.atlassian.com/console)
227-
2. Set redirect URL: `https://your-testplanit-domain/api/auth/jira/callback`
228-
3. Configure in TestPlanIt:
229-
230-
```text
231-
Client ID: [from-atlassian]
232-
Client Secret: [from-atlassian]
233-
```
226+
1. Create an OAuth 2.0 (3LO) integration in the [Atlassian Developer Console](https://developer.atlassian.com/console).
227+
2. Under **Permissions**, add the **Jira API** and select these scopes: `read:jira-work`, `write:jira-work`, and `read:jira-user`. (TestPlanIt also requests `offline_access` during authorization so it can refresh tokens — you don't add that one in the console.)
228+
3. Under **Authorization**, set the **Callback URL** to:
234229

235-
Benefits:
230+
```text
231+
<your-testplanit-url>/api/integrations/oauth/jira/callback
232+
```
236233

237-
- Provides user-specific authentication
238-
- Each user authorizes their own Jira access
239-
- Issues created with the actual user as reporter
234+
TestPlanIt shows this exact URL (with a copy button) in the integration dialog when you choose OAuth 2.0, so you don't have to construct it by hand.
235+
4. In TestPlanIt, add a Jira integration, choose **OAuth 2.0**, and enter the **Client ID** and **Client Secret** from the app, plus your Jira site URL (e.g. `https://your-domain.atlassian.net`).
236+
5. Finish the shared steps in [Completing the OAuth setup](#completing-the-oauth-setup-all-providers) — the integration must be authorized, activated, assigned to a project, and have a linked project before issues can be created.
240237

241238
#### GitHub with Personal Access Token
242239

@@ -336,10 +333,10 @@ OAuth 2.0 gives **per-user attribution**: each user authorizes individually and
336333

337334
Creating the OAuth app and entering the Client ID/Secret is only half the setup. Issue creation stays blocked until an administrator (or project manager) finishes these steps **in order**:
338335

339-
1. **Activate the integration.** In **Administration → Issue Integrations**, click **Test Connection** on the new integration. This validates the configuration and marks it **Active**. An inactive integration does not appear when assigning to a project.
336+
1. **Activate the integration by authorizing it.** A new OAuth 2.0 integration shows **Awaiting authorization** — this is expected, not an error. (Running **Test Connection** only confirms the Client ID/Secret are well-formed; OAuth has no client-credentials grant, so it cannot mark the integration Active on its own.) In **Administration → Issue Integrations**, click **Authorize** on the integration row, and grant access on the provider's consent screen. This connects your account and flips the integration to **Active**. An integration that is not yet Active does not appear when assigning to a project.
340337
2. **Assign it to a project.** In **Project Settings → Issue Integrations**, click **Assign** on the integration.
341338
3. **Link an external project (repository).** In the integration's settings panel, use **Linked External Projects → Add Projects**, select the target repository (e.g. `owner/repo`), click **Add Selected**, then **Save Settings**. This step is required — without a linked external project the Create Issue dialog reports **"Integration Not Configured"** and the **Create** button stays disabled.
342-
4. **Authorize — once per user.** Each person who will create issues opens **Project → Integrations**, clicks **Authorize**, and grants access on the provider's consent screen. Because authorization is per-user, every issue is attributed to the individual who created it.
339+
4. **Authorize the remaining users — once per user.** Authorization is per-user, so each *additional* person who will create issues opens **Project → Integrations**, clicks **Authorize**, and grants access on the provider's consent screen. (The admin who activated the integration in step 1 is already authorized.) Every issue is then attributed to the individual who created it.
343340

344341
#### Azure DevOps
345342

testplanit/.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,26 @@ DATABASE_URL="postgresql://user:password@postgres:5432/testplanit?schema=public"
2828
# POSTGRES_PORT=5432
2929

3030
# Next.js Configuration
31+
# NEXTAUTH_URL must be the app's public URL. It also forms the OAuth callback
32+
# (redirect) URI that issue-tracker integrations register with their providers:
33+
# ${NEXTAUTH_URL}/api/integrations/oauth/<provider>/callback
34+
# (e.g. .../api/integrations/oauth/jira/callback). OAuth client credentials
35+
# for Jira/GitHub/GitLab/Gitea are entered per-integration in
36+
# Administration → Issue Integrations — not via environment variables.
3137
NEXTAUTH_URL="http://localhost:3000"
3238

3339
# Generate a secure secret for NextAuth (e.g., using `openssl rand -base64 32`)
3440
NEXTAUTH_SECRET=$(openssl rand -base64 32)
3541

42+
# Legacy Jira OAuth fallback (optional, deprecated):
43+
# Older single-app deployments configured Jira OAuth globally via these vars.
44+
# New installs should leave them unset and configure the Client ID/Secret
45+
# per-integration in the admin UI instead; they are only consulted when an
46+
# integration does not provide its own credentials.
47+
# JIRA_CLIENT_ID=
48+
# JIRA_CLIENT_SECRET=
49+
# JIRA_REDIRECT_URI=
50+
3651
# Public API URL (optional)
3752
# NEXT_PUBLIC_API_URL="http://localhost:3000/api"
3853

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
Tooltip,
6+
TooltipContent,
7+
TooltipTrigger,
8+
} from "@/components/ui/tooltip";
9+
import { Integration } from "@prisma/client";
10+
import { ShieldCheck } from "lucide-react";
11+
import { useTranslations } from "next-intl";
12+
13+
interface AuthorizeIntegrationButtonProps {
14+
integration: Integration;
15+
}
16+
17+
/**
18+
* Row action that starts the per-user OAuth (3LO) authorization for an
19+
* integration that is configured but not yet connected. It uses the generic
20+
* OAuth route, which only needs an integrationId (no project assignment); the
21+
* callback stores the user's token and flips the integration to ACTIVE.
22+
*/
23+
export function AuthorizeIntegrationButton({
24+
integration,
25+
}: AuthorizeIntegrationButtonProps) {
26+
const t = useTranslations("admin.integrations");
27+
28+
const handleAuthorize = () => {
29+
const params = new URLSearchParams({
30+
integrationId: integration.id.toString(),
31+
});
32+
window.location.href = `/api/integrations/oauth/${integration.provider.toLowerCase()}/auth?${params.toString()}`;
33+
};
34+
35+
return (
36+
<Tooltip>
37+
<TooltipTrigger asChild>
38+
<Button
39+
variant="ghost"
40+
onClick={handleAuthorize}
41+
className="px-2 py-1 h-auto text-warning-foreground"
42+
>
43+
<ShieldCheck className="h-4 w-4" />
44+
<span className="sr-only">{t("authorize")}</span>
45+
</Button>
46+
</TooltipTrigger>
47+
<TooltipContent>
48+
<p>{t("authorizeHint")}</p>
49+
</TooltipContent>
50+
</Tooltip>
51+
);
52+
}

testplanit/app/[locale]/admin/integrations/columns.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Link, Plug } from "lucide-react";
99
import { useTranslations } from "next-intl";
1010
import { useMemo } from "react";
1111
import { siGithub, siGitlab, siJira, siRedmine } from "simple-icons";
12+
import { AuthorizeIntegrationButton } from "./AuthorizeIntegrationButton";
1213
import { DeleteIntegrationButton } from "./DeleteIntegrationButton";
1314
import { EditIntegrationButton } from "./EditIntegrationButton";
1415
import { SyncIntegrationButton } from "./SyncIntegrationButton";
@@ -127,6 +128,26 @@ export const useColumns = (
127128
ERROR: "destructive",
128129
};
129130

131+
// An OAuth 2.0 (3LO) integration that isn't yet connected is in a
132+
// valid, expected mid-setup state — not a misconfiguration. Show
133+
// "Awaiting authorization" instead of a bare "Inactive" so the
134+
// admin understands the next step is to authorize, not that they
135+
// did something wrong.
136+
const awaitingAuthorization =
137+
row.original.authType === "OAUTH2" &&
138+
row.original.status === "INACTIVE";
139+
140+
if (awaitingAuthorization) {
141+
return (
142+
<Badge
143+
variant="outline"
144+
className="border-warning text-warning-foreground"
145+
>
146+
{t("status.awaitingAuthorization")}
147+
</Badge>
148+
);
149+
}
150+
130151
return (
131152
<Badge variant={statusColors[row.original.status] as any}>
132153
{t(`status.${row.original.status.toLowerCase()}` as any)}
@@ -228,6 +249,13 @@ export const useColumns = (
228249
meta: { isPinned: "right" },
229250
cell: ({ row }) => (
230251
<div className="bg-primary-foreground whitespace-nowrap flex justify-center gap-1">
252+
{row.original.authType === "OAUTH2" &&
253+
row.original.status === "INACTIVE" && (
254+
<AuthorizeIntegrationButton
255+
key={`authorize-${row.original.id}`}
256+
integration={row.original}
257+
/>
258+
)}
231259
<SyncIntegrationButton
232260
key={`sync-${row.original.id}`}
233261
integration={row.original}

testplanit/app/api/integrations/jira/auth/route.ts

Lines changed: 0 additions & 78 deletions
This file was deleted.

testplanit/app/api/integrations/jira/callback/route.ts

Lines changed: 0 additions & 118 deletions
This file was deleted.

0 commit comments

Comments
 (0)