diff --git a/backend/routes/garmin.py b/backend/routes/garmin.py index 859aad0..762d13d 100644 --- a/backend/routes/garmin.py +++ b/backend/routes/garmin.py @@ -56,6 +56,22 @@ def auth_status(): return jsonify({"hasToken": garmin_svc.has_token(g.user_id)}) +@bp.route("/disconnect", methods=["POST"]) +@require_auth +def disconnect(): + """Drop the stored Garmin token so the next sync must re-authenticate. + + Deletes the OAuth token (the UI calls this a "退出 Garmin 账号"). garmin_email + stays on the user record, so re-login only needs the password again. Already + synced health data is untouched. + """ + garmin_svc.delete_token(g.user_id) + return jsonify({ + "ok": True, + "message": "已退出 Garmin 账号,已保存的授权令牌已删除,下次同步需重新登录获取新令牌。", + }) + + @bp.route("/login", methods=["POST"]) @require_auth def login(): diff --git a/backend/services/garmin.py b/backend/services/garmin.py index a9f3d5a..f64e1ae 100644 --- a/backend/services/garmin.py +++ b/backend/services/garmin.py @@ -257,6 +257,18 @@ def has_token(user_id): return load_token(user_id) is not None +def delete_token(user_id): + """Forget the stored Garmin OAuth token. + + The next sync or login will have to re-authenticate and mint a fresh token. + garmin_email on the user record is left in place so re-login only needs the + password. The cached client — built from the old token's session — is dropped + in the same step so a stale session can't keep being reused. + """ + execute("DELETE FROM garmin_tokens WHERE user_id = ?", [user_id]) + forget_client(user_id) + + # An authenticated client, reused across requests in this process. # # Building one costs ~11s against Garmin — loading the token, refreshing the diff --git a/client/src/pages/DataSync.css b/client/src/pages/DataSync.css index aefba40..69103ac 100644 --- a/client/src/pages/DataSync.css +++ b/client/src/pages/DataSync.css @@ -281,3 +281,21 @@ } .sync-list li { margin-bottom: 0.2rem; } + +/* Account actions — destructive ones are flagged in red so they don't read + like the routine sync buttons above them. */ +.sync-account { + margin-top: 1.4rem; + padding-top: 1.1rem; + border-top: 1px solid var(--border); +} + +.sync-disconnect { + color: var(--danger, #d23b3b); + border-color: var(--danger, #d23b3b); +} + +.sync-disconnect:hover:not(:disabled) { + background: var(--danger, #d23b3b); + color: #fff; +} diff --git a/client/src/pages/SyncPage.tsx b/client/src/pages/SyncPage.tsx index c43e170..ecbc3c0 100644 --- a/client/src/pages/SyncPage.tsx +++ b/client/src/pages/SyncPage.tsx @@ -164,6 +164,30 @@ function SyncPage() { setCodeSubmitted(false); }; + // --- account ------------------------------------------------------------ + /** Delete the stored Garmin token so the next sync forces a fresh login. */ + const disconnect = async () => { + if ( + !window.confirm( + '退出 Garmin 账号会删除已保存的授权令牌,下次同步需重新登录。确定继续?' + ) + ) { + return; + } + setError(''); + setMessage(''); + setLoading(true); + try { + const res = await apiClient.disconnectGarmin(); + setHasToken(false); + setMessage(res.message || '已退出 Garmin 账号,下次同步需重新登录获取新令牌。'); + } catch (err: any) { + setError(errorMessage(err, '退出失败')); + } finally { + setLoading(false); + } + }; + // --- sync --------------------------------------------------------------- /** The last couple of days, awaited inline — seconds, not minutes. */ @@ -382,6 +406,19 @@ function SyncPage() { + +
+ 删除已保存的授权令牌,下次同步需重新登录获取新令牌;已同步的数据不会丢失。 +
+