Skip to content

Commit fee4f20

Browse files
DavidsonGomesclaude
andcommitted
release: v0.13.1 — dashboard delete fix + YouTube auto refresh
- Dashboard: add DELETE /api/social-accounts/<platform>/<index> so the trash icon on /integrations actually removes accounts (was calling an inexistent /disconnect route) - YouTube client: auto-refresh expired OAuth access tokens via refresh_token and persist to .env Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 037794c commit fee4f20

6 files changed

Lines changed: 97 additions & 7 deletions

File tree

.claude/skills/int-youtube/scripts/youtube_client.py

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,78 @@ def _load_dotenv():
2929
_load_dotenv()
3030

3131
BASE_URL = "https://www.googleapis.com/youtube/v3"
32+
TOKEN_URL = "https://oauth2.googleapis.com/token"
33+
34+
35+
# ── OAuth refresh ────────────────────────────────────
36+
37+
def _env_path() -> Path:
38+
return Path(__file__).resolve().parents[4] / ".env"
39+
40+
41+
def _set_env_var(key: str, value: str):
42+
"""Upsert a key in .env (mirror of social-auth/env_manager.set_env)."""
43+
path = _env_path()
44+
lines = []
45+
found = False
46+
if path.exists():
47+
with open(path) as f:
48+
for line in f:
49+
stripped = line.strip()
50+
if stripped and not stripped.startswith("#") and "=" in stripped:
51+
k = stripped.split("=", 1)[0].strip()
52+
if k == key:
53+
lines.append(f"{key}={value}\n")
54+
found = True
55+
continue
56+
lines.append(line if line.endswith("\n") else line + "\n")
57+
if not found:
58+
lines.append(f"{key}={value}\n")
59+
with open(path, "w") as f:
60+
f.writelines(lines)
61+
62+
63+
def _refresh_access_token(account: dict) -> str:
64+
"""Exchange refresh_token for a new access_token. Persists to .env and updates account in place. Returns new token or empty string."""
65+
refresh_token = account.get("refresh_token", "")
66+
client_id = os.environ.get("YOUTUBE_OAUTH_CLIENT_ID", "")
67+
client_secret = os.environ.get("YOUTUBE_OAUTH_CLIENT_SECRET", "")
68+
if not (refresh_token and client_id and client_secret):
69+
return ""
70+
71+
data = urllib.parse.urlencode({
72+
"refresh_token": refresh_token,
73+
"client_id": client_id,
74+
"client_secret": client_secret,
75+
"grant_type": "refresh_token",
76+
}).encode()
77+
78+
req = urllib.request.Request(
79+
TOKEN_URL,
80+
data=data,
81+
method="POST",
82+
headers={"Content-Type": "application/x-www-form-urlencoded"},
83+
)
84+
try:
85+
with urllib.request.urlopen(req, timeout=15) as resp:
86+
tok = json.loads(resp.read().decode("utf-8"))
87+
except urllib.error.HTTPError as e:
88+
sys.stderr.write(f"[youtube] refresh failed: {e.read().decode('utf-8', 'replace')[:300]}\n")
89+
return ""
90+
except Exception as e:
91+
sys.stderr.write(f"[youtube] refresh error: {e}\n")
92+
return ""
93+
94+
new_token = tok.get("access_token", "")
95+
if not new_token:
96+
return ""
97+
98+
idx = account.get("index", "")
99+
env_key = f"SOCIAL_YOUTUBE_{idx}_ACCESS_TOKEN" if idx else "YOUTUBE_ACCESS_TOKEN"
100+
_set_env_var(env_key, new_token)
101+
os.environ[env_key] = new_token
102+
account["access_token"] = new_token
103+
return new_token
32104

33105

34106
# ── Account discovery ────────────────────────────────
@@ -81,8 +153,9 @@ def _get_account(label_or_index: str = None) -> dict:
81153

82154
# ── API calls ────────────────────────────────────────
83155

84-
def _api_get(path: str, params: dict, account: dict) -> dict:
85-
"""Make authenticated GET request."""
156+
def _api_get(path: str, params: dict, account: dict, _retried: bool = False) -> dict:
157+
"""Make authenticated GET request. Auto-refreshes expired OAuth tokens once on 401."""
158+
params = dict(params) # don't mutate caller's dict
86159
if account.get("access_token"):
87160
params["access_token"] = account["access_token"]
88161
elif account.get("api_key"):
@@ -97,6 +170,11 @@ def _api_get(path: str, params: dict, account: dict) -> dict:
97170
with urllib.request.urlopen(url, timeout=15) as resp:
98171
return json.loads(resp.read().decode("utf-8"))
99172
except urllib.error.HTTPError as e:
173+
# 401 Unauthorized — try to refresh access_token once
174+
if e.code == 401 and not _retried and account.get("refresh_token"):
175+
new_token = _refresh_access_token(account)
176+
if new_token:
177+
return _api_get(path, {k: v for k, v in params.items() if k != "access_token"}, account, _retried=True)
100178
body = e.read().decode("utf-8", errors="replace")
101179
return {"error": f"HTTP {e.code}", "detail": body[:500]}
102180
except Exception as e:

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.13.1] - 2026-04-10
9+
10+
### Fixed
11+
12+
- **Dashboard — delete social account now works** — the trash icon on `/integrations` was calling `POST /disconnect/{platform}/{index}`, a route that only exists in the standalone `social-auth` Flask app (port 8765), not in the dashboard backend (port 8080), so clicks silently 404'd. Added `DELETE /api/social-accounts/<platform>/<int:index>` to `dashboard/backend/app.py` reusing `env_manager.delete_account`, and updated `dashboard/frontend/src/pages/Integrations.tsx` to call `api.delete()` and consume the returned `{platforms}` payload in a single round-trip.
13+
- **YouTube — automatic OAuth token refresh**`SOCIAL_YOUTUBE_*_ACCESS_TOKEN` expires after ~1h, forcing a manual reconnect through social-auth. The `social-auth` OAuth flow already requested `access_type=offline` + `prompt=consent` and saved `REFRESH_TOKEN`, but `youtube_client.py` never used it. Added `_refresh_access_token(account)` that exchanges the refresh token at `https://oauth2.googleapis.com/token`, persists the new access token to `.env` (`SOCIAL_YOUTUBE_{N}_ACCESS_TOKEN`) and `os.environ`, and made `_api_get` auto-retry once on `HTTP 401` when a refresh token is available. Transparent to all callers (skills, routines, agents). Requires `YOUTUBE_OAUTH_CLIENT_ID` and `YOUTUBE_OAUTH_CLIENT_SECRET` in `.env` (already present for any OAuth-connected account).
14+
815
## [0.13.0] - 2026-04-10
916

1017
### Added

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@evoapi/evo-nexus",
3-
"version": "0.13.0",
3+
"version": "0.13.1",
44
"description": "Unofficial open source toolkit for Claude Code — AI-powered business operating system",
55
"keywords": [
66
"claude-code",

dashboard/backend/app.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,12 @@ def social_accounts():
270270
from env_manager import all_platforms_with_accounts
271271
return {"platforms": all_platforms_with_accounts()}
272272

273+
@app.route("/api/social-accounts/<platform>/<int:index>", methods=["DELETE"])
274+
def delete_social_account(platform, index):
275+
from env_manager import delete_account, all_platforms_with_accounts
276+
delete_account(platform, index)
277+
return {"ok": True, "platforms": all_platforms_with_accounts()}
278+
273279
# --------------- Serve React build ---------------
274280
FRONTEND_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist"
275281

dashboard/frontend/src/pages/Integrations.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,8 @@ export default function Integrations() {
125125

126126
const handleDisconnect = async (platformId: string, index: number) => {
127127
try {
128-
await fetch(`/disconnect/${platformId}/${index}`, { method: 'POST' })
129-
const socialData = await api.get('/social-accounts')
130-
setPlatforms(socialData?.platforms || [])
128+
const data = await api.delete(`/social-accounts/${platformId}/${index}`)
129+
setPlatforms(data?.platforms || [])
131130
} catch (e) {
132131
console.error(e)
133132
}

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "evo-nexus"
3-
version = "0.13.0"
3+
version = "0.13.1"
44
description = "Unofficial open source toolkit for Claude Code — AI-powered business operating system"
55
requires-python = ">=3.10"
66
dependencies = [

0 commit comments

Comments
 (0)