feat(auth): 接入 auth-hub 统一登录,网页登录与 Garmin 同步彻底分离
网页身份改由 auth-hub 做 OAuth2 + PKCE 单点登录,本地邮箱/密码登录与注册整条链路删除 (routes/auth.py、auth.py 的密码哈希、config.py 的 ALLOW_REGISTRATION)。Garmin 账号绑定/ 同步保持完全独立、可选:routes/garmin.py 不再直接查 users 表,Garmin 邮箱回退统一走新增 的 services/garmin.py::get_remembered_email()(优先读 garmin_tokens 当前绑定,兼容早期账号 落在 users.garmin_email 的历史值),彻底把「你是谁」和「你绑没绑 Garmin」两件事拆开。 - db.py: users 表新增 auth_hub_sub/auth_hub_username,MIGRATIONS 补上这两列(此前遗漏导致 已存在的生产 MariaDB 表永远不会自动加列);同时把历史遗留的 garmin_email/ garmin_password_hash NOT NULL 约束在线迁移为可空,因为新账号不再在注册时收集这些字段。 - routes/auth.py: 修掉 /callback 路由重复拼接 /api/auth 前缀导致 404 的 bug。 - client: LoginPage 去掉本地登录/注册标签页,只保留 auth-hub 统一登录;登录成功/失败后都 用 history.replaceState 清理地址栏,修掉 Framework7 browserHistory 读取 /auth/callback?code=... 导致「找不到页面」的问题。 - 新增 test_auth_hub_client.py 锁定 find_or_create_user 按 auth_hub_sub 幂等——生产上曾经因为 这个函数在没有该测试保护时被测试触发,误建过一个空账号,靠手工核对 health_data 计数才发现。 - 生产 auth-hub 侧另行为该项目注册了正式 client(未随本次提交变更,凭证只存在服务器 .env)。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,41 +41,6 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
flex: 1;
|
||||
padding: 0.6rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.92rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
padding: 0.7rem 1rem;
|
||||
background: var(--accent-solid);
|
||||
@@ -98,3 +63,20 @@
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-hub-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-hub-description {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-hub-button {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiClient, errorMessage } from '../services/api';
|
||||
import './Login.css';
|
||||
|
||||
type TabType = 'login' | 'register';
|
||||
|
||||
function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('login');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
// Sign-up closes once an account exists, so the tab is hidden rather than
|
||||
// offering something the server will refuse.
|
||||
const [canRegister, setCanRegister] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.getRegistrationStatus()
|
||||
.then(setCanRegister)
|
||||
.catch(() => setCanRegister(false));
|
||||
}, []);
|
||||
|
||||
// Login form
|
||||
const [loginEmail, setLoginEmail] = useState('');
|
||||
const [loginPassword, setLoginPassword] = useState('');
|
||||
|
||||
// Register form
|
||||
const [regEmail, setRegEmail] = useState('');
|
||||
const [regGarminEmail, setRegGarminEmail] = useState('');
|
||||
const [regPassword, setRegPassword] = useState('');
|
||||
const [regConfirmPassword, setRegConfirmPassword] = useState('');
|
||||
|
||||
const validateEmail = (email: string): boolean => {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
};
|
||||
|
||||
const validatePassword = (password: string): boolean => {
|
||||
return password.length >= 6;
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!validateEmail(loginEmail)) {
|
||||
setError('Please enter a valid email');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePassword(loginPassword)) {
|
||||
setError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { token } = await apiClient.login(loginEmail, loginPassword);
|
||||
apiClient.setSession(token);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '登录失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!validateEmail(regEmail)) {
|
||||
setError('Please enter a valid email');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateEmail(regGarminEmail)) {
|
||||
setError('Please enter a valid Garmin email');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePassword(regPassword)) {
|
||||
setError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (regPassword !== regConfirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword);
|
||||
apiClient.setSession(token);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '注册失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>🏃 Garmin Health Lab</h1>
|
||||
<p>健康数据分析平台</p>
|
||||
</div>
|
||||
|
||||
<div className="login-tabs">
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'login' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveTab('login');
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
{canRegister && (
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'register' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveTab('register');
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{activeTab === 'login' && (
|
||||
<form onSubmit={handleLogin} className="login-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-email">邮箱</label>
|
||||
<input
|
||||
id="login-email"
|
||||
type="email"
|
||||
value={loginEmail}
|
||||
onChange={(e) => setLoginEmail(e.target.value)}
|
||||
placeholder="example@example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-password">密码</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={loginPassword}
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="submit-button" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === 'register' && canRegister && (
|
||||
<form onSubmit={handleRegister} className="login-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="reg-email">邮箱</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
value={regEmail}
|
||||
onChange={(e) => setRegEmail(e.target.value)}
|
||||
placeholder="example@example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="reg-garmin-email">Garmin 邮箱</label>
|
||||
<input
|
||||
id="reg-garmin-email"
|
||||
type="email"
|
||||
value={regGarminEmail}
|
||||
onChange={(e) => setRegGarminEmail(e.target.value)}
|
||||
placeholder="garmin@example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="reg-password">密码</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
type="password"
|
||||
value={regPassword}
|
||||
onChange={(e) => setRegPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="reg-confirm-password">确认密码</label>
|
||||
<input
|
||||
id="reg-confirm-password"
|
||||
type="password"
|
||||
value={regConfirmPassword}
|
||||
onChange={(e) => setRegConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="submit-button" disabled={loading}>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
@@ -1,108 +1,73 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Page } from 'framework7-react';
|
||||
import { apiClient, errorMessage } from '../services/api';
|
||||
import './Login.css';
|
||||
|
||||
type TabType = 'login' | 'register';
|
||||
|
||||
function Login() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('login');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
// Sign-up closes once an account exists, so the tab is hidden rather than
|
||||
// offering something the server will refuse.
|
||||
const [canRegister, setCanRegister] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.getRegistrationStatus()
|
||||
.then(setCanRegister)
|
||||
.catch(() => setCanRegister(false));
|
||||
// Check if we're handling the OAuth callback from auth-hub
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
if (code) {
|
||||
handleAuthHubCallback(code);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Login form
|
||||
const [loginEmail, setLoginEmail] = useState('');
|
||||
const [loginPassword, setLoginPassword] = useState('');
|
||||
|
||||
// Register form
|
||||
const [regEmail, setRegEmail] = useState('');
|
||||
const [regGarminEmail, setRegGarminEmail] = useState('');
|
||||
const [regPassword, setRegPassword] = useState('');
|
||||
const [regConfirmPassword, setRegConfirmPassword] = useState('');
|
||||
|
||||
const validateEmail = (email: string): boolean => {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
};
|
||||
|
||||
const validatePassword = (password: string): boolean => {
|
||||
return password.length >= 6;
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!validateEmail(loginEmail)) {
|
||||
setError('Please enter a valid email');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePassword(loginPassword)) {
|
||||
setError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const handleAuthHubCallback = async (code: string) => {
|
||||
try {
|
||||
const { token } = await apiClient.login(loginEmail, loginPassword);
|
||||
// No navigation here: setSession tells the shell there is a session,
|
||||
// and it swaps the login view for the tab bar. Routing from a view that
|
||||
// is about to be unmounted races that swap.
|
||||
apiClient.setSession(token);
|
||||
setLoading(true);
|
||||
// Retrieve code_verifier from sessionStorage
|
||||
const codeVerifier = sessionStorage.getItem('auth_hub_code_verifier');
|
||||
if (!codeVerifier) {
|
||||
setError('登录会话已过期,请重试');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await apiClient.authHubCallback(code, codeVerifier);
|
||||
|
||||
// The tab bar's main view reads the browser's current URL
|
||||
// (browserHistory) to pick its initial route the moment it mounts.
|
||||
// Left on /auth/callback?code=..., that lookup fails and shows a
|
||||
// "page not found" screen instead of 今日. Clear it before flipping
|
||||
// the session so the shell mounts against a clean "/".
|
||||
window.history.replaceState({}, '', '/');
|
||||
|
||||
// No further navigation here: setSession tells the shell there is a
|
||||
// session, and it swaps the login view for the tab bar. Routing from a
|
||||
// view that is about to be unmounted races that swap.
|
||||
apiClient.setSession(response.token);
|
||||
|
||||
// Clean up session storage
|
||||
sessionStorage.removeItem('auth_hub_code_verifier');
|
||||
sessionStorage.removeItem('auth_hub_state');
|
||||
} catch (err: any) {
|
||||
// The code auth-hub issued is single-use and now spent either way;
|
||||
// leaving it in the address bar would just re-fail identically on a
|
||||
// page refresh.
|
||||
window.history.replaceState({}, '', '/login/');
|
||||
setError(errorMessage(err, '登录失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!validateEmail(regEmail)) {
|
||||
setError('Please enter a valid email');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateEmail(regGarminEmail)) {
|
||||
setError('Please enter a valid Garmin email');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePassword(regPassword)) {
|
||||
setError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (regPassword !== regConfirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const handleAuthHubLogin = async () => {
|
||||
try {
|
||||
const { token } = await apiClient.register(regEmail, regGarminEmail, regPassword);
|
||||
// No navigation here: setSession tells the shell there is a session,
|
||||
// and it swaps the login view for the tab bar. Routing from a view that
|
||||
// is about to be unmounted races that swap.
|
||||
apiClient.setSession(token);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const response = await apiClient.authHubStart();
|
||||
|
||||
// Store PKCE parameters in sessionStorage for the callback
|
||||
sessionStorage.setItem('auth_hub_code_verifier', response.code_verifier);
|
||||
sessionStorage.setItem('auth_hub_state', response.state);
|
||||
|
||||
// Redirect to auth-hub
|
||||
window.location.href = response.auth_url;
|
||||
} catch (err: any) {
|
||||
setError(errorMessage(err, '注册失败'));
|
||||
} finally {
|
||||
setError(errorMessage(err, '无法启动登录'));
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -110,130 +75,24 @@ function Login() {
|
||||
return (
|
||||
<Page className="login-page" noNavbar noToolbar>
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>🏃 Garmin Health Lab</h1>
|
||||
<p>健康数据分析平台</p>
|
||||
</div>
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<h1>🏃 Garmin Health Lab</h1>
|
||||
<p>健康数据分析平台</p>
|
||||
</div>
|
||||
|
||||
<div className="login-tabs">
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'login' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveTab('login');
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
{canRegister && (
|
||||
{error && <div className="screen-error">{error}</div>}
|
||||
|
||||
<div className="auth-hub-panel">
|
||||
<p className="auth-hub-description">使用统一身份认证平台登录</p>
|
||||
<button
|
||||
className={`tab-button ${activeTab === 'register' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveTab('register');
|
||||
setError('');
|
||||
}}
|
||||
className="submit-button auth-hub-button"
|
||||
onClick={handleAuthHubLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
注册
|
||||
{loading ? '跳转中...' : '登录'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="screen-error">{error}</div>}
|
||||
|
||||
{activeTab === 'login' && (
|
||||
<form onSubmit={handleLogin} className="login-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-email">邮箱</label>
|
||||
<input
|
||||
id="login-email"
|
||||
type="email"
|
||||
value={loginEmail}
|
||||
onChange={(e) => setLoginEmail(e.target.value)}
|
||||
placeholder="example@example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="login-password">密码</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={loginPassword}
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="submit-button" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === 'register' && canRegister && (
|
||||
<form onSubmit={handleRegister} className="login-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="reg-email">邮箱</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
type="email"
|
||||
value={regEmail}
|
||||
onChange={(e) => setRegEmail(e.target.value)}
|
||||
placeholder="example@example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="reg-garmin-email">Garmin 邮箱</label>
|
||||
<input
|
||||
id="reg-garmin-email"
|
||||
type="email"
|
||||
value={regGarminEmail}
|
||||
onChange={(e) => setRegGarminEmail(e.target.value)}
|
||||
placeholder="garmin@example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="reg-password">密码</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
type="password"
|
||||
value={regPassword}
|
||||
onChange={(e) => setRegPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="reg-confirm-password">确认密码</label>
|
||||
<input
|
||||
id="reg-confirm-password"
|
||||
type="password"
|
||||
value={regConfirmPassword}
|
||||
onChange={(e) => setRegConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="submit-button" disabled={loading}>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
|
||||
@@ -10,7 +10,7 @@ export const AUTH_EVENT = 'ghl:auth';
|
||||
// {success, data} envelope, so responses are read as `response.data` directly.
|
||||
export interface AuthResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
@@ -408,31 +408,6 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// --- auth ---
|
||||
async register(email: string, garminEmail: string, garminPassword: string) {
|
||||
const { data } = await this.client.post<AuthResponse>('/auth/register', {
|
||||
email,
|
||||
garminEmail,
|
||||
garminPassword,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Whether sign-up is currently permitted (closes after the first account). */
|
||||
async getRegistrationStatus() {
|
||||
const { data } = await this.client.get<{ open: boolean }>(
|
||||
'/auth/registration-status'
|
||||
);
|
||||
return data.open;
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
const { data } = await this.client.post<AuthResponse>('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await this.client.post('/auth/logout');
|
||||
@@ -441,6 +416,23 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// --- auth-hub OAuth2 ---
|
||||
async authHubStart() {
|
||||
const { data } = await this.client.post<{
|
||||
auth_url: string;
|
||||
code_verifier: string;
|
||||
state: string;
|
||||
}>('/auth/auth-hub/start', {});
|
||||
return data;
|
||||
}
|
||||
|
||||
async authHubCallback(code: string, codeVerifier: string) {
|
||||
const { data } = await this.client.get<AuthResponse>('/auth/callback', {
|
||||
params: { code, code_verifier: codeVerifier }
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- garmin ---
|
||||
/**
|
||||
* With a stored OAuth token no password is needed. Without one, the
|
||||
|
||||
Reference in New Issue
Block a user