@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,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure browser message parsing for auth.eda.cn postMessage envelopes.
|
|
3
|
+
*
|
|
4
|
+
* auth.eda.cn posts `JSON.stringify({ category: 1, data: { type, data } })`
|
|
5
|
+
* to the parent window with `targetOrigin: '*'`.
|
|
6
|
+
*
|
|
7
|
+
* SECURITY NOTE (offline deployment): the DSH harness runs fully offline /
|
|
8
|
+
* local (127.0.0.1), so there is no public attack surface of a malicious
|
|
9
|
+
* website posting a forged token at us. The origin gate is therefore dropped
|
|
10
|
+
* by design — webviews may even report an opaque origin ("null") for the
|
|
11
|
+
* embedded auth.eda.cn iframe, which would otherwise reject legitimate login
|
|
12
|
+
* messages. What remains is the ENVELOPE validation in `parseAuthMessage`
|
|
13
|
+
* (category 1 + well-formed token/userId), which keeps unrelated window
|
|
14
|
+
* messages from ever corrupting the credential cache.
|
|
15
|
+
*
|
|
16
|
+
* Envelope types (see `/Users/admin/code/eda-cn-login/lib/kicadTools.ts`):
|
|
17
|
+
* { category: 1, data: { type: 'update_access_token', data: { userId, token, expires_at, ... } } }
|
|
18
|
+
* { category: 1, data: { type: 'logout', data: null } }
|
|
19
|
+
* { category: 1, data: { type: 'close_dialog', data: null } }
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const AUTH_ORIGIN = 'https://auth.eda.cn'
|
|
23
|
+
|
|
24
|
+
/**「Go to profile」destination: the eda.cn account page. */
|
|
25
|
+
export const PROFILE_URL = 'https://www.eda.cn/account/profile'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the「Go to profile」URL: the eda.cn account page WITH the access token
|
|
29
|
+
* in the query, mirroring `hq-eda-ai`'s `UserMenu`
|
|
30
|
+
* (`/account/profile?token=…&phone=…`).
|
|
31
|
+
*
|
|
32
|
+
* The token is always attached: eda.cn consumes it to establish the session
|
|
33
|
+
* and strips it from the address bar / history itself, so there is nothing to
|
|
34
|
+
* leak beyond the target site. `encodeURIComponent` is required (not cosmetic):
|
|
35
|
+
* tokens are base64-ish and may contain `+`, `/` or `=`, and a raw `+` in a
|
|
36
|
+
* query string decodes to a space, which would corrupt the credential.
|
|
37
|
+
*/
|
|
38
|
+
export function buildProfileUrl(options: { token: string; phone?: string | number }): string {
|
|
39
|
+
const phone = options.phone === undefined || options.phone === null ? '' : String(options.phone)
|
|
40
|
+
return `${PROFILE_URL}?token=${encodeURIComponent(options.token)}&phone=${encodeURIComponent(phone)}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Contract version of the auth.eda.cn embed, shared with the web app
|
|
45
|
+
* (`hq-eda-ai` LoginDialog) so both send the same cache-busting `v=`.
|
|
46
|
+
*/
|
|
47
|
+
export const AUTH_IFRAME_VERSION = '20260409'
|
|
48
|
+
|
|
49
|
+
/** UI language of the auth.eda.cn embed. */
|
|
50
|
+
export type AuthLocale = 'zh' | 'en'
|
|
51
|
+
|
|
52
|
+
/** Color scheme of the auth.eda.cn embed (its own vocabulary: light | dark). */
|
|
53
|
+
export type AuthTheme = 'light' | 'dark'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* auth.eda.cn's own language ids, keyed by our locale id.
|
|
57
|
+
*
|
|
58
|
+
* The embed reads `?locale=`, NOT `lang`: `eda-cn-login/app/layout.tsx` reads
|
|
59
|
+
* `urlParams.get('locale')` and `components/ui/LanguageContext.tsx`
|
|
60
|
+
* (`getLangFromUrl`) only accepts the ids in `locales/index.ts` — `cn` and
|
|
61
|
+
* `en` (`zh` / `zh_CN` are aliased to `cn` there, but we send the canonical
|
|
62
|
+
* id outright).
|
|
63
|
+
*
|
|
64
|
+
* NOTE: `hq-eda-ai`'s `LoginDialog.tsx` sends `lang=zh`, which the embed
|
|
65
|
+
* IGNORES, so its login card always falls back to whatever the browser asks
|
|
66
|
+
* for. We send `locale` (what is actually read) and keep `lang` alongside it
|
|
67
|
+
* for parity with the web app and forward compatibility.
|
|
68
|
+
*/
|
|
69
|
+
export const AUTH_LOCALE_ID: Record<AuthLocale, string> = { zh: 'cn', en: 'en' }
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Options for the auth.eda.cn overlay iframe opened by `auth.login()`.
|
|
73
|
+
*
|
|
74
|
+
* The embed has TWO rendering modes, and which one is right depends on the
|
|
75
|
+
* surface that hosts the iframe:
|
|
76
|
+
*
|
|
77
|
+
* - **Transparent card mode** (default, no `fill`): the embedded page sets
|
|
78
|
+
* `html[data-iframe-mode="true"]` and the root paints
|
|
79
|
+
* `background: transparent` (see `eda-cn-login/app/page.tsx` — the wrapper
|
|
80
|
+
* only gets the `bg-transparent` class when `fill !== 'full'`). The host
|
|
81
|
+
* then paints a card around the iframe (e.g. the login dialog's backdrop
|
|
82
|
+
* + centered card) so Blink's white `BaseBackgroundColor()` canvas never
|
|
83
|
+
* shows. This is the right mode when the iframe sits inside a host-painted
|
|
84
|
+
* card with its own visual edge — e.g. the sidebar-triggered login dialog.
|
|
85
|
+
*
|
|
86
|
+
* - **Fill mode** (`fill: 'full'`): the embed's `DialogContent` becomes
|
|
87
|
+
* `w-full h-full max-w-none max-h-none left-0 top-0 rounded-none border-none`
|
|
88
|
+
* (see `eda-cn-login/components/LoginDialog.tsx` — `fillFull` branch at
|
|
89
|
+
* line 61) and the wrapper drops `bg-transparent` so the page paints its
|
|
90
|
+
* own `bg-background` edge-to-edge. This is the right mode when the iframe
|
|
91
|
+
* fills its host container (e.g. the toolview card) and there is no
|
|
92
|
+
* surrounding card to mask the embed's rounded corners or transparent
|
|
93
|
+
* 20px grid strips.
|
|
94
|
+
*
|
|
95
|
+
* The `lang` and `theme` params follow the host UI in both modes.
|
|
96
|
+
*/
|
|
97
|
+
export interface LoginOptions {
|
|
98
|
+
/** Ask auth.eda.cn to self-close on an outside click (default `true`). */
|
|
99
|
+
closeOnOutsideClick?: boolean
|
|
100
|
+
/** Embed UI language (default `zh`). */
|
|
101
|
+
lang?: AuthLocale
|
|
102
|
+
/** Embed color scheme (default `light`). */
|
|
103
|
+
theme?: AuthTheme
|
|
104
|
+
/**
|
|
105
|
+
* Set to `'full'` to make the embed fill its iframe viewport edge-to-edge
|
|
106
|
+
* (no rounded corners, no transparent grid strips, embed paints its own
|
|
107
|
+
* `bg-background`). Omit for the transparent card mode described above.
|
|
108
|
+
* `true` is accepted as a shorthand for `'full'`.
|
|
109
|
+
*/
|
|
110
|
+
fill?: 'full' | 'transparent' | true
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface AuthTokenPayload {
|
|
114
|
+
id: string
|
|
115
|
+
token: string
|
|
116
|
+
nickname?: string
|
|
117
|
+
/** User avatar URL (`headimage` in the auth.eda.cn payload). */
|
|
118
|
+
avatar?: string
|
|
119
|
+
/** Bound mobile number; forwarded to the eda.cn profile page as `phone=`. */
|
|
120
|
+
phone?: string
|
|
121
|
+
/** unix seconds; undefined = no expiry */
|
|
122
|
+
expiresAt?: number
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the auth.eda.cn embed URL.
|
|
127
|
+
*
|
|
128
|
+
* The URL switches between two rendering modes based on `options.fill`:
|
|
129
|
+
* - `fill: 'full'` (or `true`) → `fill=full` is sent; the embed's
|
|
130
|
+
* `DialogContent` becomes `w-full h-full … rounded-none` and the wrapper
|
|
131
|
+
* drops `bg-transparent`, so the embed fills the iframe viewport with
|
|
132
|
+
* its own `bg-background`. Use this when the iframe is the surface (e.g.
|
|
133
|
+
* the toolview card).
|
|
134
|
+
* - any other value (including unset) → no `fill` is sent; the embed stays
|
|
135
|
+
* in transparent card mode. The host is responsible for painting a card
|
|
136
|
+
* around the iframe so Blink's white base canvas never reaches the user.
|
|
137
|
+
*/
|
|
138
|
+
export function buildLoginUrl(options: LoginOptions & { baseUrl?: string } = {}): string {
|
|
139
|
+
const url = new URL(options.baseUrl ?? `${AUTH_ORIGIN}/`)
|
|
140
|
+
url.searchParams.set('v', AUTH_IFRAME_VERSION)
|
|
141
|
+
if (options.closeOnOutsideClick !== false) url.searchParams.set('clickOutsideToClose', 'true')
|
|
142
|
+
if (options.fill === 'full' || options.fill === true) url.searchParams.set('fill', 'full')
|
|
143
|
+
url.searchParams.set('transparent', 'true')
|
|
144
|
+
const lang = options.lang ?? 'zh'
|
|
145
|
+
// `locale` is the param auth.eda.cn reads; `lang` keeps parity with
|
|
146
|
+
// hq-eda-ai's LoginDialog (see AUTH_LOCALE_ID).
|
|
147
|
+
url.searchParams.set('locale', AUTH_LOCALE_ID[lang])
|
|
148
|
+
url.searchParams.set('lang', lang)
|
|
149
|
+
url.searchParams.set('theme', options.theme ?? 'light')
|
|
150
|
+
return url.toString()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type ParsedAuthMessage =
|
|
154
|
+
| { kind: 'token'; info: AuthTokenPayload }
|
|
155
|
+
| { kind: 'logout' }
|
|
156
|
+
| { kind: 'close' }
|
|
157
|
+
|
|
158
|
+
/** Structural event (origin + data) so tests don't need a real MessageEvent. */
|
|
159
|
+
export interface AuthMessageEventLike {
|
|
160
|
+
origin: string
|
|
161
|
+
data: unknown
|
|
162
|
+
/** The posting window; retained for completeness (origin is not gated). */
|
|
163
|
+
source?: unknown
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface RawEnvelope {
|
|
167
|
+
category?: unknown
|
|
168
|
+
data?: { type?: unknown; data?: unknown }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Coerce an id field (string or number, as auth.eda.cn sends) to a string. */
|
|
172
|
+
function stringifyId(value: unknown): string | null {
|
|
173
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
174
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function parseAuthMessage(raw: unknown): ParsedAuthMessage | null {
|
|
179
|
+
let envelope: RawEnvelope | null = null
|
|
180
|
+
if (typeof raw === 'string') {
|
|
181
|
+
try {
|
|
182
|
+
envelope = JSON.parse(raw) as RawEnvelope
|
|
183
|
+
} catch {
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
} else if (raw !== null && typeof raw === 'object') {
|
|
187
|
+
envelope = raw as RawEnvelope
|
|
188
|
+
}
|
|
189
|
+
if (!envelope || envelope.category !== 1) return null
|
|
190
|
+
const data = envelope.data
|
|
191
|
+
if (!data || typeof data !== 'object') return null
|
|
192
|
+
|
|
193
|
+
switch (data.type) {
|
|
194
|
+
case 'update_access_token': {
|
|
195
|
+
const d = data.data
|
|
196
|
+
if (!d || typeof d !== 'object') return null
|
|
197
|
+
const record = d as Record<string, unknown>
|
|
198
|
+
const token = typeof record.token === 'string' && record.token.length > 0 ? record.token : null
|
|
199
|
+
// auth.eda.cn sends userId/id as NUMBERS (e.g. 6215935) — coerce to string.
|
|
200
|
+
const id = stringifyId(record.userId) ?? stringifyId(record.id)
|
|
201
|
+
if (!token || !id) return null
|
|
202
|
+
const nickname = typeof record.nickname === 'string' && record.nickname.length > 0 ? record.nickname : undefined
|
|
203
|
+
// auth.eda.cn sends the avatar as `headimage`; `avatar` accepted as alias.
|
|
204
|
+
const avatar = typeof record.headimage === 'string' && record.headimage.length > 0
|
|
205
|
+
? record.headimage
|
|
206
|
+
: typeof record.avatar === 'string' && record.avatar.length > 0 ? record.avatar : undefined
|
|
207
|
+
// Phone may arrive as a string or a number (mirrors `stringifyId`).
|
|
208
|
+
const phone = stringifyId(record.phone) ?? undefined
|
|
209
|
+
const expiresAt = typeof record.expires_at === 'number' ? record.expires_at : undefined
|
|
210
|
+
return {
|
|
211
|
+
kind: 'token',
|
|
212
|
+
info: {
|
|
213
|
+
id,
|
|
214
|
+
token,
|
|
215
|
+
...(nickname !== undefined ? { nickname } : {}),
|
|
216
|
+
...(avatar !== undefined ? { avatar } : {}),
|
|
217
|
+
...(phone !== undefined ? { phone } : {}),
|
|
218
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
case 'logout':
|
|
223
|
+
return { kind: 'logout' }
|
|
224
|
+
case 'close_dialog':
|
|
225
|
+
return { kind: 'close' }
|
|
226
|
+
default:
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Origin-agnostic envelope parsing. The ONLY entry point for window message events. */
|
|
232
|
+
export function handleAuthMessage(event: AuthMessageEventLike): ParsedAuthMessage | null {
|
|
233
|
+
return parseAuthMessage(event.data)
|
|
234
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localStorage-backed credential cache (client side). Survives reload, which
|
|
3
|
+
* is what makes the fingerprint silent-login restore (acceptance group D) work.
|
|
4
|
+
*/
|
|
5
|
+
import type { AuthTokenPayload } from './lib.js'
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_STORAGE_KEY = 'huaqiu.dsh.auth'
|
|
8
|
+
|
|
9
|
+
export interface AuthStorage {
|
|
10
|
+
get(): AuthTokenPayload | null
|
|
11
|
+
set(info: AuthTokenPayload): void
|
|
12
|
+
clear(): void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createAuthStorage(
|
|
16
|
+
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>,
|
|
17
|
+
key: string = DEFAULT_STORAGE_KEY,
|
|
18
|
+
): AuthStorage {
|
|
19
|
+
return {
|
|
20
|
+
get() {
|
|
21
|
+
const raw = storage.getItem(key)
|
|
22
|
+
if (!raw) return null
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(raw) as AuthTokenPayload
|
|
25
|
+
if (!parsed || typeof parsed.token !== 'string' || typeof parsed.id !== 'string') return null
|
|
26
|
+
// Parity with auth.eda.cn's 5-day token window.
|
|
27
|
+
if (parsed.expiresAt !== undefined && parsed.expiresAt * 1000 <= Date.now()) {
|
|
28
|
+
storage.removeItem(key)
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
return parsed
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
set(info) {
|
|
37
|
+
storage.setItem(key, JSON.stringify(info))
|
|
38
|
+
},
|
|
39
|
+
clear() {
|
|
40
|
+
storage.removeItem(key)
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser→node credential transport over the plugin-owned webServer routes
|
|
3
|
+
* (same-origin; no CORS, no external dependency). This is the chosen Phase 0A
|
|
4
|
+
* browser→host channel — `apiProxy`'s dispatch table is closed, so a
|
|
5
|
+
* plugin-owned `webServer` route is the smallest supported extension point.
|
|
6
|
+
*/
|
|
7
|
+
import type { AuthTokenPayload } from './lib.js'
|
|
8
|
+
|
|
9
|
+
export interface AuthTransport {
|
|
10
|
+
pushSession(info: AuthTokenPayload): Promise<void>
|
|
11
|
+
pushLogout(): Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createWebServerAuthTransport(
|
|
15
|
+
base: string = '/api/v1/huaqiu/auth',
|
|
16
|
+
doFetch: typeof fetch = globalThis.fetch.bind(globalThis),
|
|
17
|
+
): AuthTransport {
|
|
18
|
+
return {
|
|
19
|
+
async pushSession(info) {
|
|
20
|
+
const res = await doFetch(`${base}/session`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'content-type': 'application/json' },
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
token: info.token,
|
|
25
|
+
userId: info.id,
|
|
26
|
+
...(info.nickname !== undefined ? { nickname: info.nickname } : {}),
|
|
27
|
+
}),
|
|
28
|
+
})
|
|
29
|
+
if (!res.ok) throw new Error(`auth push failed: HTTP ${res.status}`)
|
|
30
|
+
},
|
|
31
|
+
async pushLogout() {
|
|
32
|
+
const res = await doFetch(`${base}/logout`, { method: 'POST' })
|
|
33
|
+
if (!res.ok) throw new Error(`auth logout push failed: HTTP ${res.status}`)
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login-state + result rendering helpers shared by the client React cards.
|
|
3
|
+
*
|
|
4
|
+
* Every style is a FUNCTION of the active color scheme: the cards are inline
|
|
5
|
+
* styled (the client bundle ships no CSS file), so light/dark support has to
|
|
6
|
+
* be expressed in JS. Colors prefer DSH's `--dsw-alias-*` tokens and fall back
|
|
7
|
+
* to an explicit per-scheme value (see `sidebar-action.tsx`).
|
|
8
|
+
*/
|
|
9
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
10
|
+
import type { Translate } from '../i18n.js'
|
|
11
|
+
|
|
12
|
+
/** Tool result content block (subset of DSH `ContentBlock`). */
|
|
13
|
+
interface ContentBlockLike {
|
|
14
|
+
type?: string
|
|
15
|
+
text?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Structural tool-call block subset (we only read settled text content). */
|
|
19
|
+
export interface ToolBlockLike {
|
|
20
|
+
content?: readonly ContentBlockLike[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Best-effort JSON.parse of the tool's text output blocks. */
|
|
24
|
+
export function parseToolResult(block: ToolBlockLike | undefined): Record<string, unknown> | null {
|
|
25
|
+
if (!block || !Array.isArray(block.content)) return null
|
|
26
|
+
const text = block.content
|
|
27
|
+
.filter((c): c is ContentBlockLike => !!c && c.type === 'text' && typeof c.text === 'string')
|
|
28
|
+
.map((c) => c.text as string)
|
|
29
|
+
.join('')
|
|
30
|
+
if (!text) return null
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(text) as unknown
|
|
33
|
+
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
|
|
34
|
+
} catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** True when the parsed result is the auth-gate signal. */
|
|
40
|
+
export function isNeedsAuthResult(result: Record<string, unknown> | null): result is Record<string, unknown> & { status: 'needs_auth' } {
|
|
41
|
+
return !!result && result.status === 'needs_auth'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const AUTH_ORIGIN = 'https://auth.eda.cn'
|
|
45
|
+
|
|
46
|
+
/** Card colors for one color scheme (DSH token first, explicit fallback second). */
|
|
47
|
+
export interface CardPalette {
|
|
48
|
+
surface: string
|
|
49
|
+
border: string
|
|
50
|
+
text: string
|
|
51
|
+
muted: string
|
|
52
|
+
success: string
|
|
53
|
+
danger: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const LIGHT_CARD_PALETTE: CardPalette = {
|
|
57
|
+
surface: 'var(--dsw-alias-bg-layer-1, #ffffff)',
|
|
58
|
+
border: 'var(--dsw-alias-border-l1, #e4e7ec)',
|
|
59
|
+
text: 'var(--dsw-alias-label-primary, inherit)',
|
|
60
|
+
muted: 'var(--dsw-alias-label-secondary, #5b6472)',
|
|
61
|
+
success: 'var(--dsw-alias-state-success-primary, #1677ff)',
|
|
62
|
+
danger: 'var(--dsw-alias-state-error-primary, #d4380d)',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const DARK_CARD_PALETTE: CardPalette = {
|
|
66
|
+
surface: 'var(--dsw-alias-bg-layer-1, #20242c)',
|
|
67
|
+
border: 'var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.14))',
|
|
68
|
+
text: 'var(--dsw-alias-label-primary, #e6eaf0)',
|
|
69
|
+
muted: 'var(--dsw-alias-label-secondary, #8b95a5)',
|
|
70
|
+
success: 'var(--dsw-alias-state-success-primary, #4cc38a)',
|
|
71
|
+
danger: 'var(--dsw-alias-state-error-primary, #ff7875)',
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function cardPalette(dark: boolean): CardPalette {
|
|
75
|
+
return dark ? DARK_CARD_PALETTE : LIGHT_CARD_PALETTE
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function cardStyle(palette: CardPalette): CSSProperties {
|
|
79
|
+
return {
|
|
80
|
+
border: `1px solid ${palette.border}`,
|
|
81
|
+
borderRadius: 10,
|
|
82
|
+
padding: '12px 14px',
|
|
83
|
+
margin: '4px 0',
|
|
84
|
+
background: palette.surface,
|
|
85
|
+
color: palette.text,
|
|
86
|
+
fontFamily: 'inherit',
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const TITLE_STYLE: CSSProperties = {
|
|
91
|
+
fontSize: 14,
|
|
92
|
+
fontWeight: 600,
|
|
93
|
+
margin: '0 0 6px',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const STATUS_STYLE: CSSProperties = {
|
|
97
|
+
fontSize: 13,
|
|
98
|
+
margin: '0 0 10px',
|
|
99
|
+
lineHeight: 1.5,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Iframe height for both the dialog and the toolview card. Tuned to the
|
|
104
|
+
* auth.eda.cn login form's actual painted height (≈390px at 768px width,
|
|
105
|
+
* measured with a magenta iframe element background so the embedded doc's
|
|
106
|
+
* transparent top/bottom strips are obvious). The dialog and toolview both
|
|
107
|
+
* use this same number for consistency.
|
|
108
|
+
*
|
|
109
|
+
* Note: the auth.eda.cn page wrapper is `grid-rows-[20px_1fr_20px]`, so the
|
|
110
|
+
* embedded doc always leaves two 20px transparent strips above and below the
|
|
111
|
+
* form — they are NOT additional empty space we can shave off; they are
|
|
112
|
+
* always there in the embed's own layout. The 30px buffer above the 390px
|
|
113
|
+
* content (→ 440) gives the form room to grow slightly on error states
|
|
114
|
+
* without immediately overflowing.
|
|
115
|
+
*/
|
|
116
|
+
export const LOGIN_IFRAME_HEIGHT = 440
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The embedded login iframe: painted with the same surface as the wrapping
|
|
120
|
+
* card so the login box blends in both schemes.
|
|
121
|
+
*
|
|
122
|
+
* Why not `background: transparent`? Blink's `BaseBackgroundColor()` falls
|
|
123
|
+
* back to WHITE whenever the embedded doc's root element has a transparent
|
|
124
|
+
* background (and auth.eda.cn's `data-iframe-mode` page is exactly that).
|
|
125
|
+
* That white canvas shows through wherever the document doesn't paint, which
|
|
126
|
+
* reads as a glaring white "frame" around the login card in dark mode. Light
|
|
127
|
+
* mode hid the bug because the white canvas happened to match the light
|
|
128
|
+
* host. Painting the iframe ELEMENT with the card's surface (DSH alias
|
|
129
|
+
* `--dsw-alias-bg-layer-1` with a per-scheme fallback) puts a dark sheet in
|
|
130
|
+
* dark mode and a light sheet in light mode, so the login card sits on a
|
|
131
|
+
* surface that blends with the host in both schemes.
|
|
132
|
+
*/
|
|
133
|
+
export function iframeStyle(palette: CardPalette): CSSProperties {
|
|
134
|
+
return {
|
|
135
|
+
width: '100%',
|
|
136
|
+
height: LOGIN_IFRAME_HEIGHT,
|
|
137
|
+
border: `1px solid ${palette.border}`,
|
|
138
|
+
borderRadius: 8,
|
|
139
|
+
background: palette.surface,
|
|
140
|
+
display: 'block',
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function StatusLine({
|
|
145
|
+
authenticated,
|
|
146
|
+
nickname,
|
|
147
|
+
palette,
|
|
148
|
+
t,
|
|
149
|
+
}: {
|
|
150
|
+
authenticated: boolean
|
|
151
|
+
nickname?: string
|
|
152
|
+
palette: CardPalette
|
|
153
|
+
t: Translate
|
|
154
|
+
}): ReactNode {
|
|
155
|
+
if (authenticated) {
|
|
156
|
+
return (
|
|
157
|
+
<p style={{ ...STATUS_STYLE, color: palette.success }}>
|
|
158
|
+
{t('card.loggedIn', {
|
|
159
|
+
nickname: nickname ? t('card.nicknameSep', { nickname }) : '',
|
|
160
|
+
})}
|
|
161
|
+
</p>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
return (
|
|
165
|
+
<p style={{ ...STATUS_STYLE, color: palette.danger }}>
|
|
166
|
+
{t('card.loggedOut')}
|
|
167
|
+
</p>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Huaqiu (华秋) mark, used as the sidebar auth trigger's DEFAULT icon
|
|
3
|
+
* (mirrors `HQ_ICON` in `hq-eda-ai/apps/web/src/components/ui/icons.tsx`).
|
|
4
|
+
*
|
|
5
|
+
* Plain inline SVG: the DSH client bundle ships as a classic script with no
|
|
6
|
+
* Tailwind, so the Next.js wrapper (div + utility classes) is dropped and the
|
|
7
|
+
* 40×40 viewBox paths are kept verbatim.
|
|
8
|
+
*/
|
|
9
|
+
export interface HqIconProps {
|
|
10
|
+
size?: number
|
|
11
|
+
/** Brand blue by default; the paths are monochrome so one fill covers all. */
|
|
12
|
+
color?: string
|
|
13
|
+
title?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function HQ_ICON({ size = 24, color = '#1a81c4', title }: HqIconProps): React.JSX.Element {
|
|
17
|
+
return (
|
|
18
|
+
<svg
|
|
19
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
20
|
+
viewBox="0 0 40 40"
|
|
21
|
+
width={size}
|
|
22
|
+
height={size}
|
|
23
|
+
role={title ? 'img' : undefined}
|
|
24
|
+
aria-hidden={title ? undefined : true}
|
|
25
|
+
focusable="false"
|
|
26
|
+
style={{ display: 'block', flex: '0 0 auto' }}
|
|
27
|
+
>
|
|
28
|
+
{title ? <title>{title}</title> : null}
|
|
29
|
+
<path
|
|
30
|
+
fill={color}
|
|
31
|
+
fillRule="evenodd"
|
|
32
|
+
d="M29.71,30a2.75,2.75,0,1,0,2.75,2.74A2.74,2.74,0,0,0,29.71,30Z"
|
|
33
|
+
/>
|
|
34
|
+
<path
|
|
35
|
+
fill={color}
|
|
36
|
+
fillRule="evenodd"
|
|
37
|
+
d="M26.59,10.49H13.41a5.93,5.93,0,0,0-5.91,5.9V29.58a5.93,5.93,0,0,0,5.91,5.91H26.85a4,4,0,0,1-1.13-2.78,4.43,4.43,0,0,1,.1-.9H13.41a2.23,2.23,0,0,1-2.22-2.22V16.39a2.23,2.23,0,0,1,2.22-2.21H26.59a2.23,2.23,0,0,1,2.22,2.21V28.81a4.43,4.43,0,0,1,.9-.1,4,4,0,0,1,2.78,1.13,2.26,2.26,0,0,0,0-.26V16.39A5.93,5.93,0,0,0,26.59,10.49Z"
|
|
38
|
+
/>
|
|
39
|
+
<path
|
|
40
|
+
fill={color}
|
|
41
|
+
fillRule="evenodd"
|
|
42
|
+
d="M26.38,27.52V18.46a1.85,1.85,0,0,0-1.85-1.85h0a1.84,1.84,0,0,0-1.84,1.85v2.68H17.31V18.46a1.84,1.84,0,0,0-1.84-1.85h0a1.85,1.85,0,0,0-1.85,1.85v9.06a1.85,1.85,0,0,0,1.85,1.85h0a1.84,1.84,0,0,0,1.84-1.85V24.83h5.38v2.69a1.84,1.84,0,0,0,1.84,1.85h0A1.85,1.85,0,0,0,26.38,27.52Z"
|
|
43
|
+
/>
|
|
44
|
+
<circle fill={color} cx="20" cy="5.04" r="2.86" />
|
|
45
|
+
<rect fill={color} x="19" y="5.04" width="2" height="6.7" />
|
|
46
|
+
<path fill={color} d="M6.37,17.71a4.89,4.89,0,0,0,0,9.78Z" />
|
|
47
|
+
<path fill={color} d="M33.63,17.71a4.89,4.89,0,1,1,0,9.78Z" />
|
|
48
|
+
</svg>
|
|
49
|
+
)
|
|
50
|
+
}
|