@huaqiu/dsh-auth 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-auth",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "main": "./lib/index.mjs",
6
6
  "types": "./lib/index.d.mts",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.0",
35
- "@huaqiu/dsh-plugin-log": "0.5.1"
35
+ "@huaqiu/dsh-plugin-log": "0.5.3"
36
36
  },
37
37
  "files": [
38
38
  "lib",
@@ -5,12 +5,17 @@
5
5
  import {
6
6
  AUTH_ORIGIN,
7
7
  buildLoginUrl,
8
+ buildSubscriptionUrl,
9
+ fetchSubscriptionQuota,
8
10
  handleAuthMessage,
11
+ SUBSCRIPTION_PREFERRED_SIZE,
9
12
  type AuthMessageEventLike,
10
13
  type AuthTokenPayload,
11
14
  type LoginOptions,
15
+ type SubscriptionQuota,
12
16
  } from './lib.js'
13
17
  import { closeLoginDialog, isLoginDialogOpen, openLoginDialog } from './ui/login-dialog.js'
18
+ import { closeSubscriptionDialog, isSubscriptionDialogOpen, openSubscriptionDialog } from './ui/subscription-dialog.js'
14
19
  import type { AuthStorage } from './storage.js'
15
20
  import type { AuthTransport } from './transport.js'
16
21
 
@@ -22,6 +27,19 @@ export interface AuthClientDeps {
22
27
  loginUrl?: string
23
28
  windowLike: Pick<Window, 'addEventListener' | 'removeEventListener'>
24
29
  documentLike: Pick<Document, 'createElement' | 'body'>
30
+ /**
31
+ * Host-side native dialog opener (wired by the client entry from the lazy
32
+ * `hqEdge` service). In host mode the subscription dialog must be opened by
33
+ * the EDA host through DialogService.OpenUrl (hqEdge.openUrl) — a browser
34
+ * iframe cannot be shown over the host UI. Absent/undefined = the host has
35
+ * no dialog service; `openSubscriptionDialog()` then no-ops and the caller
36
+ * degrades gracefully.
37
+ */
38
+ openHostDialog?: (url: string, options: {
39
+ title?: string
40
+ size?: { width?: number; height?: number }
41
+ key?: string
42
+ }) => Promise<void>
25
43
  }
26
44
 
27
45
  export interface AuthClient {
@@ -46,6 +64,22 @@ export interface AuthClient {
46
64
  * the UI keeps showing the authenticated state.
47
65
  */
48
66
  logout(): Promise<void>
67
+ /**
68
+ * Active subscription quota for one eda.cn product, fetched DIRECTLY from
69
+ * eda.cn (`/sub-api/subscriptions/active`) — no hq-edge hop, so the
70
+ * sidebar can show live quota even in standalone DSH. Null on fetch
71
+ * failure / no credential (callers decide fail-open vs fail-closed).
72
+ */
73
+ getSubscriptionQuota(productCode: string): Promise<SubscriptionQuota | null>
74
+ /**
75
+ * Open the subscription landing page (`gen.eda.cn/subscription` with the
76
+ * token). Host mode: ask the EDA host to open its NATIVE dialog through
77
+ * DialogService.OpenUrl (preferred size 1400×740). Standalone DSH: open
78
+ * the in-app iframe dialog at the same preferred size. No-op when the
79
+ * credential is missing; host mode without a dialog service no-ops too
80
+ * (the UI falls back to its own subscribe button + error copy).
81
+ */
82
+ openSubscriptionDialog(): Promise<void>
49
83
  onAuthStateChanged(listener: (info: AuthTokenPayload | null) => void): () => void
50
84
  }
51
85
  /** Route window 'message' events here. Exposed for direct testing. */
@@ -195,6 +229,34 @@ export function createAuthClient(deps: AuthClientDeps): AuthClient {
195
229
  emit(null)
196
230
  closeIframe()
197
231
  },
232
+ getSubscriptionQuota: async (productCode: string): Promise<SubscriptionQuota | null> => {
233
+ if (hostMode && !hostSessionLoaded) await resolveHost()
234
+ const token = await auth.getAccessToken()
235
+ const id = hostMode
236
+ ? (hostSession?.id ?? null)
237
+ : (hostSession?.id ?? storage.get()?.id ?? null)
238
+ if (!token || !id) return null
239
+ return fetchSubscriptionQuota({ userId: id, token, productCode })
240
+ },
241
+ openSubscriptionDialog: async (): Promise<void> => {
242
+ if (hostMode && !hostSessionLoaded) await resolveHost()
243
+ const token = await auth.getAccessToken()
244
+ if (!token) return
245
+ const url = buildSubscriptionUrl({ token })
246
+ if (hostMode) {
247
+ // The EDA host owns the UI — open its native dialog (fire-and-forget).
248
+ // No dialog service → no-op; the caller keeps its subscribe button.
249
+ if (!deps.openHostDialog) return
250
+ await deps.openHostDialog(url, {
251
+ title: 'Huaqiu EDA subscription',
252
+ size: { width: SUBSCRIPTION_PREFERRED_SIZE.width, height: SUBSCRIPTION_PREFERRED_SIZE.height },
253
+ key: 'hq-subscription-dialog',
254
+ })
255
+ return
256
+ }
257
+ if (isSubscriptionDialogOpen()) return
258
+ openSubscriptionDialog(url, { size: SUBSCRIPTION_PREFERRED_SIZE })
259
+ },
198
260
  onAuthStateChanged: (listener: (info: AuthTokenPayload | null) => void): (() => void) => {
199
261
  listeners.add(listener)
200
262
  return () => listeners.delete(listener)
@@ -293,6 +355,7 @@ export function createAuthClient(deps: AuthClientDeps): AuthClient {
293
355
  dispose() {
294
356
  deps.windowLike.removeEventListener('message', onWindowMessage)
295
357
  closeIframe()
358
+ if (isSubscriptionDialogOpen()) closeSubscriptionDialog()
296
359
  hostMode = false
297
360
  hostSession = null
298
361
  hostSessionLoaded = false
@@ -26,6 +26,25 @@ const zh = {
26
26
  'menu.profile': '个人中心',
27
27
  'menu.logout': '退出登录',
28
28
 
29
+ // Subscription quota + upgrade (sidebar account menu / HIT cards).
30
+ 'subscription.title': '订阅',
31
+ 'subscription.dialogTitle': '华秋 EDA 订阅',
32
+ 'subscription.gen': '系统设计(GEN)',
33
+ 'subscription.erc': '电路检查(ERC)',
34
+ 'subscription.package': '套餐:{name}',
35
+ 'subscription.quota': '可用次数:{count}',
36
+ 'subscription.quotaCount': '{used} / {total}次',
37
+ 'subscription.noQuota': '无可用次数',
38
+ 'subscription.unsubscribed': '未订阅',
39
+ 'subscription.subscribe': '订阅 / 升级',
40
+ 'subscription.subscribing': '正在打开订阅…',
41
+ 'subscription.desc': '当前账号没有可用的 GEN 生成次数,请订阅或升级套餐后重试。',
42
+ 'subscription.hostUnavailable': '无法打开订阅页面(宿主未提供对话框服务)。',
43
+ // Avatar popup (redesigned sidebar account menu).
44
+ 'subscription.quotaHeader': '套餐与使用额度',
45
+ 'subscription.upgrade': '订阅 / 升级套餐',
46
+ 'subscription.online': '在线',
47
+
29
48
  'card.title': '华秋 EDA AI(eda.cn)登录',
30
49
  'card.desc': '工具「{tool}」需要登录华秋 EDA AI 账号才能继续。请在下方的登录框完成登录(或点击左侧「华秋EDA AI登录」按钮);登录完成后,回复助手「已登录,请重试」,助手会自动重新调用该工具。',
31
50
  'card.loggedIn': '✓ 已登录{nickname} —— 现在可以回复助手「已登录,请重试」,助手会重新调用工具。',
@@ -52,6 +71,24 @@ const en: Record<AuthCopyKey, string> = {
52
71
  'menu.profile': 'Go to profile',
53
72
  'menu.logout': 'Log out',
54
73
 
74
+ 'subscription.title': 'Subscription',
75
+ 'subscription.dialogTitle': 'Huaqiu EDA subscription',
76
+ 'subscription.gen': 'System design (GEN)',
77
+ 'subscription.erc': 'Circuit check (ERC)',
78
+ 'subscription.package': 'Plan: {name}',
79
+ 'subscription.quota': 'Available: {count}',
80
+ 'subscription.quotaCount': '{used} / {total}',
81
+ 'subscription.noQuota': 'No quota left',
82
+ 'subscription.unsubscribed': 'Not subscribed',
83
+ 'subscription.subscribe': 'Subscribe / Upgrade',
84
+ 'subscription.subscribing': 'Opening subscription…',
85
+ 'subscription.desc': 'This account has no GEN generation quota left. Subscribe or upgrade to continue.',
86
+ 'subscription.hostUnavailable': 'Cannot open the subscription page (the host provides no dialog service).',
87
+ // Avatar popup (redesigned sidebar account menu).
88
+ 'subscription.quotaHeader': 'Plan & usage quota',
89
+ 'subscription.upgrade': 'Subscribe / Upgrade plan',
90
+ 'subscription.online': 'Online',
91
+
55
92
  'card.title': 'Huaqiu EDA AI (eda.cn) login',
56
93
  // The reply phrase used to be hardcoded to the Chinese "已登录,请重试" even
57
94
  // in these English strings, telling an English-speaking user to type Chinese.
@@ -73,6 +73,21 @@ export function apply(ctx: ClientContext): () => void {
73
73
  transport: createWebServerAuthTransport(),
74
74
  windowLike: window,
75
75
  documentLike: document,
76
+ // Host mode subscription dialog: the EDA host owns the UI. Open its
77
+ // native window through the hqEdge service's DialogService.OpenUrl
78
+ // bridge (fire-and-forget); absent hqEdge → no-op, the caller's
79
+ // subscribe button stays usable and reports the limitation.
80
+ openHostDialog: async (url, options) => {
81
+ const hqEdge = ctx.get?.<{
82
+ openUrl?(url: string, opts?: Record<string, unknown>): Promise<unknown>
83
+ } | undefined>('hqEdge')
84
+ if (!hqEdge || typeof hqEdge.openUrl !== 'function') return
85
+ await hqEdge.openUrl(url, {
86
+ ...(options.title ? { title: options.title } : {}),
87
+ ...(options.size ? { size: options.size } : {}),
88
+ ...(options.key ? { key: options.key } : {}),
89
+ })
90
+ },
76
91
  })
77
92
 
78
93
  // The sidebar needs the host identity (targetHost) to decide whether the
package/src/client/lib.ts CHANGED
@@ -24,6 +24,117 @@ export const AUTH_ORIGIN = 'https://auth.eda.cn'
24
24
  /**「Go to profile」destination: the eda.cn account page. */
25
25
  export const PROFILE_URL = 'https://www.eda.cn/account/profile'
26
26
 
27
+ // ── Subscription (quota + upgrade dialog) ────────────────────────────────────
28
+
29
+ /**
30
+ * Subscription landing page — the same page `hq-eda-ai` serves at
31
+ * `gen.eda.cn/subscription` (token taken from the URL query; see its
32
+ * `UserInfoContext`). The preferred window size for BOTH surfaces (KiCad
33
+ * native dialog via DialogService.OpenUrl and the standalone iframe dialog)
34
+ * is 1400×740.
35
+ */
36
+ export const SUBSCRIPTION_ORIGIN = 'https://gen.eda.cn'
37
+ export const SUBSCRIPTION_PATH = '/subscription'
38
+ export const SUBSCRIPTION_PREFERRED_SIZE = { width: 1400, height: 740 } as const
39
+
40
+ /**
41
+ * eda.cn subscription product codes.
42
+ * - GEN (system/schematic design): `auto-design` — the SAME product code
43
+ * `hq-eda-ai` uses (`apps/web/src/lib/config/product-code.ts`), i.e. the
44
+ * subscription sold on `gen.eda.cn/subscription`.
45
+ * - ERC: `erc` — the code `hq-edge`'s own quota proxy
46
+ * (`apps/server/src/routes/project.ts`) queries with.
47
+ */
48
+ export const GEN_PRODUCT_CODE = 'auto-design'
49
+ export const ERC_PRODUCT_CODE = 'erc'
50
+
51
+ /**
52
+ * Build the subscription landing URL. Mirrors `buildProfileUrl`: the token is
53
+ * always attached — `gen.eda.cn/subscription` consumes it to establish the
54
+ * session and hides it itself. `encodeURIComponent` is required (tokens are
55
+ * base64-ish and may contain `+`, `/` or `=`).
56
+ */
57
+ export function buildSubscriptionUrl(options: { token: string }): string {
58
+ return `${SUBSCRIPTION_ORIGIN}${SUBSCRIPTION_PATH}?token=${encodeURIComponent(options.token)}`
59
+ }
60
+
61
+ /** Normalized active-subscription quota view (whatever the backend sends). */
62
+ export interface SubscriptionQuota {
63
+ hasSubscription: boolean
64
+ packageName: string
65
+ currentQuota: number
66
+ totalQuota: number
67
+ }
68
+
69
+ const SUB_API_PREFIX = '/sub-api'
70
+ const SUB_API_SUCCESS_CODES = [200, 200000]
71
+
72
+ function unwrapSubApiPayload<T>(payload: unknown): T | null {
73
+ if (payload && typeof payload === 'object' && 'code' in payload) {
74
+ const p = payload as { code: number; result?: T; data?: T }
75
+ if (!SUB_API_SUCCESS_CODES.includes(p.code)) return null
76
+ if (p.result !== undefined) return p.result as T
77
+ if (p.data !== undefined) return p.data as T
78
+ return undefined as T
79
+ }
80
+ return payload as T
81
+ }
82
+
83
+ function quotaOf(subscription: Record<string, unknown> | null | undefined): SubscriptionQuota {
84
+ if (!subscription || (typeof subscription === 'object' && Object.keys(subscription).length === 0)) {
85
+ return { hasSubscription: false, packageName: '', currentQuota: 0, totalQuota: 0 }
86
+ }
87
+ const currentQuota = Number(subscription.currentQuota) || 0
88
+ return {
89
+ hasSubscription: currentQuota > 0,
90
+ packageName: typeof subscription.packageName === 'string' ? subscription.packageName : '',
91
+ currentQuota,
92
+ totalQuota: Number(subscription.totalQuota) || 0,
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Fetch the active subscription quota DIRECTLY from eda.cn
98
+ * (`GET {baseUrl}/sub-api/subscriptions/active?userId&productCode` with
99
+ * `x-token` + `X-User-Id` headers) — the same API `hq-eda-ai`'s
100
+ * `getActiveSubscription` and `hq-edge`'s ERC quota proxy call, so displaying
101
+ * the available quota never depends on an hq-edge hop.
102
+ *
103
+ * Returns null when the fetch/unwrap fails (callers decide fail-open vs
104
+ * fail-closed); a well-formed response always yields a `SubscriptionQuota`
105
+ * (possibly with `hasSubscription: false`).
106
+ */
107
+ export async function fetchSubscriptionQuota(options: {
108
+ userId: string | number
109
+ token: string
110
+ productCode: string
111
+ baseUrl?: string
112
+ fetchImpl?: typeof fetch
113
+ }): Promise<SubscriptionQuota | null> {
114
+ const { userId, token, productCode, baseUrl = 'https://www.eda.cn' } = options
115
+ const fetchImpl = options.fetchImpl ?? fetch
116
+ const url = `${baseUrl}${SUB_API_PREFIX}/subscriptions/active?${new URLSearchParams({
117
+ userId: String(userId),
118
+ productCode,
119
+ }).toString()}`
120
+ try {
121
+ const res = await fetchImpl(url, {
122
+ method: 'GET',
123
+ headers: {
124
+ Accept: 'application/json, text/plain, */*',
125
+ 'Content-Type': 'application/json',
126
+ 'x-token': token,
127
+ 'X-User-Id': String(userId),
128
+ },
129
+ })
130
+ if (!res || !res.ok) return null
131
+ const json = (await res.json()) as unknown
132
+ return quotaOf(unwrapSubApiPayload<Record<string, unknown>>(json))
133
+ } catch {
134
+ return null
135
+ }
136
+ }
137
+
27
138
  /**
28
139
  * Build the「Go to profile」URL: the eda.cn account page WITH the access token
29
140
  * in the query, mirroring `hq-eda-ai`'s `UserMenu`