@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,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
+ }