@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.
@@ -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
+ })