diff --git a/app/github/routes.py b/app/github/routes.py
index c5aae29..cc6c3f9 100644
--- a/app/github/routes.py
+++ b/app/github/routes.py
@@ -102,7 +102,11 @@ def callback():
error = request.args.get("error")
if error:
ratelimit.record(key)
- flash(f"GitHub authorization failed: {error}", "error")
+ if error == "access_denied":
+ # The user cancelled GitHub's consent screen: show the dedicated
+ # denial page instead of a red "authorization failed" banner.
+ return render_template("github/index.html", account=None, denied=True)
+ flash("GitHub authorization failed. Please try again.", "error")
return redirect(url_for("github.index"))
state = request.args.get("state")
@@ -135,8 +139,8 @@ def callback():
token_data = {}
if response.status_code >= 400 or "access_token" not in token_data:
ratelimit.record(key)
- message = token_data.get("error_description") or token_data.get("error") or response.text
- flash(f"GitHub authorization failed: {message}", "error")
+ current_app.logger.warning("GitHub token exchange failed: %s", token_data)
+ flash("GitHub authorization failed. Please try again.", "error")
return redirect(url_for("github.index"))
token = token_data["access_token"]
@@ -145,7 +149,8 @@ def callback():
user = client.get_user()
except GitHubError as exc:
ratelimit.record(key)
- flash(f"Could not verify your GitHub account: {exc}", "error")
+ current_app.logger.warning("GitHub user verification failed: %s", exc)
+ flash("Could not verify your GitHub account. Please try again.", "error")
return redirect(url_for("github.index"))
account = GithubAccount.query.filter_by(user_id=current_user.id).first()
diff --git a/app/services/github.py b/app/services/github.py
index 7e17d20..82066ee 100644
--- a/app/services/github.py
+++ b/app/services/github.py
@@ -137,6 +137,7 @@ class GitHubInvalidError(GitHubError):
ERROR_MESSAGES = {
"not_connected": "Connect your GitHub account to use this feature.",
"auth": "Your GitHub connection is no longer valid. Reconnect your account.",
+ "cancelled": "GitHub connection was cancelled. You can retry whenever you're ready.",
"permission": "GitHub denied access to this resource.",
"not_found": "The requested GitHub resource was not found.",
"rate_limit": "GitHub API rate limit reached. Please try again later.",
@@ -146,6 +147,20 @@ class GitHubInvalidError(GitHubError):
}
+def is_access_denied(error: str | None) -> bool:
+ """Return ``True`` when GitHub reports the user denied the OAuth consent.
+
+ GitHub sends ``error=access_denied`` on the callback when the user clicks
+ "Cancel" on the consent screen. That is a user cancellation, not a
+ failure, so callers should treat it as such (issue: OAuth denial page).
+ """
+ return (error or "").strip().lower() == "access_denied"
+
+
+class GitHubCancelledError(GitHubError):
+ kind = "cancelled"
+
+
def github_error_message(exc: GitHubError) -> str:
"""Return the stable user-facing message for ``exc``'s kind."""
return ERROR_MESSAGES.get(exc.kind, ERROR_MESSAGES["github_error"])
diff --git a/app/static/js/github.js b/app/static/js/github.js
index 10bb86c..17bddb6 100644
--- a/app/static/js/github.js
+++ b/app/static/js/github.js
@@ -4,8 +4,25 @@
(function () {
"use strict";
+ var CONNECT_URL = "/github/connect";
+
+ // Map a raw GitHub code to a user-facing message. Never echo the
+ // upstream payload back to the user; only well-known codes are translated.
+ function friendlyError(code) {
+ switch (code) {
+ case "access_denied":
+ return "You cancelled the GitHub connection. No access was granted.";
+ case "bad_verification_code":
+ return "The connection request expired. Please try connecting again.";
+ case "oauth_error":
+ return "GitHub could not complete the connection. Please try again.";
+ default:
+ return "GitHub connection failed. Please try again.";
+ }
+ }
+
window.GitHub = {
- CSRF_TOKEN: null,
+ CSRF_TOK: null,
getCsrf: function () {
if (this.CSRF_TOKEN !== null) return this.CSRF_TOKEN;
@@ -30,7 +47,7 @@
var error = new Error(data && data.error ? data.error : "Request failed (" + response.status + ").");
error.kind = data && data.kind;
if (error.kind === "auth" || error.kind === "not_connected") {
- window.location.assign("/github/connect");
+ window.location.assign(CONNECT_URL);
}
throw error;
}
@@ -54,7 +71,7 @@
});
},
- // Escape HTML, then treat newlines as
and URLs as links.
+ // Escape HTML, then treat newlines as
and URLs as links.
renderMarkdownish: function (text) {
if (!text) return "";
var html = this.escapeHtml(text);
@@ -83,6 +100,42 @@
}, 6000);
},
+ // Handle an OAuth callback failure. `access_denied` is a user
+ // cancellation, not an error, so it is reported as a neutral notice
+ // with a retry path. Any other code is shown as a generic friendly
+ // message; the raw GitHub payload is never rendered.
+ handleCallbackError: function (code, container) {
+ var denied = code === "access_denied";
+ var message = friendlyError(code);
+ var target = container || document.querySelector(".github-callback");
+ if (target) {
+ target.hidden = false;
+ target.innerHTML = "";
+ var card = document.createElement("div");
+ card.className = denied ? "callback-card callback-cancelled" : "callback-card callback-error";
+ var title = document.createElement("h2");
+ title.textContent = denied ? "GitHub connection cancelled" : "Couldn’t connect to GitHub";
+ var body = document.createElement("p");
+ body.textContent = message;
+ card.appendChild(title);
+ card.appendChild(body);
+ if (denied) {
+ var explain = document.createElement("p");
+ explain.className = "field-hint";
+ explain.textContent = "Connecting GitHub grants this app read access to your repositories, issues, and pull requests so it can help with code reviews. You can revoke access at any time from your GitHub settings.";
+ card.appendChild(explain);
+ }
+ var retry = document.createElement("a");
+ retry.href = CONNECT_URL;
+ retry.className = "btn btn-primary";
+ retry.textContent = "Connect GitHub";
+ card.appendChild(retry);
+ target.appendChild(card);
+ } else {
+ this.flashError(message);
+ }
+ },
+
// Render Prev/Next controls for a paginated GitHub list endpoint.
// `meta` is the {page, per_page, has_prev, has_next, total_pages} envelope
// returned by the issues/pulls APIs; `onPage(pageNumber)` is invoked when
diff --git a/app/templates/errors/oauth_denied.html b/app/templates/errors/oauth_denied.html
new file mode 100644
index 0000000..5b8a82f
--- /dev/null
+++ b/app/templates/errors/oauth_denied.html
@@ -0,0 +1,25 @@
+{% extends "base.html" %}
+
+{% block title %}GitHub connection cancelled - {{ config.APP_NAME }}{% endblock %}
+
+{% block content %}
+
You cancelled the GitHub authorization request, so no access was granted. Nothing was changed on your account.
+ +You can revoke access at any time from your GitHub settings, and you can disconnect here at any time.
+Browse your repositories, files, branches, and commits.
-Open any repository to review its issues and PRs with AI analysis.
{% else %} + {% if denied %} +You canceled the GitHub authorization request. No changes were made to your account.
+When you connect, this app will be able to:
+Your token is encrypted before it is stored and is never exposed in the browser. You can disconnect at any time.
+ Connect GitHub +Connect your GitHub account to browse repositories, review issues and pull requests, and ask the AI to analyze your code.
Connect GitHubYou will be redirected to GitHub to authorize access. Your token is encrypted before it is stored and is never exposed in the browser.