@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,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar login dialog — a real modal (backdrop + centered card + iframe).
|
|
3
|
+
*
|
|
4
|
+
* WHY A DIALOG AND NOT A FULL-VIEWPORT IFRAME
|
|
5
|
+
*
|
|
6
|
+
* The embed (`auth.eda.cn`) reads only two URL params: `fill` and
|
|
7
|
+
* `clickOutsideToClose`. With `fill !== 'full'` it sets
|
|
8
|
+
* `data-iframe-mode="true"` on `<html>` and the CSS rule
|
|
9
|
+
* `html[data-iframe-mode=true], html[data-iframe-mode=true] body { background: 0 0 !important }`
|
|
10
|
+
* makes its root transparent — but the page wrapper still uses a
|
|
11
|
+
* `grid-rows-[20px_1fr_20px]` layout, so the 20px strips above/below the
|
|
12
|
+
* card are empty and show through to whatever is behind the iframe. Behind
|
|
13
|
+
* the iframe element, with no background set, Blink falls back to a WHITE
|
|
14
|
+
* base background canvas. That white is what was reading as a "white frame
|
|
15
|
+
* around the login card in dark mode" (and was invisibly there in light
|
|
16
|
+
* mode, blending with the white host).
|
|
17
|
+
*
|
|
18
|
+
* The two ways out:
|
|
19
|
+
* 1. Paint the iframe element with a color → in a full-viewport iframe
|
|
20
|
+
* that blanks the whole app with that color (light surface = white
|
|
21
|
+
* blocks the light host; dark surface = dark blocks the dark host).
|
|
22
|
+
* 2. Make the iframe CARD-SIZED and put it inside a host-painted card,
|
|
23
|
+
* so the iframe's background can be `transparent` and the card's
|
|
24
|
+
* surface shows through wherever the embedded doc is transparent.
|
|
25
|
+
* This is the pattern the toolview card already uses
|
|
26
|
+
* (`needs-auth-toolview.tsx`) and the pattern `hq-eda-ai`'s
|
|
27
|
+
* `LoginDialog.tsx` uses.
|
|
28
|
+
*
|
|
29
|
+
* We use (2): a fixed full-viewport backdrop (semi-transparent black) +
|
|
30
|
+
* centered card (host surface bg) + the iframe (transparent inner doc,
|
|
31
|
+
* card surface as element bg so Blink's white canvas never reaches the
|
|
32
|
+
* user). Click on the backdrop, the × button, Escape, or the auth embed's
|
|
33
|
+
* own `close_dialog` postMessage closes the dialog.
|
|
34
|
+
*/
|
|
35
|
+
import { LOGIN_IFRAME_HEIGHT } from './common.js'
|
|
36
|
+
import { buildLoginUrl, type AuthLocale, type AuthTheme } from '../lib.js'
|
|
37
|
+
import { translate } from '../i18n.js'
|
|
38
|
+
import { getCurrentLocale, getCurrentSurfaceColor, subscribeUiEnv, syncUiEnv } from '../ui-env.js'
|
|
39
|
+
|
|
40
|
+
/** Aria / data attribute names — stable so tests and CSS can target them. */
|
|
41
|
+
export const DIALOG_OVERLAY_ATTR = 'data-hq-auth-dialog'
|
|
42
|
+
export const DIALOG_CARD_ATTR = 'data-hq-auth-dialog-card'
|
|
43
|
+
export const DIALOG_IFRAME_ATTR = 'data-hq-auth-dialog-iframe'
|
|
44
|
+
export const DIALOG_CLOSE_ATTR = 'data-hq-auth-dialog-close'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Iframe height. Tuned to the auth.eda.cn login form's actual painted height
|
|
48
|
+
* (≈390px at 768px width, measured with a magenta iframe element background
|
|
49
|
+
* so the embedded doc's transparent 20px top/bottom strips are obvious). The
|
|
50
|
+
* shared constant lives in `./common.jsx` so the dialog and the toolview
|
|
51
|
+
* card stay in lock-step.
|
|
52
|
+
*/
|
|
53
|
+
const IFRAME_HEIGHT = LOGIN_IFRAME_HEIGHT
|
|
54
|
+
const CARD_MAX_WIDTH = 768
|
|
55
|
+
|
|
56
|
+
let container: HTMLDivElement | null = null
|
|
57
|
+
let unsubscribe: (() => void) | null = null
|
|
58
|
+
let onCloseRequested: (() => void) | null = null
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Open the login dialog. Idempotent: a second call while open is a no-op
|
|
62
|
+
* (mirrors the client-side `if (iframe) return` guard). `onClose` fires
|
|
63
|
+
* whenever the dialog closes for ANY reason (backdrop click, Escape, close
|
|
64
|
+
* button, postMessage, programmatic close) — the auth client uses it to
|
|
65
|
+
* unblock its own `isOpen` state.
|
|
66
|
+
*/
|
|
67
|
+
export function openLoginDialog(options: { lang?: AuthLocale; theme?: AuthTheme } = {}, onClose?: () => void): void {
|
|
68
|
+
if (container) return
|
|
69
|
+
// The ui-env module reads the DOM once at import time. Re-read here so
|
|
70
|
+
// the dialog picks up the current theme even if no React component has
|
|
71
|
+
// subscribed yet (e.g. when the sidebar opens the dialog before any
|
|
72
|
+
// card has mounted), or when a test sets the attribute after import.
|
|
73
|
+
syncUiEnv()
|
|
74
|
+
const locale = options.lang ?? getCurrentLocale()
|
|
75
|
+
|
|
76
|
+
const root = document.createElement('div')
|
|
77
|
+
root.setAttribute(DIALOG_OVERLAY_ATTR, '')
|
|
78
|
+
// Backdrop: dim the host without blanking it. Theme-agnostic — rgba black
|
|
79
|
+
// works on both light and dark hosts.
|
|
80
|
+
root.style.cssText = [
|
|
81
|
+
'position:fixed',
|
|
82
|
+
'inset:0',
|
|
83
|
+
'width:100vw',
|
|
84
|
+
'height:100vh',
|
|
85
|
+
'border:0',
|
|
86
|
+
'z-index:2147483647',
|
|
87
|
+
'background:rgba(0, 0, 0, 0.55)',
|
|
88
|
+
'display:flex',
|
|
89
|
+
'align-items:center',
|
|
90
|
+
'justify-content:center',
|
|
91
|
+
'box-sizing:border-box',
|
|
92
|
+
].join(';')
|
|
93
|
+
|
|
94
|
+
const card = document.createElement('div')
|
|
95
|
+
card.setAttribute(DIALOG_CARD_ATTR, '')
|
|
96
|
+
const applyCardColors = (): void => {
|
|
97
|
+
const surface = getCurrentSurfaceColor()
|
|
98
|
+
// No card border: a 1px border on each side would shrink the iframe's
|
|
99
|
+
// viewport to 766px on a 768px card, missing the embed's `md:grid-cols`
|
|
100
|
+
// (Tailwind `md` = 768px) threshold by 2px and silently falling back to
|
|
101
|
+
// a single-column layout. The card's box-shadow already gives the card
|
|
102
|
+
// a clear visual edge against the dimmed host.
|
|
103
|
+
card.style.cssText = [
|
|
104
|
+
`width:min(100vw, ${CARD_MAX_WIDTH}px)`,
|
|
105
|
+
// Card height = iframe height exactly. A taller card would leave a
|
|
106
|
+
// strip of card surface below the form (the flex column has only one
|
|
107
|
+
// child, the iframe, so any extra height piles up at the bottom).
|
|
108
|
+
`height:min(90vh, ${IFRAME_HEIGHT}px)`,
|
|
109
|
+
'border-radius:12px',
|
|
110
|
+
'box-shadow:0 24px 48px rgba(0, 0, 0, 0.32)',
|
|
111
|
+
'position:relative',
|
|
112
|
+
'box-sizing:border-box',
|
|
113
|
+
`background:${surface}`,
|
|
114
|
+
'display:flex',
|
|
115
|
+
'flex-direction:column',
|
|
116
|
+
].join(';')
|
|
117
|
+
}
|
|
118
|
+
applyCardColors()
|
|
119
|
+
|
|
120
|
+
const closeButton = document.createElement('button')
|
|
121
|
+
closeButton.setAttribute(DIALOG_CLOSE_ATTR, '')
|
|
122
|
+
closeButton.type = 'button'
|
|
123
|
+
closeButton.setAttribute('aria-label', translate(locale, 'dialog.close'))
|
|
124
|
+
closeButton.title = translate(locale, 'dialog.close')
|
|
125
|
+
closeButton.textContent = '×'
|
|
126
|
+
closeButton.style.cssText = [
|
|
127
|
+
'position:absolute',
|
|
128
|
+
'top:6px',
|
|
129
|
+
'right:10px',
|
|
130
|
+
'width:28px',
|
|
131
|
+
'height:28px',
|
|
132
|
+
'border:0',
|
|
133
|
+
'background:transparent',
|
|
134
|
+
'color:var(--dsw-alias-label-secondary, #5b6472)',
|
|
135
|
+
'font-size:22px',
|
|
136
|
+
'line-height:1',
|
|
137
|
+
'cursor:pointer',
|
|
138
|
+
'border-radius:6px',
|
|
139
|
+
'padding:0',
|
|
140
|
+
].join(';')
|
|
141
|
+
closeButton.addEventListener('click', closeLoginDialog)
|
|
142
|
+
|
|
143
|
+
const iframe = document.createElement('iframe')
|
|
144
|
+
iframe.setAttribute(DIALOG_IFRAME_ATTR, '')
|
|
145
|
+
iframe.src = buildLoginUrl({ lang: options.lang, theme: options.theme })
|
|
146
|
+
iframe.title = translate(locale, 'card.title')
|
|
147
|
+
iframe.allow = 'clipboard-write'
|
|
148
|
+
// The iframe element background = the card surface. That is what masks
|
|
149
|
+
// Blink's white base canvas in the embedded doc's transparent strips
|
|
150
|
+
// (see file header). The embedded doc itself is transparent because
|
|
151
|
+
// buildLoginUrl never sends `fill=full`.
|
|
152
|
+
iframe.style.cssText = [
|
|
153
|
+
'width:100%',
|
|
154
|
+
`height:${IFRAME_HEIGHT}px`,
|
|
155
|
+
'border:0',
|
|
156
|
+
'border-radius:8px',
|
|
157
|
+
`background:${getCurrentSurfaceColor()}`,
|
|
158
|
+
'display:block',
|
|
159
|
+
'flex:0 0 auto',
|
|
160
|
+
].join(';')
|
|
161
|
+
|
|
162
|
+
card.appendChild(closeButton)
|
|
163
|
+
card.appendChild(iframe)
|
|
164
|
+
root.appendChild(card)
|
|
165
|
+
document.body.appendChild(root)
|
|
166
|
+
|
|
167
|
+
// Backdrop click: close ONLY when the click lands on the backdrop itself,
|
|
168
|
+
// not on the card. The card stops propagation in its own click handler.
|
|
169
|
+
root.addEventListener('mousedown', backdropMouseDown)
|
|
170
|
+
card.addEventListener('mousedown', stopPropagation)
|
|
171
|
+
document.addEventListener('keydown', onKeyDown)
|
|
172
|
+
|
|
173
|
+
// React to theme/locale flips so the card surface + iframe background
|
|
174
|
+
// track the host (a mid-session switch while the dialog is open would
|
|
175
|
+
// otherwise leave a stale-colored card).
|
|
176
|
+
unsubscribe = subscribeUiEnv(() => {
|
|
177
|
+
if (!container) return
|
|
178
|
+
applyCardColors()
|
|
179
|
+
iframe.style.background = getCurrentSurfaceColor()
|
|
180
|
+
closeButton.title = translate(getCurrentLocale(), 'dialog.close')
|
|
181
|
+
closeButton.setAttribute('aria-label', closeButton.title)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
container = root
|
|
185
|
+
onCloseRequested = onClose ?? null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Programmatic close (used by the auth client after a successful login). */
|
|
189
|
+
export function closeLoginDialog(): void {
|
|
190
|
+
if (!container) return
|
|
191
|
+
container.remove()
|
|
192
|
+
container = null
|
|
193
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
194
|
+
unsubscribe?.()
|
|
195
|
+
unsubscribe = null
|
|
196
|
+
const cb = onCloseRequested
|
|
197
|
+
onCloseRequested = null
|
|
198
|
+
cb?.()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** True while the dialog is mounted. */
|
|
202
|
+
export function isLoginDialogOpen(): boolean {
|
|
203
|
+
return container !== null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function backdropMouseDown(event: MouseEvent): void {
|
|
207
|
+
if (event.target === container) closeLoginDialog()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function stopPropagation(event: MouseEvent): void {
|
|
211
|
+
event.stopPropagation()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function onKeyDown(event: KeyboardEvent): void {
|
|
215
|
+
if (event.key === 'Escape') {
|
|
216
|
+
event.stopPropagation()
|
|
217
|
+
closeLoginDialog()
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed `tool.call.toolview` renderer for the Huaqiu EDA tools.
|
|
3
|
+
*
|
|
4
|
+
* When a Huaqiu tool returns `status: "needs_auth"`, this card renders the
|
|
5
|
+
* login human-in-the-loop step: an embedded auth.eda.cn login iframe plus a
|
|
6
|
+
* live login-state line. The singleton auth client's `message` listener
|
|
7
|
+
* already receives the postMessage from this same-origin iframe, caches the
|
|
8
|
+
* credential and pushes it to the node service, so after the user logs in the
|
|
9
|
+
* card flips to「已登录」and the model can retry the tool.
|
|
10
|
+
*
|
|
11
|
+
* The embed is the second of the two FULL login surfaces (the other is the
|
|
12
|
+
* sidebar overlay): it uses the same `buildLoginUrl()` contract — always
|
|
13
|
+
* transparent — and passes the host's language and color scheme.
|
|
14
|
+
*
|
|
15
|
+
* For any other result it renders a faithful JSON fallback (the generic row
|
|
16
|
+
* that this keyed entry replaces), so nothing is lost for successful calls.
|
|
17
|
+
*/
|
|
18
|
+
import { memo, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
|
|
19
|
+
import { getAuthState, subscribeAuth, syncAuthNow } from '../auth-state.js'
|
|
20
|
+
import { buildLoginUrl } from '../lib.js'
|
|
21
|
+
import { useIsDark, useLocale } from '../ui-env.js'
|
|
22
|
+
import { useT } from '../i18n.js'
|
|
23
|
+
import {
|
|
24
|
+
cardPalette,
|
|
25
|
+
cardStyle,
|
|
26
|
+
iframeStyle,
|
|
27
|
+
isNeedsAuthResult,
|
|
28
|
+
parseToolResult,
|
|
29
|
+
StatusLine,
|
|
30
|
+
TITLE_STYLE,
|
|
31
|
+
type ToolBlockLike,
|
|
32
|
+
} from './common.jsx'
|
|
33
|
+
|
|
34
|
+
export interface NeedsAuthToolViewProps {
|
|
35
|
+
toolName: string
|
|
36
|
+
block?: ToolBlockLike
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DESC_STYLE = {
|
|
40
|
+
fontSize: 13,
|
|
41
|
+
margin: '0 0 10px',
|
|
42
|
+
lineHeight: 1.5,
|
|
43
|
+
} as const
|
|
44
|
+
|
|
45
|
+
function JsonFallback({ toolName, block }: { toolName: string; block?: ToolBlockLike }): React.JSX.Element {
|
|
46
|
+
const result = useMemo(() => parseToolResult(block), [block])
|
|
47
|
+
const dark = useIsDark()
|
|
48
|
+
const t = useT()
|
|
49
|
+
const palette = cardPalette(dark)
|
|
50
|
+
return (
|
|
51
|
+
<div style={cardStyle(palette)}>
|
|
52
|
+
<p style={TITLE_STYLE}>{t('card.tool', { tool: toolName })}</p>
|
|
53
|
+
<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 320, overflow: 'auto' }}>
|
|
54
|
+
{result ? JSON.stringify(result, null, 2) : t('card.empty')}
|
|
55
|
+
</pre>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function LoginCard({ toolName }: { toolName: string }): React.JSX.Element {
|
|
61
|
+
const authState = useSyncExternalStore(subscribeAuth, getAuthState)
|
|
62
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null)
|
|
63
|
+
const dark = useIsDark()
|
|
64
|
+
const locale = useLocale()
|
|
65
|
+
const t = useT()
|
|
66
|
+
const palette = cardPalette(dark)
|
|
67
|
+
// Healing: if the browser already holds a token (e.g. the node half was
|
|
68
|
+
// reset by a server restart), push it again the moment the login card
|
|
69
|
+
// mounts so the tool gate flips to authenticated without a manual re-login.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
syncAuthNow()
|
|
72
|
+
}, [toolName])
|
|
73
|
+
|
|
74
|
+
// Toolview is the second of the two FULL login surfaces (the other is the
|
|
75
|
+
// sidebar-triggered login dialog). Unlike the dialog — which sits inside a
|
|
76
|
+
// host-painted card with its own visual edge and therefore wants the embed
|
|
77
|
+
// in TRANSPARENT card mode — the toolview card IS the surface: the iframe
|
|
78
|
+
// fills it edge-to-edge. So we pass `fill: 'full'` and let the embed paint
|
|
79
|
+
// its own `bg-background` (dark in dark theme, light in light theme). This
|
|
80
|
+
// eliminates the white gaps that the transparent mode's 20px grid strips
|
|
81
|
+
// would otherwise leave above and below the form.
|
|
82
|
+
const src = useMemo(
|
|
83
|
+
() => buildLoginUrl({ fill: 'full', lang: locale, theme: dark ? 'dark' : 'light' }),
|
|
84
|
+
[locale, dark],
|
|
85
|
+
)
|
|
86
|
+
// Force a full iframe remount when theme/locale flips. Chrome's
|
|
87
|
+
// `iframe.src =` update keeps the old embed loaded and ignores the new
|
|
88
|
+
// `fill`/`theme` params (the embed is a single Next.js page that reads
|
|
89
|
+
// params once on mount); only a remount picks them up.
|
|
90
|
+
const remountKey = `${locale}|${dark ? 'd' : 'l'}`
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div style={cardStyle(palette)}>
|
|
94
|
+
<p style={TITLE_STYLE}>{t('card.title')}</p>
|
|
95
|
+
<p style={{ ...DESC_STYLE, color: palette.muted }}>
|
|
96
|
+
{t('card.desc', { tool: toolName })}
|
|
97
|
+
</p>
|
|
98
|
+
<StatusLine authenticated={authState.authenticated} nickname={authState.nickname} palette={palette} t={t} />
|
|
99
|
+
<iframe
|
|
100
|
+
key={remountKey}
|
|
101
|
+
ref={iframeRef}
|
|
102
|
+
src={src}
|
|
103
|
+
title={t('card.title')}
|
|
104
|
+
style={iframeStyle(palette)}
|
|
105
|
+
allow="clipboard-write"
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const HuaqiuToolView = memo(function HuaqiuToolView(props: NeedsAuthToolViewProps): React.JSX.Element {
|
|
112
|
+
const { toolName, block } = props
|
|
113
|
+
const result = useMemo(() => parseToolResult(block), [block])
|
|
114
|
+
if (isNeedsAuthResult(result)) {
|
|
115
|
+
return <LoginCard toolName={toolName} />
|
|
116
|
+
}
|
|
117
|
+
return <JsonFallback toolName={toolName} block={block} />
|
|
118
|
+
})
|