Compare commits
2 Commits
4331a07462
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e911a7c1f0 | ||
|
|
db7f182030 |
@@ -496,10 +496,22 @@ def delete_token(user_id):
|
|||||||
"""Forget the stored Garmin OAuth token.
|
"""Forget the stored Garmin OAuth token.
|
||||||
|
|
||||||
The next sync or login will have to re-authenticate and mint a fresh 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
|
garmin_email is meant to survive this — the UI's own "只需一次" promise is
|
||||||
password. The cached client — built from the old token's session — is dropped
|
that a disconnect still leaves the address remembered — but for every
|
||||||
in the same step so a stale session can't keep being reused.
|
auth-hub-era account (i.e. all of them now) that address lives *only* on
|
||||||
|
the row this deletes: `users.garmin_email` is a legacy column nothing
|
||||||
|
since auth-hub has ever written to (see `get_remembered_email`). Without
|
||||||
|
copying it forward first, "退出 Garmin 账号" quietly erases the one thing
|
||||||
|
it promised to keep, and the next bind attempt 400s on a blank email field
|
||||||
|
with no visible connection to the disconnect that caused it.
|
||||||
|
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
|
row = query_one("SELECT garmin_email FROM garmin_tokens WHERE user_id = ?", [user_id])
|
||||||
|
email = (row or {}).get("garmin_email")
|
||||||
|
if email:
|
||||||
|
execute("UPDATE users SET garmin_email = ? WHERE id = ?", [email, user_id])
|
||||||
execute("DELETE FROM garmin_tokens WHERE user_id = ?", [user_id])
|
execute("DELETE FROM garmin_tokens WHERE user_id = ?", [user_id])
|
||||||
forget_client(user_id)
|
forget_client(user_id)
|
||||||
|
|
||||||
|
|||||||
@@ -338,16 +338,41 @@ class TestRememberedEmail:
|
|||||||
)
|
)
|
||||||
assert garmin_svc.get_remembered_email(user["id"]) == "legacy@example.com"
|
assert garmin_svc.get_remembered_email(user["id"]) == "legacy@example.com"
|
||||||
|
|
||||||
def test_disconnecting_drops_the_current_binding_but_not_the_legacy_value(
|
def test_disconnecting_remembers_the_most_recent_email_not_a_stale_legacy_one(
|
||||||
self, db, user
|
self, db, user
|
||||||
):
|
):
|
||||||
|
"""The current binding's email is copied forward on disconnect (see
|
||||||
|
`delete_token`), so it wins over whatever older address happened to be
|
||||||
|
sitting in the legacy column — the account last used `current@…`, and
|
||||||
|
that is what the next bind attempt should be offered."""
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE users SET garmin_email = ? WHERE id = ?",
|
"UPDATE users SET garmin_email = ? WHERE id = ?",
|
||||||
["legacy@example.com", user["id"]],
|
["legacy@example.com", user["id"]],
|
||||||
)
|
)
|
||||||
garmin_svc.save_token(user["id"], "tok", "current@example.com")
|
garmin_svc.save_token(user["id"], "tok", "current@example.com")
|
||||||
garmin_svc.delete_token(user["id"])
|
garmin_svc.delete_token(user["id"])
|
||||||
assert garmin_svc.get_remembered_email(user["id"]) == "legacy@example.com"
|
assert garmin_svc.get_remembered_email(user["id"]) == "current@example.com"
|
||||||
|
|
||||||
|
def test_disconnecting_an_auth_hub_account_still_remembers_the_email(
|
||||||
|
self, db, user
|
||||||
|
):
|
||||||
|
"""The case the test above does not cover, and the one that actually
|
||||||
|
broke: an auth-hub account has no pre-existing legacy row — the only
|
||||||
|
copy of the email is the one `delete_token` is about to remove. Without
|
||||||
|
copying it forward first, 退出 Garmin 账号 silently breaks its own
|
||||||
|
"只需一次" promise, and the next bind attempt 400s on a blank email
|
||||||
|
with no visible link back to the disconnect that caused it.
|
||||||
|
"""
|
||||||
|
assert garmin_svc.get_remembered_email(user["id"]) == ""
|
||||||
|
garmin_svc.save_token(user["id"], "tok", "current@example.com")
|
||||||
|
garmin_svc.delete_token(user["id"])
|
||||||
|
assert garmin_svc.get_remembered_email(user["id"]) == "current@example.com"
|
||||||
|
|
||||||
|
def test_disconnecting_twice_does_not_forget_the_email(self, db, user):
|
||||||
|
garmin_svc.save_token(user["id"], "tok", "current@example.com")
|
||||||
|
garmin_svc.delete_token(user["id"])
|
||||||
|
garmin_svc.delete_token(user["id"]) # no token row left to read from
|
||||||
|
assert garmin_svc.get_remembered_email(user["id"]) == "current@example.com"
|
||||||
|
|
||||||
|
|
||||||
class TestMfaHandling:
|
class TestMfaHandling:
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ function SyncPage() {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
/* Which action 强制重试 should retry — the two flows share one error banner
|
||||||
|
but call different endpoints, and retrying the wrong one either does
|
||||||
|
nothing (sync with no token bound yet) or silently drops the email/
|
||||||
|
password the user just typed. */
|
||||||
|
const [retryAction, setRetryAction] = useState<'sync' | 'login' | null>(null);
|
||||||
/* Whether the last attempt was refused by the recorded cooldown. Gates the
|
/* Whether the last attempt was refused by the recorded cooldown. Gates the
|
||||||
强制重试 button: offering it unconditionally would invite the very thing
|
强制重试 button: offering it unconditionally would invite the very thing
|
||||||
the cooldown prevents. */
|
the cooldown prevents. */
|
||||||
@@ -99,10 +104,14 @@ function SyncPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- Garmin login -------------------------------------------------------
|
// --- Garmin login -------------------------------------------------------
|
||||||
const startLogin = async (e: React.FormEvent) => {
|
/* `force` is only ever true when the user clicks 强制重试 after a refusal —
|
||||||
e.preventDefault();
|
never the default path, because retrying inside Garmin's real cooldown
|
||||||
|
window is what extends it (see services/garmin.py's sso_cooldown). */
|
||||||
|
const startLogin = async (e?: React.FormEvent, force?: boolean) => {
|
||||||
|
e?.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
setBlocked(false);
|
||||||
if (!email) {
|
if (!email) {
|
||||||
setError('请输入 Garmin 邮箱');
|
setError('请输入 Garmin 邮箱');
|
||||||
return;
|
return;
|
||||||
@@ -114,7 +123,7 @@ function SyncPage() {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const sid = await apiClient.startGarminLogin(password, email);
|
const sid = await apiClient.startGarminLogin(password, email, force);
|
||||||
// The password is only ever needed for this one request.
|
// The password is only ever needed for this one request.
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setSession(sid);
|
setSession(sid);
|
||||||
@@ -123,6 +132,10 @@ function SyncPage() {
|
|||||||
beginPolling(sid);
|
beginPolling(sid);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(errorMessage(err, '登录失败'));
|
setError(errorMessage(err, '登录失败'));
|
||||||
|
if (err?.response?.status === 429 && err?.response?.data?.status === 'rate_limited') {
|
||||||
|
setBlocked(true);
|
||||||
|
setRetryAction('login');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -246,6 +259,7 @@ function SyncPage() {
|
|||||||
setError('');
|
setError('');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
setBlocked(false);
|
setBlocked(false);
|
||||||
|
setRetryAction('sync');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 0 means "everything" and -1 "since the last sync"; the backend caps
|
// 0 means "everything" and -1 "since the last sync"; the backend caps
|
||||||
@@ -327,7 +341,9 @@ function SyncPage() {
|
|||||||
{blocked && (
|
{blocked && (
|
||||||
<button
|
<button
|
||||||
className="sync-force"
|
className="sync-force"
|
||||||
onClick={() => syncHistory(true)}
|
onClick={() => (
|
||||||
|
retryAction === 'login' ? startLogin(undefined, true) : syncHistory(true)
|
||||||
|
)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
我确认已恢复,强制重试
|
我确认已恢复,强制重试
|
||||||
|
|||||||
@@ -658,10 +658,13 @@ class ApiClient {
|
|||||||
* Start an interactive Garmin login. Returns a session id; the login runs
|
* Start an interactive Garmin login. Returns a session id; the login runs
|
||||||
* in the background and parks if Garmin asks for a two-factor code.
|
* in the background and parks if Garmin asks for a two-factor code.
|
||||||
*/
|
*/
|
||||||
async startGarminLogin(garminPassword: string, garminEmail?: string) {
|
async startGarminLogin(
|
||||||
|
garminPassword: string, garminEmail?: string, force?: boolean
|
||||||
|
) {
|
||||||
const { data } = await this.client.post<{ session: string }>('/garmin/login', {
|
const { data } = await this.client.post<{ session: string }>('/garmin/login', {
|
||||||
garminPassword,
|
garminPassword,
|
||||||
...(garminEmail ? { garminEmail } : {}),
|
...(garminEmail ? { garminEmail } : {}),
|
||||||
|
...(force ? { force: true } : {}),
|
||||||
});
|
});
|
||||||
return data.session;
|
return data.session;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user