@n24q02m/hull 0.4.0 → 0.5.0-beta.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/README.md +47 -16
- package/build/cli/build-cli.d.ts +5 -6
- package/build/cli/build-cli.d.ts.map +1 -1
- package/build/cli/build-cli.js +4 -61
- package/build/cli/build-cli.js.map +1 -1
- package/build/crypto/index.d.ts +1 -1
- package/build/crypto/index.d.ts.map +1 -1
- package/build/crypto/index.js +1 -1
- package/build/crypto/index.js.map +1 -1
- package/build/crypto/kdf.d.ts +0 -13
- package/build/crypto/kdf.d.ts.map +1 -1
- package/build/crypto/kdf.js +3 -18
- package/build/crypto/kdf.js.map +1 -1
- package/build/index.d.ts +1 -11
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -16
- package/build/index.js.map +1 -1
- package/build/storage/account-registry.d.ts +53 -0
- package/build/storage/account-registry.d.ts.map +1 -0
- package/build/storage/account-registry.js +148 -0
- package/build/storage/account-registry.js.map +1 -0
- package/build/storage/backends.d.ts +0 -26
- package/build/storage/backends.d.ts.map +1 -1
- package/build/storage/backends.js +0 -65
- package/build/storage/backends.js.map +1 -1
- package/build/storage/config-file.d.ts +2 -19
- package/build/storage/config-file.d.ts.map +1 -1
- package/build/storage/config-file.js +2 -23
- package/build/storage/config-file.js.map +1 -1
- package/build/storage/credential-store.d.ts +1 -9
- package/build/storage/credential-store.d.ts.map +1 -1
- package/build/storage/credential-store.js +4 -16
- package/build/storage/credential-store.js.map +1 -1
- package/build/storage/data-dir.d.ts +2 -2
- package/build/storage/data-dir.js +2 -2
- package/build/storage/encryption.d.ts.map +1 -1
- package/build/storage/encryption.js +4 -0
- package/build/storage/encryption.js.map +1 -1
- package/build/storage/index.d.ts +5 -3
- package/build/storage/index.d.ts.map +1 -1
- package/build/storage/index.js +5 -3
- package/build/storage/index.js.map +1 -1
- package/build/storage/key-pool-crypto.d.ts +44 -0
- package/build/storage/key-pool-crypto.d.ts.map +1 -0
- package/build/storage/key-pool-crypto.js +132 -0
- package/build/storage/key-pool-crypto.js.map +1 -0
- package/build/storage/key-pool-store.d.ts +134 -0
- package/build/storage/key-pool-store.d.ts.map +1 -0
- package/build/storage/key-pool-store.js +470 -0
- package/build/storage/key-pool-store.js.map +1 -0
- package/build/storage/key-pool.d.ts +172 -0
- package/build/storage/key-pool.d.ts.map +1 -0
- package/build/storage/key-pool.js +374 -0
- package/build/storage/key-pool.js.map +1 -0
- package/build/storage/resolver.js +1 -1
- package/build/storage/resolver.js.map +1 -1
- package/build/token-auth/index.d.ts +1 -1
- package/build/token-auth/index.js +1 -1
- package/build/transport/index.d.ts +1 -2
- package/build/transport/index.d.ts.map +1 -1
- package/build/transport/index.js +1 -2
- package/build/transport/index.js.map +1 -1
- package/build/transport/local-server.d.ts +12 -128
- package/build/transport/local-server.d.ts.map +1 -1
- package/build/transport/local-server.js +20 -262
- package/build/transport/local-server.js.map +1 -1
- package/build/transport/streamable-http.d.ts +6 -7
- package/build/transport/streamable-http.d.ts.map +1 -1
- package/build/transport/streamable-http.js +6 -6
- package/build/transport/streamable-http.js.map +1 -1
- package/build/util/browser.d.ts.map +1 -0
- package/build/{relay → util}/browser.js +2 -2
- package/build/util/browser.js.map +1 -0
- package/package.json +4 -21
- package/build/auth/credential-form.d.ts +0 -163
- package/build/auth/credential-form.d.ts.map +0 -1
- package/build/auth/credential-form.js +0 -1869
- package/build/auth/credential-form.js.map +0 -1
- package/build/auth/delegated-oauth-app.d.ts +0 -90
- package/build/auth/delegated-oauth-app.d.ts.map +0 -1
- package/build/auth/delegated-oauth-app.js +0 -881
- package/build/auth/delegated-oauth-app.js.map +0 -1
- package/build/auth/index.d.ts +0 -12
- package/build/auth/index.d.ts.map +0 -1
- package/build/auth/index.js +0 -12
- package/build/auth/index.js.map +0 -1
- package/build/auth/local-oauth-app.d.ts +0 -152
- package/build/auth/local-oauth-app.d.ts.map +0 -1
- package/build/auth/local-oauth-app.js +0 -975
- package/build/auth/local-oauth-app.js.map +0 -1
- package/build/auth/relay-login.d.ts +0 -46
- package/build/auth/relay-login.d.ts.map +0 -1
- package/build/auth/relay-login.js +0 -197
- package/build/auth/relay-login.js.map +0 -1
- package/build/auth/router.d.ts +0 -18
- package/build/auth/router.d.ts.map +0 -1
- package/build/auth/router.js +0 -62
- package/build/auth/router.js.map +0 -1
- package/build/auth/session-store.d.ts +0 -49
- package/build/auth/session-store.d.ts.map +0 -1
- package/build/auth/session-store.js +0 -94
- package/build/auth/session-store.js.map +0 -1
- package/build/auth/stable-sub.d.ts +0 -27
- package/build/auth/stable-sub.d.ts.map +0 -1
- package/build/auth/stable-sub.js +0 -38
- package/build/auth/stable-sub.js.map +0 -1
- package/build/auth/well-known.d.ts +0 -4
- package/build/auth/well-known.d.ts.map +0 -1
- package/build/auth/well-known.js +0 -26
- package/build/auth/well-known.js.map +0 -1
- package/build/oauth/cache.d.ts +0 -22
- package/build/oauth/cache.d.ts.map +0 -1
- package/build/oauth/cache.js +0 -24
- package/build/oauth/cache.js.map +0 -1
- package/build/oauth/index.d.ts +0 -6
- package/build/oauth/index.d.ts.map +0 -1
- package/build/oauth/index.js +0 -6
- package/build/oauth/index.js.map +0 -1
- package/build/oauth/jwt-issuer.d.ts +0 -87
- package/build/oauth/jwt-issuer.d.ts.map +0 -1
- package/build/oauth/jwt-issuer.js +0 -205
- package/build/oauth/jwt-issuer.js.map +0 -1
- package/build/oauth/pkce.d.ts +0 -10
- package/build/oauth/pkce.d.ts.map +0 -1
- package/build/oauth/pkce.js +0 -34
- package/build/oauth/pkce.js.map +0 -1
- package/build/oauth/provider.d.ts +0 -52
- package/build/oauth/provider.d.ts.map +0 -1
- package/build/oauth/provider.js +0 -98
- package/build/oauth/provider.js.map +0 -1
- package/build/oauth/user-store.d.ts +0 -25
- package/build/oauth/user-store.d.ts.map +0 -1
- package/build/oauth/user-store.js +0 -85
- package/build/oauth/user-store.js.map +0 -1
- package/build/relay/browser.d.ts.map +0 -1
- package/build/relay/browser.js.map +0 -1
- package/build/relay/client.d.ts +0 -36
- package/build/relay/client.d.ts.map +0 -1
- package/build/relay/client.js +0 -135
- package/build/relay/client.js.map +0 -1
- package/build/relay/session.d.ts +0 -12
- package/build/relay/session.d.ts.map +0 -1
- package/build/relay/session.js +0 -55
- package/build/relay/session.js.map +0 -1
- package/build/relay/tool-helpers.d.ts +0 -85
- package/build/relay/tool-helpers.d.ts.map +0 -1
- package/build/relay/tool-helpers.js +0 -103
- package/build/relay/tool-helpers.js.map +0 -1
- package/build/relay/wordlist.d.ts +0 -2
- package/build/relay/wordlist.d.ts.map +0 -1
- package/build/relay/wordlist.js +0 -7782
- package/build/relay/wordlist.js.map +0 -1
- package/build/schema/types.d.ts +0 -67
- package/build/schema/types.d.ts.map +0 -1
- package/build/schema/types.js +0 -2
- package/build/schema/types.js.map +0 -1
- package/build/storage/mode.d.ts +0 -23
- package/build/storage/mode.d.ts.map +0 -1
- package/build/storage/mode.js +0 -46
- package/build/storage/mode.js.map +0 -1
- package/build/storage/session-lock.d.ts +0 -19
- package/build/storage/session-lock.d.ts.map +0 -1
- package/build/storage/session-lock.js +0 -105
- package/build/storage/session-lock.js.map +0 -1
- package/build/transport/oauth-middleware.d.ts +0 -45
- package/build/transport/oauth-middleware.d.ts.map +0 -1
- package/build/transport/oauth-middleware.js +0 -86
- package/build/transport/oauth-middleware.js.map +0 -1
- /package/build/{relay → util}/browser.d.ts +0 -0
|
@@ -1,1869 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Credential form HTML renderer.
|
|
3
|
-
*
|
|
4
|
-
* Renders a dark-themed HTML form from a RelayConfigSchema.
|
|
5
|
-
* Used as the OAuth authorization page presented to the user during relay config.
|
|
6
|
-
*
|
|
7
|
-
* This is a TypeScript port of core-py's `credential_form.py`. CSS and embedded JS
|
|
8
|
-
* are kept verbatim so the rendered form has visual + behavioral parity between
|
|
9
|
-
* Python and TS servers.
|
|
10
|
-
*/
|
|
11
|
-
/**
|
|
12
|
-
* Return true iff every required field in `schema` has a non-empty value in
|
|
13
|
-
* `config`. For schemas whose fields are all optional, requires the explicit
|
|
14
|
-
* `_setup_complete: "true"` flag — see core-py `is_schema_complete` docstring.
|
|
15
|
-
*
|
|
16
|
-
* Strict equality on the flag: only the literal string `"true"` counts. This
|
|
17
|
-
* guards against legacy / partial bootstrap entries (e.g. peer-shared cloud
|
|
18
|
-
* keys) tricking the auto-open gate into thinking the user has configured.
|
|
19
|
-
*/
|
|
20
|
-
export function isSchemaComplete(config, schema) {
|
|
21
|
-
if (!config)
|
|
22
|
-
return false;
|
|
23
|
-
const fields = schema.fields ?? [];
|
|
24
|
-
let hasRequired = false;
|
|
25
|
-
for (let i = 0; i < fields.length; i++) {
|
|
26
|
-
const field = fields[i];
|
|
27
|
-
if (field.required === true) {
|
|
28
|
-
hasRequired = true;
|
|
29
|
-
if (!config[field.key]) {
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
if (hasRequired) {
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
return config._setup_complete === 'true';
|
|
38
|
-
}
|
|
39
|
-
function escapeHtml(value) {
|
|
40
|
-
return String(value ?? '')
|
|
41
|
-
.replace(/&/g, '&')
|
|
42
|
-
.replace(/</g, '<')
|
|
43
|
-
.replace(/>/g, '>')
|
|
44
|
-
.replace(/"/g, '"')
|
|
45
|
-
.replace(/'/g, ''');
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Shared CSS for every relay/auth HTML page rendered by core-ts.
|
|
49
|
-
*
|
|
50
|
-
* Kept verbatim with `packages/core-py/src/mcp_core/auth/credential_form.py`'s
|
|
51
|
-
* `_FORM_SHELL_CSS` constant so the credential form, the `/login` password
|
|
52
|
-
* gate, and any future relay page have identical visual styling regardless of
|
|
53
|
-
* which language renders them. Extending this string requires updating the
|
|
54
|
-
* Python parity copy in the same commit.
|
|
55
|
-
*/
|
|
56
|
-
const FORM_SHELL_CSS = ` *, *::before, *::after {
|
|
57
|
-
box-sizing: border-box;
|
|
58
|
-
margin: 0;
|
|
59
|
-
padding: 0;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
body {
|
|
63
|
-
background-color: #0f0f0f;
|
|
64
|
-
color: #e8e8e8;
|
|
65
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
66
|
-
font-size: 15px;
|
|
67
|
-
line-height: 1.6;
|
|
68
|
-
min-height: 100vh;
|
|
69
|
-
display: flex;
|
|
70
|
-
align-items: flex-start;
|
|
71
|
-
justify-content: center;
|
|
72
|
-
padding: 2rem 1rem;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
.container {
|
|
76
|
-
width: 100%;
|
|
77
|
-
max-width: 480px;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
.card {
|
|
81
|
-
background-color: #1a1a1a;
|
|
82
|
-
border: 1px solid #2a2a2a;
|
|
83
|
-
border-radius: 12px;
|
|
84
|
-
padding: 2rem;
|
|
85
|
-
margin-bottom: 1.25rem;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
.server-header {
|
|
89
|
-
margin-bottom: 1.5rem;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
.server-name {
|
|
93
|
-
font-size: 1.375rem;
|
|
94
|
-
font-weight: 600;
|
|
95
|
-
color: #ffffff;
|
|
96
|
-
margin-bottom: 0.375rem;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
.server-id {
|
|
100
|
-
font-size: 0.8125rem;
|
|
101
|
-
color: #9ca3af;
|
|
102
|
-
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
|
103
|
-
margin-bottom: 0.5rem;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
.server-description {
|
|
107
|
-
font-size: 0.9rem;
|
|
108
|
-
color: #9ca3af;
|
|
109
|
-
margin-top: 0.5rem;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
.form-title {
|
|
113
|
-
display: block;
|
|
114
|
-
font-size: 0.875rem;
|
|
115
|
-
font-weight: 500;
|
|
116
|
-
color: #aaa;
|
|
117
|
-
text-transform: uppercase;
|
|
118
|
-
letter-spacing: 0.05em;
|
|
119
|
-
margin-bottom: 1.25rem;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
.field-group {
|
|
123
|
-
margin-bottom: 1.25rem;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
.field-label {
|
|
127
|
-
display: flex;
|
|
128
|
-
align-items: center;
|
|
129
|
-
gap: 0.5rem;
|
|
130
|
-
font-size: 0.875rem;
|
|
131
|
-
font-weight: 500;
|
|
132
|
-
color: #ccc;
|
|
133
|
-
margin-bottom: 0.375rem;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
.required-badge {
|
|
137
|
-
font-size: 0.6875rem;
|
|
138
|
-
font-weight: 500;
|
|
139
|
-
color: #f87171;
|
|
140
|
-
background-color: rgba(248, 113, 113, 0.1);
|
|
141
|
-
border: 1px solid rgba(248, 113, 113, 0.25);
|
|
142
|
-
border-radius: 4px;
|
|
143
|
-
padding: 0.1rem 0.4rem;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
.optional-badge {
|
|
147
|
-
font-size: 0.6875rem;
|
|
148
|
-
font-weight: 400;
|
|
149
|
-
color: #9ca3af;
|
|
150
|
-
background-color: rgba(255, 255, 255, 0.04);
|
|
151
|
-
border: 1px solid #333;
|
|
152
|
-
border-radius: 4px;
|
|
153
|
-
padding: 0.1rem 0.4rem;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
.field-input {
|
|
157
|
-
width: 100%;
|
|
158
|
-
background-color: #111;
|
|
159
|
-
border: 1px solid #2e2e2e;
|
|
160
|
-
border-radius: 8px;
|
|
161
|
-
color: #e8e8e8;
|
|
162
|
-
font-size: 0.9375rem;
|
|
163
|
-
padding: 0.625rem 0.875rem;
|
|
164
|
-
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
165
|
-
outline: none;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
.field-input:focus {
|
|
169
|
-
border-color: #4a6fa5;
|
|
170
|
-
box-shadow: 0 0 0 3px rgba(74, 111, 165, 0.2);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
.field-input::placeholder {
|
|
174
|
-
color: #9ca3af;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
.field-input[aria-invalid="true"] {
|
|
178
|
-
border-color: #f87171;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
.field-input[aria-invalid="true"]:focus {
|
|
182
|
-
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.2);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
.field-input:disabled {
|
|
186
|
-
opacity: 0.5;
|
|
187
|
-
cursor: not-allowed;
|
|
188
|
-
background-color: #0f0f0f;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
.model-chain { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; position: relative; padding: 8px; border: 1px solid #2a2a3a; border-radius: 8px; background: #14141f; transition: border-color 0.15s ease, box-shadow 0.15s ease; }
|
|
192
|
-
.model-chain:focus-within { border-color: #4a6fa5; box-shadow: 0 0 0 3px rgba(74, 111, 165, 0.2); }
|
|
193
|
-
.mc-chips { display: flex; flex-wrap: wrap; gap: 6px; width: 100%; }
|
|
194
|
-
.mc-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; background: #23233a; border: 1px solid #34344a; border-radius: 6px; font-size: 13px; cursor: grab; }
|
|
195
|
-
.mc-chip.dragging { opacity: 0.4; }
|
|
196
|
-
.mc-chip .mc-order { color: #8a8aa5; font-variant-numeric: tabular-nums; }
|
|
197
|
-
.mc-chip button { background: none; border: none; color: #b56; cursor: pointer; font-size: 14px; line-height: 1; padding: 0; }
|
|
198
|
-
.mc-chip button:hover, .mc-chip button:focus-visible { color: #ff6b6b; outline: 2px solid #ff6b6b; outline-offset: 2px; border-radius: 2px; }
|
|
199
|
-
.mc-typeahead { flex: 1; min-width: 140px; background: transparent; border: none; color: inherit; outline: none; font-size: 14px; padding: 4px; }
|
|
200
|
-
.mc-dropdown { position: absolute; top: 100%; left: 0; right: 0; z-index: 10; background: #1b1b2a; border: 1px solid #2a2a3a; border-radius: 8px; margin-top: 4px; max-height: 220px; overflow-y: auto; }
|
|
201
|
-
.mc-dropdown label { display: flex; align-items: center; gap: 8px; padding: 6px 10px; cursor: pointer; font-size: 13px; }
|
|
202
|
-
.mc-dropdown label:hover, .mc-dropdown label:focus-within { background: #23233a; }
|
|
203
|
-
.mc-badge { font-size: 12px; color: #8a8aa5; width: 100%; }
|
|
204
|
-
|
|
205
|
-
.help-text {
|
|
206
|
-
font-size: 0.8125rem;
|
|
207
|
-
color: #9ca3af;
|
|
208
|
-
margin-top: 0.375rem;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
.help-text a {
|
|
212
|
-
color: #6c9bd2;
|
|
213
|
-
text-decoration: none;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
.help-text a:hover {
|
|
217
|
-
text-decoration: underline;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
.help-text a:focus-visible {
|
|
221
|
-
outline: 2px solid #4a6fa5;
|
|
222
|
-
outline-offset: 2px;
|
|
223
|
-
border-radius: 2px;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
.submit-btn {
|
|
227
|
-
width: 100%;
|
|
228
|
-
background-color: #4a6fa5;
|
|
229
|
-
border: none;
|
|
230
|
-
border-radius: 8px;
|
|
231
|
-
color: #fff;
|
|
232
|
-
cursor: pointer;
|
|
233
|
-
font-size: 0.9375rem;
|
|
234
|
-
font-weight: 500;
|
|
235
|
-
padding: 0.75rem 1.5rem;
|
|
236
|
-
transition: background-color 0.15s ease, opacity 0.15s ease;
|
|
237
|
-
margin-top: 0.5rem;
|
|
238
|
-
display: flex;
|
|
239
|
-
justify-content: center;
|
|
240
|
-
align-items: center;
|
|
241
|
-
gap: 0.5rem;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
.submit-btn:hover {
|
|
245
|
-
background-color: #5a7fb5;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
.submit-btn:focus-visible {
|
|
249
|
-
outline: 2px solid #6c9bd2;
|
|
250
|
-
outline-offset: 2px;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
.submit-btn:disabled {
|
|
254
|
-
opacity: 0.5;
|
|
255
|
-
cursor: not-allowed;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
.submit-btn[aria-busy="true"] {
|
|
259
|
-
cursor: wait;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
.submit-btn[aria-busy="true"]::before {
|
|
263
|
-
content: "";
|
|
264
|
-
width: 1rem;
|
|
265
|
-
height: 1rem;
|
|
266
|
-
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
267
|
-
border-top-color: #fff;
|
|
268
|
-
border-radius: 50%;
|
|
269
|
-
animation: spin 0.8s linear infinite;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
@keyframes spin {
|
|
273
|
-
to {
|
|
274
|
-
transform: rotate(360deg);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
.status-box {
|
|
279
|
-
display: none;
|
|
280
|
-
border-radius: 8px;
|
|
281
|
-
font-size: 0.875rem;
|
|
282
|
-
margin-top: 1rem;
|
|
283
|
-
padding: 0.75rem 1rem;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
.status-box.success {
|
|
287
|
-
background-color: rgba(52, 199, 89, 0.1);
|
|
288
|
-
border: 1px solid rgba(52, 199, 89, 0.3);
|
|
289
|
-
color: #34c759;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
.status-box.error {
|
|
293
|
-
background-color: rgba(248, 113, 113, 0.1);
|
|
294
|
-
border: 1px solid rgba(248, 113, 113, 0.3);
|
|
295
|
-
color: #f87171;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
.status-box.info {
|
|
299
|
-
background-color: rgba(59, 130, 246, 0.1);
|
|
300
|
-
border: 1px solid rgba(59, 130, 246, 0.3);
|
|
301
|
-
color: #e8e8e8;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
.status-box a {
|
|
305
|
-
color: #60a5fa;
|
|
306
|
-
font-weight: 500;
|
|
307
|
-
text-decoration: none;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
.status-box a:hover {
|
|
311
|
-
text-decoration: underline;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
.status-box a:focus-visible {
|
|
315
|
-
outline: 2px solid #60a5fa;
|
|
316
|
-
outline-offset: 2px;
|
|
317
|
-
border-radius: 2px;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
.capabilities-section {
|
|
321
|
-
margin-top: 0;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
.capabilities-title {
|
|
325
|
-
font-size: 0.875rem;
|
|
326
|
-
font-weight: 500;
|
|
327
|
-
color: #aaa;
|
|
328
|
-
text-transform: uppercase;
|
|
329
|
-
letter-spacing: 0.05em;
|
|
330
|
-
margin-bottom: 0.875rem;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
.capabilities-list {
|
|
334
|
-
list-style: none;
|
|
335
|
-
display: flex;
|
|
336
|
-
flex-direction: column;
|
|
337
|
-
gap: 0.625rem;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
.capability-item {
|
|
341
|
-
background-color: #111;
|
|
342
|
-
border: 1px solid #2a2a2a;
|
|
343
|
-
border-radius: 8px;
|
|
344
|
-
padding: 0.75rem 1rem;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
.capability-header {
|
|
348
|
-
display: flex;
|
|
349
|
-
align-items: center;
|
|
350
|
-
justify-content: space-between;
|
|
351
|
-
gap: 0.5rem;
|
|
352
|
-
margin-bottom: 0.25rem;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
.capability-label {
|
|
356
|
-
font-size: 0.875rem;
|
|
357
|
-
font-weight: 500;
|
|
358
|
-
color: #ccc;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
.capability-priority {
|
|
362
|
-
font-size: 0.6875rem;
|
|
363
|
-
font-weight: 500;
|
|
364
|
-
border-radius: 4px;
|
|
365
|
-
padding: 0.1rem 0.4rem;
|
|
366
|
-
text-transform: capitalize;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
.priority-high {
|
|
370
|
-
color: #f87171;
|
|
371
|
-
background-color: rgba(248, 113, 113, 0.1);
|
|
372
|
-
border: 1px solid rgba(248, 113, 113, 0.25);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
.priority-medium {
|
|
376
|
-
color: #fbbf24;
|
|
377
|
-
background-color: rgba(251, 191, 36, 0.1);
|
|
378
|
-
border: 1px solid rgba(251, 191, 36, 0.25);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
.priority-low {
|
|
382
|
-
color: #6ee7b7;
|
|
383
|
-
background-color: rgba(110, 231, 183, 0.1);
|
|
384
|
-
border: 1px solid rgba(110, 231, 183, 0.25);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
.capability-desc {
|
|
388
|
-
font-size: 0.8125rem;
|
|
389
|
-
color: #9ca3af;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
:root {
|
|
393
|
-
color-scheme: light dark;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
@media (prefers-color-scheme: light) {
|
|
397
|
-
body {
|
|
398
|
-
background-color: #f4f5f7;
|
|
399
|
-
color: #1f2937;
|
|
400
|
-
}
|
|
401
|
-
.card {
|
|
402
|
-
background-color: #ffffff;
|
|
403
|
-
border-color: #e5e7eb;
|
|
404
|
-
}
|
|
405
|
-
.server-name {
|
|
406
|
-
color: #111827;
|
|
407
|
-
}
|
|
408
|
-
.server-id,
|
|
409
|
-
.server-description,
|
|
410
|
-
.help-text,
|
|
411
|
-
.mc-badge,
|
|
412
|
-
.form-title,
|
|
413
|
-
.capabilities-title,
|
|
414
|
-
.capability-desc {
|
|
415
|
-
color: #6b7280;
|
|
416
|
-
}
|
|
417
|
-
.field-label,
|
|
418
|
-
.capability-label {
|
|
419
|
-
color: #374151;
|
|
420
|
-
}
|
|
421
|
-
.field-input {
|
|
422
|
-
background-color: #ffffff;
|
|
423
|
-
border-color: #d1d5db;
|
|
424
|
-
color: #1f2937;
|
|
425
|
-
}
|
|
426
|
-
.field-input::placeholder {
|
|
427
|
-
color: #9ca3af;
|
|
428
|
-
}
|
|
429
|
-
.field-input:disabled {
|
|
430
|
-
background-color: #f3f4f6;
|
|
431
|
-
}
|
|
432
|
-
.optional-badge {
|
|
433
|
-
color: #6b7280;
|
|
434
|
-
background-color: #f3f4f6;
|
|
435
|
-
border-color: #d1d5db;
|
|
436
|
-
}
|
|
437
|
-
.capability-item {
|
|
438
|
-
background-color: #f9fafb;
|
|
439
|
-
border-color: #e5e7eb;
|
|
440
|
-
}
|
|
441
|
-
.model-chain {
|
|
442
|
-
background: #f9fafb;
|
|
443
|
-
border-color: #d1d5db;
|
|
444
|
-
}
|
|
445
|
-
.mc-chip {
|
|
446
|
-
background: #eef1f6;
|
|
447
|
-
border-color: #d1d5db;
|
|
448
|
-
}
|
|
449
|
-
.mc-dropdown {
|
|
450
|
-
background: #ffffff;
|
|
451
|
-
border-color: #d1d5db;
|
|
452
|
-
}
|
|
453
|
-
.mc-dropdown label:hover,
|
|
454
|
-
.mc-dropdown label:focus-within {
|
|
455
|
-
background: #eef1f6;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
`;
|
|
459
|
-
/**
|
|
460
|
-
* Wrap `bodyHtml` in the shared dark-theme HTML shell.
|
|
461
|
-
*
|
|
462
|
-
* The shell provides `<!DOCTYPE html>`, `<head>` (charset, viewport, a
|
|
463
|
-
* Content-Security-Policy meta, escaped `<title>`, embedded `FORM_SHELL_CSS`)
|
|
464
|
-
* and a `<body>` whose only child is `bodyHtml`. `bodyHtml` is inserted
|
|
465
|
-
* verbatim, so callers MUST pre-escape any untrusted values they interpolate.
|
|
466
|
-
*
|
|
467
|
-
* The CSP (`default-src 'none'; style-src 'unsafe-inline'; script-src
|
|
468
|
-
* 'unsafe-inline'; connect-src 'self'`) permits the page's own inline
|
|
469
|
-
* `<style>`/`<script>` and same-origin `fetch` submits while blocking any
|
|
470
|
-
* external resource load.
|
|
471
|
-
*
|
|
472
|
-
* `title` is HTML-escaped before being placed in `<title>`.
|
|
473
|
-
*
|
|
474
|
-
* Used by `renderCredentialForm` (relay credential form) and by
|
|
475
|
-
* `loginGetHandler` in `relay-login.ts` (the `/login` password gate) so both
|
|
476
|
-
* pages share identical typography, palette, card layout, and input styling.
|
|
477
|
-
* Parity with Python `render_form_shell` in
|
|
478
|
-
* `packages/core-py/src/mcp_core/auth/credential_form.py`.
|
|
479
|
-
*/
|
|
480
|
-
export function renderFormShell(title, bodyHtml) {
|
|
481
|
-
const safeTitle = escapeHtml(title);
|
|
482
|
-
return `<!DOCTYPE html>
|
|
483
|
-
<html lang="en">
|
|
484
|
-
<head>
|
|
485
|
-
<meta charset="UTF-8" />
|
|
486
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
487
|
-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'" />
|
|
488
|
-
<title>${safeTitle}</title>
|
|
489
|
-
<style>
|
|
490
|
-
${FORM_SHELL_CSS} </style>
|
|
491
|
-
</head>
|
|
492
|
-
<body>
|
|
493
|
-
${bodyHtml}
|
|
494
|
-
</body>
|
|
495
|
-
</html>`;
|
|
496
|
-
}
|
|
497
|
-
function renderField(field, value = '') {
|
|
498
|
-
const key = escapeHtml(field.key ?? '');
|
|
499
|
-
const label = escapeHtml(field.label ?? '');
|
|
500
|
-
const fieldType = escapeHtml(field.type ?? 'text');
|
|
501
|
-
const placeholder = escapeHtml(field.placeholder ?? '');
|
|
502
|
-
const helpText = escapeHtml(field.helpText ?? '');
|
|
503
|
-
const helpUrl = escapeHtml(field.helpUrl ?? '');
|
|
504
|
-
const required = Boolean(field.required);
|
|
505
|
-
const requiredAttr = required ? ' required' : '';
|
|
506
|
-
const requiredBadge = required
|
|
507
|
-
? '<span class="required-badge" aria-hidden="true">Required</span>'
|
|
508
|
-
: '<span class="optional-badge" aria-hidden="true">Optional</span>';
|
|
509
|
-
const valueAttr = value ? ` value="${escapeHtml(value)}"` : '';
|
|
510
|
-
const patternAttr = field.validation ? ` pattern="${escapeHtml(field.validation)}"` : '';
|
|
511
|
-
let helpHtml = '';
|
|
512
|
-
let ariaDescribedby = '';
|
|
513
|
-
if (helpText) {
|
|
514
|
-
ariaDescribedby = ` aria-describedby="help-${key}"`;
|
|
515
|
-
if (helpUrl) {
|
|
516
|
-
helpHtml = `<p class="help-text" id="help-${key}"><a href="${helpUrl}" target="_blank" rel="noopener noreferrer">${helpText}</a></p>`;
|
|
517
|
-
}
|
|
518
|
-
else {
|
|
519
|
-
helpHtml = `<p class="help-text" id="help-${key}">${helpText}</p>`;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
return `
|
|
523
|
-
<div class="field-group">
|
|
524
|
-
<label for="field-${key}" class="field-label">
|
|
525
|
-
${label}
|
|
526
|
-
${requiredBadge}
|
|
527
|
-
</label>
|
|
528
|
-
<input
|
|
529
|
-
id="field-${key}"
|
|
530
|
-
name="${key}"
|
|
531
|
-
type="${fieldType}"
|
|
532
|
-
placeholder="${placeholder}"
|
|
533
|
-
class="field-input"
|
|
534
|
-
autocomplete="off"
|
|
535
|
-
autocorrect="off"
|
|
536
|
-
autocapitalize="off"
|
|
537
|
-
spellcheck="false"${valueAttr}${patternAttr}${requiredAttr}${ariaDescribedby}
|
|
538
|
-
/>
|
|
539
|
-
${helpHtml}
|
|
540
|
-
</div>`;
|
|
541
|
-
}
|
|
542
|
-
function renderCapability(cap) {
|
|
543
|
-
const label = escapeHtml(cap.label ?? '');
|
|
544
|
-
const priority = escapeHtml(cap.priority ?? '');
|
|
545
|
-
const description = escapeHtml(cap.description ?? '');
|
|
546
|
-
const priorityClass = priority ? `priority-${priority}` : 'priority-medium';
|
|
547
|
-
const descHtml = description ? `<p class="capability-desc">${description}</p>` : '';
|
|
548
|
-
return `
|
|
549
|
-
<li class="capability-item">
|
|
550
|
-
<div class="capability-header">
|
|
551
|
-
<span class="capability-label">${label}</span>
|
|
552
|
-
<span class="capability-priority ${priorityClass}">${priority}</span>
|
|
553
|
-
</div>
|
|
554
|
-
${descHtml}
|
|
555
|
-
</li>`;
|
|
556
|
-
}
|
|
557
|
-
// ===========================================================================
|
|
558
|
-
// Schema-level tabs + dynamic card group (W4.1)
|
|
559
|
-
// ---------------------------------------------------------------------------
|
|
560
|
-
// Two OPT-IN capabilities that let servers declare richer credential UIs
|
|
561
|
-
// through the schema alone (no forked renderer):
|
|
562
|
-
// * `tabs` -> mutually-exclusive credential modes (e.g. telegram
|
|
563
|
-
// bot-token vs phone/OTP), only the active tab submits.
|
|
564
|
-
// * `cardGroup` -> a repeatable field group with Add/Remove (e.g. email
|
|
565
|
-
// multi-account), submitted as a JSON array.
|
|
566
|
-
// A schema that declares NEITHER key renders through the unchanged flat-field
|
|
567
|
-
// path in `renderCredentialForm` below, byte-for-byte identical to before.
|
|
568
|
-
// The feature CSS ships as a `<style>` block inside the body — only when the
|
|
569
|
-
// feature is used — so the shared `FORM_SHELL_CSS` (and the flat form) stay
|
|
570
|
-
// untouched. Kept in parity with core-py `credential_form.py`.
|
|
571
|
-
// ===========================================================================
|
|
572
|
-
// Tab CSS (scoped `.tabs`/`.tab`/`.tab-panel`); emitted in-body only for
|
|
573
|
-
// tabbed forms. Palette mirrors the shared shell so tabs blend into the card.
|
|
574
|
-
const TABS_CSS = ` <style>
|
|
575
|
-
.tabs {
|
|
576
|
-
display: flex;
|
|
577
|
-
gap: 0;
|
|
578
|
-
margin-bottom: 1.5rem;
|
|
579
|
-
border-bottom: 1px solid #2a2a2a;
|
|
580
|
-
}
|
|
581
|
-
.tab {
|
|
582
|
-
flex: 1;
|
|
583
|
-
padding: 0.75rem 1rem;
|
|
584
|
-
background: transparent;
|
|
585
|
-
border: none;
|
|
586
|
-
color: #9ca3af;
|
|
587
|
-
cursor: pointer;
|
|
588
|
-
font-size: 0.9rem;
|
|
589
|
-
font-weight: 500;
|
|
590
|
-
border-bottom: 2px solid transparent;
|
|
591
|
-
transition: color 0.15s ease, border-color 0.15s ease;
|
|
592
|
-
font-family: inherit;
|
|
593
|
-
}
|
|
594
|
-
.tab:not(:disabled):hover {
|
|
595
|
-
color: #ccc;
|
|
596
|
-
}
|
|
597
|
-
.tab:focus-visible {
|
|
598
|
-
outline: 2px solid #4a6fa5;
|
|
599
|
-
outline-offset: -2px;
|
|
600
|
-
border-radius: 4px;
|
|
601
|
-
}
|
|
602
|
-
.tab.active {
|
|
603
|
-
color: #fff;
|
|
604
|
-
border-bottom-color: #4a6fa5;
|
|
605
|
-
}
|
|
606
|
-
.tab:disabled {
|
|
607
|
-
cursor: not-allowed;
|
|
608
|
-
opacity: 0.5;
|
|
609
|
-
}
|
|
610
|
-
.tab-panel {
|
|
611
|
-
display: none;
|
|
612
|
-
}
|
|
613
|
-
.tab-panel.active {
|
|
614
|
-
display: block;
|
|
615
|
-
}
|
|
616
|
-
@media (prefers-color-scheme: light) {
|
|
617
|
-
.tabs {
|
|
618
|
-
border-bottom-color: #e5e7eb;
|
|
619
|
-
}
|
|
620
|
-
.tab {
|
|
621
|
-
color: #6b7280;
|
|
622
|
-
}
|
|
623
|
-
.tab:not(:disabled):hover {
|
|
624
|
-
color: #374151;
|
|
625
|
-
}
|
|
626
|
-
.tab.active {
|
|
627
|
-
color: #111827;
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
</style>
|
|
631
|
-
`;
|
|
632
|
-
// Card-group CSS (scoped `.card-group-*`); emitted in-body only for card
|
|
633
|
-
// forms. `.card-group-item` avoids the shell's `.card` name deliberately.
|
|
634
|
-
const CARD_GROUP_CSS = ` <style>
|
|
635
|
-
.card-group-item {
|
|
636
|
-
border: 1px solid #2a2a2a;
|
|
637
|
-
border-radius: 10px;
|
|
638
|
-
padding: 1rem;
|
|
639
|
-
margin-bottom: 0.875rem;
|
|
640
|
-
background-color: #121212;
|
|
641
|
-
}
|
|
642
|
-
.card-group-header {
|
|
643
|
-
display: flex;
|
|
644
|
-
justify-content: space-between;
|
|
645
|
-
align-items: center;
|
|
646
|
-
margin-bottom: 0.75rem;
|
|
647
|
-
}
|
|
648
|
-
.card-group-title {
|
|
649
|
-
font-size: 0.95rem;
|
|
650
|
-
font-weight: 600;
|
|
651
|
-
color: #ddd;
|
|
652
|
-
max-width: 300px;
|
|
653
|
-
white-space: nowrap;
|
|
654
|
-
overflow: hidden;
|
|
655
|
-
text-overflow: ellipsis;
|
|
656
|
-
}
|
|
657
|
-
.card-group-remove {
|
|
658
|
-
background: transparent;
|
|
659
|
-
color: #f87171;
|
|
660
|
-
border: 1px solid rgba(248, 113, 113, 0.3);
|
|
661
|
-
border-radius: 6px;
|
|
662
|
-
padding: 0.25rem 0.6rem;
|
|
663
|
-
cursor: pointer;
|
|
664
|
-
font-size: 0.75rem;
|
|
665
|
-
font-family: inherit;
|
|
666
|
-
}
|
|
667
|
-
.card-group-remove:hover:not(:disabled) {
|
|
668
|
-
background-color: rgba(248, 113, 113, 0.08);
|
|
669
|
-
}
|
|
670
|
-
.card-group-remove:focus-visible {
|
|
671
|
-
outline: 2px solid #f87171;
|
|
672
|
-
outline-offset: 2px;
|
|
673
|
-
}
|
|
674
|
-
.card-group-add {
|
|
675
|
-
width: 100%;
|
|
676
|
-
background-color: transparent;
|
|
677
|
-
color: #6c9bd2;
|
|
678
|
-
border: 1px dashed #3a5a8a;
|
|
679
|
-
border-radius: 8px;
|
|
680
|
-
padding: 0.625rem 1rem;
|
|
681
|
-
cursor: pointer;
|
|
682
|
-
font-size: 0.875rem;
|
|
683
|
-
margin-bottom: 1rem;
|
|
684
|
-
font-family: inherit;
|
|
685
|
-
transition: background-color 0.15s ease, border-color 0.15s ease;
|
|
686
|
-
}
|
|
687
|
-
.card-group-add:hover:not(:disabled) {
|
|
688
|
-
background-color: rgba(108, 155, 210, 0.08);
|
|
689
|
-
border-color: #4a6fa5;
|
|
690
|
-
}
|
|
691
|
-
.card-group-add:focus-visible {
|
|
692
|
-
outline: 2px solid #6c9bd2;
|
|
693
|
-
outline-offset: 2px;
|
|
694
|
-
}
|
|
695
|
-
.card-group-add:disabled {
|
|
696
|
-
opacity: 0.5;
|
|
697
|
-
cursor: not-allowed;
|
|
698
|
-
border-color: #2a3a4a;
|
|
699
|
-
}
|
|
700
|
-
@media (prefers-color-scheme: light) {
|
|
701
|
-
.card-group-item {
|
|
702
|
-
background-color: #f9fafb;
|
|
703
|
-
border-color: #e5e7eb;
|
|
704
|
-
}
|
|
705
|
-
.card-group-title {
|
|
706
|
-
color: #374151;
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
</style>
|
|
710
|
-
`;
|
|
711
|
-
// Multi-step (OTP / 2FA) step-input UI, shared by tab forms. Mirrors the flat
|
|
712
|
-
// form's `showStepInput`/`submitStep` semantics (same `/otp` POST, same
|
|
713
|
-
// redirect-follow on completion) so a tabbed server's phone->OTP->2FA chain
|
|
714
|
-
// behaves identically to the default form.
|
|
715
|
-
const STEP_UI_JS = `
|
|
716
|
-
function showStepInput(ns) {
|
|
717
|
-
if (form && form.style.display !== "none") { form.style.display = "none"; }
|
|
718
|
-
var tabsEl = document.querySelector(".tabs");
|
|
719
|
-
if (tabsEl) { tabsEl.style.display = "none"; }
|
|
720
|
-
var container = document.getElementById("step-container");
|
|
721
|
-
var promptEl, inputEl, buttonEl, errorEl;
|
|
722
|
-
if (container) {
|
|
723
|
-
promptEl = document.getElementById("step-prompt");
|
|
724
|
-
inputEl = document.getElementById("step-input");
|
|
725
|
-
buttonEl = document.getElementById("step-submit");
|
|
726
|
-
errorEl = document.getElementById("step-error");
|
|
727
|
-
errorEl.style.display = "none"; errorEl.textContent = "";
|
|
728
|
-
inputEl.value = ""; inputEl.disabled = false;
|
|
729
|
-
buttonEl.disabled = false; buttonEl.removeAttribute("aria-busy"); buttonEl.textContent = "Verify";
|
|
730
|
-
} else {
|
|
731
|
-
var card = form.parentNode;
|
|
732
|
-
container = document.createElement("div"); container.id = "step-container";
|
|
733
|
-
promptEl = document.createElement("label"); promptEl.id = "step-prompt";
|
|
734
|
-
promptEl.setAttribute("for", "step-input"); promptEl.className = "form-title";
|
|
735
|
-
container.appendChild(promptEl);
|
|
736
|
-
var fieldGroup = document.createElement("div"); fieldGroup.className = "field-group";
|
|
737
|
-
inputEl = document.createElement("input"); inputEl.id = "step-input"; inputEl.className = "field-input";
|
|
738
|
-
inputEl.setAttribute("autocomplete", "off"); inputEl.setAttribute("autocorrect", "off");
|
|
739
|
-
inputEl.setAttribute("autocapitalize", "off"); inputEl.setAttribute("spellcheck", "false");
|
|
740
|
-
fieldGroup.appendChild(inputEl); container.appendChild(fieldGroup);
|
|
741
|
-
buttonEl = document.createElement("button"); buttonEl.type = "button"; buttonEl.id = "step-submit";
|
|
742
|
-
buttonEl.className = "submit-btn"; buttonEl.textContent = "Verify"; container.appendChild(buttonEl);
|
|
743
|
-
errorEl = document.createElement("div"); errorEl.id = "step-error"; errorEl.className = "status-box error";
|
|
744
|
-
errorEl.setAttribute("role", "alert"); errorEl.setAttribute("aria-live", "polite"); errorEl.setAttribute("aria-atomic", "true"); errorEl.style.display = "none"; container.appendChild(errorEl);
|
|
745
|
-
card.appendChild(container);
|
|
746
|
-
buttonEl.addEventListener("click", function () { submitStep(); });
|
|
747
|
-
inputEl.addEventListener("keydown", function (evt) { if (evt.key === "Enter") { evt.preventDefault(); submitStep(); } });
|
|
748
|
-
inputEl.addEventListener("input", function () { inputEl.removeAttribute("aria-invalid"); inputEl.removeAttribute("aria-errormessage"); errorEl.style.display = "none"; });
|
|
749
|
-
}
|
|
750
|
-
promptEl.textContent = ns.text || "";
|
|
751
|
-
inputEl.setAttribute("type", ns.input_type || "text");
|
|
752
|
-
inputEl.setAttribute("placeholder", ns.placeholder || "");
|
|
753
|
-
inputEl.dataset.field = ns.field || "value";
|
|
754
|
-
inputEl.focus();
|
|
755
|
-
}
|
|
756
|
-
function submitStep() {
|
|
757
|
-
var inputEl = document.getElementById("step-input");
|
|
758
|
-
var buttonEl = document.getElementById("step-submit");
|
|
759
|
-
var errorEl = document.getElementById("step-error");
|
|
760
|
-
var fieldName = inputEl.dataset.field || "value";
|
|
761
|
-
var value = inputEl.value;
|
|
762
|
-
inputEl.removeAttribute("aria-invalid");
|
|
763
|
-
if (value.trim() === "") {
|
|
764
|
-
errorEl.textContent = "Please enter a value."; errorEl.style.display = "block";
|
|
765
|
-
inputEl.setAttribute("aria-invalid", "true"); inputEl.setAttribute("aria-errormessage", "step-error");
|
|
766
|
-
inputEl.focus(); return;
|
|
767
|
-
}
|
|
768
|
-
errorEl.style.display = "none"; errorEl.textContent = "";
|
|
769
|
-
buttonEl.disabled = true; buttonEl.textContent = "Verifying..."; buttonEl.setAttribute("aria-busy", "true");
|
|
770
|
-
inputEl.disabled = true;
|
|
771
|
-
var body = {}; body[fieldName] = value;
|
|
772
|
-
fetch(otpUrl(), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
|
773
|
-
.then(function (response) { return response.json().then(function (data) {
|
|
774
|
-
if (data.ok) {
|
|
775
|
-
if (data.next_step && (data.next_step.type === "otp_required" || data.next_step.type === "password_required")) {
|
|
776
|
-
showStepInput(data.next_step);
|
|
777
|
-
} else if (typeof data.redirect_url === "string" && data.redirect_url.length > 0) {
|
|
778
|
-
var c = document.getElementById("step-container"); while (c.firstChild) { c.removeChild(c.firstChild); }
|
|
779
|
-
var done = document.createElement("div"); done.className = "status-box success"; done.style.display = "block";
|
|
780
|
-
done.setAttribute("role", "alert"); done.setAttribute("aria-live", "polite"); done.setAttribute("aria-atomic", "true"); done.textContent = "Setup complete! Redirecting..."; c.appendChild(done);
|
|
781
|
-
window.location.replace(data.redirect_url);
|
|
782
|
-
} else {
|
|
783
|
-
var c2 = document.getElementById("step-container"); while (c2.firstChild) { c2.removeChild(c2.firstChild); }
|
|
784
|
-
var done2 = document.createElement("div"); done2.className = "status-box success"; done2.style.display = "block";
|
|
785
|
-
done2.setAttribute("role", "alert"); done2.setAttribute("aria-live", "polite"); done2.setAttribute("aria-atomic", "true"); done2.textContent = "Setup complete! You can close this tab."; c2.appendChild(done2);
|
|
786
|
-
}
|
|
787
|
-
} else {
|
|
788
|
-
errorEl.textContent = data.error || data.error_description || "Verification failed."; errorEl.style.display = "block";
|
|
789
|
-
inputEl.disabled = false; inputEl.setAttribute("aria-invalid", "true"); inputEl.setAttribute("aria-errormessage", "step-error");
|
|
790
|
-
buttonEl.disabled = false; buttonEl.textContent = "Verify"; buttonEl.removeAttribute("aria-busy"); inputEl.focus();
|
|
791
|
-
}
|
|
792
|
-
}); })
|
|
793
|
-
.catch(function (err) {
|
|
794
|
-
errorEl.textContent = "Network error: " + err.message; errorEl.style.display = "block";
|
|
795
|
-
inputEl.disabled = false; inputEl.setAttribute("aria-invalid", "true"); inputEl.setAttribute("aria-errormessage", "step-error");
|
|
796
|
-
buttonEl.disabled = false; buttonEl.textContent = "Verify"; buttonEl.removeAttribute("aria-busy"); inputEl.focus();
|
|
797
|
-
});
|
|
798
|
-
}
|
|
799
|
-
`;
|
|
800
|
-
// Tab form behaviour: tab switching (click + ARIA-tablist arrow keys), then a
|
|
801
|
-
// submit that collects ONLY the active panel's fields. `__SUBMIT_URL__` and
|
|
802
|
-
// `__INITIAL_TAB__` are substituted at render time; `STEP_UI_JS` is spliced in
|
|
803
|
-
// for OTP/2FA support.
|
|
804
|
-
const TABS_SCRIPT = ` <script>
|
|
805
|
-
(function () {
|
|
806
|
-
var form = document.getElementById("credential-form");
|
|
807
|
-
var submitBtn = document.getElementById("submit-btn");
|
|
808
|
-
var statusBox = document.getElementById("status-box");
|
|
809
|
-
var submitUrl = "__SUBMIT_URL__";
|
|
810
|
-
var activeTab = "__INITIAL_TAB__";
|
|
811
|
-
var tabs = document.querySelectorAll(".tab");
|
|
812
|
-
var tabsArray = Array.prototype.slice.call(tabs);
|
|
813
|
-
var pendingRedirectUrl = null;
|
|
814
|
-
|
|
815
|
-
function showStatus(type, message) {
|
|
816
|
-
statusBox.className = "status-box " + type;
|
|
817
|
-
statusBox.textContent = message;
|
|
818
|
-
statusBox.style.display = "block";
|
|
819
|
-
}
|
|
820
|
-
function otpUrl() {
|
|
821
|
-
return submitUrl.replace(/\\/authorize.*/, "/otp");
|
|
822
|
-
}
|
|
823
|
-
${STEP_UI_JS}
|
|
824
|
-
tabs.forEach(function (tab, index) {
|
|
825
|
-
tab.addEventListener("click", function () {
|
|
826
|
-
if (tab.disabled) { return; }
|
|
827
|
-
activeTab = tab.dataset.tab;
|
|
828
|
-
tabs.forEach(function (t) {
|
|
829
|
-
t.classList.remove("active");
|
|
830
|
-
t.setAttribute("aria-selected", "false");
|
|
831
|
-
t.setAttribute("tabindex", "-1");
|
|
832
|
-
});
|
|
833
|
-
tab.classList.add("active");
|
|
834
|
-
tab.setAttribute("aria-selected", "true");
|
|
835
|
-
tab.setAttribute("tabindex", "0");
|
|
836
|
-
document.querySelectorAll(".tab-panel").forEach(function (p) { p.classList.remove("active"); });
|
|
837
|
-
var panel = document.querySelector('.tab-panel[data-panel="' + activeTab + '"]');
|
|
838
|
-
if (panel) { panel.classList.add("active"); }
|
|
839
|
-
statusBox.style.display = "none";
|
|
840
|
-
statusBox.textContent = "";
|
|
841
|
-
form.querySelectorAll(".field-input").forEach(function (i) { i.removeAttribute("aria-invalid"); });
|
|
842
|
-
});
|
|
843
|
-
tab.addEventListener("keydown", function (e) {
|
|
844
|
-
var targetIndex = -1;
|
|
845
|
-
if (e.key === "ArrowRight") { targetIndex = index + 1; if (targetIndex >= tabsArray.length) { targetIndex = 0; } }
|
|
846
|
-
else if (e.key === "ArrowLeft") { targetIndex = index - 1; if (targetIndex < 0) { targetIndex = tabsArray.length - 1; } }
|
|
847
|
-
if (targetIndex !== -1) { e.preventDefault(); tabsArray[targetIndex].focus(); tabsArray[targetIndex].click(); }
|
|
848
|
-
});
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
form.addEventListener("input", function (event) {
|
|
852
|
-
if (event.target.classList.contains("field-input")) {
|
|
853
|
-
event.target.removeAttribute("aria-invalid");
|
|
854
|
-
event.target.removeAttribute("aria-errormessage");
|
|
855
|
-
statusBox.style.display = "none";
|
|
856
|
-
}
|
|
857
|
-
});
|
|
858
|
-
|
|
859
|
-
form.addEventListener("submit", function (event) {
|
|
860
|
-
event.preventDefault();
|
|
861
|
-
var activePanel = document.querySelector(".tab-panel.active");
|
|
862
|
-
var inputs = activePanel ? activePanel.querySelectorAll(".field-input") : [];
|
|
863
|
-
var payload = {};
|
|
864
|
-
var valid = true;
|
|
865
|
-
var firstInvalid = null;
|
|
866
|
-
inputs.forEach(function (input) {
|
|
867
|
-
if (input.hasAttribute("required") && input.value.trim() === "") {
|
|
868
|
-
valid = false;
|
|
869
|
-
input.setAttribute("aria-invalid", "true");
|
|
870
|
-
input.setAttribute("aria-errormessage", "status-box");
|
|
871
|
-
if (!firstInvalid) { firstInvalid = input; }
|
|
872
|
-
} else {
|
|
873
|
-
input.removeAttribute("aria-invalid");
|
|
874
|
-
input.removeAttribute("aria-errormessage");
|
|
875
|
-
payload[input.name] = input.value;
|
|
876
|
-
}
|
|
877
|
-
});
|
|
878
|
-
if (!valid) {
|
|
879
|
-
showStatus("error", "Please fill in all required fields.");
|
|
880
|
-
if (firstInvalid) { firstInvalid.focus(); }
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
submitBtn.disabled = true;
|
|
884
|
-
submitBtn.textContent = "Connecting...";
|
|
885
|
-
submitBtn.setAttribute("aria-busy", "true");
|
|
886
|
-
statusBox.style.display = "none";
|
|
887
|
-
tabs.forEach(function (t) { t.disabled = true; });
|
|
888
|
-
function reenable() {
|
|
889
|
-
submitBtn.disabled = false;
|
|
890
|
-
submitBtn.textContent = "Connect";
|
|
891
|
-
submitBtn.removeAttribute("aria-busy");
|
|
892
|
-
form.querySelectorAll(".field-input").forEach(function (i) { i.disabled = false; });
|
|
893
|
-
tabs.forEach(function (t) { t.disabled = false; });
|
|
894
|
-
}
|
|
895
|
-
function lockConnected() {
|
|
896
|
-
form.querySelectorAll(".field-input").forEach(function (i) { i.disabled = true; });
|
|
897
|
-
submitBtn.disabled = true;
|
|
898
|
-
submitBtn.removeAttribute("aria-busy");
|
|
899
|
-
submitBtn.textContent = "Connected";
|
|
900
|
-
}
|
|
901
|
-
fetch(submitUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) })
|
|
902
|
-
.then(function (response) { return response.json().then(function (data) {
|
|
903
|
-
if (data.ok) {
|
|
904
|
-
if (typeof data.redirect_url === "string" && data.redirect_url.length > 0) { pendingRedirectUrl = data.redirect_url; }
|
|
905
|
-
if (data.next_step && (data.next_step.type === "otp_required" || data.next_step.type === "password_required")) {
|
|
906
|
-
statusBox.style.display = "none";
|
|
907
|
-
showStepInput(data.next_step);
|
|
908
|
-
} else if (data.next_step && data.next_step.type === "info") {
|
|
909
|
-
lockConnected();
|
|
910
|
-
showStatus("success", data.next_step.message || "Setup saved. Additional steps may be required.");
|
|
911
|
-
} else if (pendingRedirectUrl) {
|
|
912
|
-
lockConnected();
|
|
913
|
-
showStatus("success", "Credentials saved. Redirecting...");
|
|
914
|
-
window.location.replace(pendingRedirectUrl);
|
|
915
|
-
} else {
|
|
916
|
-
lockConnected();
|
|
917
|
-
showStatus("success", data.message || "Connected successfully. You can close this window.");
|
|
918
|
-
}
|
|
919
|
-
} else {
|
|
920
|
-
showStatus("error", data.error || data.error_description || "Request failed.");
|
|
921
|
-
reenable();
|
|
922
|
-
}
|
|
923
|
-
}); })
|
|
924
|
-
.catch(function (err) {
|
|
925
|
-
showStatus("error", "Network error: " + err.message);
|
|
926
|
-
reenable();
|
|
927
|
-
});
|
|
928
|
-
});
|
|
929
|
-
})();
|
|
930
|
-
</script>`;
|
|
931
|
-
// Card-group behaviour: a JS builder that clones the declared fields per card,
|
|
932
|
-
// Add/Remove with focus management, and a submit that serialises every card into
|
|
933
|
-
// a JSON array under `__GROUP_KEY__`. All DOM is built with
|
|
934
|
-
// createElement/textContent/setAttribute (no innerHTML with user values).
|
|
935
|
-
const CARD_GROUP_SCRIPT = ` <script>
|
|
936
|
-
(function () {
|
|
937
|
-
var submitUrl = "__SUBMIT_URL__";
|
|
938
|
-
var CARD_FIELDS = __CARD_FIELDS__;
|
|
939
|
-
var GROUP_KEY = "__GROUP_KEY__";
|
|
940
|
-
var TITLE_FIELD = "__TITLE_FIELD__";
|
|
941
|
-
var ITEM_LABEL = "__ITEM_LABEL__";
|
|
942
|
-
var MIN_ITEMS = __MIN_ITEMS__;
|
|
943
|
-
|
|
944
|
-
var container = document.getElementById("card-group-container");
|
|
945
|
-
var addBtn = document.getElementById("card-group-add");
|
|
946
|
-
var form = document.getElementById("credential-form");
|
|
947
|
-
var submitBtn = document.getElementById("submit-btn");
|
|
948
|
-
var statusBox = document.getElementById("status-box");
|
|
949
|
-
var formFieldset = document.getElementById("form-fieldset");
|
|
950
|
-
var uid = 0;
|
|
951
|
-
var pendingRedirectUrl = null;
|
|
952
|
-
|
|
953
|
-
function showStatus(type, message) {
|
|
954
|
-
statusBox.className = "status-box " + type;
|
|
955
|
-
statusBox.textContent = message;
|
|
956
|
-
statusBox.style.display = "block";
|
|
957
|
-
}
|
|
958
|
-
function safeRedirect(url) {
|
|
959
|
-
try {
|
|
960
|
-
var parsed = new URL(url, window.location.origin);
|
|
961
|
-
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
962
|
-
window.location.replace(parsed.href);
|
|
963
|
-
return true;
|
|
964
|
-
}
|
|
965
|
-
} catch (e) { /* fail safe */ }
|
|
966
|
-
return false;
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
function buildField(spec, cardUid) {
|
|
970
|
-
var group = document.createElement("div");
|
|
971
|
-
group.className = "field-group";
|
|
972
|
-
var fid = "field-" + GROUP_KEY + "-" + spec.key + "-" + cardUid;
|
|
973
|
-
|
|
974
|
-
var label = document.createElement("label");
|
|
975
|
-
label.className = "field-label";
|
|
976
|
-
label.setAttribute("for", fid);
|
|
977
|
-
label.textContent = spec.label || "";
|
|
978
|
-
var badge = document.createElement("span");
|
|
979
|
-
badge.setAttribute("aria-hidden", "true");
|
|
980
|
-
if (spec.required) { badge.className = "required-badge"; badge.textContent = "Required"; }
|
|
981
|
-
else { badge.className = "optional-badge"; badge.textContent = "Optional"; }
|
|
982
|
-
label.appendChild(document.createTextNode(" "));
|
|
983
|
-
label.appendChild(badge);
|
|
984
|
-
group.appendChild(label);
|
|
985
|
-
|
|
986
|
-
var input = document.createElement("input");
|
|
987
|
-
input.id = fid;
|
|
988
|
-
input.className = "field-input";
|
|
989
|
-
input.setAttribute("type", spec.type || "text");
|
|
990
|
-
input.setAttribute("name", GROUP_KEY + "[" + cardUid + "]." + spec.key);
|
|
991
|
-
input.dataset.field = spec.key;
|
|
992
|
-
input.setAttribute("autocomplete", "off");
|
|
993
|
-
input.setAttribute("autocorrect", "off");
|
|
994
|
-
input.setAttribute("autocapitalize", "off");
|
|
995
|
-
input.setAttribute("spellcheck", "false");
|
|
996
|
-
if (spec.placeholder) { input.setAttribute("placeholder", spec.placeholder); }
|
|
997
|
-
if (spec.required) { input.setAttribute("required", "required"); }
|
|
998
|
-
group.appendChild(input);
|
|
999
|
-
|
|
1000
|
-
if (spec.helpText) {
|
|
1001
|
-
var help = document.createElement("p");
|
|
1002
|
-
help.className = "help-text";
|
|
1003
|
-
help.id = "help-" + GROUP_KEY + "-" + spec.key + "-" + cardUid;
|
|
1004
|
-
if (spec.helpUrl) {
|
|
1005
|
-
var a = document.createElement("a");
|
|
1006
|
-
a.setAttribute("href", spec.helpUrl);
|
|
1007
|
-
a.setAttribute("target", "_blank");
|
|
1008
|
-
a.setAttribute("rel", "noopener noreferrer");
|
|
1009
|
-
a.textContent = spec.helpText;
|
|
1010
|
-
help.appendChild(a);
|
|
1011
|
-
} else {
|
|
1012
|
-
help.textContent = spec.helpText;
|
|
1013
|
-
}
|
|
1014
|
-
input.setAttribute("aria-describedby", help.id);
|
|
1015
|
-
group.appendChild(help);
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
input.addEventListener("input", function () {
|
|
1019
|
-
if (input.hasAttribute("aria-invalid")) { input.removeAttribute("aria-invalid"); }
|
|
1020
|
-
});
|
|
1021
|
-
return input;
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
function updateTitles() {
|
|
1025
|
-
var cards = container.querySelectorAll(".card-group-item");
|
|
1026
|
-
for (var i = 0; i < cards.length; i++) {
|
|
1027
|
-
var titleEl = cards[i].querySelector(".card-group-title");
|
|
1028
|
-
var titleInput = TITLE_FIELD ? cards[i].querySelector('input[data-field="' + TITLE_FIELD + '"]') : null;
|
|
1029
|
-
var titleVal = titleInput && titleInput.value ? titleInput.value.trim() : "";
|
|
1030
|
-
var titleStr = titleVal ? titleVal : (ITEM_LABEL + " " + (i + 1));
|
|
1031
|
-
if (titleEl) { titleEl.textContent = titleStr; titleEl.title = titleStr; }
|
|
1032
|
-
var removeBtn = cards[i].querySelector(".card-group-remove");
|
|
1033
|
-
if (removeBtn) {
|
|
1034
|
-
removeBtn.style.display = cards.length > MIN_ITEMS ? "" : "none";
|
|
1035
|
-
removeBtn.setAttribute("aria-label", "Remove " + titleStr);
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
function createCard() {
|
|
1041
|
-
var cardUid = uid++;
|
|
1042
|
-
var card = document.createElement("div");
|
|
1043
|
-
card.className = "card-group-item";
|
|
1044
|
-
card.dataset.uid = String(cardUid);
|
|
1045
|
-
card.setAttribute("role", "group");
|
|
1046
|
-
var titleId = "card-group-title-" + cardUid;
|
|
1047
|
-
card.setAttribute("aria-labelledby", titleId);
|
|
1048
|
-
|
|
1049
|
-
var header = document.createElement("div");
|
|
1050
|
-
header.className = "card-group-header";
|
|
1051
|
-
var title = document.createElement("h3");
|
|
1052
|
-
title.id = titleId;
|
|
1053
|
-
title.className = "card-group-title";
|
|
1054
|
-
title.textContent = ITEM_LABEL;
|
|
1055
|
-
header.appendChild(title);
|
|
1056
|
-
|
|
1057
|
-
var removeBtn = document.createElement("button");
|
|
1058
|
-
removeBtn.type = "button";
|
|
1059
|
-
removeBtn.className = "card-group-remove";
|
|
1060
|
-
removeBtn.textContent = "Remove";
|
|
1061
|
-
removeBtn.addEventListener("click", function () {
|
|
1062
|
-
var inputs = card.querySelectorAll("input");
|
|
1063
|
-
var hasData = false;
|
|
1064
|
-
for (var i = 0; i < inputs.length; i++) { if (inputs[i].value.trim() !== "") { hasData = true; break; } }
|
|
1065
|
-
if (hasData && !window.confirm("This " + ITEM_LABEL.toLowerCase() + " has unsaved data. Remove it?")) { return; }
|
|
1066
|
-
var prev = card.previousElementSibling;
|
|
1067
|
-
var next = card.nextElementSibling;
|
|
1068
|
-
var focusTarget = (prev && prev.classList && prev.classList.contains("card-group-item")) ? prev :
|
|
1069
|
-
(next && next.classList && next.classList.contains("card-group-item")) ? next : null;
|
|
1070
|
-
card.remove();
|
|
1071
|
-
updateTitles();
|
|
1072
|
-
if (focusTarget) {
|
|
1073
|
-
var fi = focusTarget.querySelector("input");
|
|
1074
|
-
if (fi) { fi.focus(); return; }
|
|
1075
|
-
}
|
|
1076
|
-
if (addBtn) { addBtn.focus(); }
|
|
1077
|
-
});
|
|
1078
|
-
header.appendChild(removeBtn);
|
|
1079
|
-
card.appendChild(header);
|
|
1080
|
-
|
|
1081
|
-
for (var f = 0; f < CARD_FIELDS.length; f++) {
|
|
1082
|
-
var input = buildField(CARD_FIELDS[f], cardUid);
|
|
1083
|
-
card.appendChild(input.parentNode);
|
|
1084
|
-
if (CARD_FIELDS[f].key === TITLE_FIELD) {
|
|
1085
|
-
input.addEventListener("input", updateTitles);
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
return card;
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
function collectCards() {
|
|
1092
|
-
var cards = container.querySelectorAll(".card-group-item");
|
|
1093
|
-
var arr = [];
|
|
1094
|
-
for (var i = 0; i < cards.length; i++) {
|
|
1095
|
-
var inputs = cards[i].querySelectorAll(".field-input");
|
|
1096
|
-
var obj = {};
|
|
1097
|
-
var hasAny = false;
|
|
1098
|
-
for (var j = 0; j < inputs.length; j++) {
|
|
1099
|
-
obj[inputs[j].dataset.field] = inputs[j].value;
|
|
1100
|
-
if (inputs[j].value.trim() !== "") { hasAny = true; }
|
|
1101
|
-
}
|
|
1102
|
-
if (hasAny) { arr.push(obj); }
|
|
1103
|
-
}
|
|
1104
|
-
return arr;
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
function renderOAuthDeviceCode(nextStep) {
|
|
1108
|
-
statusBox.className = "status-box info";
|
|
1109
|
-
statusBox.style.display = "block";
|
|
1110
|
-
while (statusBox.firstChild) { statusBox.removeChild(statusBox.firstChild); }
|
|
1111
|
-
var strong = document.createElement("strong");
|
|
1112
|
-
strong.textContent = "Finish sign-in";
|
|
1113
|
-
statusBox.appendChild(strong);
|
|
1114
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1115
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1116
|
-
statusBox.appendChild(document.createTextNode("Visit:"));
|
|
1117
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1118
|
-
var link = document.createElement("a");
|
|
1119
|
-
link.setAttribute("href", nextStep.verification_url);
|
|
1120
|
-
link.setAttribute("target", "_blank");
|
|
1121
|
-
link.setAttribute("rel", "noopener noreferrer");
|
|
1122
|
-
link.textContent = nextStep.verification_url;
|
|
1123
|
-
statusBox.appendChild(link);
|
|
1124
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1125
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1126
|
-
statusBox.appendChild(document.createTextNode("Enter code: "));
|
|
1127
|
-
var codeEl = document.createElement("strong");
|
|
1128
|
-
codeEl.style.fontSize = "1.2em";
|
|
1129
|
-
codeEl.style.letterSpacing = "0.1em";
|
|
1130
|
-
codeEl.style.userSelect = "all";
|
|
1131
|
-
codeEl.style.cursor = "copy";
|
|
1132
|
-
codeEl.title = "Click to select";
|
|
1133
|
-
codeEl.textContent = nextStep.user_code;
|
|
1134
|
-
statusBox.appendChild(codeEl);
|
|
1135
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1136
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1137
|
-
var waiting = document.createElement("span");
|
|
1138
|
-
waiting.id = "device-waiting";
|
|
1139
|
-
waiting.setAttribute("role", "alert");
|
|
1140
|
-
waiting.setAttribute("aria-live", "polite");
|
|
1141
|
-
waiting.setAttribute("aria-atomic", "true");
|
|
1142
|
-
waiting.style.color = "#9ca3af";
|
|
1143
|
-
waiting.textContent = "Waiting for authorization...";
|
|
1144
|
-
statusBox.appendChild(waiting);
|
|
1145
|
-
var statusUrl = submitUrl.replace(/\\/authorize.*/, "/setup-status");
|
|
1146
|
-
var pollId = setInterval(function () {
|
|
1147
|
-
fetch(statusUrl)
|
|
1148
|
-
.then(function (r) { return r.json(); })
|
|
1149
|
-
.then(function (s) {
|
|
1150
|
-
if (s && s.outlook === "complete") {
|
|
1151
|
-
clearInterval(pollId);
|
|
1152
|
-
statusBox.className = "status-box success";
|
|
1153
|
-
while (statusBox.firstChild) { statusBox.removeChild(statusBox.firstChild); }
|
|
1154
|
-
var done = document.createElement("strong");
|
|
1155
|
-
done.textContent = "Setup complete!";
|
|
1156
|
-
statusBox.appendChild(done);
|
|
1157
|
-
submitBtn.textContent = "Connected";
|
|
1158
|
-
if (typeof pendingRedirectUrl === "string" && pendingRedirectUrl.length > 0) {
|
|
1159
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1160
|
-
statusBox.appendChild(document.createTextNode("Redirecting..."));
|
|
1161
|
-
safeRedirect(pendingRedirectUrl);
|
|
1162
|
-
} else {
|
|
1163
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1164
|
-
statusBox.appendChild(document.createTextNode("You can close this tab."));
|
|
1165
|
-
}
|
|
1166
|
-
} else if (s && typeof s.outlook === "string" && s.outlook.indexOf("error:") === 0) {
|
|
1167
|
-
clearInterval(pollId);
|
|
1168
|
-
var w = document.getElementById("device-waiting");
|
|
1169
|
-
if (w) { w.style.color = "#ff453a"; w.textContent = "Authorization failed: " + s.outlook.slice(6) + ". Please retry setup."; }
|
|
1170
|
-
}
|
|
1171
|
-
})
|
|
1172
|
-
.catch(function () {});
|
|
1173
|
-
}, 3000);
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
container.appendChild(createCard());
|
|
1177
|
-
for (var s = 1; s < MIN_ITEMS; s++) { container.appendChild(createCard()); }
|
|
1178
|
-
updateTitles();
|
|
1179
|
-
|
|
1180
|
-
addBtn.addEventListener("click", function () {
|
|
1181
|
-
var newCard = createCard();
|
|
1182
|
-
container.appendChild(newCard);
|
|
1183
|
-
updateTitles();
|
|
1184
|
-
var fi = newCard.querySelector("input");
|
|
1185
|
-
if (fi) { fi.focus(); }
|
|
1186
|
-
});
|
|
1187
|
-
|
|
1188
|
-
form.addEventListener("submit", function (evt) {
|
|
1189
|
-
evt.preventDefault();
|
|
1190
|
-
statusBox.style.display = "none";
|
|
1191
|
-
if (!form.checkValidity()) {
|
|
1192
|
-
showStatus("error", "Please fill in all required fields.");
|
|
1193
|
-
var firstInvalid = form.querySelector(":invalid");
|
|
1194
|
-
if (firstInvalid) {
|
|
1195
|
-
firstInvalid.setAttribute("aria-invalid", "true");
|
|
1196
|
-
firstInvalid.setAttribute("aria-errormessage", "status-box");
|
|
1197
|
-
firstInvalid.focus();
|
|
1198
|
-
}
|
|
1199
|
-
return;
|
|
1200
|
-
}
|
|
1201
|
-
var items = collectCards();
|
|
1202
|
-
if (items.length === 0) {
|
|
1203
|
-
showStatus("error", "Please add at least one " + ITEM_LABEL.toLowerCase() + ".");
|
|
1204
|
-
return;
|
|
1205
|
-
}
|
|
1206
|
-
var payload = {};
|
|
1207
|
-
payload[GROUP_KEY] = items;
|
|
1208
|
-
|
|
1209
|
-
formFieldset.disabled = true;
|
|
1210
|
-
submitBtn.setAttribute("aria-busy", "true");
|
|
1211
|
-
submitBtn.textContent = "Connecting...";
|
|
1212
|
-
|
|
1213
|
-
fetch(submitUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) })
|
|
1214
|
-
.then(function (resp) { return resp.json().then(function (data) {
|
|
1215
|
-
if (!data.ok) {
|
|
1216
|
-
showStatus("error", data.error || data.error_description || "Request failed.");
|
|
1217
|
-
formFieldset.disabled = false;
|
|
1218
|
-
submitBtn.removeAttribute("aria-busy");
|
|
1219
|
-
submitBtn.textContent = "Connect";
|
|
1220
|
-
submitBtn.focus();
|
|
1221
|
-
return;
|
|
1222
|
-
}
|
|
1223
|
-
if (typeof data.redirect_url === "string" && data.redirect_url.length > 0) { pendingRedirectUrl = data.redirect_url; }
|
|
1224
|
-
if (data.next_step && data.next_step.type === "oauth_device_code") {
|
|
1225
|
-
submitBtn.textContent = "Awaiting authorization...";
|
|
1226
|
-
submitBtn.removeAttribute("aria-busy");
|
|
1227
|
-
renderOAuthDeviceCode(data.next_step);
|
|
1228
|
-
return;
|
|
1229
|
-
}
|
|
1230
|
-
if (pendingRedirectUrl) {
|
|
1231
|
-
showStatus("success", "Credentials saved. Redirecting...");
|
|
1232
|
-
submitBtn.textContent = "Connected";
|
|
1233
|
-
submitBtn.removeAttribute("aria-busy");
|
|
1234
|
-
if (!safeRedirect(pendingRedirectUrl)) {
|
|
1235
|
-
showStatus("error", "Setup complete, but refused to redirect to unsafe URL.");
|
|
1236
|
-
}
|
|
1237
|
-
return;
|
|
1238
|
-
}
|
|
1239
|
-
showStatus("success", data.message || "Setup complete! You can close this tab.");
|
|
1240
|
-
submitBtn.textContent = "Connected";
|
|
1241
|
-
submitBtn.removeAttribute("aria-busy");
|
|
1242
|
-
}); })
|
|
1243
|
-
.catch(function (err) {
|
|
1244
|
-
showStatus("error", "Network error: " + err.message);
|
|
1245
|
-
formFieldset.disabled = false;
|
|
1246
|
-
submitBtn.removeAttribute("aria-busy");
|
|
1247
|
-
submitBtn.textContent = "Connect";
|
|
1248
|
-
submitBtn.focus();
|
|
1249
|
-
});
|
|
1250
|
-
});
|
|
1251
|
-
})();
|
|
1252
|
-
</script>`;
|
|
1253
|
-
/** Escape a string for safe embedding inside a double-quoted JS literal. */
|
|
1254
|
-
function jsString(value) {
|
|
1255
|
-
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('<', '\\u003c');
|
|
1256
|
-
}
|
|
1257
|
-
/**
|
|
1258
|
-
* Return the id of the tab that should render active on load. Defaults to the
|
|
1259
|
-
* first tab; a valid `initialTab` hint wins, an unknown hint falls back.
|
|
1260
|
-
*/
|
|
1261
|
-
function resolveActiveTab(tabs, initialTab) {
|
|
1262
|
-
const ids = tabs.map((t) => String(t.id ?? ''));
|
|
1263
|
-
if (initialTab !== undefined && ids.includes(initialTab)) {
|
|
1264
|
-
return initialTab;
|
|
1265
|
-
}
|
|
1266
|
-
return ids.length > 0 ? ids[0] : '';
|
|
1267
|
-
}
|
|
1268
|
-
/**
|
|
1269
|
-
* Optional workspace-username field shared by the flat + feature forms. Carries
|
|
1270
|
-
* the `.field-input` class so the form collector picks it into the POST as
|
|
1271
|
-
* `__sub_username`. Optional, so it never blocks submit.
|
|
1272
|
-
*/
|
|
1273
|
-
function usernameFieldHtml() {
|
|
1274
|
-
return ('<div class="field-group">' +
|
|
1275
|
-
'<label for="field-__sub_username" class="field-label">Workspace / username' +
|
|
1276
|
-
' <span class="optional-badge" aria-hidden="true">Optional</span></label>' +
|
|
1277
|
-
'<input id="field-__sub_username" type="text" name="__sub_username"' +
|
|
1278
|
-
' class="field-input" placeholder="e.g. alice" aria-describedby="help-__sub_username"' +
|
|
1279
|
-
' autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />' +
|
|
1280
|
-
'<p class="help-text" id="help-__sub_username">Leave blank for a one-off session. Set the same value on' +
|
|
1281
|
-
' every device to keep your saved data (one shared bucket per username).</p>' +
|
|
1282
|
-
'</div>');
|
|
1283
|
-
}
|
|
1284
|
-
function renderCapabilitiesSection(capabilityInfo) {
|
|
1285
|
-
if (capabilityInfo.length === 0) {
|
|
1286
|
-
return '';
|
|
1287
|
-
}
|
|
1288
|
-
const itemsHtml = capabilityInfo.map(renderCapability).join('');
|
|
1289
|
-
return `
|
|
1290
|
-
<section class="capabilities-section" aria-labelledby="capabilities-title">
|
|
1291
|
-
<h2 class="capabilities-title" id="capabilities-title">Capabilities Requested</h2>
|
|
1292
|
-
<ul class="capabilities-list">${itemsHtml}
|
|
1293
|
-
</ul>
|
|
1294
|
-
</section>`;
|
|
1295
|
-
}
|
|
1296
|
-
/**
|
|
1297
|
-
* Render a credential form whose fields are split across mutually-exclusive
|
|
1298
|
-
* tabs. Only the active tab's fields are collected on submit. Multi-step
|
|
1299
|
-
* (OTP / 2FA) chaining works exactly as the flat form.
|
|
1300
|
-
*/
|
|
1301
|
-
function renderTabbedCredentialForm(schema, options) {
|
|
1302
|
-
const displayName = escapeHtml(schema.displayName ?? schema.server ?? 'Configuration');
|
|
1303
|
-
const server = escapeHtml(schema.server ?? '');
|
|
1304
|
-
const description = escapeHtml(schema.description ?? '');
|
|
1305
|
-
const title = options.pageTitle !== undefined ? escapeHtml(options.pageTitle) : displayName;
|
|
1306
|
-
const submitUrlEscaped = escapeHtml(options.submitUrl);
|
|
1307
|
-
const prefill = options.prefill ?? {};
|
|
1308
|
-
const tabs = schema.tabs ?? [];
|
|
1309
|
-
const activeId = resolveActiveTab(tabs, options.initialTab);
|
|
1310
|
-
const tabButtons = [];
|
|
1311
|
-
const tabPanels = [];
|
|
1312
|
-
for (const tab of tabs) {
|
|
1313
|
-
const tid = escapeHtml(tab.id ?? '');
|
|
1314
|
-
const label = escapeHtml(tab.label ?? '');
|
|
1315
|
-
const isActive = (tab.id ?? '') === activeId;
|
|
1316
|
-
const activeCls = isActive ? ' active' : '';
|
|
1317
|
-
const ariaSelected = isActive ? 'true' : 'false';
|
|
1318
|
-
const tabindex = isActive ? '0' : '-1';
|
|
1319
|
-
tabButtons.push(`<button type="button" id="tab-${tid}" class="tab${activeCls}" data-tab="${tid}"` +
|
|
1320
|
-
` role="tab" aria-selected="${ariaSelected}" tabindex="${tabindex}"` +
|
|
1321
|
-
` aria-controls="panel-${tid}">${label}</button>`);
|
|
1322
|
-
const panelFields = (tab.fields ?? []).map((f) => renderField(f, prefill[f.key] ?? '')).join('');
|
|
1323
|
-
tabPanels.push(`<div id="panel-${tid}" class="tab-panel${activeCls}" data-panel="${tid}"` +
|
|
1324
|
-
` role="tabpanel" aria-labelledby="tab-${tid}">${panelFields}\n </div>`);
|
|
1325
|
-
}
|
|
1326
|
-
const tabsHtml = tabButtons.join('\n ');
|
|
1327
|
-
const panelsHtml = tabPanels.join('\n ');
|
|
1328
|
-
const usernameHtml = options.includeUsernameField ? usernameFieldHtml() : '';
|
|
1329
|
-
const capabilitiesHtml = renderCapabilitiesSection(schema.capabilityInfo ?? []);
|
|
1330
|
-
const descriptionHtml = description ? `<p class="server-description" id="server-desc">${description}</p>` : '';
|
|
1331
|
-
const formAria = description ? ' aria-describedby="server-desc"' : '';
|
|
1332
|
-
const script = TABS_SCRIPT.replaceAll('__SUBMIT_URL__', submitUrlEscaped).replaceAll('__INITIAL_TAB__', activeId);
|
|
1333
|
-
const bodyHtml = `${TABS_CSS} <div class="container">
|
|
1334
|
-
<div class="card">
|
|
1335
|
-
<div class="server-header">
|
|
1336
|
-
<h1 class="server-name">${displayName}</h1>
|
|
1337
|
-
<div class="server-id">${server}</div>
|
|
1338
|
-
${descriptionHtml}
|
|
1339
|
-
</div>
|
|
1340
|
-
|
|
1341
|
-
<div class="tabs" role="tablist" aria-label="Credential mode">
|
|
1342
|
-
${tabsHtml}
|
|
1343
|
-
</div>
|
|
1344
|
-
|
|
1345
|
-
<form id="credential-form"${formAria} novalidate>
|
|
1346
|
-
${usernameHtml}${panelsHtml}
|
|
1347
|
-
|
|
1348
|
-
<button type="submit" class="submit-btn" id="submit-btn">Connect</button>
|
|
1349
|
-
|
|
1350
|
-
<div class="status-box" id="status-box" role="alert" aria-live="polite" aria-atomic="true"></div>
|
|
1351
|
-
</form>
|
|
1352
|
-
</div>
|
|
1353
|
-
${capabilitiesHtml}
|
|
1354
|
-
</div>
|
|
1355
|
-
${script}`;
|
|
1356
|
-
return renderFormShell(title, bodyHtml);
|
|
1357
|
-
}
|
|
1358
|
-
/**
|
|
1359
|
-
* Render a credential form built around one repeatable card group. The group's
|
|
1360
|
-
* fields are cloned per card (Add/Remove) and submitted as a JSON array under
|
|
1361
|
-
* the group `key`. An Outlook-style device-code follow-up is supported.
|
|
1362
|
-
*/
|
|
1363
|
-
function renderCardGroupCredentialForm(schema, options) {
|
|
1364
|
-
const displayName = escapeHtml(schema.displayName ?? schema.server ?? 'Configuration');
|
|
1365
|
-
const server = escapeHtml(schema.server ?? '');
|
|
1366
|
-
const description = escapeHtml(schema.description ?? '');
|
|
1367
|
-
const title = options.pageTitle !== undefined ? escapeHtml(options.pageTitle) : displayName;
|
|
1368
|
-
const submitUrlEscaped = escapeHtml(options.submitUrl);
|
|
1369
|
-
const group = schema.cardGroup;
|
|
1370
|
-
const groupKey = String(group.key ?? 'items');
|
|
1371
|
-
const itemLabel = String(group.itemLabel ?? 'Item');
|
|
1372
|
-
const addLabel = escapeHtml(group.addButtonLabel ?? '+ Add');
|
|
1373
|
-
const minItems = Number(group.minItems ?? 1);
|
|
1374
|
-
const titleField = String(group.titleField ?? '');
|
|
1375
|
-
const groupHeading = escapeHtml(group.heading ?? `${itemLabel}s`);
|
|
1376
|
-
const fields = group.fields ?? [];
|
|
1377
|
-
// `<` is escaped to `<` so the JSON cannot terminate the <script> early.
|
|
1378
|
-
const fieldsJson = JSON.stringify(fields).replaceAll('<', '\\u003c');
|
|
1379
|
-
const usernameHtml = options.includeUsernameField ? usernameFieldHtml() : '';
|
|
1380
|
-
const capabilitiesHtml = renderCapabilitiesSection(schema.capabilityInfo ?? []);
|
|
1381
|
-
const descriptionHtml = description ? `<p class="server-description" id="server-desc">${description}</p>` : '';
|
|
1382
|
-
// `__CARD_FIELDS__` (the only placeholder carrying user-controlled JSON) is
|
|
1383
|
-
// substituted LAST so a crafted field value cannot masquerade as another token.
|
|
1384
|
-
const script = CARD_GROUP_SCRIPT.replaceAll('__SUBMIT_URL__', submitUrlEscaped)
|
|
1385
|
-
.replaceAll('__GROUP_KEY__', jsString(groupKey))
|
|
1386
|
-
.replaceAll('__TITLE_FIELD__', jsString(titleField))
|
|
1387
|
-
.replaceAll('__ITEM_LABEL__', jsString(itemLabel))
|
|
1388
|
-
.replaceAll('__MIN_ITEMS__', String(minItems))
|
|
1389
|
-
.replaceAll('__CARD_FIELDS__', fieldsJson);
|
|
1390
|
-
const bodyHtml = `${CARD_GROUP_CSS} <div class="container">
|
|
1391
|
-
<div class="card">
|
|
1392
|
-
<div class="server-header">
|
|
1393
|
-
<h1 class="server-name">${displayName}</h1>
|
|
1394
|
-
<div class="server-id">${server}</div>
|
|
1395
|
-
${descriptionHtml}
|
|
1396
|
-
</div>
|
|
1397
|
-
|
|
1398
|
-
<h2 class="form-title" id="form-title">${groupHeading}</h2>
|
|
1399
|
-
|
|
1400
|
-
<form id="credential-form" aria-labelledby="form-title" novalidate>
|
|
1401
|
-
<fieldset id="form-fieldset" style="border: none; padding: 0; margin: 0;">
|
|
1402
|
-
${usernameHtml}<div id="card-group-container"></div>
|
|
1403
|
-
|
|
1404
|
-
<button type="button" class="card-group-add" id="card-group-add">${addLabel}</button>
|
|
1405
|
-
|
|
1406
|
-
<button type="submit" class="submit-btn" id="submit-btn">Connect</button>
|
|
1407
|
-
</fieldset>
|
|
1408
|
-
|
|
1409
|
-
<div class="status-box" id="status-box" role="alert" aria-live="polite" aria-atomic="true"></div>
|
|
1410
|
-
</form>
|
|
1411
|
-
</div>
|
|
1412
|
-
${capabilitiesHtml}
|
|
1413
|
-
</div>
|
|
1414
|
-
${script}`;
|
|
1415
|
-
return renderFormShell(title, bodyHtml);
|
|
1416
|
-
}
|
|
1417
|
-
/**
|
|
1418
|
-
* Render a dark-themed HTML credential form from a RelayConfigSchema.
|
|
1419
|
-
*
|
|
1420
|
-
* @param schema RelayConfigSchema with server metadata and field definitions.
|
|
1421
|
-
* @param options.submitUrl URL the form POSTs to as JSON via fetch().
|
|
1422
|
-
* @param options.pageTitle Optional browser tab title. Defaults to displayName.
|
|
1423
|
-
* @param options.prefill Optional ``{KEY: VALUE}`` map for input ``value=`` attrs.
|
|
1424
|
-
* @returns Complete HTML document string, XSS-safe with all dynamic content escaped.
|
|
1425
|
-
*/
|
|
1426
|
-
export function renderCredentialForm(schema, options) {
|
|
1427
|
-
// Schema-level capabilities (opt-in): dispatch to the dedicated renderer.
|
|
1428
|
-
// A schema declaring neither key falls through to the unchanged flat form.
|
|
1429
|
-
if (schema.tabs && schema.tabs.length > 0) {
|
|
1430
|
-
return renderTabbedCredentialForm(schema, options);
|
|
1431
|
-
}
|
|
1432
|
-
if (schema.cardGroup) {
|
|
1433
|
-
return renderCardGroupCredentialForm(schema, options);
|
|
1434
|
-
}
|
|
1435
|
-
const displayName = escapeHtml(schema.displayName ?? schema.server ?? 'Configuration');
|
|
1436
|
-
const server = escapeHtml(schema.server ?? '');
|
|
1437
|
-
const description = escapeHtml(schema.description ?? '');
|
|
1438
|
-
const title = options.pageTitle !== undefined ? escapeHtml(options.pageTitle) : displayName;
|
|
1439
|
-
const submitUrlEscaped = escapeHtml(options.submitUrl);
|
|
1440
|
-
const fields = schema.fields ?? [];
|
|
1441
|
-
const capabilityInfo = schema.capabilityInfo ?? [];
|
|
1442
|
-
const prefill = options.prefill ?? {};
|
|
1443
|
-
const fieldsHtml = fields.map((f) => renderField(f, prefill[f.key] ?? '')).join('');
|
|
1444
|
-
const usernameHtml = options.includeUsernameField ? usernameFieldHtml() : '';
|
|
1445
|
-
let capabilitiesHtml = '';
|
|
1446
|
-
if (capabilityInfo.length > 0) {
|
|
1447
|
-
const itemsHtml = capabilityInfo.map(renderCapability).join('');
|
|
1448
|
-
capabilitiesHtml = `
|
|
1449
|
-
<section class="capabilities-section" aria-labelledby="capabilities-title">
|
|
1450
|
-
<h2 class="capabilities-title" id="capabilities-title">Capabilities Requested</h2>
|
|
1451
|
-
<ul class="capabilities-list">${itemsHtml}
|
|
1452
|
-
</ul>
|
|
1453
|
-
</section>`;
|
|
1454
|
-
}
|
|
1455
|
-
const descriptionHtml = description ? `<p class="server-description" id="server-desc">${description}</p>` : '';
|
|
1456
|
-
const formAria = description ? ' aria-describedby="server-desc"' : '';
|
|
1457
|
-
// The body is wrapped in `renderFormShell` below. The shell injects the
|
|
1458
|
-
// `<head>` (with the shared `FORM_SHELL_CSS`) and the `<body>` opening +
|
|
1459
|
-
// closing tags, so this template starts at the first child of `<body>`.
|
|
1460
|
-
const bodyHtml = ` <div class="container">
|
|
1461
|
-
<div class="card">
|
|
1462
|
-
<div class="server-header">
|
|
1463
|
-
<h1 class="server-name">${displayName}</h1>
|
|
1464
|
-
<div class="server-id">${server}</div>
|
|
1465
|
-
${descriptionHtml}
|
|
1466
|
-
</div>
|
|
1467
|
-
|
|
1468
|
-
<p class="form-title" id="form-title">Enter your credentials</p>
|
|
1469
|
-
|
|
1470
|
-
<form id="credential-form" aria-labelledby="form-title"${formAria} novalidate>
|
|
1471
|
-
${usernameHtml}${fieldsHtml}
|
|
1472
|
-
|
|
1473
|
-
<button type="submit" class="submit-btn" id="submit-btn">
|
|
1474
|
-
Connect
|
|
1475
|
-
</button>
|
|
1476
|
-
|
|
1477
|
-
<div class="status-box" id="status-box" role="alert" aria-live="polite" aria-atomic="true"></div>
|
|
1478
|
-
</form>
|
|
1479
|
-
</div>
|
|
1480
|
-
${capabilitiesHtml}
|
|
1481
|
-
</div>
|
|
1482
|
-
|
|
1483
|
-
<script>
|
|
1484
|
-
(function () {
|
|
1485
|
-
var form = document.getElementById("credential-form");
|
|
1486
|
-
var submitBtn = document.getElementById("submit-btn");
|
|
1487
|
-
var statusBox = document.getElementById("status-box");
|
|
1488
|
-
var submitUrl = "${submitUrlEscaped}";
|
|
1489
|
-
|
|
1490
|
-
function showStatus(type, message) {
|
|
1491
|
-
statusBox.className = "status-box " + type;
|
|
1492
|
-
statusBox.textContent = message;
|
|
1493
|
-
statusBox.style.display = "block";
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
// Derive /otp endpoint URL from submitUrl (replaces /authorize... with /otp).
|
|
1497
|
-
function otpUrl() {
|
|
1498
|
-
return submitUrl.replace(/\\/authorize.*/, "/otp");
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
// Render (or update in-place) a step-input UI for otp_required / password_required.
|
|
1502
|
-
// ns: next_step object with { text, field, input_type, placeholder }.
|
|
1503
|
-
// All textual content from ns is inserted via textContent (never innerHTML).
|
|
1504
|
-
function showStepInput(ns) {
|
|
1505
|
-
// Hide the original credential form after first transition.
|
|
1506
|
-
if (form && form.style.display !== "none") {
|
|
1507
|
-
form.style.display = "none";
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
// If a step container already exists, update it in-place (chained next_step).
|
|
1511
|
-
var container = document.getElementById("step-container");
|
|
1512
|
-
var promptEl, inputEl, buttonEl, errorEl;
|
|
1513
|
-
if (container) {
|
|
1514
|
-
promptEl = document.getElementById("step-prompt");
|
|
1515
|
-
inputEl = document.getElementById("step-input");
|
|
1516
|
-
buttonEl = document.getElementById("step-submit");
|
|
1517
|
-
errorEl = document.getElementById("step-error");
|
|
1518
|
-
errorEl.style.display = "none";
|
|
1519
|
-
errorEl.textContent = "";
|
|
1520
|
-
inputEl.value = "";
|
|
1521
|
-
inputEl.disabled = false;
|
|
1522
|
-
buttonEl.disabled = false;
|
|
1523
|
-
buttonEl.removeAttribute("aria-busy");
|
|
1524
|
-
buttonEl.textContent = "Verify";
|
|
1525
|
-
} else {
|
|
1526
|
-
// Build a fresh step-input container inside the card.
|
|
1527
|
-
var card = form.parentNode;
|
|
1528
|
-
container = document.createElement("div");
|
|
1529
|
-
container.id = "step-container";
|
|
1530
|
-
|
|
1531
|
-
promptEl = document.createElement("label");
|
|
1532
|
-
promptEl.id = "step-prompt";
|
|
1533
|
-
promptEl.setAttribute("for", "step-input");
|
|
1534
|
-
promptEl.className = "form-title";
|
|
1535
|
-
container.appendChild(promptEl);
|
|
1536
|
-
|
|
1537
|
-
var fieldGroup = document.createElement("div");
|
|
1538
|
-
fieldGroup.className = "field-group";
|
|
1539
|
-
inputEl = document.createElement("input");
|
|
1540
|
-
inputEl.id = "step-input";
|
|
1541
|
-
inputEl.className = "field-input";
|
|
1542
|
-
inputEl.setAttribute("autocomplete", "off");
|
|
1543
|
-
inputEl.setAttribute("autocorrect", "off");
|
|
1544
|
-
inputEl.setAttribute("autocapitalize", "off");
|
|
1545
|
-
inputEl.setAttribute("spellcheck", "false");
|
|
1546
|
-
fieldGroup.appendChild(inputEl);
|
|
1547
|
-
container.appendChild(fieldGroup);
|
|
1548
|
-
|
|
1549
|
-
buttonEl = document.createElement("button");
|
|
1550
|
-
buttonEl.type = "button";
|
|
1551
|
-
buttonEl.id = "step-submit";
|
|
1552
|
-
buttonEl.className = "submit-btn";
|
|
1553
|
-
buttonEl.textContent = "Verify";
|
|
1554
|
-
container.appendChild(buttonEl);
|
|
1555
|
-
|
|
1556
|
-
errorEl = document.createElement("div");
|
|
1557
|
-
errorEl.id = "step-error";
|
|
1558
|
-
errorEl.className = "status-box error";
|
|
1559
|
-
errorEl.setAttribute("role", "alert");
|
|
1560
|
-
errorEl.setAttribute("aria-live", "polite");
|
|
1561
|
-
errorEl.setAttribute("aria-atomic", "true");
|
|
1562
|
-
errorEl.style.display = "none";
|
|
1563
|
-
container.appendChild(errorEl);
|
|
1564
|
-
|
|
1565
|
-
card.appendChild(container);
|
|
1566
|
-
|
|
1567
|
-
// Submit handlers (attached once, read current field name from dataset).
|
|
1568
|
-
buttonEl.addEventListener("click", function () {
|
|
1569
|
-
submitStep();
|
|
1570
|
-
});
|
|
1571
|
-
inputEl.addEventListener("keydown", function (evt) {
|
|
1572
|
-
if (evt.key === "Enter") {
|
|
1573
|
-
evt.preventDefault();
|
|
1574
|
-
submitStep();
|
|
1575
|
-
}
|
|
1576
|
-
});
|
|
1577
|
-
inputEl.addEventListener("input", function() {
|
|
1578
|
-
inputEl.removeAttribute("aria-invalid");
|
|
1579
|
-
inputEl.removeAttribute("aria-errormessage");
|
|
1580
|
-
errorEl.style.display = "none";
|
|
1581
|
-
});
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
// Populate prompt + input attributes via safe DOM APIs.
|
|
1585
|
-
promptEl.textContent = ns.text || "";
|
|
1586
|
-
inputEl.setAttribute("type", ns.input_type || "text");
|
|
1587
|
-
inputEl.setAttribute("placeholder", ns.placeholder || "");
|
|
1588
|
-
// Stash field name so submitStep can read it at click time.
|
|
1589
|
-
inputEl.dataset.field = ns.field || "value";
|
|
1590
|
-
inputEl.focus();
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
function submitStep() {
|
|
1594
|
-
var inputEl = document.getElementById("step-input");
|
|
1595
|
-
var buttonEl = document.getElementById("step-submit");
|
|
1596
|
-
var errorEl = document.getElementById("step-error");
|
|
1597
|
-
var fieldName = inputEl.dataset.field || "value";
|
|
1598
|
-
var value = inputEl.value;
|
|
1599
|
-
|
|
1600
|
-
inputEl.removeAttribute("aria-invalid");
|
|
1601
|
-
|
|
1602
|
-
if (value.trim() === "") {
|
|
1603
|
-
errorEl.textContent = "Please enter a value.";
|
|
1604
|
-
errorEl.style.display = "block";
|
|
1605
|
-
inputEl.setAttribute("aria-invalid", "true");
|
|
1606
|
-
inputEl.setAttribute("aria-errormessage", "step-error");
|
|
1607
|
-
inputEl.focus();
|
|
1608
|
-
return;
|
|
1609
|
-
}
|
|
1610
|
-
errorEl.style.display = "none";
|
|
1611
|
-
errorEl.textContent = "";
|
|
1612
|
-
buttonEl.disabled = true;
|
|
1613
|
-
buttonEl.textContent = "Verifying...";
|
|
1614
|
-
buttonEl.setAttribute("aria-busy", "true");
|
|
1615
|
-
inputEl.disabled = true;
|
|
1616
|
-
inputEl.removeAttribute("aria-invalid");
|
|
1617
|
-
|
|
1618
|
-
var body = {};
|
|
1619
|
-
body[fieldName] = value;
|
|
1620
|
-
|
|
1621
|
-
fetch(otpUrl(), {
|
|
1622
|
-
method: "POST",
|
|
1623
|
-
headers: { "Content-Type": "application/json" },
|
|
1624
|
-
body: JSON.stringify(body),
|
|
1625
|
-
})
|
|
1626
|
-
.then(function (response) {
|
|
1627
|
-
return response.json().then(function (data) {
|
|
1628
|
-
if (data.ok) {
|
|
1629
|
-
if (data.next_step && (data.next_step.type === "otp_required" || data.next_step.type === "password_required")) {
|
|
1630
|
-
// Chain: update in place with new prompt/field/type.
|
|
1631
|
-
showStepInput(data.next_step);
|
|
1632
|
-
} else if (typeof data.redirect_url === "string" && data.redirect_url.length > 0) {
|
|
1633
|
-
// Multi-step auth finished: follow the OAuth redirect so
|
|
1634
|
-
// the originating client callback server receives the
|
|
1635
|
-
// code. Without this the external client hangs forever.
|
|
1636
|
-
var container = document.getElementById("step-container");
|
|
1637
|
-
while (container.firstChild) {
|
|
1638
|
-
container.removeChild(container.firstChild);
|
|
1639
|
-
}
|
|
1640
|
-
var done = document.createElement("div");
|
|
1641
|
-
done.className = "status-box success";
|
|
1642
|
-
done.style.display = "block";
|
|
1643
|
-
done.setAttribute("role", "alert");
|
|
1644
|
-
done.setAttribute("aria-live", "polite");
|
|
1645
|
-
done.setAttribute("aria-atomic", "true");
|
|
1646
|
-
done.textContent = "Setup complete! Redirecting...";
|
|
1647
|
-
container.appendChild(done);
|
|
1648
|
-
window.location.replace(data.redirect_url);
|
|
1649
|
-
} else {
|
|
1650
|
-
// Completed.
|
|
1651
|
-
var container = document.getElementById("step-container");
|
|
1652
|
-
while (container.firstChild) {
|
|
1653
|
-
container.removeChild(container.firstChild);
|
|
1654
|
-
}
|
|
1655
|
-
var done = document.createElement("div");
|
|
1656
|
-
done.className = "status-box success";
|
|
1657
|
-
done.style.display = "block";
|
|
1658
|
-
done.setAttribute("role", "alert");
|
|
1659
|
-
done.setAttribute("aria-live", "polite");
|
|
1660
|
-
done.setAttribute("aria-atomic", "true");
|
|
1661
|
-
done.textContent = "Setup complete! You can close this tab.";
|
|
1662
|
-
container.appendChild(done);
|
|
1663
|
-
}
|
|
1664
|
-
} else {
|
|
1665
|
-
// Error: show message, re-enable input + button for retry, keep value.
|
|
1666
|
-
errorEl.textContent = data.error || data.error_description || "Verification failed.";
|
|
1667
|
-
errorEl.style.display = "block";
|
|
1668
|
-
inputEl.disabled = false;
|
|
1669
|
-
inputEl.setAttribute("aria-invalid", "true");
|
|
1670
|
-
inputEl.setAttribute("aria-errormessage", "step-error");
|
|
1671
|
-
buttonEl.disabled = false;
|
|
1672
|
-
buttonEl.textContent = "Verify";
|
|
1673
|
-
buttonEl.removeAttribute("aria-busy");
|
|
1674
|
-
inputEl.focus();
|
|
1675
|
-
}
|
|
1676
|
-
});
|
|
1677
|
-
})
|
|
1678
|
-
.catch(function (err) {
|
|
1679
|
-
errorEl.textContent = "Network error: " + err.message;
|
|
1680
|
-
errorEl.style.display = "block";
|
|
1681
|
-
inputEl.disabled = false;
|
|
1682
|
-
inputEl.setAttribute("aria-invalid", "true");
|
|
1683
|
-
inputEl.setAttribute("aria-errormessage", "step-error");
|
|
1684
|
-
buttonEl.disabled = false;
|
|
1685
|
-
buttonEl.textContent = "Verify";
|
|
1686
|
-
buttonEl.removeAttribute("aria-busy");
|
|
1687
|
-
inputEl.focus();
|
|
1688
|
-
});
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
form.addEventListener("input", function (event) {
|
|
1692
|
-
if (event.target.classList.contains("field-input")) {
|
|
1693
|
-
event.target.removeAttribute("aria-invalid");
|
|
1694
|
-
event.target.removeAttribute("aria-errormessage");
|
|
1695
|
-
statusBox.style.display = "none";
|
|
1696
|
-
}
|
|
1697
|
-
});
|
|
1698
|
-
|
|
1699
|
-
form.addEventListener("submit", function (event) {
|
|
1700
|
-
event.preventDefault();
|
|
1701
|
-
|
|
1702
|
-
var inputs = form.querySelectorAll(".field-input");
|
|
1703
|
-
var payload = {};
|
|
1704
|
-
var valid = true;
|
|
1705
|
-
var firstInvalid = null;
|
|
1706
|
-
|
|
1707
|
-
inputs.forEach(function (input) {
|
|
1708
|
-
if (input.hasAttribute("required") && input.value.trim() === "") {
|
|
1709
|
-
valid = false;
|
|
1710
|
-
input.setAttribute("aria-invalid", "true");
|
|
1711
|
-
input.setAttribute("aria-errormessage", "status-box");
|
|
1712
|
-
if (!firstInvalid) {
|
|
1713
|
-
firstInvalid = input;
|
|
1714
|
-
}
|
|
1715
|
-
} else {
|
|
1716
|
-
input.removeAttribute("aria-invalid");
|
|
1717
|
-
input.removeAttribute("aria-errormessage");
|
|
1718
|
-
payload[input.name] = input.value;
|
|
1719
|
-
}
|
|
1720
|
-
});
|
|
1721
|
-
|
|
1722
|
-
if (!valid) {
|
|
1723
|
-
showStatus("error", "Please fill in all required fields.");
|
|
1724
|
-
if (firstInvalid) {
|
|
1725
|
-
firstInvalid.focus();
|
|
1726
|
-
}
|
|
1727
|
-
return;
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
|
-
submitBtn.disabled = true;
|
|
1731
|
-
submitBtn.textContent = "Connecting...";
|
|
1732
|
-
submitBtn.setAttribute("aria-busy", "true");
|
|
1733
|
-
statusBox.style.display = "none";
|
|
1734
|
-
|
|
1735
|
-
fetch(submitUrl, {
|
|
1736
|
-
method: "POST",
|
|
1737
|
-
headers: { "Content-Type": "application/json" },
|
|
1738
|
-
body: JSON.stringify(payload),
|
|
1739
|
-
})
|
|
1740
|
-
.then(function (response) {
|
|
1741
|
-
return response.json().then(function (data) {
|
|
1742
|
-
if (data.ok) {
|
|
1743
|
-
form.querySelectorAll(".field-input").forEach(function (i) {
|
|
1744
|
-
i.disabled = true;
|
|
1745
|
-
});
|
|
1746
|
-
submitBtn.disabled = true;
|
|
1747
|
-
submitBtn.textContent = "Connected";
|
|
1748
|
-
submitBtn.removeAttribute("aria-busy");
|
|
1749
|
-
var pendingRedirectUrl = (typeof data.redirect_url === "string" && data.redirect_url.length > 0) ? data.redirect_url : null;
|
|
1750
|
-
if (data.next_step && data.next_step.type === "oauth_device_code") {
|
|
1751
|
-
var ns = data.next_step;
|
|
1752
|
-
statusBox.textContent = "";
|
|
1753
|
-
var title = document.createElement("strong");
|
|
1754
|
-
title.textContent = "API keys saved!";
|
|
1755
|
-
statusBox.appendChild(title);
|
|
1756
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1757
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1758
|
-
var label = document.createTextNode("Authorize Google Drive sync:");
|
|
1759
|
-
statusBox.appendChild(label);
|
|
1760
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1761
|
-
var link = document.createElement("a");
|
|
1762
|
-
link.href = ns.verification_url;
|
|
1763
|
-
link.target = "_blank";
|
|
1764
|
-
link.rel = "noopener";
|
|
1765
|
-
link.textContent = ns.verification_url;
|
|
1766
|
-
statusBox.appendChild(link);
|
|
1767
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1768
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1769
|
-
var codeLabel = document.createTextNode("Enter code: ");
|
|
1770
|
-
statusBox.appendChild(codeLabel);
|
|
1771
|
-
var codeEl = document.createElement("strong");
|
|
1772
|
-
codeEl.style.cssText = "font-size:1.2em;letter-spacing:0.1em;user-select:all;cursor:copy";
|
|
1773
|
-
codeEl.title = "Click to select";
|
|
1774
|
-
codeEl.textContent = ns.user_code;
|
|
1775
|
-
statusBox.appendChild(codeEl);
|
|
1776
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1777
|
-
statusBox.appendChild(document.createElement("br"));
|
|
1778
|
-
var waiting = document.createElement("span");
|
|
1779
|
-
waiting.id = "gdrive-waiting";
|
|
1780
|
-
waiting.style.color = "#9ca3af";
|
|
1781
|
-
waiting.setAttribute("role", "alert");
|
|
1782
|
-
waiting.setAttribute("aria-live", "polite");
|
|
1783
|
-
waiting.setAttribute("aria-atomic", "true");
|
|
1784
|
-
waiting.textContent = "Waiting for authorization...";
|
|
1785
|
-
statusBox.appendChild(waiting);
|
|
1786
|
-
statusBox.className = "status-box info";
|
|
1787
|
-
statusBox.style.display = "block";
|
|
1788
|
-
// Poll /setup-status until GDrive auth completes or fails.
|
|
1789
|
-
// Success: gdrive === "complete"
|
|
1790
|
-
// Failure: gdrive starts with "error:" -- show red message + stop polling.
|
|
1791
|
-
var pollInterval = setInterval(function () {
|
|
1792
|
-
fetch(submitUrl.replace(/\\/authorize.*/, "/setup-status"))
|
|
1793
|
-
.then(function (r) { return r.json(); })
|
|
1794
|
-
.then(function (s) {
|
|
1795
|
-
if (s.gdrive === "complete") {
|
|
1796
|
-
clearInterval(pollInterval);
|
|
1797
|
-
if (pendingRedirectUrl) {
|
|
1798
|
-
window.location.replace(pendingRedirectUrl);
|
|
1799
|
-
return;
|
|
1800
|
-
}
|
|
1801
|
-
var w = document.getElementById("gdrive-waiting");
|
|
1802
|
-
if (w) {
|
|
1803
|
-
w.style.color = "#34c759";
|
|
1804
|
-
w.textContent = "Google Drive authorized! Setup complete. You can close this tab.";
|
|
1805
|
-
}
|
|
1806
|
-
} else if (typeof s.gdrive === "string" && s.gdrive.indexOf("error:") === 0) {
|
|
1807
|
-
clearInterval(pollInterval);
|
|
1808
|
-
var w = document.getElementById("gdrive-waiting");
|
|
1809
|
-
if (w) {
|
|
1810
|
-
w.style.color = "#ff453a";
|
|
1811
|
-
w.textContent = "Google Drive authorization failed: " + s.gdrive.slice(6) + ". Please retry setup.";
|
|
1812
|
-
}
|
|
1813
|
-
}
|
|
1814
|
-
})
|
|
1815
|
-
.catch(function () {});
|
|
1816
|
-
}, 3000);
|
|
1817
|
-
} else if (data.next_step && (data.next_step.type === "otp_required" || data.next_step.type === "password_required")) {
|
|
1818
|
-
// Multi-step auth: hide form, show step input UI.
|
|
1819
|
-
statusBox.style.display = "none";
|
|
1820
|
-
showStepInput(data.next_step);
|
|
1821
|
-
} else if (data.next_step && data.next_step.type === "info") {
|
|
1822
|
-
showStatus("success", data.next_step.message || "Setup saved. Additional steps may be required.");
|
|
1823
|
-
} else if (typeof data.redirect_url === "string" && data.redirect_url.length > 0) {
|
|
1824
|
-
// OAuth authorization-code flow: server returned the
|
|
1825
|
-
// client's redirect_uri with ?code=...&state=... appended.
|
|
1826
|
-
// The browser MUST navigate there so the client callback
|
|
1827
|
-
// server (external tool, test harness, desktop app) can
|
|
1828
|
-
// exchange the code for a JWT. Without this the handshake
|
|
1829
|
-
// hangs forever and the form silently reports success
|
|
1830
|
-
// while the client waits on a callback that never fires.
|
|
1831
|
-
showStatus("success", "Credentials saved. Redirecting...");
|
|
1832
|
-
window.location.replace(data.redirect_url);
|
|
1833
|
-
} else {
|
|
1834
|
-
var successMsg = data.message || "Connected successfully. You can close this window.";
|
|
1835
|
-
showStatus("success", successMsg);
|
|
1836
|
-
}
|
|
1837
|
-
} else {
|
|
1838
|
-
showStatus("error", data.error || data.error_description || "Request failed.");
|
|
1839
|
-
submitBtn.disabled = false;
|
|
1840
|
-
submitBtn.textContent = "Connect";
|
|
1841
|
-
submitBtn.removeAttribute("aria-busy");
|
|
1842
|
-
}
|
|
1843
|
-
});
|
|
1844
|
-
})
|
|
1845
|
-
.catch(function (err) {
|
|
1846
|
-
showStatus("error", "Network error: " + err.message);
|
|
1847
|
-
submitBtn.disabled = false;
|
|
1848
|
-
submitBtn.textContent = "Connect";
|
|
1849
|
-
submitBtn.removeAttribute("aria-busy");
|
|
1850
|
-
});
|
|
1851
|
-
});
|
|
1852
|
-
})();
|
|
1853
|
-
</script>`;
|
|
1854
|
-
return renderFormShell(title, bodyHtml);
|
|
1855
|
-
}
|
|
1856
|
-
/** Return true if field stores a credential that must not be re-rendered to HTML. */
|
|
1857
|
-
export function isSecretField(field) {
|
|
1858
|
-
return field.secret === true;
|
|
1859
|
-
}
|
|
1860
|
-
/**
|
|
1861
|
-
* Return true if field represents an OAuth-managed credential.
|
|
1862
|
-
*
|
|
1863
|
-
* OAuth fields render as "Re-authorize" buttons, not raw input boxes.
|
|
1864
|
-
* Examples: refresh_token, access_token, id_token.
|
|
1865
|
-
*/
|
|
1866
|
-
export function isOAuthField(field) {
|
|
1867
|
-
return field.oauthField === true;
|
|
1868
|
-
}
|
|
1869
|
-
//# sourceMappingURL=credential-form.js.map
|