@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
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sidebar.footer.action` entry: the Huaqiu EDA account trigger at the bottom
|
|
3
|
+
* of the DSH sidebar (beside Settings).
|
|
4
|
+
*
|
|
5
|
+
* - Not logged in: shows the HQ icon and opens the login dialog through
|
|
6
|
+
* `auth.login({ lang, theme })` — a real modal (backdrop + centered card +
|
|
7
|
+
* auth.eda.cn iframe) that is ALWAYS TRANSPARENT in the embed itself
|
|
8
|
+
* (`fill=full` is never sent, see `lib.ts#buildLoginUrl`), and the card
|
|
9
|
+
* surface masks Blink's white base canvas so the login card floats over
|
|
10
|
+
* the dimmed app in both light and dark themes. `lang`/`theme` follow the
|
|
11
|
+
* host UI. Click on the backdrop, the × button, or Escape closes it, and
|
|
12
|
+
* auth.eda.cn's own `close_dialog` postMessage closes it as well.
|
|
13
|
+
* - Logged in: the trigger becomes the user's AVATAR (`headimage` from the
|
|
14
|
+
* auth.eda.cn payload, HQ icon while it is missing/fails to load) and a click
|
|
15
|
+
* opens a context menu with「Go to profile」(the eda.cn account page, with
|
|
16
|
+
* the access token) and「Log out」— the same shape as `hq-eda-ai`'s
|
|
17
|
+
* `UserMenu`, portalled to `document.body` with fixed positioning so the
|
|
18
|
+
* sidebar's `overflow: hidden` can never clip it.
|
|
19
|
+
*
|
|
20
|
+
* THEMING: colors prefer DSH's `--dsw-alias-*` tokens (so a custom host theme
|
|
21
|
+
* is honored) and fall back to an explicit light/dark pair chosen from
|
|
22
|
+
* `useIsDark()`; the two paths cannot disagree, because ui-layout's presenter
|
|
23
|
+
* writes `body[data-ds-dark-theme]` from the very snapshot that installs those
|
|
24
|
+
* tokens.
|
|
25
|
+
*/
|
|
26
|
+
import { memo, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'
|
|
27
|
+
import { createPortal } from 'react-dom'
|
|
28
|
+
import { getAuth, getAuthState, subscribeAuth } from '../auth-state.js'
|
|
29
|
+
import { buildProfileUrl } from '../lib.js'
|
|
30
|
+
import { useIsDark, useLocale } from '../ui-env.js'
|
|
31
|
+
import { useT } from '../i18n.js'
|
|
32
|
+
import { HQ_ICON } from './hq-icon.jsx'
|
|
33
|
+
|
|
34
|
+
export interface SidebarFooterActionOwnerProps {
|
|
35
|
+
wide?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const AVATAR_SIZE = 26
|
|
39
|
+
const ICON_SIZE = 22
|
|
40
|
+
|
|
41
|
+
/** One color scheme's menu colors (DSH token first, explicit fallback second). */
|
|
42
|
+
interface Palette {
|
|
43
|
+
surface: string
|
|
44
|
+
border: string
|
|
45
|
+
text: string
|
|
46
|
+
muted: string
|
|
47
|
+
hover: string
|
|
48
|
+
danger: string
|
|
49
|
+
dangerHover: string
|
|
50
|
+
avatarBg: string
|
|
51
|
+
shadow: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const LIGHT_PALETTE: Palette = {
|
|
55
|
+
surface: 'var(--dsw-alias-bg-overlay, #ffffff)',
|
|
56
|
+
border: 'var(--dsw-alias-border-l1, #e4e7ec)',
|
|
57
|
+
text: 'var(--dsw-alias-label-primary, #3a4356)',
|
|
58
|
+
muted: 'var(--dsw-alias-label-secondary, #8a94a6)',
|
|
59
|
+
hover: 'var(--dsw-alias-interactive-bg-hover, #f5f7fa)',
|
|
60
|
+
danger: 'var(--dsw-alias-state-error-primary, #d4380d)',
|
|
61
|
+
dangerHover: 'rgba(216, 56, 13, 0.08)',
|
|
62
|
+
avatarBg: 'var(--dsw-alias-bg-layer-2, #eef2f7)',
|
|
63
|
+
shadow: '0 12px 32px rgba(15, 23, 42, 0.16)',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const DARK_PALETTE: Palette = {
|
|
67
|
+
surface: 'var(--dsw-alias-bg-overlay, #20242c)',
|
|
68
|
+
border: 'var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.14))',
|
|
69
|
+
text: 'var(--dsw-alias-label-primary, #e6eaf0)',
|
|
70
|
+
muted: 'var(--dsw-alias-label-secondary, #8b95a5)',
|
|
71
|
+
hover: 'var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, 0.08))',
|
|
72
|
+
danger: 'var(--dsw-alias-state-error-primary, #ff7875)',
|
|
73
|
+
dangerHover: 'rgba(255, 120, 117, 0.14)',
|
|
74
|
+
avatarBg: 'var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, 0.10))',
|
|
75
|
+
shadow: '0 12px 32px rgba(0, 0, 0, 0.46)',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const TRIGGER_BASE: CSSProperties = {
|
|
79
|
+
width: '100%',
|
|
80
|
+
display: 'flex',
|
|
81
|
+
alignItems: 'center',
|
|
82
|
+
gap: 8,
|
|
83
|
+
padding: '8px 12px',
|
|
84
|
+
border: 'none',
|
|
85
|
+
borderRadius: 8,
|
|
86
|
+
background: 'transparent',
|
|
87
|
+
fontSize: 13,
|
|
88
|
+
fontWeight: 500,
|
|
89
|
+
cursor: 'pointer',
|
|
90
|
+
textAlign: 'left',
|
|
91
|
+
whiteSpace: 'nowrap',
|
|
92
|
+
overflow: 'hidden',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const MENU_BASE: CSSProperties = {
|
|
96
|
+
position: 'fixed',
|
|
97
|
+
zIndex: 2147483000,
|
|
98
|
+
minWidth: 184,
|
|
99
|
+
padding: 6,
|
|
100
|
+
borderWidth: 1,
|
|
101
|
+
borderStyle: 'solid',
|
|
102
|
+
borderRadius: 12,
|
|
103
|
+
fontFamily: 'inherit',
|
|
104
|
+
fontSize: 13,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const MENU_HEADER_BASE: CSSProperties = {
|
|
108
|
+
padding: '6px 10px 8px',
|
|
109
|
+
fontSize: 12,
|
|
110
|
+
overflow: 'hidden',
|
|
111
|
+
textOverflow: 'ellipsis',
|
|
112
|
+
whiteSpace: 'nowrap',
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const MENU_ITEM_BASE: CSSProperties = {
|
|
116
|
+
display: 'flex',
|
|
117
|
+
alignItems: 'center',
|
|
118
|
+
gap: 8,
|
|
119
|
+
width: '100%',
|
|
120
|
+
padding: '8px 10px',
|
|
121
|
+
border: 'none',
|
|
122
|
+
borderRadius: 8,
|
|
123
|
+
background: 'transparent',
|
|
124
|
+
font: 'inherit',
|
|
125
|
+
fontSize: 13,
|
|
126
|
+
textAlign: 'left',
|
|
127
|
+
cursor: 'pointer',
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One menu row. Hover is tracked in state: the client bundle ships no CSS
|
|
132
|
+
* file, so inline styles cannot express `:hover`.
|
|
133
|
+
*/
|
|
134
|
+
function MenuItem({
|
|
135
|
+
label,
|
|
136
|
+
icon,
|
|
137
|
+
danger,
|
|
138
|
+
palette,
|
|
139
|
+
onSelect,
|
|
140
|
+
}: {
|
|
141
|
+
label: string
|
|
142
|
+
icon: React.JSX.Element
|
|
143
|
+
danger?: boolean
|
|
144
|
+
palette: Palette
|
|
145
|
+
onSelect: () => void
|
|
146
|
+
}): React.JSX.Element {
|
|
147
|
+
const [hovered, setHovered] = useState(false)
|
|
148
|
+
return (
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
role="menuitem"
|
|
152
|
+
style={{
|
|
153
|
+
...MENU_ITEM_BASE,
|
|
154
|
+
color: danger ? palette.danger : palette.text,
|
|
155
|
+
background: hovered ? (danger ? palette.dangerHover : palette.hover) : 'transparent',
|
|
156
|
+
}}
|
|
157
|
+
onMouseEnter={() => setHovered(true)}
|
|
158
|
+
onMouseLeave={() => setHovered(false)}
|
|
159
|
+
onClick={onSelect}
|
|
160
|
+
>
|
|
161
|
+
{icon}
|
|
162
|
+
<span>{label}</span>
|
|
163
|
+
</button>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function UserIcon(): React.JSX.Element {
|
|
168
|
+
return (
|
|
169
|
+
<svg
|
|
170
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
171
|
+
width={15}
|
|
172
|
+
height={15}
|
|
173
|
+
viewBox="0 0 24 24"
|
|
174
|
+
fill="none"
|
|
175
|
+
stroke="currentColor"
|
|
176
|
+
strokeWidth={2}
|
|
177
|
+
strokeLinecap="round"
|
|
178
|
+
strokeLinejoin="round"
|
|
179
|
+
aria-hidden
|
|
180
|
+
style={{ flex: '0 0 auto' }}
|
|
181
|
+
>
|
|
182
|
+
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
|
183
|
+
<circle cx="12" cy="7" r="4" />
|
|
184
|
+
</svg>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function LogoutIcon(): React.JSX.Element {
|
|
189
|
+
return (
|
|
190
|
+
<svg
|
|
191
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
192
|
+
width={15}
|
|
193
|
+
height={15}
|
|
194
|
+
viewBox="0 0 24 24"
|
|
195
|
+
fill="none"
|
|
196
|
+
stroke="currentColor"
|
|
197
|
+
strokeWidth={2}
|
|
198
|
+
strokeLinecap="round"
|
|
199
|
+
strokeLinejoin="round"
|
|
200
|
+
aria-hidden
|
|
201
|
+
style={{ flex: '0 0 auto' }}
|
|
202
|
+
>
|
|
203
|
+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
204
|
+
<polyline points="16 17 21 12 16 7" />
|
|
205
|
+
<line x1="21" x2="9" y1="12" y2="12" />
|
|
206
|
+
</svg>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const HuaqiuAuthSidebarAction = memo(function HuaqiuAuthSidebarAction({ wide }: SidebarFooterActionOwnerProps): React.JSX.Element | null {
|
|
211
|
+
const authState = useSyncExternalStore(subscribeAuth, getAuthState)
|
|
212
|
+
const auth = getAuth()
|
|
213
|
+
const dark = useIsDark()
|
|
214
|
+
const locale = useLocale()
|
|
215
|
+
const t = useT()
|
|
216
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
217
|
+
const [menuStyle, setMenuStyle] = useState<CSSProperties | null>(null)
|
|
218
|
+
const [avatarBroken, setAvatarBroken] = useState(false)
|
|
219
|
+
const [hovered, setHovered] = useState(false)
|
|
220
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
221
|
+
const menuRef = useRef<HTMLDivElement | null>(null)
|
|
222
|
+
|
|
223
|
+
const palette = dark ? DARK_PALETTE : LIGHT_PALETTE
|
|
224
|
+
const authenticated = authState.authenticated
|
|
225
|
+
const avatar = authenticated && !avatarBroken ? authState.avatar : undefined
|
|
226
|
+
const showLabel = wide !== false
|
|
227
|
+
|
|
228
|
+
// A new avatar URL is a fresh chance to render it.
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
setAvatarBroken(false)
|
|
231
|
+
}, [authState.avatar])
|
|
232
|
+
|
|
233
|
+
// Logging out (from anywhere: menu, another tab surface, node invalidation)
|
|
234
|
+
// must never leave an orphan menu pointing at a signed-out account.
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (!authenticated) setMenuOpen(false)
|
|
237
|
+
}, [authenticated])
|
|
238
|
+
|
|
239
|
+
// Anchor the portalled menu to the trigger before paint: the sidebar footer
|
|
240
|
+
// sits at the bottom edge, so the menu grows UPWARD from the trigger's top.
|
|
241
|
+
useLayoutEffect(() => {
|
|
242
|
+
if (!menuOpen || !triggerRef.current) return
|
|
243
|
+
const rect = triggerRef.current.getBoundingClientRect()
|
|
244
|
+
setMenuStyle({
|
|
245
|
+
...MENU_BASE,
|
|
246
|
+
background: palette.surface,
|
|
247
|
+
borderColor: palette.border,
|
|
248
|
+
color: palette.text,
|
|
249
|
+
boxShadow: palette.shadow,
|
|
250
|
+
left: Math.max(8, Math.round(rect.left)),
|
|
251
|
+
bottom: Math.max(8, Math.round(window.innerHeight - rect.top + 8)),
|
|
252
|
+
...(wide ? { width: Math.round(rect.width) } : {}),
|
|
253
|
+
})
|
|
254
|
+
}, [menuOpen, wide, avatar, palette])
|
|
255
|
+
|
|
256
|
+
// Close on: outside click, Escape, resize or scroll (the anchor moved).
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (!menuOpen) return
|
|
259
|
+
const onPointerDown = (event: MouseEvent): void => {
|
|
260
|
+
const target = event.target as Node
|
|
261
|
+
if (triggerRef.current?.contains(target)) return
|
|
262
|
+
if (menuRef.current?.contains(target)) return
|
|
263
|
+
setMenuOpen(false)
|
|
264
|
+
}
|
|
265
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
266
|
+
if (event.key === 'Escape') setMenuOpen(false)
|
|
267
|
+
}
|
|
268
|
+
const dismiss = (): void => setMenuOpen(false)
|
|
269
|
+
document.addEventListener('mousedown', onPointerDown)
|
|
270
|
+
document.addEventListener('keydown', onKeyDown)
|
|
271
|
+
window.addEventListener('resize', dismiss)
|
|
272
|
+
window.addEventListener('scroll', dismiss, true)
|
|
273
|
+
return () => {
|
|
274
|
+
document.removeEventListener('mousedown', onPointerDown)
|
|
275
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
276
|
+
window.removeEventListener('resize', dismiss)
|
|
277
|
+
window.removeEventListener('scroll', dismiss, true)
|
|
278
|
+
}
|
|
279
|
+
}, [menuOpen])
|
|
280
|
+
|
|
281
|
+
if (!auth) return null
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
*「Go to profile」always carries the token, so eda.cn can establish the
|
|
285
|
+
* session in the opened tab (it hides the token itself — see
|
|
286
|
+
* `lib.ts#buildProfileUrl`). The snapshot normally has it; fall back to the
|
|
287
|
+
* client so a stale snapshot can never open an unauthenticated tab.
|
|
288
|
+
*/
|
|
289
|
+
const openProfile = (): void => {
|
|
290
|
+
setMenuOpen(false)
|
|
291
|
+
void (async () => {
|
|
292
|
+
const info = authState.token
|
|
293
|
+
? { token: authState.token, phone: authState.phone }
|
|
294
|
+
: await auth.getUserInfo()
|
|
295
|
+
.then((i) => (i ? { token: i.token, phone: i.phone } : null))
|
|
296
|
+
.catch(() => null)
|
|
297
|
+
if (!info?.token) return
|
|
298
|
+
window.open(buildProfileUrl(info), '_blank', 'noopener,noreferrer')
|
|
299
|
+
})()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const label = authenticated
|
|
303
|
+
? (authState.nickname ?? t('sidebar.account'))
|
|
304
|
+
: t('sidebar.login')
|
|
305
|
+
|
|
306
|
+
const title = authenticated ? t('sidebar.accountTitle') : t('sidebar.loginTitle')
|
|
307
|
+
const triggerBackground = menuOpen || hovered ? palette.hover : 'transparent'
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<div style={{ position: 'relative', width: '100%' }}>
|
|
311
|
+
<button
|
|
312
|
+
ref={triggerRef}
|
|
313
|
+
type="button"
|
|
314
|
+
aria-haspopup="menu"
|
|
315
|
+
aria-expanded={menuOpen}
|
|
316
|
+
onClick={() => {
|
|
317
|
+
if (!authenticated) {
|
|
318
|
+
// Always-transparent embed in the host's language and color scheme;
|
|
319
|
+
// `closeOnOutsideClick` defaults to true. The login dialog owns
|
|
320
|
+
// the DOM (backdrop + card + iframe) and closes itself on
|
|
321
|
+
// backdrop click, Escape, the × button, or the embed's
|
|
322
|
+
// `close_dialog` postMessage.
|
|
323
|
+
void auth.login({ lang: locale, theme: dark ? 'dark' : 'light' })
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
setMenuOpen((open) => !open)
|
|
327
|
+
}}
|
|
328
|
+
onMouseEnter={() => setHovered(true)}
|
|
329
|
+
onMouseLeave={() => setHovered(false)}
|
|
330
|
+
style={{
|
|
331
|
+
...TRIGGER_BASE,
|
|
332
|
+
color: palette.text,
|
|
333
|
+
padding: wide ? '8px 12px' : '8px 6px',
|
|
334
|
+
background: triggerBackground,
|
|
335
|
+
}}
|
|
336
|
+
title={title}
|
|
337
|
+
>
|
|
338
|
+
{avatar ? (
|
|
339
|
+
<span
|
|
340
|
+
style={{
|
|
341
|
+
flex: '0 0 auto',
|
|
342
|
+
width: AVATAR_SIZE,
|
|
343
|
+
height: AVATAR_SIZE,
|
|
344
|
+
borderRadius: '50%',
|
|
345
|
+
overflow: 'hidden',
|
|
346
|
+
background: palette.avatarBg,
|
|
347
|
+
display: 'block',
|
|
348
|
+
}}
|
|
349
|
+
>
|
|
350
|
+
<img
|
|
351
|
+
src={avatar}
|
|
352
|
+
alt=""
|
|
353
|
+
width={AVATAR_SIZE}
|
|
354
|
+
height={AVATAR_SIZE}
|
|
355
|
+
onError={() => setAvatarBroken(true)}
|
|
356
|
+
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
|
357
|
+
/>
|
|
358
|
+
</span>
|
|
359
|
+
) : (
|
|
360
|
+
<HQ_ICON size={ICON_SIZE} />
|
|
361
|
+
)}
|
|
362
|
+
{showLabel ? (
|
|
363
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
|
364
|
+
) : null}
|
|
365
|
+
</button>
|
|
366
|
+
|
|
367
|
+
{menuOpen && menuStyle
|
|
368
|
+
? createPortal(
|
|
369
|
+
<div ref={menuRef} role="menu" style={menuStyle}>
|
|
370
|
+
{authState.nickname ? (
|
|
371
|
+
<div style={{ ...MENU_HEADER_BASE, color: palette.muted }} title={authState.nickname}>{authState.nickname}</div>
|
|
372
|
+
) : null}
|
|
373
|
+
<MenuItem
|
|
374
|
+
label={t('menu.profile')}
|
|
375
|
+
icon={<UserIcon />}
|
|
376
|
+
palette={palette}
|
|
377
|
+
onSelect={openProfile}
|
|
378
|
+
/>
|
|
379
|
+
<MenuItem
|
|
380
|
+
label={t('menu.logout')}
|
|
381
|
+
icon={<LogoutIcon />}
|
|
382
|
+
danger
|
|
383
|
+
palette={palette}
|
|
384
|
+
onSelect={() => {
|
|
385
|
+
setMenuOpen(false)
|
|
386
|
+
void auth.logout()
|
|
387
|
+
}}
|
|
388
|
+
/>
|
|
389
|
+
</div>,
|
|
390
|
+
document.body,
|
|
391
|
+
)
|
|
392
|
+
: null}
|
|
393
|
+
</div>
|
|
394
|
+
)
|
|
395
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host theme + locale sensing for the client UI.
|
|
3
|
+
*
|
|
4
|
+
* The DSH slot system injects React components with PROPS, not the cordis ctx,
|
|
5
|
+
* so the cards cannot reach `ctx.theme` / `ctx.locale` the way a plugin body
|
|
6
|
+
* can. Both services do, however, publish their state into the DOM, and that
|
|
7
|
+
* is what this module reads:
|
|
8
|
+
*
|
|
9
|
+
* - THEME — `ui-layout`'s presenter switches `body[data-ds-dark-theme]` from
|
|
10
|
+
* the resolved snapshot (`packages/client/ui-layout/src/client/theme-presenter.ts`,
|
|
11
|
+
* `DARK_ATTRIBUTE`), so the attribute's presence IS the dark palette. Same
|
|
12
|
+
* signal the sibling packages already use
|
|
13
|
+
* (`dsh-tool-schematic-gen/src/client/theme.ts`). `prefers-color-scheme` is
|
|
14
|
+
* deliberately NOT consulted: DSH resolves `system` itself, and an OS-dark /
|
|
15
|
+
* DSH-light combination would then be misdetected.
|
|
16
|
+
* - LOCALE — `dsh-client-locale` writes `<html lang>` on every locale change
|
|
17
|
+
* (`syncDocumentLanguage`: `zh-CN` | `en`). Falling back to the browser's
|
|
18
|
+
* own `navigator.languages` keeps the UI usable on hosts without that
|
|
19
|
+
* plugin. Chinese is the last resort because this is a Chinese-first app
|
|
20
|
+
* (and `hq-eda-ai` defaults to zh: `languageMap[lang] || "zh"`).
|
|
21
|
+
*
|
|
22
|
+
* Both are exposed as `useSyncExternalStore` snapshots so every mounted card
|
|
23
|
+
* re-renders together when the user flips theme or language.
|
|
24
|
+
*/
|
|
25
|
+
import { useSyncExternalStore } from 'react'
|
|
26
|
+
import type { AuthLocale, AuthTheme } from './lib.js'
|
|
27
|
+
|
|
28
|
+
/** DSH's dark-palette marker, written by ui-layout's theme presenter. */
|
|
29
|
+
export const DARK_ATTRIBUTE = 'data-ds-dark-theme'
|
|
30
|
+
|
|
31
|
+
function isDarkDocument(): boolean {
|
|
32
|
+
if (typeof document === 'undefined') return false
|
|
33
|
+
if (document.body?.hasAttribute(DARK_ATTRIBUTE)) return true
|
|
34
|
+
// Fallbacks for hosts that mark the scheme on <html> instead of <body>.
|
|
35
|
+
const root = document.documentElement
|
|
36
|
+
if (!root) return false
|
|
37
|
+
const dataTheme = root.getAttribute('data-theme')
|
|
38
|
+
if (dataTheme !== null) return dataTheme.toLowerCase() === 'dark'
|
|
39
|
+
return root.classList.contains('dark')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `zh-CN`, `zh-Hans`, `en-GB`, … → our locale id (`undefined` = unknown). */
|
|
43
|
+
function localeFromTag(tag: string | null | undefined): AuthLocale | undefined {
|
|
44
|
+
if (!tag) return undefined
|
|
45
|
+
const primary = tag.toLowerCase().split('-')[0]
|
|
46
|
+
return primary === 'zh' || primary === 'en' ? primary : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function detectLocale(): AuthLocale {
|
|
50
|
+
if (typeof document !== 'undefined') {
|
|
51
|
+
const fromDocument = localeFromTag(document.documentElement?.getAttribute('lang'))
|
|
52
|
+
if (fromDocument) return fromDocument
|
|
53
|
+
}
|
|
54
|
+
if (typeof navigator !== 'undefined' && typeof window !== 'undefined') {
|
|
55
|
+
// `window` is the browser test: Node exposes a global `navigator`
|
|
56
|
+
// reporting the machine's own language, which would otherwise decide the
|
|
57
|
+
// locale for non-browser runs (same guard DSH's locale plugin uses).
|
|
58
|
+
for (const tag of [...(navigator.languages ?? []), navigator.language]) {
|
|
59
|
+
const match = localeFromTag(tag)
|
|
60
|
+
if (match) return match
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return 'zh'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let dark = isDarkDocument()
|
|
67
|
+
let locale = detectLocale()
|
|
68
|
+
const listeners = new Set<() => void>()
|
|
69
|
+
let darkObserver: MutationObserver | null = null
|
|
70
|
+
let localeObserver: MutationObserver | null = null
|
|
71
|
+
|
|
72
|
+
function notify(): void {
|
|
73
|
+
for (const listener of [...listeners]) {
|
|
74
|
+
try {
|
|
75
|
+
listener()
|
|
76
|
+
} catch {
|
|
77
|
+
/* one crashing subscriber must not strand the rest on a stale value */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Re-read the DOM and notify only what actually changed. */
|
|
83
|
+
export function syncUiEnv(): void {
|
|
84
|
+
let changed = false
|
|
85
|
+
const nextDark = isDarkDocument()
|
|
86
|
+
if (nextDark !== dark) {
|
|
87
|
+
dark = nextDark
|
|
88
|
+
changed = true
|
|
89
|
+
}
|
|
90
|
+
const nextLocale = detectLocale()
|
|
91
|
+
if (nextLocale !== locale) {
|
|
92
|
+
locale = nextLocale
|
|
93
|
+
changed = true
|
|
94
|
+
}
|
|
95
|
+
if (changed) notify()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Start observing (idempotent; also re-reads so no change is missed). */
|
|
99
|
+
function watch(): void {
|
|
100
|
+
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return
|
|
101
|
+
if (!darkObserver && document.body) {
|
|
102
|
+
darkObserver = new MutationObserver(syncUiEnv)
|
|
103
|
+
darkObserver.observe(document.body, { attributes: true, attributeFilter: [DARK_ATTRIBUTE] })
|
|
104
|
+
}
|
|
105
|
+
if (!localeObserver && document.documentElement) {
|
|
106
|
+
localeObserver = new MutationObserver(syncUiEnv)
|
|
107
|
+
localeObserver.observe(document.documentElement, {
|
|
108
|
+
attributes: true,
|
|
109
|
+
attributeFilter: ['lang', 'data-theme', 'class'],
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
syncUiEnv()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function subscribe(callback: () => void): () => void {
|
|
116
|
+
watch()
|
|
117
|
+
listeners.add(callback)
|
|
118
|
+
return () => {
|
|
119
|
+
listeners.delete(callback)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Imperative subscription for non-React consumers (e.g. the login dialog's
|
|
125
|
+
* backdrop/card DOM). The callback fires on every theme or locale flip.
|
|
126
|
+
*/
|
|
127
|
+
export function subscribeUiEnv(callback: () => void): () => void {
|
|
128
|
+
return subscribe(callback)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const getDark = (): boolean => dark
|
|
132
|
+
const getLocale = (): AuthLocale => locale
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Synchronous read of the current dark-palette state. Safe outside React
|
|
136
|
+
* (the auth client uses it when appending the overlay iframe, before any
|
|
137
|
+
* component has a chance to subscribe).
|
|
138
|
+
*/
|
|
139
|
+
export function getCurrentDark(): boolean {
|
|
140
|
+
return dark
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Synchronous read of the current host UI locale. */
|
|
144
|
+
export function getCurrentLocale(): AuthLocale {
|
|
145
|
+
return locale
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Synchronous read of the current host surface color (matches the palette). */
|
|
149
|
+
export function getCurrentSurfaceColor(): string {
|
|
150
|
+
return dark ? 'var(--dsw-alias-bg-layer-1, #20242c)' : 'var(--dsw-alias-bg-layer-1, #ffffff)'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `true` while the host renders the dark palette. */
|
|
154
|
+
export function useIsDark(): boolean {
|
|
155
|
+
return useSyncExternalStore(subscribe, getDark, getDark)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The host UI language. */
|
|
159
|
+
export function useLocale(): AuthLocale {
|
|
160
|
+
return useSyncExternalStore(subscribe, getLocale, getLocale)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The host color scheme in auth.eda.cn's own vocabulary. */
|
|
164
|
+
export function useColorScheme(): AuthTheme {
|
|
165
|
+
return useIsDark() ? 'dark' : 'light'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Release the observers (called from `apply()`'s disposer). */
|
|
169
|
+
export function disposeUiEnv(): void {
|
|
170
|
+
darkObserver?.disconnect()
|
|
171
|
+
localeObserver?.disconnect()
|
|
172
|
+
darkObserver = null
|
|
173
|
+
localeObserver = null
|
|
174
|
+
listeners.clear()
|
|
175
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HQ Edge host mode for `dsh-auth`.
|
|
3
|
+
*
|
|
4
|
+
* In standalone DSH, credentials arrive only when the user logs in through the
|
|
5
|
+
* browser (auth.eda.cn iframe → postMessage → `webServer` route →
|
|
6
|
+
* `setCredentials`). That means the node half is empty until a browser tab
|
|
7
|
+
* focuses the card, and every tool returns `needs_auth` in the gap — including
|
|
8
|
+
* across a process restart (the in-memory cache is gone).
|
|
9
|
+
*
|
|
10
|
+
* Host mode removes that gap. When HQ Edge is the host, it already holds the
|
|
11
|
+
* operator-supplied token + user id, and exposes them on a loopback route. The
|
|
12
|
+
* node half fetches them on boot and is authoritative immediately, without
|
|
13
|
+
* waiting for a browser. The browser half is untouched — host mode is *mode*,
|
|
14
|
+
* not API (spec §5/§14).
|
|
15
|
+
*
|
|
16
|
+
* Resolution order inside `getUserInfo()` (spec §6.2):
|
|
17
|
+
* 1. host session — HQ Edge configured → fetch + cache (TTL)
|
|
18
|
+
* 2. pushed session — what the browser half sent (today's behaviour)
|
|
19
|
+
* 3. persisted file — node-side `~/.dsh/auth/session.json`, written on every set
|
|
20
|
+
* 4. null → tools return needs_auth
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface HuaqiuAuthConfig {
|
|
24
|
+
/** HQ Edge base URL, e.g. "http://localhost:18080". Absent → standalone. */
|
|
25
|
+
hqEdgeBaseUrl?: string
|
|
26
|
+
/** Path on the host; default "/api/v1/auth/token". */
|
|
27
|
+
hostAuthPath?: string
|
|
28
|
+
/** Seconds a host session is reused before re-fetching. Default 300. */
|
|
29
|
+
hostSessionTtlSeconds?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_HOST_AUTH_PATH = '/api/v1/auth/token'
|
|
33
|
+
export const DEFAULT_HOST_TTL_SECONDS = 300
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the effective config: overlay `config` (highest) > env (safety net
|
|
37
|
+
* for non-supervisor installs) > defaults. Centralised here so only `dsh-auth`
|
|
38
|
+
* inspects these variables (spec §8).
|
|
39
|
+
*/
|
|
40
|
+
export function resolveHostConfig(
|
|
41
|
+
config?: Partial<HuaqiuAuthConfig> | null,
|
|
42
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
43
|
+
): HuaqiuAuthConfig {
|
|
44
|
+
const baseUrl = config?.hqEdgeBaseUrl
|
|
45
|
+
?? env.HQ_EDGE_BASE_URL
|
|
46
|
+
?? ''
|
|
47
|
+
const hostAuthPath = config?.hostAuthPath
|
|
48
|
+
?? env.HQ_EDGE_AUTH_PATH
|
|
49
|
+
?? DEFAULT_HOST_AUTH_PATH
|
|
50
|
+
const ttlRaw = config?.hostSessionTtlSeconds ?? env.HQ_EDGE_HOST_TTL_SECONDS
|
|
51
|
+
let ttl = DEFAULT_HOST_TTL_SECONDS
|
|
52
|
+
if (typeof ttlRaw === 'number' && Number.isFinite(ttlRaw) && ttlRaw > 0) {
|
|
53
|
+
ttl = ttlRaw
|
|
54
|
+
} else if (typeof ttlRaw === 'string' && ttlRaw.trim().length > 0) {
|
|
55
|
+
const parsed = Number.parseInt(ttlRaw, 10)
|
|
56
|
+
if (Number.isFinite(parsed) && parsed > 0) ttl = parsed
|
|
57
|
+
}
|
|
58
|
+
return { hqEdgeBaseUrl: baseUrl, hostAuthPath, hostSessionTtlSeconds: ttl }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface HostSession {
|
|
62
|
+
info: ResolvedHostUser
|
|
63
|
+
fetchedAt: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ResolvedHostUser {
|
|
67
|
+
id: string
|
|
68
|
+
token: string
|
|
69
|
+
nickname?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetches and caches the host (HQ Edge) session. The cache is memory-only with a
|
|
74
|
+
* TTL; the loopback GET is cheap and the token is static, so we never persist it.
|
|
75
|
+
*/
|
|
76
|
+
export class HostSessionResolver {
|
|
77
|
+
private cache: HostSession | null = null
|
|
78
|
+
|
|
79
|
+
constructor(
|
|
80
|
+
readonly baseUrl: string,
|
|
81
|
+
readonly path: string,
|
|
82
|
+
readonly ttlMs: number,
|
|
83
|
+
private readonly doFetch: typeof fetch = globalThis.fetch.bind(globalThis),
|
|
84
|
+
) {}
|
|
85
|
+
|
|
86
|
+
/** Host mode is active iff a base URL was configured. */
|
|
87
|
+
get enabled(): boolean {
|
|
88
|
+
return this.baseUrl.length > 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async resolve(): Promise<ResolvedHostUser | null> {
|
|
92
|
+
if (!this.enabled) return null
|
|
93
|
+
const now = Date.now()
|
|
94
|
+
if (this.cache !== null && now - this.cache.fetchedAt < this.ttlMs) {
|
|
95
|
+
return this.cache.info
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const res = await this.doFetch(`${this.baseUrl}${this.path}`, {
|
|
99
|
+
method: 'GET',
|
|
100
|
+
headers: { accept: 'application/json' },
|
|
101
|
+
})
|
|
102
|
+
if (!res.ok) return this.cache?.info ?? null
|
|
103
|
+
const data = await res.json() as Record<string, unknown>
|
|
104
|
+
const info = normalizeHostUser(data)
|
|
105
|
+
if (!info) return this.cache?.info ?? null
|
|
106
|
+
this.cache = { info, fetchedAt: now }
|
|
107
|
+
return info
|
|
108
|
+
} catch {
|
|
109
|
+
// Network error: fall back to a previously cached value if we have one.
|
|
110
|
+
return this.cache?.info ?? null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Drop the cached value so the next `resolve()` re-fetches (reactive invalidation). */
|
|
115
|
+
clear(): void {
|
|
116
|
+
this.cache = null
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function asId(raw: unknown): string | null {
|
|
121
|
+
if (typeof raw === 'string' && raw.length > 0) return raw
|
|
122
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw)
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Parse the host route payload into a credential, tolerating key-name drift. */
|
|
127
|
+
export function normalizeHostUser(data: Record<string, unknown>): ResolvedHostUser | null {
|
|
128
|
+
const token = typeof data.token === 'string' && data.token.length > 0
|
|
129
|
+
? data.token
|
|
130
|
+
: null
|
|
131
|
+
const id = asId(data.userId)
|
|
132
|
+
?? asId(data.id)
|
|
133
|
+
?? asId(data.user_id)
|
|
134
|
+
if (!token || !id) return null
|
|
135
|
+
const nickname = typeof data.nickname === 'string' && data.nickname.length > 0
|
|
136
|
+
? data.nickname
|
|
137
|
+
: undefined
|
|
138
|
+
return { id, token, ...(nickname ? { nickname } : {}) }
|
|
139
|
+
}
|