feat(web/usercenter): Next.js 用户中心静态导出 + mock/http 双数据层 (tsk_3FIPC8lSnAfJ)
新建 web/usercenter/:Next.js App Router + output:'export' 纯静态导出,
直接复用 design/ui_kits/usercenter React 源码(概览/订阅/兑换/邀请/设置)。
阶段 A(mock,本提交):
- 复刻五大页面,明/暗 × zh/en 四态;colors_and_type.css 原样链入;
顶栏主题切换 + 语言段控,移动端底部 Tab + 左右滑动切换。
- 设置页新增:偏好(语言/主题) + 设备管理(列表/移除/二次确认+刷新) +
TOTP 2FA(绑定二维码占位+密钥/验证/解禁),登录二段式 TOTP。
- 数据层 lib/api:ApiClient 抽象 + MockClient/HttpClient 双实现,构建期
NEXT_PUBLIC_API_MODE 切换;统一错误体 {code,message_zh,message_en} → 双语映射。
- 会话:access token 仅内存,refresh header token + localStorage,静默续期,
登出失效(取舍:静态导出无服务端 cookie 能力,详见 README)。
- 安全:构建期注入 SRI(sha384);_headers 严格 CSP + 安全基线;
红线词扫描(铁律13) CI 红线,零命中。
阶段 B(占位待联调):HttpClient 已写好域名池+退避重试+401 续期;
TOTP/登录二段式端点占位待 #1 契约增补;me/redeem/devices 切真实链路依赖 #2/#3/#4。
验证:npm run lint / redline / build 均通过,静态产物 out/ 无服务端依赖。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
// client.ts — 数据层入口:构建期环境开关切换 mock / http。
|
||||
import type { ApiClient } from './types';
|
||||
import { MockClient } from './mock';
|
||||
import { HttpClient } from './http';
|
||||
|
||||
export * from './types';
|
||||
|
||||
let singleton: ApiClient | null = null;
|
||||
|
||||
export function apiMode(): 'mock' | 'http' {
|
||||
return process.env.NEXT_PUBLIC_API_MODE === 'http' ? 'http' : 'mock';
|
||||
}
|
||||
|
||||
/** 单例客户端:mock 模式保留内存状态(订阅重置/设备移除等可连续演示) */
|
||||
export function getClient(): ApiClient {
|
||||
if (!singleton) singleton = apiMode() === 'http' ? new HttpClient() : new MockClient();
|
||||
return singleton;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// errors.ts — 错误码 → 双语文案映射表(阶段 B 落地)
|
||||
import { ApiError } from './types';
|
||||
import type { Lang } from '../i18n';
|
||||
|
||||
/**
|
||||
* 后端统一返回 {code, message_zh, message_en};前端优先用后端文案,
|
||||
* 兜底用本表(后端文案缺失/网络层错误时)。codes 与 #1 openapi 对齐。
|
||||
*/
|
||||
export const ERROR_TEXT: Record<string, { zh: string; en: string }> = {
|
||||
invalid_credentials: { zh: '邮箱或密码错误', en: 'Wrong email or password' },
|
||||
rate_limited: { zh: '操作过于频繁,请稍后再试', en: 'Too many requests, slow down' },
|
||||
account_locked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' },
|
||||
totp_required: { zh: '需要双重认证动态码', en: 'Two-factor code required' },
|
||||
totp_invalid: { zh: '动态码不正确,请重试', en: 'Invalid code, try again' },
|
||||
code_invalid: { zh: '激活码无效', en: 'Invalid activation code' },
|
||||
code_used: { zh: '激活码已被使用', en: 'Activation code already used' },
|
||||
code_expired: { zh: '激活码已过期', en: 'Activation code expired' },
|
||||
device_limit: { zh: '设备数量已达上限', en: 'Device limit reached' },
|
||||
device_not_found: { zh: '设备不存在', en: 'Device not found' },
|
||||
unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again' },
|
||||
network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' },
|
||||
unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' },
|
||||
};
|
||||
|
||||
/** 把任意错误转成当前语言文案(后端文案优先,再查表,最后 unknown) */
|
||||
export function bilingual(err: unknown, lang: Lang): string {
|
||||
if (err instanceof ApiError) {
|
||||
const fromServer = lang === 'zh' ? err.message_zh : err.message_en;
|
||||
if (fromServer) return fromServer;
|
||||
const m = ERROR_TEXT[err.code];
|
||||
if (m) return m[lang];
|
||||
}
|
||||
return ERROR_TEXT.unknown[lang];
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// http.ts — HttpClient:真实后端薄客户端(阶段 B 联调)。
|
||||
// 特性:API 域名池(故障转移) + 指数退避重试 + 统一错误体解析 + 静默续期。
|
||||
import {
|
||||
ApiClient,
|
||||
ApiError,
|
||||
ApiErrorBody,
|
||||
Device,
|
||||
LoginResult,
|
||||
Me,
|
||||
RedeemResult,
|
||||
Session,
|
||||
SubscriptionInfo,
|
||||
TotpSetup,
|
||||
} from './types';
|
||||
import {
|
||||
accessValid,
|
||||
clearSession,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setSession,
|
||||
} from './session';
|
||||
|
||||
function domains(): string[] {
|
||||
const raw = process.env.NEXT_PUBLIC_API_DOMAINS || '';
|
||||
const list = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return list.length ? list : ['/api'];
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
interface ReqOpts {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
auth?: boolean;
|
||||
/** 401 时是否尝试静默续期后重放(默认 true,refresh 自身置 false 防递归) */
|
||||
allowRefresh?: boolean;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export class HttpClient implements ApiClient {
|
||||
private refreshing: Promise<Session> | null = null;
|
||||
|
||||
/** 跨域名池 + 退避的核心请求 */
|
||||
private async request<T>(path: string, opts: ReqOpts = {}): Promise<T> {
|
||||
const { method = 'GET', body, auth = true, allowRefresh = true } = opts;
|
||||
|
||||
if (auth && !accessValid() && getRefreshToken()) {
|
||||
await this.ensureFresh();
|
||||
}
|
||||
|
||||
const pool = domains();
|
||||
let lastErr: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < pool.length * 2; attempt++) {
|
||||
const base = pool[attempt % pool.length];
|
||||
try {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const token = getAccessToken();
|
||||
if (auth && token) headers['Authorization'] = `Bearer ${token}`;
|
||||
if (opts.refreshToken) headers['X-Refresh-Token'] = opts.refreshToken;
|
||||
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
credentials: 'omit',
|
||||
});
|
||||
|
||||
if (res.status === 401 && auth && allowRefresh && getRefreshToken()) {
|
||||
await this.ensureFresh(true);
|
||||
// 续期后重放一次(不再允许二次续期)
|
||||
return this.request<T>(path, { ...opts, allowRefresh: false });
|
||||
}
|
||||
|
||||
if (!res.ok) throw await this.toApiError(res);
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
// 业务错误(ApiError)不重试;仅网络/5xx 才退避换域名
|
||||
if (err instanceof ApiError && err.code !== 'network') throw err;
|
||||
await sleep(Math.min(1000 * 2 ** attempt, 4000));
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof ApiError
|
||||
? lastErr
|
||||
: new ApiError({ code: 'network', message_zh: '网络异常,请稍后重试', message_en: 'Network error, please retry' });
|
||||
}
|
||||
|
||||
private async toApiError(res: Response): Promise<ApiError> {
|
||||
let bodyJson: Partial<ApiErrorBody> = {};
|
||||
try {
|
||||
bodyJson = await res.json();
|
||||
} catch {
|
||||
/* non-json error */
|
||||
}
|
||||
const code = bodyJson.code || (res.status === 401 ? 'unauthorized' : 'unknown');
|
||||
const retryAfter = Number(res.headers.get('Retry-After')) || undefined;
|
||||
return new ApiError(
|
||||
{
|
||||
code,
|
||||
message_zh: bodyJson.message_zh || '',
|
||||
message_en: bodyJson.message_en || '',
|
||||
},
|
||||
retryAfter,
|
||||
);
|
||||
}
|
||||
|
||||
private ensureFresh(force = false): Promise<Session> {
|
||||
if (!force && accessValid()) return Promise.resolve(null as unknown as Session);
|
||||
if (this.refreshing) return this.refreshing;
|
||||
this.refreshing = this.refresh().finally(() => {
|
||||
this.refreshing = null;
|
||||
});
|
||||
return this.refreshing;
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<LoginResult> {
|
||||
const r = await this.request<
|
||||
{ kind: 'session'; session: Session } | { kind: 'totp_required'; pending_token: string }
|
||||
>('/v1/auth/login', { method: 'POST', body: { email, password }, auth: false });
|
||||
if ('pending_token' in r) return { kind: 'totp_required', pendingToken: r.pending_token };
|
||||
setSession(r.session);
|
||||
return r;
|
||||
}
|
||||
|
||||
async loginTotp(pendingToken: string, code: string): Promise<Session> {
|
||||
const s = await this.request<Session>('/v1/auth/login/totp', {
|
||||
method: 'POST',
|
||||
body: { pending_token: pendingToken, code },
|
||||
auth: false,
|
||||
});
|
||||
setSession(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
async refresh(): Promise<Session> {
|
||||
const rt = getRefreshToken();
|
||||
if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' });
|
||||
const s = await this.request<Session>('/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
allowRefresh: false,
|
||||
refreshToken: rt,
|
||||
});
|
||||
setSession(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
getMe = () => this.request<Me>('/v1/me');
|
||||
getSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription');
|
||||
resetSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription/reset', { method: 'POST' });
|
||||
listDevices = () => this.request<Device[]>('/v1/me/devices');
|
||||
removeDevice = (id: string) => this.request<void>(`/v1/me/devices/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
redeem = (code: string) => this.request<RedeemResult>('/v1/me/redeem', { method: 'POST', body: { code } });
|
||||
|
||||
// 契约增补待 #1 合入:POST /v1/me/totp/setup|verify|disable
|
||||
totpSetup = () => this.request<TotpSetup>('/v1/me/totp/setup', { method: 'POST' });
|
||||
totpVerify = (code: string) => this.request<void>('/v1/me/totp/verify', { method: 'POST', body: { code } });
|
||||
totpDisable = (code: string) => this.request<void>('/v1/me/totp/disable', { method: 'POST', body: { code } });
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await this.request<void>('/v1/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// mock.ts — MockClient:演示数据,供阶段 A 的 UI 验收使用,零网络依赖。
|
||||
import {
|
||||
ApiClient,
|
||||
ApiError,
|
||||
Device,
|
||||
LoginResult,
|
||||
Me,
|
||||
RedeemResult,
|
||||
Session,
|
||||
SubscriptionInfo,
|
||||
TotpSetup,
|
||||
} from './types';
|
||||
import { setSession, clearSession } from './session';
|
||||
|
||||
const delay = (ms = 420) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function makeSession(): Session {
|
||||
return {
|
||||
accessToken: 'mock-access-' + Math.random().toString(36).slice(2),
|
||||
refreshToken: 'mock-refresh-' + Math.random().toString(36).slice(2),
|
||||
accessExpiresAt: Date.now() + 10 * 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
export class MockClient implements ApiClient {
|
||||
// 演示态:PRO 会员,已开启 2FA(故登录走二段式)
|
||||
private totpEnabled = true;
|
||||
private pending: string | null = null;
|
||||
private subUrl = 'https://sub.pangolin.vpn/s/8f3kx92m';
|
||||
private devices: Device[] = [
|
||||
{ id: 'd1', name: 'iPhone 15 Pro', platform: 'iOS 18', lastActive: '刚刚', current: true },
|
||||
{ id: 'd2', name: 'MacBook Air', platform: 'macOS 15', lastActive: '2 小时前', current: false },
|
||||
];
|
||||
|
||||
async login(email: string, password: string): Promise<LoginResult> {
|
||||
await delay();
|
||||
// 演示锁定/失败:用特定凭证触发,便于验收双语文案
|
||||
if (password === 'locked') {
|
||||
throw new ApiError(
|
||||
{ code: 'account_locked', message_zh: '失败次数过多,账户已临时锁定', message_en: 'Too many attempts — account temporarily locked' },
|
||||
30,
|
||||
);
|
||||
}
|
||||
if (password === 'wrong' || !/\S+@\S+\.\S+/.test(email)) {
|
||||
throw new ApiError({ code: 'invalid_credentials', message_zh: '邮箱或密码错误', message_en: 'Wrong email or password' });
|
||||
}
|
||||
if (this.totpEnabled) {
|
||||
this.pending = 'mock-pending-' + Math.random().toString(36).slice(2);
|
||||
return { kind: 'totp_required', pendingToken: this.pending };
|
||||
}
|
||||
const s = makeSession();
|
||||
setSession(s);
|
||||
return { kind: 'session', session: s };
|
||||
}
|
||||
|
||||
async loginTotp(_pendingToken: string, code: string): Promise<Session> {
|
||||
await delay();
|
||||
if (code === '000000') {
|
||||
throw new ApiError({ code: 'totp_invalid', message_zh: '动态码不正确,请重试', message_en: 'Invalid code, try again' });
|
||||
}
|
||||
const s = makeSession();
|
||||
setSession(s);
|
||||
this.pending = null;
|
||||
return s;
|
||||
}
|
||||
|
||||
async refresh(): Promise<Session> {
|
||||
await delay(150);
|
||||
const s = makeSession();
|
||||
setSession(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
async getMe(): Promise<Me> {
|
||||
await delay(260);
|
||||
return {
|
||||
email: 'me@pangolin.vpn',
|
||||
plan: 'pro',
|
||||
expiresAt: '2026-12-31',
|
||||
devicesUsed: 2,
|
||||
devicesMax: 5,
|
||||
quotaTodayMin: null,
|
||||
dataTodayGB: 0.8,
|
||||
weeklyGB: [1.8, 2.4, 1.2, 3.1, 4.0, 5.2, 2.6],
|
||||
totpEnabled: this.totpEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
async getSubscription(): Promise<SubscriptionInfo> {
|
||||
await delay(160);
|
||||
return { url: this.subUrl };
|
||||
}
|
||||
|
||||
async resetSubscription(): Promise<SubscriptionInfo> {
|
||||
await delay(500);
|
||||
this.subUrl = 'https://sub.pangolin.vpn/s/' + Math.random().toString(36).slice(2, 10);
|
||||
return { url: this.subUrl };
|
||||
}
|
||||
|
||||
async listDevices(): Promise<Device[]> {
|
||||
await delay(220);
|
||||
return [...this.devices];
|
||||
}
|
||||
|
||||
async removeDevice(id: string): Promise<void> {
|
||||
await delay(360);
|
||||
this.devices = this.devices.filter((d) => d.id !== id);
|
||||
}
|
||||
|
||||
async redeem(code: string): Promise<RedeemResult> {
|
||||
await delay(520);
|
||||
const c = code.trim().toUpperCase();
|
||||
if (c === 'LOCKED') {
|
||||
throw new ApiError(
|
||||
{ code: 'rate_limited', message_zh: '操作过于频繁,请稍后再试', message_en: 'Too many requests, slow down' },
|
||||
20,
|
||||
);
|
||||
}
|
||||
if (c === 'USED') {
|
||||
throw new ApiError({ code: 'code_used', message_zh: '激活码已被使用', message_en: 'Activation code already used' });
|
||||
}
|
||||
if (c.length < 4 || c === 'INVALID') {
|
||||
throw new ApiError({ code: 'code_invalid', message_zh: '激活码无效', message_en: 'Invalid activation code' });
|
||||
}
|
||||
return { plan: 'pro', expiresAt: '2027-12-31' };
|
||||
}
|
||||
|
||||
async totpSetup(): Promise<TotpSetup> {
|
||||
await delay(260);
|
||||
const secret = 'JBSWY3DPEHPK3PXP';
|
||||
return {
|
||||
secret,
|
||||
otpauthUri: `otpauth://totp/Pangolin:me@pangolin.vpn?secret=${secret}&issuer=Pangolin`,
|
||||
};
|
||||
}
|
||||
|
||||
async totpVerify(code: string): Promise<void> {
|
||||
await delay(300);
|
||||
if (code === '000000' || code.length !== 6) {
|
||||
throw new ApiError({ code: 'totp_invalid', message_zh: '动态码不正确,请重试', message_en: 'Invalid code, try again' });
|
||||
}
|
||||
this.totpEnabled = true;
|
||||
}
|
||||
|
||||
async totpDisable(code: string): Promise<void> {
|
||||
await delay(300);
|
||||
if (code === '000000' || code.length !== 6) {
|
||||
throw new ApiError({ code: 'totp_invalid', message_zh: '动态码不正确,请重试', message_en: 'Invalid code, try again' });
|
||||
}
|
||||
this.totpEnabled = false;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await delay(120);
|
||||
this.pending = null;
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// session.ts — 会话存储
|
||||
// access token:仅内存(防 XSS 持久化窃取);refresh token:localStorage(header token 方案)。
|
||||
// 取舍:doc/05 §2 给「HttpOnly+Secure cookie 或 header token」两选;纯静态导出无服务端
|
||||
// 设置 cookie 的能力,故取 header token + localStorage,并配合静默续期与登出失效收敛风险。
|
||||
import type { Session } from './types';
|
||||
|
||||
const REFRESH_KEY = 'pg_uc_refresh';
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let accessExpiresAt = 0;
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
export function accessValid(skewMs = 15_000): boolean {
|
||||
return !!accessToken && Date.now() < accessExpiresAt - skewMs;
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
return window.localStorage.getItem(REFRESH_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSession(s: Session): void {
|
||||
accessToken = s.accessToken;
|
||||
accessExpiresAt = s.accessExpiresAt;
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(REFRESH_KEY, s.refreshToken);
|
||||
} catch {
|
||||
/* ignore quota / privacy mode */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
accessToken = null;
|
||||
accessExpiresAt = 0;
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.removeItem(REFRESH_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasRefresh(): boolean {
|
||||
return !!getRefreshToken();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// types.ts — 数据层契约(mock / http 双实现共用)
|
||||
|
||||
export type Plan = 'free' | 'pro';
|
||||
|
||||
export interface Session {
|
||||
/** access token:仅存内存,绝不落盘 */
|
||||
accessToken: string;
|
||||
/** refresh token:localStorage(header token 方案,见 README 取舍说明) */
|
||||
refreshToken: string;
|
||||
/** access 过期时间戳(ms) */
|
||||
accessExpiresAt: number;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
email: string;
|
||||
plan: Plan;
|
||||
/** PRO 到期日 YYYY-MM-DD;免费版为 null */
|
||||
expiresAt: string | null;
|
||||
devicesUsed: number;
|
||||
devicesMax: number;
|
||||
/** 今日剩余时长(分钟);PRO 为 null 表示不限 */
|
||||
quotaTodayMin: number | null;
|
||||
dataTodayGB: number;
|
||||
/** 近 7 日流量(GB) */
|
||||
weeklyGB: number[];
|
||||
totpEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
lastActive: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface RedeemResult {
|
||||
plan: Plan;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface TotpSetup {
|
||||
/** base32 密钥 */
|
||||
secret: string;
|
||||
/** otpauth:// URI(可渲染二维码) */
|
||||
otpauthUri: string;
|
||||
}
|
||||
|
||||
/** 统一错误体 —— 与后端约定 {code, message_zh, message_en} */
|
||||
export interface ApiErrorBody {
|
||||
code: string;
|
||||
message_zh: string;
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string;
|
||||
message_zh: string;
|
||||
message_en: string;
|
||||
/** 锁定类错误:剩余秒数 */
|
||||
retryAfter?: number;
|
||||
constructor(body: ApiErrorBody, retryAfter?: number) {
|
||||
super(body.code);
|
||||
this.name = 'ApiError';
|
||||
this.code = body.code;
|
||||
this.message_zh = body.message_zh;
|
||||
this.message_en = body.message_en;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录第一段结果:成功直接给 session,或要求二段 TOTP */
|
||||
export type LoginResult =
|
||||
| { kind: 'session'; session: Session }
|
||||
| { kind: 'totp_required'; pendingToken: string };
|
||||
|
||||
export interface ApiClient {
|
||||
login(email: string, password: string): Promise<LoginResult>;
|
||||
/** 登录二段式:提交 TOTP 动态码换取 session */
|
||||
loginTotp(pendingToken: string, code: string): Promise<Session>;
|
||||
refresh(): Promise<Session>;
|
||||
getMe(): Promise<Me>;
|
||||
getSubscription(): Promise<SubscriptionInfo>;
|
||||
resetSubscription(): Promise<SubscriptionInfo>;
|
||||
listDevices(): Promise<Device[]>;
|
||||
removeDevice(id: string): Promise<void>;
|
||||
redeem(code: string): Promise<RedeemResult>;
|
||||
totpSetup(): Promise<TotpSetup>;
|
||||
totpVerify(code: string): Promise<void>;
|
||||
totpDisable(code: string): Promise<void>;
|
||||
logout(): Promise<void>;
|
||||
}
|
||||
Reference in New Issue
Block a user