Skip to content
Closed
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
58 changes: 45 additions & 13 deletions src/app/components/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,16 @@ export default function SettingsPage() {
const [jiraApiEmail, setJiraApiEmail] = createSignal("");
const [jiraApiToken, setJiraApiToken] = createSignal("");
const [jiraApiSubdomain, setJiraApiSubdomain] = createSignal("");
const [jiraApiDomain, setJiraApiDomain] = createSignal("atlassian.net");
const [jiraApiCustomUrl, setJiraApiCustomUrl] = createSignal("");
const [jiraApiConnecting, setJiraApiConnecting] = createSignal(false);
const [jiraApiError, setJiraApiError] = createSignal<string | null>(null);
const [jiraApiMode, setJiraApiMode] = createSignal(false);

const jiraApiSiteUrl = () => {
if (jiraApiDomain() === "custom") return jiraApiCustomUrl().trim().replace(/\/$/, "");
const sub = jiraApiSubdomain().trim();
return sub ? `https://${sub}.atlassian.net` : "";
return sub ? `https://${sub}.${jiraApiDomain()}` : "";
};

function handleJiraOAuthConnect() {
Expand All @@ -244,7 +247,9 @@ export default function SettingsPage() {
const token = jiraApiToken().trim();
const siteUrl = jiraApiSiteUrl();
if (!email || !token || !siteUrl) {
setJiraApiError("Email, API token, and site name are all required.");
setJiraApiError(jiraApiDomain() === "custom"
? "Email, API token, and site URL are all required."
: "Email, API token, and site name are all required.");
return;
}
setJiraApiConnecting(true);
Expand Down Expand Up @@ -299,6 +304,8 @@ export default function SettingsPage() {
setJiraApiEmail("");
setJiraApiToken("");
setJiraApiSubdomain("");
setJiraApiDomain("atlassian.net");
setJiraApiCustomUrl("");
setJiraApiMode(false);
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
Expand Down Expand Up @@ -917,16 +924,41 @@ export default function SettingsPage() {
aria-label="Atlassian API token"
/>
<div class="flex items-center gap-1">
<span class="text-sm text-base-content/60 shrink-0">https://</span>
<input
type="text"
placeholder="yoursite"
value={jiraApiSubdomain()}
onInput={(e) => setJiraApiSubdomain(e.currentTarget.value)}
class="input input-sm w-32"
aria-label="Jira site name"
/>
<span class="text-sm text-base-content/60">.atlassian.net</span>
<Show when={jiraApiDomain() !== "custom"}>
<span class="text-sm text-base-content/60 shrink-0">https://</span>
</Show>
<Show
when={jiraApiDomain() !== "custom"}
fallback={
<input
type="url"
placeholder="https://jira.yourcompany.com"
value={jiraApiCustomUrl()}
onInput={(e) => setJiraApiCustomUrl(e.currentTarget.value)}
class="input input-sm flex-1"
aria-label="Jira site URL"
/>
}
>
<input
type="text"
placeholder="yoursite"
value={jiraApiSubdomain()}
onInput={(e) => setJiraApiSubdomain(e.currentTarget.value)}
class="input input-sm w-32"
aria-label="Jira site name"
/>
<span class="text-sm text-base-content/60">.</span>
</Show>
<select
value={jiraApiDomain()}
onChange={(e) => setJiraApiDomain(e.currentTarget.value)}
class="select select-sm"
aria-label="Jira domain"
>
<option value="atlassian.net">atlassian.net</option>
<option value="custom">Custom domain</option>
</select>
</div>
<Show when={jiraApiError()}>
<p class="text-xs text-error">{jiraApiError()}</p>
Expand All @@ -942,7 +974,7 @@ export default function SettingsPage() {
</button>
<button
type="button"
onClick={() => { setJiraApiMode(false); setJiraApiError(null); setJiraApiSubdomain(""); }}
onClick={() => { setJiraApiMode(false); setJiraApiError(null); setJiraApiSubdomain(""); setJiraApiDomain("atlassian.net"); setJiraApiCustomUrl(""); }}
class="btn btn-sm btn-ghost"
>
Cancel
Expand Down
4 changes: 1 addition & 3 deletions src/app/lib/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,10 @@ export function isSafeGitHubUrl(url: string): boolean {
}
}

const ATLASSIAN_HOST_RE = /^[a-z0-9-]+\.atlassian\.net$/i;

export function isSafeJiraSiteUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" && ATLASSIAN_HOST_RE.test(parsed.hostname);
return parsed.protocol === "https:" && parsed.hostname.includes(".");
} catch {
return false;
}
Expand Down
16 changes: 10 additions & 6 deletions tests/lib/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,20 @@ describe("isSafeJiraSiteUrl", () => {
expect(isSafeJiraSiteUrl("http://mysite.atlassian.net")).toBe(false);
});

it("returns false for bare atlassian.net (no subdomain)", () => {
expect(isSafeJiraSiteUrl("https://atlassian.net")).toBe(false);
it("returns true for bare atlassian.net (valid HTTPS domain)", () => {
expect(isSafeJiraSiteUrl("https://atlassian.net")).toBe(true);
});

it("returns false for a non-atlassian.net domain", () => {
expect(isSafeJiraSiteUrl("https://evil.com")).toBe(false);
it("returns true for a custom domain (self-hosted Jira)", () => {
expect(isSafeJiraSiteUrl("https://jira.mycompany.com")).toBe(true);
});

it("returns false for a multi-level subdomain (evil.foo.atlassian.net)", () => {
expect(isSafeJiraSiteUrl("https://evil.foo.atlassian.net")).toBe(false);
it("returns true for a multi-level subdomain", () => {
expect(isSafeJiraSiteUrl("https://jira.internal.mycompany.com")).toBe(true);
});

it("returns false for a bare TLD (no dot in hostname)", () => {
expect(isSafeJiraSiteUrl("https://localhost")).toBe(false);
});

it("returns false for a javascript: URL", () => {
Expand Down