@huaqiu/dsh-auth 0.1.1
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/LICENSE +21 -0
- package/README.md +22 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +1427 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.mts +73 -0
- package/lib/index.mjs +291 -0
- package/package.json +55 -0
- package/src/client/auth-state.ts +96 -0
- package/src/client/client.ts +169 -0
- package/src/client/i18n.ts +97 -0
- package/src/client/index.tsx +109 -0
- package/src/client/lib.ts +234 -0
- package/src/client/storage.ts +43 -0
- package/src/client/transport.ts +36 -0
- package/src/client/ui/common.tsx +169 -0
- package/src/client/ui/hq-icon.tsx +50 -0
- package/src/client/ui/login-dialog.ts +219 -0
- package/src/client/ui/needs-auth-toolview.tsx +118 -0
- package/src/client/ui/sidebar-action.tsx +395 -0
- package/src/client/ui-env.ts +175 -0
- package/src/host.ts +139 -0
- package/src/index.ts +42 -0
- package/src/routes.ts +84 -0
- package/src/service.ts +172 -0
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huaqiu/dsh-auth",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./lib/index.mjs",
|
|
6
|
+
"types": "./lib/index.d.mts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./lib/index.d.mts",
|
|
10
|
+
"default": "./lib/index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./client": {
|
|
13
|
+
"default": "./lib/client.js"
|
|
14
|
+
},
|
|
15
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"dsh": {
|
|
19
|
+
"bundle": {
|
|
20
|
+
"patch": "./cordis.patch.yml"
|
|
21
|
+
},
|
|
22
|
+
"client": {
|
|
23
|
+
"platform": "web",
|
|
24
|
+
"inject": [
|
|
25
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
31
|
+
"@deepseek-ai/dsh-host-webserver": ">=0.1.0-rc.0 <0.2.0"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@deepseek-ai/dsh-home-paths": ">=0.1.0-rc.0 <0.2.0"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"lib",
|
|
38
|
+
"src",
|
|
39
|
+
"cordis.patch.yml"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/react": "^19.2.18",
|
|
46
|
+
"@types/react-dom": "^19.2.5",
|
|
47
|
+
"react": "^19.2.8",
|
|
48
|
+
"react-dom": "^19.2.8"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"build": "tsdown",
|
|
53
|
+
"test": "vitest run"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level auth state store shared by the client React components.
|
|
3
|
+
*
|
|
4
|
+
* The DSH slot system injects React components with props, not the cordis ctx,
|
|
5
|
+
* so the components read login state through this tiny external store
|
|
6
|
+
* (`useSyncExternalStore`), fed by the singleton `huaqiuAuth` client service
|
|
7
|
+
* created in `apply()`. When the user logs in (in the sidebar overlay or an
|
|
8
|
+
* embedded card iframe), `onAuthStateChanged` fires and every mounted card /
|
|
9
|
+
* sidebar button re-renders.
|
|
10
|
+
*/
|
|
11
|
+
import type { AuthClient } from './client.js'
|
|
12
|
+
|
|
13
|
+
export interface AuthState {
|
|
14
|
+
authenticated: boolean
|
|
15
|
+
nickname?: string
|
|
16
|
+
/** Avatar URL for the sidebar trigger; absent → the HQ icon is shown. */
|
|
17
|
+
avatar?: string
|
|
18
|
+
/**
|
|
19
|
+
* Access token, needed by「Go to profile」(eda.cn takes it from the query
|
|
20
|
+
* and hides it itself). Kept in the store, never rendered or logged.
|
|
21
|
+
*/
|
|
22
|
+
token?: string
|
|
23
|
+
/** Bound mobile number, forwarded to the profile page as `phone=`. */
|
|
24
|
+
phone?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Snapshot for one credential payload (`null` = logged out). */
|
|
28
|
+
function stateOf(info: AuthTokenPayloadLike | null): AuthState {
|
|
29
|
+
if (!info) return { authenticated: false }
|
|
30
|
+
return {
|
|
31
|
+
authenticated: true,
|
|
32
|
+
...(info.nickname ? { nickname: info.nickname } : {}),
|
|
33
|
+
...(info.avatar ? { avatar: info.avatar } : {}),
|
|
34
|
+
...(info.token ? { token: info.token } : {}),
|
|
35
|
+
...(info.phone ? { phone: info.phone } : {}),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type AuthTokenPayloadLike = { nickname?: string; avatar?: string; token?: string; phone?: string } | null
|
|
40
|
+
|
|
41
|
+
let auth: AuthClient['auth'] | null = null
|
|
42
|
+
let state: AuthState = { authenticated: false }
|
|
43
|
+
const listeners = new Set<() => void>()
|
|
44
|
+
let unsubscribe: (() => void) | null = null
|
|
45
|
+
let syncNow: (() => void) | null = null
|
|
46
|
+
|
|
47
|
+
function setState(next: AuthState): void {
|
|
48
|
+
state = next
|
|
49
|
+
for (const l of listeners) l()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Attach the singleton auth capability and push the initial snapshot. */
|
|
53
|
+
export function registerAuth(a: AuthClient['auth']): void {
|
|
54
|
+
auth = a
|
|
55
|
+
unsubscribe = a.onAuthStateChanged((info) => {
|
|
56
|
+
setState(stateOf(info))
|
|
57
|
+
})
|
|
58
|
+
void a.getUserInfo()
|
|
59
|
+
.then((info) => setState(stateOf(info)))
|
|
60
|
+
.catch(() => setState({ authenticated: false }))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The live auth capability (for login()/logout() from components). */
|
|
64
|
+
export function getAuth(): AuthClient['auth'] | null {
|
|
65
|
+
return auth
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Current snapshot, for `useSyncExternalStore`'s getSnapshot. */
|
|
69
|
+
export function getAuthState(): AuthState {
|
|
70
|
+
return state
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Subscribe, for `useSyncExternalStore`'s subscribe. */
|
|
74
|
+
export function subscribeAuth(callback: () => void): () => void {
|
|
75
|
+
listeners.add(callback)
|
|
76
|
+
return () => listeners.delete(callback)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Register the node re-sync hook (wired in apply(); called by the login card on mount). */
|
|
80
|
+
export function registerAuthSync(fn: () => void): void {
|
|
81
|
+
syncNow = fn
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Re-push the persisted credential to the node half, if one is available. */
|
|
85
|
+
export function syncAuthNow(): void {
|
|
86
|
+
syncNow?.()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function disposeAuth(): void {
|
|
90
|
+
unsubscribe?.()
|
|
91
|
+
unsubscribe = null
|
|
92
|
+
auth = null
|
|
93
|
+
syncNow = null
|
|
94
|
+
listeners.clear()
|
|
95
|
+
state = { authenticated: false }
|
|
96
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth client core — the Phase 0A POC logic, factored as a testable factory.
|
|
3
|
+
* `apply()` in index.ts wires this to the real window/document/localStorage.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
AUTH_ORIGIN,
|
|
7
|
+
buildLoginUrl,
|
|
8
|
+
handleAuthMessage,
|
|
9
|
+
type AuthMessageEventLike,
|
|
10
|
+
type AuthTokenPayload,
|
|
11
|
+
type LoginOptions,
|
|
12
|
+
} from './lib.js'
|
|
13
|
+
import { closeLoginDialog, isLoginDialogOpen, openLoginDialog } from './ui/login-dialog.js'
|
|
14
|
+
import type { AuthStorage } from './storage.js'
|
|
15
|
+
import type { AuthTransport } from './transport.js'
|
|
16
|
+
|
|
17
|
+
export interface AuthClientDeps {
|
|
18
|
+
storage: AuthStorage
|
|
19
|
+
transport: AuthTransport
|
|
20
|
+
/** Strict origin gate; defaults to auth.eda.cn. */
|
|
21
|
+
trustedOrigin?: string
|
|
22
|
+
loginUrl?: string
|
|
23
|
+
windowLike: Pick<Window, 'addEventListener' | 'removeEventListener'>
|
|
24
|
+
documentLike: Pick<Document, 'createElement' | 'body'>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AuthClient {
|
|
28
|
+
auth: {
|
|
29
|
+
isAuthenticated(): boolean
|
|
30
|
+
getAccessToken(): Promise<string | null>
|
|
31
|
+
getUserInfo(): Promise<AuthTokenPayload | null>
|
|
32
|
+
login(options?: LoginOptions): Promise<void>
|
|
33
|
+
logout(): Promise<void>
|
|
34
|
+
onAuthStateChanged(listener: (info: AuthTokenPayload | null) => void): () => void
|
|
35
|
+
}
|
|
36
|
+
/** Route window 'message' events here. Exposed for direct testing. */
|
|
37
|
+
handleMessageEvent(event: AuthMessageEventLike): void
|
|
38
|
+
/** Re-push persisted credentials on boot (acceptance group D). */
|
|
39
|
+
restore(): Promise<void>
|
|
40
|
+
/** Re-push persisted credentials on demand (heals a reset/absent node half). */
|
|
41
|
+
syncNow(): Promise<void>
|
|
42
|
+
dispose(): void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createAuthClient(deps: AuthClientDeps): AuthClient {
|
|
46
|
+
const trustedOrigin = deps.trustedOrigin ?? AUTH_ORIGIN
|
|
47
|
+
const loginUrl = deps.loginUrl ?? `${AUTH_ORIGIN}/`
|
|
48
|
+
const { storage, transport } = deps
|
|
49
|
+
const listeners = new Set<(info: AuthTokenPayload | null) => void>()
|
|
50
|
+
|
|
51
|
+
const emit = (info: AuthTokenPayload | null): void => {
|
|
52
|
+
for (const listener of listeners) listener(info)
|
|
53
|
+
}
|
|
54
|
+
const closeIframe = (): void => {
|
|
55
|
+
// The dialog module owns the DOM. Tear it down on any close path
|
|
56
|
+
// (token success, logout, close_dialog postMessage, dispose).
|
|
57
|
+
if (isLoginDialogOpen()) closeLoginDialog()
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Open the login dialog (backdrop + centered card + auth.eda.cn iframe).
|
|
61
|
+
*
|
|
62
|
+
* The dialog is ALWAYS transparent (no `transparent` option exists): the
|
|
63
|
+
* embedded doc sets its own root to `background: transparent` (we never
|
|
64
|
+
* send `fill=full`), and the iframe sits inside a host-painted card so
|
|
65
|
+
* Blink's white base canvas never reaches the user. See the long header
|
|
66
|
+
* in `ui/login-dialog.ts` for the full why.
|
|
67
|
+
*/
|
|
68
|
+
const openIframe = (options: LoginOptions = {}): void => {
|
|
69
|
+
if (isLoginDialogOpen()) return
|
|
70
|
+
const baseUrl = loginUrl
|
|
71
|
+
openLoginDialog(
|
|
72
|
+
{
|
|
73
|
+
...(options.lang ? { lang: options.lang } : {}),
|
|
74
|
+
...(options.theme ? { theme: options.theme } : {}),
|
|
75
|
+
},
|
|
76
|
+
() => {
|
|
77
|
+
// Re-render safety: nothing to do here — the auth client's own state
|
|
78
|
+
// is just the dialog-open boolean, which `isLoginDialogOpen()` reads
|
|
79
|
+
// directly from the dialog module.
|
|
80
|
+
void baseUrl
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const auth = {
|
|
86
|
+
isAuthenticated: (): boolean => storage.get() !== null,
|
|
87
|
+
getAccessToken: async (): Promise<string | null> => storage.get()?.token ?? null,
|
|
88
|
+
getUserInfo: async (): Promise<AuthTokenPayload | null> => storage.get(),
|
|
89
|
+
login: async (options?: LoginOptions): Promise<void> => openIframe(options ?? {}),
|
|
90
|
+
logout: async (): Promise<void> => {
|
|
91
|
+
storage.clear()
|
|
92
|
+
try {
|
|
93
|
+
await transport.pushLogout()
|
|
94
|
+
} catch {
|
|
95
|
+
/* node may be absent — local state is still cleared */
|
|
96
|
+
}
|
|
97
|
+
emit(null)
|
|
98
|
+
closeIframe()
|
|
99
|
+
},
|
|
100
|
+
onAuthStateChanged: (listener: (info: AuthTokenPayload | null) => void): (() => void) => {
|
|
101
|
+
listeners.add(listener)
|
|
102
|
+
return () => listeners.delete(listener)
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const handleMessageEvent = (event: AuthMessageEventLike): void => {
|
|
107
|
+
// Offline deployment: no origin gate (see lib.ts). Envelope validation is
|
|
108
|
+
// the only gate, so unrelated window messages can never corrupt state.
|
|
109
|
+
const msg = handleAuthMessage(event)
|
|
110
|
+
if (!msg) return
|
|
111
|
+
if (msg.kind === 'token') {
|
|
112
|
+
storage.set(msg.info)
|
|
113
|
+
void transport.pushSession(msg.info).catch(() => { /* node push is best-effort; syncNow heals */ })
|
|
114
|
+
emit(msg.info)
|
|
115
|
+
closeIframe()
|
|
116
|
+
} else if (msg.kind === 'logout') {
|
|
117
|
+
storage.clear()
|
|
118
|
+
emit(null)
|
|
119
|
+
void transport.pushLogout().catch(() => { /* local state already cleared */ })
|
|
120
|
+
closeIframe()
|
|
121
|
+
} else if (msg.kind === 'close') {
|
|
122
|
+
closeIframe()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const onWindowMessage = (event: MessageEvent): void => {
|
|
127
|
+
handleMessageEvent({ origin: event.origin, data: event.data })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
deps.windowLike.addEventListener('message', onWindowMessage)
|
|
131
|
+
|
|
132
|
+
const restore = async (): Promise<void> => {
|
|
133
|
+
const restored = storage.get()
|
|
134
|
+
if (restored) {
|
|
135
|
+
try {
|
|
136
|
+
await transport.pushSession(restored)
|
|
137
|
+
} catch {
|
|
138
|
+
/* boot push is best-effort; syncNow heals */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
auth,
|
|
145
|
+
handleMessageEvent,
|
|
146
|
+
restore,
|
|
147
|
+
/**
|
|
148
|
+
* Re-push the persisted credential to the node half. Healing path: the
|
|
149
|
+
* node keeps auth in memory, so a `dsh web` restart (or a failed first
|
|
150
|
+
* push) drops it while the browser still has the token. Callers re-sync on
|
|
151
|
+
* focus / visibilitychange / login-card mount so the tool gate reflects
|
|
152
|
+
* the actual browser login without requiring a page reload.
|
|
153
|
+
*/
|
|
154
|
+
async syncNow(): Promise<void> {
|
|
155
|
+
const info = storage.get()
|
|
156
|
+
if (!info) return
|
|
157
|
+
try {
|
|
158
|
+
await transport.pushSession(info)
|
|
159
|
+
} catch {
|
|
160
|
+
/* sync is best-effort; a later focus event retries */
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
dispose() {
|
|
164
|
+
deps.windowLike.removeEventListener('message', onWindowMessage)
|
|
165
|
+
closeIframe()
|
|
166
|
+
void trustedOrigin // referenced for clarity: the auth iframe URL comes from it
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zh / en copy for every user-visible string of the auth UI (sidebar trigger,
|
|
3
|
+
* account menu, login tool card).
|
|
4
|
+
*
|
|
5
|
+
* Kept self-contained rather than registered into DSH's `ctx.locale`
|
|
6
|
+
* namespace — same call the sibling packages made
|
|
7
|
+
* (`dsh-tool-symbol-footprint/src/client/i18n.ts`) — because the slot system
|
|
8
|
+
* hands components props, not ctx, and a missing namespace would leave the UI
|
|
9
|
+
* blank. `en` is typed as `Record<AuthCopyKey, string>`, so a key added to one
|
|
10
|
+
* language without the other is a COMPILE error (bilingual balance enforced at
|
|
11
|
+
* build time, mirroring DSH's own locale registry).
|
|
12
|
+
*
|
|
13
|
+
* The en「Go to profile」/「Log out」wording is the one the sidebar spec asks
|
|
14
|
+
* for; the zh side follows `hq-eda-ai`'s `locales/cn.ts` (个人中心 / 退出登录).
|
|
15
|
+
*/
|
|
16
|
+
import { useMemo } from 'react'
|
|
17
|
+
import type { AuthLocale } from './lib.js'
|
|
18
|
+
import { useLocale } from './ui-env.js'
|
|
19
|
+
|
|
20
|
+
const zh = {
|
|
21
|
+
'sidebar.login': '华秋EDA AI登录',
|
|
22
|
+
'sidebar.loginTitle': '登录华秋 EDA AI(eda.cn)账号',
|
|
23
|
+
'sidebar.accountTitle': '华秋 EDA AI 账号',
|
|
24
|
+
'sidebar.account': '华秋EDA AI · 已登录',
|
|
25
|
+
|
|
26
|
+
'menu.profile': '个人中心',
|
|
27
|
+
'menu.logout': '退出登录',
|
|
28
|
+
|
|
29
|
+
'card.title': '华秋 EDA AI(eda.cn)登录',
|
|
30
|
+
'card.desc': '工具「{tool}」需要登录华秋 EDA AI 账号才能继续。请在下方的登录框完成登录(或点击左侧「华秋EDA AI登录」按钮);登录完成后,回复助手「已登录,请重试」,助手会自动重新调用该工具。',
|
|
31
|
+
'card.loggedIn': '✓ 已登录{nickname} —— 现在可以回复助手「已登录,请重试」,助手会重新调用工具。',
|
|
32
|
+
'card.loggedOut': '未登录 —— 请在上方登录华秋 EDA AI(eda.cn)账号,或点击左侧「华秋EDA AI登录」按钮;登录完成后让助手重试。',
|
|
33
|
+
'card.tool': '工具:{tool}',
|
|
34
|
+
'card.empty': '(无输出)',
|
|
35
|
+
// Substituted into `{nickname}` by `card.loggedIn`. zh uses a full-width
|
|
36
|
+
// colon, en a half-width one plus a space; hardcoding ':' made the English
|
|
37
|
+
// card read "Logged in:John".
|
|
38
|
+
'card.nicknameSep': ':{nickname}',
|
|
39
|
+
|
|
40
|
+
'dialog.close': '关闭',
|
|
41
|
+
} as const
|
|
42
|
+
|
|
43
|
+
/** Every key of the zh dictionary — the contract both languages satisfy. */
|
|
44
|
+
export type AuthCopyKey = keyof typeof zh
|
|
45
|
+
|
|
46
|
+
const en: Record<AuthCopyKey, string> = {
|
|
47
|
+
'sidebar.login': 'Huaqiu EDA AI login',
|
|
48
|
+
'sidebar.loginTitle': 'Sign in to your Huaqiu EDA AI (eda.cn) account',
|
|
49
|
+
'sidebar.accountTitle': 'Huaqiu EDA AI account',
|
|
50
|
+
'sidebar.account': 'Huaqiu EDA AI · signed in',
|
|
51
|
+
|
|
52
|
+
'menu.profile': 'Go to profile',
|
|
53
|
+
'menu.logout': 'Log out',
|
|
54
|
+
|
|
55
|
+
'card.title': 'Huaqiu EDA AI (eda.cn) login',
|
|
56
|
+
// The reply phrase used to be hardcoded to the Chinese "已登录,请重试" even
|
|
57
|
+
// in these English strings, telling an English-speaking user to type Chinese.
|
|
58
|
+
'card.desc': 'Tool "{tool}" needs a Huaqiu EDA AI account. Complete the login below (or use the Huaqiu EDA AI button in the sidebar), then reply "I have logged in, please retry" so the assistant can retry the tool.',
|
|
59
|
+
'card.loggedIn': '✓ Logged in{nickname} — reply "I have logged in, please retry" and the assistant will retry the tool.',
|
|
60
|
+
'card.loggedOut': 'Not logged in — sign in above, or use the Huaqiu EDA AI button in the sidebar, then ask the assistant to retry.',
|
|
61
|
+
'card.tool': 'Tool: {tool}',
|
|
62
|
+
'card.empty': '(no output)',
|
|
63
|
+
'card.nicknameSep': ': {nickname}',
|
|
64
|
+
|
|
65
|
+
'dialog.close': 'Close',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const COPY: Record<AuthLocale, Record<AuthCopyKey, string>> = { zh, en }
|
|
69
|
+
|
|
70
|
+
/** Every copy key, in declaration order (used to assert bilingual balance). */
|
|
71
|
+
export const AUTH_COPY_KEYS = Object.keys(zh) as AuthCopyKey[]
|
|
72
|
+
|
|
73
|
+
export type Translate = (key: AuthCopyKey, params?: Record<string, unknown>) => string
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Look a key up, interpolating `{name}` placeholders.
|
|
77
|
+
*
|
|
78
|
+
* Chain: active locale → zh (the source of truth) → the key itself, so a
|
|
79
|
+
* missing translation stays VISIBLE instead of blanking the UI.
|
|
80
|
+
*/
|
|
81
|
+
export function translate(locale: AuthLocale, key: AuthCopyKey, params?: Record<string, unknown>): string {
|
|
82
|
+
const template = COPY[locale]?.[key] ?? COPY.zh[key] ?? key
|
|
83
|
+
if (!params) return template
|
|
84
|
+
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
|
85
|
+
name in params ? String(params[name]) : match)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Translate bound to one locale (stable for the lifetime of that locale). */
|
|
89
|
+
export function createT(locale: AuthLocale): Translate {
|
|
90
|
+
return (key, params) => translate(locale, key, params)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Translate bound to the host UI language, re-created when it changes. */
|
|
94
|
+
export function useT(): Translate {
|
|
95
|
+
const locale = useLocale()
|
|
96
|
+
return useMemo(() => createT(locale), [locale])
|
|
97
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@huaqiu/dsh-auth` — browser half (the Phase 0A POC).
|
|
3
|
+
*
|
|
4
|
+
* Opens the auth.eda.cn login page in an overlay iframe, STRICTLY validates
|
|
5
|
+
* the postMessage origin, caches credentials in localStorage (reload restore),
|
|
6
|
+
* and pushes them to the node half over the plugin-owned webServer routes.
|
|
7
|
+
* Provides the client-side `huaqiuAuth` service mirroring the node surface.
|
|
8
|
+
*
|
|
9
|
+
* On top of the credential flow it wires the two UI surfaces the login UX
|
|
10
|
+
* needs:
|
|
11
|
+
* - `sidebar.footer.action` — a persistent 华秋EDA login entrypoint at the
|
|
12
|
+
* bottom of the sidebar (login/logout, live state).
|
|
13
|
+
* - `tool.call.toolview` (keyed per Huaqiu tool) — when a node tool returns
|
|
14
|
+
* `status: "needs_auth"` the tool card becomes the login HIT: an embedded
|
|
15
|
+
* auth.eda.cn iframe + login-state line, so login is a step of the
|
|
16
|
+
* conversation instead of a dead error the agent has to relay.
|
|
17
|
+
*/
|
|
18
|
+
import { createAuthStorage } from './storage.js'
|
|
19
|
+
import { createWebServerAuthTransport } from './transport.js'
|
|
20
|
+
import { createAuthClient, type AuthClient } from './client.js'
|
|
21
|
+
import { disposeAuth, registerAuth, registerAuthSync } from './auth-state.js'
|
|
22
|
+
import { HuaqiuToolView } from './ui/needs-auth-toolview.jsx'
|
|
23
|
+
import { HuaqiuAuthSidebarAction } from './ui/sidebar-action.jsx'
|
|
24
|
+
import { disposeUiEnv } from './ui-env.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Client cordis inject: REAL service names only (the loader maps these to
|
|
28
|
+
* `ctx.inject([...])` dependencies). The `slots` registry service comes from
|
|
29
|
+
* `@deepseek-ai/dsh-client-ui-slots`; it is required to register the toolview
|
|
30
|
+
* and sidebar entries. The PACKAGE-level `dsh.client.inject` in package.json
|
|
31
|
+
* (graph ordering) stays as-is and is NOT this export.
|
|
32
|
+
*/
|
|
33
|
+
export const inject: string[] = ['slots']
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Huaqiu tools that still surface the auth login card via this plugin.
|
|
37
|
+
*
|
|
38
|
+
* Currently EMPTY: all five Huaqiu tools now own their keyed HIT cards in
|
|
39
|
+
* their own plugins (`@huaqiu/dsh-tool-symbol-footprint` for the three
|
|
40
|
+
* symbol/footprint generators, `@huaqiu/dsh-tool-schematic-gen` for the two
|
|
41
|
+
* schematic/system generators), and each renders its own inline login card for
|
|
42
|
+
* `needs_auth`. Keeping the toolview keys here would double-register the same
|
|
43
|
+
* `tool.call.toolview` slot with an ambiguous winner.
|
|
44
|
+
*
|
|
45
|
+
* The auth plugin remains the credential owner: the `huaqiuAuth` client
|
|
46
|
+
* service, the sidebar login entrypoint and the webServer credential channel.
|
|
47
|
+
*/
|
|
48
|
+
export const AUTH_TOOL_NAMES: readonly string[] = []
|
|
49
|
+
|
|
50
|
+
/** Minimal structural client context (dsh-client-runtime provides this). */
|
|
51
|
+
export interface ClientContext {
|
|
52
|
+
provide?(name: string, value: unknown): () => void
|
|
53
|
+
slots?: {
|
|
54
|
+
inject(key: string, callback: () => () => void): () => void
|
|
55
|
+
register(spec: { name: string; key?: string; id?: string }, component: unknown): unknown
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function apply(ctx: ClientContext): () => void {
|
|
60
|
+
const client: AuthClient = createAuthClient({
|
|
61
|
+
storage: createAuthStorage(localStorage),
|
|
62
|
+
transport: createWebServerAuthTransport(),
|
|
63
|
+
windowLike: window,
|
|
64
|
+
documentLike: document,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const disposers: Array<() => void> = []
|
|
68
|
+
const disposeProvide = ctx.provide?.('huaqiuAuth', { auth: client.auth })
|
|
69
|
+
registerAuth(client.auth)
|
|
70
|
+
registerAuthSync(() => { void client.syncNow() })
|
|
71
|
+
void client.restore()
|
|
72
|
+
disposers.push(client.auth.onAuthStateChanged((info) => {
|
|
73
|
+
void client.syncNow()
|
|
74
|
+
}))
|
|
75
|
+
|
|
76
|
+
// Healing: the node half keeps auth in memory, so a server restart drops it
|
|
77
|
+
// while the browser still holds the token. Re-sync whenever the tab regains
|
|
78
|
+
// focus/visibility so the tool gate flips back to authenticated without a
|
|
79
|
+
// reload.
|
|
80
|
+
const sync = (): void => { void client.syncNow() }
|
|
81
|
+
window.addEventListener('focus', sync)
|
|
82
|
+
document.addEventListener('visibilitychange', sync)
|
|
83
|
+
disposers.push(() => {
|
|
84
|
+
window.removeEventListener('focus', sync)
|
|
85
|
+
document.removeEventListener('visibilitychange', sync)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const slots = ctx.slots
|
|
89
|
+
if (slots && typeof slots.inject === 'function' && typeof slots.register === 'function') {
|
|
90
|
+
for (const toolName of AUTH_TOOL_NAMES) {
|
|
91
|
+
disposers.push(slots.inject('tool.call.toolview', () => slots.register({ name: 'tool.call.toolview', key: toolName }, HuaqiuToolView) as () => void))
|
|
92
|
+
}
|
|
93
|
+
disposers.push(slots.inject('sidebar.footer.action', () => slots.register({ name: 'sidebar.footer.action', id: 'huaqiu-auth' }, HuaqiuAuthSidebarAction) as () => void))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
for (const dispose of disposers) {
|
|
98
|
+
try {
|
|
99
|
+
dispose()
|
|
100
|
+
} catch {
|
|
101
|
+
/* already disposed */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
disposeProvide?.()
|
|
105
|
+
client.dispose()
|
|
106
|
+
disposeAuth()
|
|
107
|
+
disposeUiEnv()
|
|
108
|
+
}
|
|
109
|
+
}
|