mirror of
https://github.com/oblien/openship.git
synced 2026-10-02 07:44:35 +08:00
fix github auth window
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { GITHUB_CONNECT_ERROR_KEY } from "@/lib/github-connect-error";
|
||||
import { storeGitHubConnectError } from "@/lib/github-connect-error";
|
||||
|
||||
/**
|
||||
* OAuth callback landing page - auto-closes the popup/window.
|
||||
@@ -17,7 +17,7 @@ export default function OAuthCallbackClose() {
|
||||
// connected". Close immediately in that case — no cookies to settle.
|
||||
const linkError = new URLSearchParams(window.location.search).get("error");
|
||||
if (linkError) {
|
||||
try { localStorage.setItem(GITHUB_CONNECT_ERROR_KEY, linkError); } catch { /* storage unavailable */ }
|
||||
storeGitHubConnectError(linkError);
|
||||
}
|
||||
// Give a brief moment for cookies to settle on success, then close.
|
||||
const timer = setTimeout(() => window.close(), linkError ? 0 : 300);
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getApiOrigin } from "@/lib/api/urls";
|
||||
import { GITHUB_CONNECT_ERROR_KEY } from "@/lib/github-connect-error";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getApiErrorMessage, githubApi } from "@/lib/api";
|
||||
import { storeGitHubConnectError } from "@/lib/github-connect-error";
|
||||
import { closeAuthWindowAfterSuccess } from "@/utils/authWindow";
|
||||
|
||||
/** GitHub App Setup URL landing page for an operator-owned self-hosted App. */
|
||||
export default function GitHubAppSetupCallback() {
|
||||
const [message, setMessage] = useState("Verifying the GitHub App installation…");
|
||||
const claimStarted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// React's development Strict Mode replays effects. The installation nonce
|
||||
// is intentionally one-shot, so never submit the same claim twice.
|
||||
if (claimStarted.current) return;
|
||||
claimStarted.current = true;
|
||||
|
||||
const fail = (detail: string) => {
|
||||
storeGitHubConnectError(detail);
|
||||
setMessage(detail);
|
||||
// Leave enough time to read the local explanation; the opener also
|
||||
// receives it from localStorage and shows the durable toast.
|
||||
closeAuthWindowAfterSuccess(2200);
|
||||
};
|
||||
|
||||
async function claim() {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const flow = query.get("flow");
|
||||
@@ -18,30 +33,13 @@ export default function GitHubAppSetupCallback() {
|
||||
const setupAction = query.get("setup_action");
|
||||
|
||||
if (!state || (flow === "manifest" ? !code : !installationId)) {
|
||||
setMessage(
|
||||
"GitHub did not return the required installation details. Start again from Settings.",
|
||||
);
|
||||
fail("GitHub did not return the required installation details. Start again from Settings.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const base = getApiOrigin(window.location.origin);
|
||||
const endpoint =
|
||||
flow === "manifest"
|
||||
? `${base}/api/github/sources/manifest/convert`
|
||||
: `${base}/api/github/installations/claim`;
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
flow === "manifest" ? { code, state } : { installationId, state, setupAction },
|
||||
),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data?.message || "Could not verify the installation.");
|
||||
|
||||
if (flow === "manifest") {
|
||||
const data = await githubApi.convertSourceManifest(state, code!);
|
||||
if (!data?.installUrl)
|
||||
throw new Error("GitHub App was created, but its install URL is missing.");
|
||||
setMessage("GitHub App created. Opening repository access…");
|
||||
@@ -49,23 +47,23 @@ export default function GitHubAppSetupCallback() {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await githubApi.claimInstallation({
|
||||
state,
|
||||
installationId: installationId!,
|
||||
setupAction: setupAction ?? undefined,
|
||||
});
|
||||
if (data?.pendingApproval) {
|
||||
setMessage("Installation requested. A GitHub organization owner must approve it.");
|
||||
closeAuthWindowAfterSuccess(2200);
|
||||
return;
|
||||
}
|
||||
setMessage(
|
||||
`${data?.installation?.login || "GitHub"} is connected. You can close this window.`,
|
||||
);
|
||||
window.setTimeout(() => window.close(), 1200);
|
||||
closeAuthWindowAfterSuccess(1200);
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof Error ? error.message : "Could not verify the installation.";
|
||||
try {
|
||||
localStorage.setItem(GITHUB_CONNECT_ERROR_KEY, detail);
|
||||
} catch {
|
||||
/* unavailable */
|
||||
}
|
||||
setMessage(detail);
|
||||
const detail = getApiErrorMessage(error, "Could not verify the installation.");
|
||||
fail(detail);
|
||||
}
|
||||
}
|
||||
void claim();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { getApiOrigin } from "@/lib/api/urls";
|
||||
import { GITHUB_CONNECT_ERROR_KEY } from "@/lib/github-connect-error";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { githubApi, endpoints, getApiErrorMessage } from "@/lib/api";
|
||||
import { resolveApiNavigationUrl } from "@/lib/api/urls";
|
||||
import { storeGitHubConnectError } from "@/lib/github-connect-error";
|
||||
import { closeAuthWindowAfterSuccess } from "@/utils/authWindow";
|
||||
|
||||
/**
|
||||
* OAuth callback for cloud mode - after GitHub OAuth completes,
|
||||
@@ -11,42 +13,55 @@ import { GITHUB_CONNECT_ERROR_KEY } from "@/lib/github-connect-error";
|
||||
* Flow: GitHub OAuth → Better Auth callback → this page → GitHub App install
|
||||
*/
|
||||
export default function OAuthCallbackInstall() {
|
||||
const [message, setMessage] = useState("Setting up GitHub access…");
|
||||
const redirectStarted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// React's development Strict Mode replays effects. Starting this transition
|
||||
// twice can mint competing installation states, so keep it one-shot.
|
||||
if (redirectStarted.current) return;
|
||||
redirectStarted.current = true;
|
||||
|
||||
// Better Auth appends ?error=<code> on a failed link (e.g. the GitHub
|
||||
// account is already linked to a different user). Hand it to the opener
|
||||
// via same-origin localStorage and close instead of proceeding to install.
|
||||
const linkError = new URLSearchParams(window.location.search).get("error");
|
||||
if (linkError) {
|
||||
try { localStorage.setItem(GITHUB_CONNECT_ERROR_KEY, linkError); } catch { /* storage unavailable */ }
|
||||
window.close();
|
||||
storeGitHubConnectError(linkError);
|
||||
closeAuthWindowAfterSuccess(0);
|
||||
return;
|
||||
}
|
||||
|
||||
async function redirect() {
|
||||
try {
|
||||
const BASE = getApiOrigin(window.location.origin);
|
||||
const res = await fetch(`${BASE}/api/github/connect`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
// Use the shared API client so self-hosted callback pages honor the
|
||||
// same-origin `/api/proxy/api` mount instead of calling localhost:4000
|
||||
// in the operator's browser.
|
||||
const data = await githubApi.connect();
|
||||
if (data?.flow === "redirect") {
|
||||
window.location.href = resolveApiNavigationUrl(
|
||||
typeof data.url === "string" ? data.url : endpoints.github.connectRedirect,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// If fetch fails, just close - the opener will detect it
|
||||
if (!data?.connected) {
|
||||
throw new Error("GitHub did not return an installation destination.");
|
||||
}
|
||||
closeAuthWindowAfterSuccess(300);
|
||||
} catch (error) {
|
||||
const detail = getApiErrorMessage(error, "Could not continue GitHub setup.");
|
||||
storeGitHubConnectError(detail);
|
||||
setMessage(detail);
|
||||
closeAuthWindowAfterSuccess(1800);
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
redirect();
|
||||
void redirect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||
<p className="text-sm text-muted-foreground">Setting up GitHub access…</p>
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from "react";
|
||||
import { githubApi } from "@/lib/api";
|
||||
import { endpoints } from "@/lib/api/endpoints";
|
||||
import {
|
||||
getApiBaseUrl,
|
||||
getApiErrorMessage,
|
||||
isAbortError,
|
||||
isNetworkError,
|
||||
} from "@/lib/api/client";
|
||||
import { getApiErrorMessage, isAbortError, isNetworkError } from "@/lib/api/client";
|
||||
import { resolveApiNavigationUrl } from "@/lib/api/urls";
|
||||
import { openAuthWindow } from "@/utils/authWindow";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import {
|
||||
GITHUB_CONNECT_ERROR_KEY,
|
||||
githubConnectErrorMessage,
|
||||
} from "@/lib/github-connect-error";
|
||||
import { consumeGitHubConnectError, githubConnectErrorMessage } from "@/lib/github-connect-error";
|
||||
|
||||
/* ── Types ────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -175,7 +161,13 @@ export type CliAction =
|
||||
* sending the operator to a shell they may not have. `command` is the
|
||||
* secondary `gh auth login` hint for bare installs that do have gh. */
|
||||
| { type: "token"; command: string; message: string }
|
||||
| { type: "device_flow"; userCode: string; verificationUri: string; expiresIn: number; interval: number };
|
||||
| {
|
||||
type: "device_flow";
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
expiresIn: number;
|
||||
interval: number;
|
||||
};
|
||||
|
||||
const GitHubContext = createContext<GitHubContextValue | undefined>(undefined);
|
||||
|
||||
@@ -200,15 +192,18 @@ const EMPTY_STATE: GitHubConnectionState = {
|
||||
primary: null,
|
||||
};
|
||||
|
||||
// OAuth grants and installation nonces are already bounded server-side. This
|
||||
// client deadline is a UX guard: a callback that cannot close its window must
|
||||
// never leave every GitHub connect button disabled forever.
|
||||
const GITHUB_REDIRECT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
|
||||
// Note: setSelfHosted is no longer driven from this context — the
|
||||
// global platform mode is owned by PlatformContext and read from
|
||||
// env.CLOUD_MODE during the initial dashboard layout. We deliberately
|
||||
// don't shadow it here.
|
||||
const { showToast } = useToast();
|
||||
const [state, setState] = useState<GitHubConnectionState>(
|
||||
initialData?.state ?? EMPTY_STATE,
|
||||
);
|
||||
const [state, setState] = useState<GitHubConnectionState>(initialData?.state ?? EMPTY_STATE);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [loading, setLoading] = useState(!initialData);
|
||||
|
||||
@@ -234,6 +229,9 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
|
||||
// (user-status, installations, install-url) on the API side, so
|
||||
// dedup is load-bearing for the SaaS request rate.
|
||||
const inflightRefresh = useRef<Promise<void> | null>(null);
|
||||
// A state update does not synchronously disable every connect trigger. Guard
|
||||
// the operation itself so a double click cannot mint two OAuth/install flows.
|
||||
const connectInFlight = useRef(false);
|
||||
|
||||
// Convenience derived from state.primary — every existing call site
|
||||
// that read `connected` keeps working.
|
||||
@@ -243,62 +241,56 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
|
||||
const refresh = useCallback(async () => {
|
||||
if (inflightRefresh.current) return inflightRefresh.current;
|
||||
const work = (async () => {
|
||||
// refresh() runs after every connect / disconnect / device-flow completion,
|
||||
// so drop the cached /github/status verdict here — the Settings card and
|
||||
// library App badge will then re-probe the new connection state instead of
|
||||
// serving the stale cached one (covers connect paths that don't go through
|
||||
// the Settings card's own force-refresh).
|
||||
githubApi.invalidateStatus();
|
||||
try {
|
||||
const res = await githubApi.getUserHome();
|
||||
const nextState: GitHubConnectionState = res?.state ?? EMPTY_STATE;
|
||||
setState(nextState);
|
||||
// refresh() runs after every connect / disconnect / device-flow completion,
|
||||
// so drop the cached /github/status verdict here — the Settings card and
|
||||
// library App badge will then re-probe the new connection state instead of
|
||||
// serving the stale cached one (covers connect paths that don't go through
|
||||
// the Settings card's own force-refresh).
|
||||
githubApi.invalidateStatus();
|
||||
try {
|
||||
const res = await githubApi.getUserHome();
|
||||
const nextState: GitHubConnectionState = res?.state ?? EMPTY_STATE;
|
||||
setState(nextState);
|
||||
|
||||
if (res?.installUrl) setInstallUrl(res.installUrl);
|
||||
else setInstallUrl(null);
|
||||
if (res?.capabilities) setCapabilities(res.capabilities as GitHubCapabilities);
|
||||
if (res?.installUrl) setInstallUrl(res.installUrl);
|
||||
else setInstallUrl(null);
|
||||
if (res?.capabilities) setCapabilities(res.capabilities as GitHubCapabilities);
|
||||
|
||||
if (nextState.primary !== null) {
|
||||
setCliAction(null);
|
||||
setAccounts(res.accounts ?? []);
|
||||
const primaryLogin =
|
||||
nextState.sources.openshipApp.login ??
|
||||
nextState.sources.ghCli.login ??
|
||||
"";
|
||||
setUserLogin(primaryLogin);
|
||||
if (!selectedOwner && primaryLogin) {
|
||||
setSelectedOwnerState(primaryLogin);
|
||||
if (nextState.primary !== null) {
|
||||
setCliAction(null);
|
||||
setAccounts(res.accounts ?? []);
|
||||
const primaryLogin =
|
||||
nextState.sources.openshipApp.login ?? nextState.sources.ghCli.login ?? "";
|
||||
setUserLogin(primaryLogin);
|
||||
if (!selectedOwner && primaryLogin) {
|
||||
setSelectedOwnerState(primaryLogin);
|
||||
}
|
||||
setRepos(res.repos ?? []);
|
||||
} else {
|
||||
setAccounts([]);
|
||||
setRepos([]);
|
||||
}
|
||||
setRepos(res.repos ?? []);
|
||||
} else {
|
||||
setAccounts([]);
|
||||
setRepos([]);
|
||||
}
|
||||
|
||||
// Surface partial-failure diagnostics from the server. The request
|
||||
// succeeded overall but one or more upstream fetches failed silently
|
||||
// server-side — show them so the user has a clue why a section is
|
||||
// empty (e.g. "App path failed: …" / "CLI repo merge failed: …").
|
||||
if (res?.errors && typeof res.errors === "object") {
|
||||
const entries = Object.entries(res.errors as Record<string, string>);
|
||||
for (const [key, message] of entries) {
|
||||
if (!message) continue;
|
||||
showToast(`GitHub ${key}: ${message}`, "error", "GitHub");
|
||||
// Surface partial-failure diagnostics from the server. The request
|
||||
// succeeded overall but one or more upstream fetches failed silently
|
||||
// server-side — show them so the user has a clue why a section is
|
||||
// empty (e.g. "App path failed: …" / "CLI repo merge failed: …").
|
||||
if (res?.errors && typeof res.errors === "object") {
|
||||
const entries = Object.entries(res.errors as Record<string, string>);
|
||||
for (const [key, message] of entries) {
|
||||
if (!message) continue;
|
||||
showToast(`GitHub ${key}: ${message}`, "error", "GitHub");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Defer transient network/abort errors to the global NetworkErrorHandler;
|
||||
// only surface ApiError-shaped failures here.
|
||||
if (isAbortError(err) || isNetworkError(err)) return;
|
||||
setState(EMPTY_STATE);
|
||||
showToast(getApiErrorMessage(err, "Couldn't load GitHub data"), "error", "GitHub");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
// Defer transient network/abort errors to the global NetworkErrorHandler;
|
||||
// only surface ApiError-shaped failures here.
|
||||
if (isAbortError(err) || isNetworkError(err)) return;
|
||||
setState(EMPTY_STATE);
|
||||
showToast(
|
||||
getApiErrorMessage(err, "Couldn't load GitHub data"),
|
||||
"error",
|
||||
"GitHub",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
inflightRefresh.current = work;
|
||||
try {
|
||||
@@ -319,87 +311,127 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
|
||||
}, [refresh, initialData]);
|
||||
|
||||
/* ── Connect GitHub ─────────────────────────────────────────── */
|
||||
const connect = useCallback(async (source?: "oauth" | "cli") => {
|
||||
setConnecting(true);
|
||||
setCliAction(null);
|
||||
const connect = useCallback(
|
||||
async (source?: "oauth" | "cli") => {
|
||||
if (connectInFlight.current) return;
|
||||
connectInFlight.current = true;
|
||||
|
||||
const finishRedirectFlow = () => {
|
||||
setConnecting(false);
|
||||
// Surface a link failure the callback page stashed (e.g. the GitHub
|
||||
// account is already linked to a different user) — otherwise the flow
|
||||
// just silently reports "not connected".
|
||||
try {
|
||||
const linkError = localStorage.getItem(GITHUB_CONNECT_ERROR_KEY);
|
||||
// Reserve the popup while this call still has a browser user gesture. The
|
||||
// API decides the actual destination asynchronously; opening it afterwards
|
||||
// is routinely blocked by popup protection. Explicit CLI/device flows do
|
||||
// not navigate away, so they do not need a window.
|
||||
let reservedWindow: ReturnType<typeof openAuthWindow> | null = null;
|
||||
setConnecting(true);
|
||||
setCliAction(null);
|
||||
|
||||
const finishConnect = () => {
|
||||
connectInFlight.current = false;
|
||||
setConnecting(false);
|
||||
};
|
||||
let redirectFinished = false;
|
||||
let redirectTimeout: number | null = null;
|
||||
const finishRedirectFlow = () => {
|
||||
if (redirectFinished) return;
|
||||
redirectFinished = true;
|
||||
if (redirectTimeout !== null) window.clearTimeout(redirectTimeout);
|
||||
finishConnect();
|
||||
// Surface a link failure the callback page stashed (e.g. the GitHub
|
||||
// account is already linked to a different user) — otherwise the flow
|
||||
// just silently reports "not connected".
|
||||
const linkError = consumeGitHubConnectError();
|
||||
if (linkError) {
|
||||
localStorage.removeItem(GITHUB_CONNECT_ERROR_KEY);
|
||||
showToast(githubConnectErrorMessage(linkError), "error", "GitHub");
|
||||
}
|
||||
} catch { /* storage unavailable */ }
|
||||
// One immediate + one short follow-up. The immediate call covers
|
||||
// the happy path; the 1500ms follow-up covers the race where the
|
||||
// popup closes before the SaaS-side cookie/DB write is visible.
|
||||
// `refresh()` is in-flight-deduped so a re-entry coalesces.
|
||||
void refresh();
|
||||
window.setTimeout(() => void refresh(), 1500);
|
||||
};
|
||||
// One immediate + one short follow-up. The immediate call covers
|
||||
// the happy path; the 1500ms follow-up covers the race where the
|
||||
// popup closes before the SaaS-side cookie/DB write is visible.
|
||||
// `refresh()` is in-flight-deduped so a re-entry coalesces.
|
||||
void refresh();
|
||||
window.setTimeout(() => void refresh(), 1500);
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await githubApi.connect(source);
|
||||
try {
|
||||
reservedWindow = source === "cli" ? null : openAuthWindow();
|
||||
const res = await githubApi.connect(source);
|
||||
|
||||
// Already connected - just refresh
|
||||
if (res?.connected) {
|
||||
setConnecting(false);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (res?.flow) {
|
||||
case "redirect": {
|
||||
// Prefer a backend-provided URL when the next step is known
|
||||
// (for example, GitHub App installation after OAuth).
|
||||
const redirectUrl = res.url ?? `${getApiBaseUrl()}${endpoints.github.connectRedirect}`;
|
||||
const handle = openAuthWindow(redirectUrl);
|
||||
handle.onClose(finishRedirectFlow);
|
||||
// Already connected - just refresh
|
||||
if (res?.connected) {
|
||||
reservedWindow?.close();
|
||||
finishConnect();
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
case "device_code":
|
||||
// Show verification code inline
|
||||
setCliAction({
|
||||
type: "device_flow",
|
||||
userCode: res.userCode,
|
||||
verificationUri: res.verificationUri,
|
||||
expiresIn: res.expiresIn,
|
||||
interval: res.interval,
|
||||
});
|
||||
setConnecting(false);
|
||||
return;
|
||||
switch (res?.flow) {
|
||||
case "redirect": {
|
||||
const handle = reservedWindow ?? openAuthWindow();
|
||||
reservedWindow = handle;
|
||||
if (handle.blocked) {
|
||||
finishConnect();
|
||||
showToast(
|
||||
"Your browser blocked the GitHub sign-in window. Allow pop-ups and try again.",
|
||||
"error",
|
||||
"GitHub",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
case "token":
|
||||
// Instance has no device client id — collect a token inline.
|
||||
setCliAction({ type: "token", command: res.command, message: res.message });
|
||||
setConnecting(false);
|
||||
return;
|
||||
// A backend URL may be absolute (github.com / cloud handoff) or an
|
||||
// API-root path. Resolve the latter against the real API mount, not
|
||||
// window.location — app.openship.io/api/... is a dashboard 404.
|
||||
const redirectUrl = resolveApiNavigationUrl(
|
||||
typeof res.url === "string" ? res.url : endpoints.github.connectRedirect,
|
||||
);
|
||||
handle.navigate(redirectUrl);
|
||||
handle.onClose(finishRedirectFlow);
|
||||
redirectTimeout = window.setTimeout(() => {
|
||||
handle.close();
|
||||
finishRedirectFlow();
|
||||
showToast("GitHub connection timed out. Please try again.", "error", "GitHub");
|
||||
}, GITHUB_REDIRECT_TIMEOUT_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
case "terminal":
|
||||
// Show terminal instruction
|
||||
setCliAction({ type: "terminal", command: res.command, message: res.message });
|
||||
setConnecting(false);
|
||||
return;
|
||||
case "device_code":
|
||||
reservedWindow?.close();
|
||||
// Show verification code inline
|
||||
setCliAction({
|
||||
type: "device_flow",
|
||||
userCode: res.userCode,
|
||||
verificationUri: res.verificationUri,
|
||||
expiresIn: res.expiresIn,
|
||||
interval: res.interval,
|
||||
});
|
||||
finishConnect();
|
||||
return;
|
||||
|
||||
default:
|
||||
setConnecting(false);
|
||||
case "token":
|
||||
reservedWindow?.close();
|
||||
// Instance has no device client id — collect a token inline.
|
||||
setCliAction({ type: "token", command: res.command, message: res.message });
|
||||
finishConnect();
|
||||
return;
|
||||
|
||||
case "terminal":
|
||||
reservedWindow?.close();
|
||||
// Show terminal instruction
|
||||
setCliAction({ type: "terminal", command: res.command, message: res.message });
|
||||
finishConnect();
|
||||
return;
|
||||
|
||||
default:
|
||||
reservedWindow?.close();
|
||||
finishConnect();
|
||||
}
|
||||
} catch (err) {
|
||||
reservedWindow?.close();
|
||||
finishConnect();
|
||||
if (isAbortError(err) || isNetworkError(err)) return;
|
||||
showToast(getApiErrorMessage(err, "Failed to connect to GitHub"), "error", "GitHub");
|
||||
}
|
||||
} catch (err) {
|
||||
setConnecting(false);
|
||||
if (isAbortError(err) || isNetworkError(err)) return;
|
||||
showToast(
|
||||
getApiErrorMessage(err, "Failed to connect to GitHub"),
|
||||
"error",
|
||||
"GitHub",
|
||||
);
|
||||
}
|
||||
}, [refresh, showToast]);
|
||||
},
|
||||
[refresh, showToast],
|
||||
);
|
||||
|
||||
/* ── Connect with a pasted token ────────────────────────────── */
|
||||
const connectWithToken = useCallback(
|
||||
@@ -426,11 +458,7 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || isNetworkError(err)) return;
|
||||
showToast(
|
||||
getApiErrorMessage(err, "Failed to disconnect from GitHub"),
|
||||
"error",
|
||||
"GitHub",
|
||||
);
|
||||
showToast(getApiErrorMessage(err, "Failed to disconnect from GitHub"), "error", "GitHub");
|
||||
}
|
||||
},
|
||||
[refresh, showToast],
|
||||
@@ -449,11 +477,7 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
|
||||
refresh();
|
||||
} else if (res?.status === "error") {
|
||||
setCliAction(null);
|
||||
showToast(
|
||||
res?.message || res?.error || "GitHub device flow failed",
|
||||
"error",
|
||||
"GitHub",
|
||||
);
|
||||
showToast(res?.message || res?.error || "GitHub device flow failed", "error", "GitHub");
|
||||
}
|
||||
} catch (err) {
|
||||
// Keep polling on transient failures. Only surface a non-network
|
||||
@@ -461,11 +485,7 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
|
||||
// instead of an interval that silently spins forever.
|
||||
if (isAbortError(err) || isNetworkError(err)) return;
|
||||
if (err instanceof Error && (err as any).status) {
|
||||
showToast(
|
||||
getApiErrorMessage(err, "GitHub device flow failed"),
|
||||
"error",
|
||||
"GitHub",
|
||||
);
|
||||
showToast(getApiErrorMessage(err, "GitHub device flow failed"), "error", "GitHub");
|
||||
}
|
||||
}
|
||||
}, interval);
|
||||
@@ -508,16 +528,12 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
|
||||
setLoadingRepos(false);
|
||||
return;
|
||||
}
|
||||
showToast(
|
||||
getApiErrorMessage(err, "Couldn't load repositories"),
|
||||
"error",
|
||||
"GitHub",
|
||||
);
|
||||
showToast(getApiErrorMessage(err, "Couldn't load repositories"), "error", "GitHub");
|
||||
} finally {
|
||||
setLoadingRepos(false);
|
||||
}
|
||||
},
|
||||
[connected, showToast]
|
||||
[connected, showToast],
|
||||
);
|
||||
|
||||
/* ── Owner change → fetch repos ─────────────────────────────── */
|
||||
@@ -528,7 +544,7 @@ export function GitHubProvider({ children, initialData }: GitHubProviderProps) {
|
||||
fetchReposForOwner(owner);
|
||||
}
|
||||
},
|
||||
[selectedOwner, fetchReposForOwner]
|
||||
[selectedOwner, fetchReposForOwner],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -234,6 +234,7 @@ export const endpoints = {
|
||||
connect: "github/connect",
|
||||
connectRedirect: "github/connect/redirect",
|
||||
connectPoll: "github/connect/poll",
|
||||
installationClaim: "github/installations/claim",
|
||||
disconnect: "github/disconnect",
|
||||
instanceToken: "github/instance-token",
|
||||
sources: "github/sources",
|
||||
|
||||
@@ -205,6 +205,14 @@ export const githubApi = {
|
||||
/** Poll device flow status */
|
||||
pollConnect: () => api.get<any>(endpoints.github.connectPoll),
|
||||
|
||||
/** Finalize a workspace-bound GitHub App installation callback. */
|
||||
claimInstallation: (input: { state: string; installationId: string; setupAction?: string }) =>
|
||||
api.post<{
|
||||
ok: boolean;
|
||||
pendingApproval?: boolean;
|
||||
installation?: { login?: string };
|
||||
}>(endpoints.github.installationClaim, input),
|
||||
|
||||
/**
|
||||
* Disconnect a GitHub source.
|
||||
* - "oauth" → remove the Openship App / OAuth account row
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { alignLoopbackOrigin } from "./urls";
|
||||
import { alignLoopbackOrigin, resolveApiNavigationUrl } from "./urls";
|
||||
|
||||
describe("alignLoopbackOrigin", () => {
|
||||
it("rewrites a 127.0.0.1 API origin when the page is served from localhost", () => {
|
||||
@@ -36,3 +36,45 @@ describe("alignLoopbackOrigin", () => {
|
||||
expect(alignLoopbackOrigin("not a url", "http://localhost:3001")).toBe("not a url");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveApiNavigationUrl", () => {
|
||||
it("maps an API-root redirect through the dashboard proxy mount", () => {
|
||||
expect(
|
||||
resolveApiNavigationUrl(
|
||||
"/api/github/connect/redirect?install_state=nonce",
|
||||
"https://app.openship.io/api/proxy/api",
|
||||
),
|
||||
).toBe("https://app.openship.io/api/proxy/api/github/connect/redirect?install_state=nonce");
|
||||
});
|
||||
|
||||
it("maps the same redirect to a split API origin", () => {
|
||||
expect(
|
||||
resolveApiNavigationUrl(
|
||||
"/api/github/connect/redirect?install_state=nonce",
|
||||
"https://api.openship.io/api",
|
||||
),
|
||||
).toBe("https://api.openship.io/api/github/connect/redirect?install_state=nonce");
|
||||
});
|
||||
|
||||
it("accepts an endpoint-registry path without duplicating the API prefix", () => {
|
||||
expect(
|
||||
resolveApiNavigationUrl("github/connect/redirect", "https://ops.example.com/api/proxy/api/"),
|
||||
).toBe("https://ops.example.com/api/proxy/api/github/connect/redirect");
|
||||
});
|
||||
|
||||
it("preserves an absolute GitHub destination", () => {
|
||||
expect(
|
||||
resolveApiNavigationUrl(
|
||||
"https://github.com/apps/openship/installations/new?state=nonce",
|
||||
"https://app.openship.io/api/proxy/api",
|
||||
),
|
||||
).toBe("https://github.com/apps/openship/installations/new?state=nonce");
|
||||
});
|
||||
|
||||
it("rejects unsafe schemes and relative paths that escape the API mount", () => {
|
||||
expect(() =>
|
||||
resolveApiNavigationUrl("javascript:alert(1)", "https://api.openship.io/api"),
|
||||
).toThrow();
|
||||
expect(() => resolveApiNavigationUrl("../auth", "https://api.openship.io/api")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,9 +27,8 @@ function resolveTarget(rawUrl?: string): Target {
|
||||
const origin = rawUrl ? originOf(rawUrl) : undefined;
|
||||
if (!origin) return DEFAULT_TARGET;
|
||||
return (
|
||||
TARGETS.find(
|
||||
(t) => originOf(t.dashboard) === origin || originOf(t.api) === origin,
|
||||
) ?? DEFAULT_TARGET
|
||||
TARGETS.find((t) => originOf(t.dashboard) === origin || originOf(t.api) === origin) ??
|
||||
DEFAULT_TARGET
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,11 +83,9 @@ function sameOriginProxyOrigin(): string | null {
|
||||
// into `window.__OPENSHIP_API_ORIGIN__` for the browser bundle (whose base URL
|
||||
// is a module-load constant — a build-time NEXT_PUBLIC var can't carry it).
|
||||
|
||||
|
||||
function localApiOverride(): string | null {
|
||||
if (typeof window !== "undefined") {
|
||||
const injected = (window as { __OPENSHIP_API_ORIGIN__?: string })
|
||||
.__OPENSHIP_API_ORIGIN__;
|
||||
const injected = (window as { __OPENSHIP_API_ORIGIN__?: string }).__OPENSHIP_API_ORIGIN__;
|
||||
if (!injected) return null;
|
||||
return alignLoopbackOrigin(injected.replace(/\/+$/, ""), window.location.origin);
|
||||
}
|
||||
@@ -136,6 +133,57 @@ export function getRestApiBaseUrl() {
|
||||
return `${getApiOrigin()}/api`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a URL returned by the API for direct browser navigation.
|
||||
*
|
||||
* Most API responses return absolute external URLs, but a few auth flows return
|
||||
* an API-root path such as `/api/github/connect/redirect`. Resolving that path
|
||||
* against `window.location` is wrong in both split-host SaaS and self-hosted
|
||||
* proxy mode: it lands on the dashboard instead of the API. Treat relative
|
||||
* values as API-relative and preserve already-absolute HTTP(S) destinations.
|
||||
*
|
||||
* `apiBase` is injectable so all deployment topologies can be covered without
|
||||
* mutating build-time environment variables in tests.
|
||||
*/
|
||||
export function resolveApiNavigationUrl(target: string, apiBase = getRestApiBaseUrl()): string {
|
||||
const value = target.trim();
|
||||
if (!value) throw new Error("API navigation URL is empty");
|
||||
|
||||
// Absolute destinations (for example github.com or api.openship.io) are
|
||||
// authoritative, but never allow a non-web scheme into window navigation.
|
||||
if (/^[A-Za-z][A-Za-z\d+.-]*:/.test(value)) {
|
||||
const absolute = new URL(value);
|
||||
if (absolute.protocol !== "http:" && absolute.protocol !== "https:") {
|
||||
throw new Error("API navigation URL must use HTTP or HTTPS");
|
||||
}
|
||||
return absolute.toString();
|
||||
}
|
||||
if (value.startsWith("//")) {
|
||||
throw new Error("Protocol-relative API navigation URLs are not allowed");
|
||||
}
|
||||
|
||||
const normalizedBase = new URL(apiBase.endsWith("/") ? apiBase : `${apiBase}/`);
|
||||
if (normalizedBase.protocol !== "http:" && normalizedBase.protocol !== "https:") {
|
||||
throw new Error("API base URL must use HTTP or HTTPS");
|
||||
}
|
||||
|
||||
// The server speaks in canonical `/api/...` paths. The browser client base
|
||||
// already includes that segment (or `/api/proxy/api`), so remove it once.
|
||||
let relative = value.replace(/^\/+/, "");
|
||||
if (relative === "api") relative = "";
|
||||
else if (relative.startsWith("api/")) relative = relative.slice("api/".length);
|
||||
|
||||
const resolved = new URL(relative, normalizedBase);
|
||||
// A server-provided relative path must not escape the selected API mount.
|
||||
if (
|
||||
resolved.origin !== normalizedBase.origin ||
|
||||
!resolved.pathname.startsWith(normalizedBase.pathname)
|
||||
) {
|
||||
throw new Error("API navigation URL escaped the API base");
|
||||
}
|
||||
return resolved.toString();
|
||||
}
|
||||
|
||||
export function getCloudDashboardUrl(rawUrl?: string) {
|
||||
return originOf(rawUrl ?? "") ?? cloudPartner(currentTarget()).dashboard;
|
||||
}
|
||||
@@ -158,4 +206,3 @@ export function getMarketingOrigin() {
|
||||
}
|
||||
return "https://openship.io";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
consumeGitHubConnectError,
|
||||
GITHUB_CONNECT_ERROR_KEY,
|
||||
githubConnectErrorMessage,
|
||||
storeGitHubConnectError,
|
||||
} from "./github-connect-error";
|
||||
|
||||
describe("githubConnectErrorMessage", () => {
|
||||
it("maps known OAuth error codes to actionable copy", () => {
|
||||
expect(githubConnectErrorMessage("account_already_linked_to_different_user")).toContain(
|
||||
"already linked to a different Openship user",
|
||||
);
|
||||
});
|
||||
|
||||
it("labels an unknown OAuth error code", () => {
|
||||
expect(githubConnectErrorMessage("provider_callback_failed")).toBe(
|
||||
"Couldn't connect GitHub (provider_callback_failed).",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a server-provided installation error message", () => {
|
||||
expect(githubConnectErrorMessage("This install link expired. Start again.")).toBe(
|
||||
"This install link expired. Start again.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub connect error storage", () => {
|
||||
it("shares a callback error once and clears it after consumption", () => {
|
||||
const values = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: vi.fn((key: string) => values.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => values.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => values.delete(key)),
|
||||
};
|
||||
|
||||
storeGitHubConnectError("claim failed", storage);
|
||||
|
||||
expect(values.get(GITHUB_CONNECT_ERROR_KEY)).toBe("claim failed");
|
||||
expect(consumeGitHubConnectError(storage)).toBe("claim failed");
|
||||
expect(consumeGitHubConnectError(storage)).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed when browser storage is unavailable", () => {
|
||||
expect(() => storeGitHubConnectError("claim failed", null)).not.toThrow();
|
||||
expect(consumeGitHubConnectError(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,44 @@
|
||||
*/
|
||||
export const GITHUB_CONNECT_ERROR_KEY = "openship.github.connectError";
|
||||
|
||||
type ConnectErrorStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
||||
|
||||
function browserStorage(): ConnectErrorStorage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Store a callback error for the window that initiated the OAuth flow. */
|
||||
export function storeGitHubConnectError(
|
||||
error: string,
|
||||
storage: ConnectErrorStorage | null = browserStorage(),
|
||||
): void {
|
||||
if (!error || !storage) return;
|
||||
try {
|
||||
storage.setItem(GITHUB_CONNECT_ERROR_KEY, error);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and clear the callback error so it cannot leak into a later attempt. */
|
||||
export function consumeGitHubConnectError(
|
||||
storage: ConnectErrorStorage | null = browserStorage(),
|
||||
): string | null {
|
||||
if (!storage) return null;
|
||||
try {
|
||||
const error = storage.getItem(GITHUB_CONNECT_ERROR_KEY);
|
||||
if (error !== null) storage.removeItem(GITHUB_CONNECT_ERROR_KEY);
|
||||
return error;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGES: Record<string, string> = {
|
||||
account_already_linked_to_different_user:
|
||||
"That GitHub account is already linked to a different Openship user. Sign in as that user, or disconnect GitHub there first.",
|
||||
@@ -20,6 +58,13 @@ const MESSAGES: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function githubConnectErrorMessage(code: string | null | undefined): string {
|
||||
if (!code) return "Couldn't connect GitHub. Please try again.";
|
||||
return MESSAGES[code] ?? `Couldn't connect GitHub (${code}).`;
|
||||
const value = code?.trim();
|
||||
if (!value) return "Couldn't connect GitHub. Please try again.";
|
||||
if (MESSAGES[value]) return MESSAGES[value];
|
||||
|
||||
// OAuth failures arrive as machine codes, while later installation-claim
|
||||
// failures are already useful server messages stored through the same
|
||||
// cross-window channel. Preserve those messages instead of wrapping them as
|
||||
// if the entire sentence were an unknown error code.
|
||||
return /^[a-z\d._-]+$/i.test(value) ? `Couldn't connect GitHub (${value}).` : value;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { closeAuthWindowAfterSuccess } from "./authWindow";
|
||||
import { closeAuthWindowAfterSuccess, openAuthWindow } from "./authWindow";
|
||||
|
||||
describe("closeAuthWindowAfterSuccess", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("closes a script-opened auth popup even when no opener is available", () => {
|
||||
@@ -30,3 +31,38 @@ describe("closeAuthWindowAfterSuccess", () => {
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openAuthWindow", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reports when popup protection blocked the reserved window", () => {
|
||||
vi.stubGlobal("window", {
|
||||
screen: { width: 1440, height: 900 },
|
||||
open: vi.fn(() => null),
|
||||
});
|
||||
|
||||
expect(openAuthWindow().blocked).toBe(true);
|
||||
});
|
||||
|
||||
it("reserves a browser window before navigating to the async auth URL", () => {
|
||||
const popup = {
|
||||
closed: false,
|
||||
location: { href: "about:blank" },
|
||||
focus: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.stubGlobal("window", {
|
||||
screen: { width: 1440, height: 900 },
|
||||
open: vi.fn(() => popup),
|
||||
});
|
||||
|
||||
const handle = openAuthWindow();
|
||||
handle.navigate("https://api.openship.io/api/github/connect/redirect");
|
||||
|
||||
expect(handle.blocked).toBe(false);
|
||||
expect(popup.location.href).toBe("https://api.openship.io/api/github/connect/redirect");
|
||||
expect(popup.focus).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
/* ── Public types ─────────────────────────────────────────────────── */
|
||||
|
||||
export interface AuthWindowHandle {
|
||||
/** True when the browser refused to create the popup. */
|
||||
readonly blocked: boolean;
|
||||
/** Redirect / open the auth URL. In browser, redirects the popup.
|
||||
* In Electron, opens system browser. */
|
||||
navigate: (url: string) => void;
|
||||
@@ -54,9 +56,7 @@ export function closeAuthWindowAfterSuccess(
|
||||
|
||||
function isElectron(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
"desktop" in window &&
|
||||
!!(window as any).desktop?.isDesktop
|
||||
typeof window !== "undefined" && "desktop" in window && !!(window as any).desktop?.isDesktop
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ function createBrowserHandle(initialUrl = "about:blank"): AuthWindowHandle {
|
||||
const popup = window.open(
|
||||
initialUrl,
|
||||
"Auth",
|
||||
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top}`
|
||||
`width=${POPUP_WIDTH},height=${POPUP_HEIGHT},left=${left},top=${top}`,
|
||||
);
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -85,6 +85,7 @@ function createBrowserHandle(initialUrl = "about:blank"): AuthWindowHandle {
|
||||
}
|
||||
|
||||
return {
|
||||
blocked: popup === null,
|
||||
navigate(url: string) {
|
||||
if (popup && !popup.closed) {
|
||||
popup.location.href = url;
|
||||
@@ -121,11 +122,14 @@ function createElectronHandle(initialUrl?: string): AuthWindowHandle {
|
||||
|
||||
// If the caller supplied a URL up front (matching the browser handle's
|
||||
// behavior where `window.open(initialUrl, ...)` opens immediately),
|
||||
// hand it to the desktop bridge now. Without this, callers that pass
|
||||
// `openAuthWindow(url)` and never call `.navigate()` (GitHubContext,
|
||||
// the legacy initAuthWindow helper) would silently no-op in desktop.
|
||||
// hand it to the desktop bridge now. This keeps that supported calling
|
||||
// convention equivalent across browser and desktop runtimes.
|
||||
if (initialUrl) {
|
||||
try { desktop?.onboarding?.openExternal?.(initialUrl); } catch { /* bridge missing */ }
|
||||
try {
|
||||
desktop?.onboarding?.openExternal?.(initialUrl);
|
||||
} catch {
|
||||
/* bridge missing */
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
@@ -136,6 +140,7 @@ function createElectronHandle(initialUrl?: string): AuthWindowHandle {
|
||||
}
|
||||
|
||||
return {
|
||||
blocked: false,
|
||||
navigate(url: string) {
|
||||
desktop.onboarding.openExternal(url);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user