@nebulr-group/bridge-svelte 0.7.0 → 0.7.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.
|
@@ -239,6 +239,35 @@
|
|
|
239
239
|
{:else if currentAuthState === 'tenant-selection'}
|
|
240
240
|
<TenantSelector onError={onError} {messages} />
|
|
241
241
|
|
|
242
|
+
<!--
|
|
243
|
+
Settling: the session is real and the host app has not navigated yet.
|
|
244
|
+
|
|
245
|
+
This branch is `!== 'unauthenticated'` rather than an explicit list of
|
|
246
|
+
`authenticated | credentials-validated` on purpose. Those two used to fall
|
|
247
|
+
through to the credentials form below, so somebody who had just typed their
|
|
248
|
+
password correctly was shown the password form again — which reads as a
|
|
249
|
+
refusal, and the reasonable response is to type it again (TBP-635). The window
|
|
250
|
+
opens when the token exchange resolves and closes only when the consumer's
|
|
251
|
+
router lands, because LoginForm fires `onLogin` and deliberately does not
|
|
252
|
+
navigate. Measured at 600ms against a local stack; longer anywhere real.
|
|
253
|
+
|
|
254
|
+
Listing the two states would fix the two we know about and leave the next
|
|
255
|
+
`AuthState` member falling into the same hole. Inverting the test means the
|
|
256
|
+
credentials form renders ONLY for `unauthenticated`, and anything else lands
|
|
257
|
+
on a spinner — wrong-but-harmless instead of wrong-and-alarming.
|
|
258
|
+
|
|
259
|
+
`login.submitting` is reused rather than given its own key: it already says
|
|
260
|
+
"Signing in…" in all twelve locales, and a second key rendering the same words
|
|
261
|
+
would be a translation burden that buys nothing.
|
|
262
|
+
-->
|
|
263
|
+
{:else if currentAuthState !== 'unauthenticated'}
|
|
264
|
+
<AuthFormWrapper heading={null} class={className} {style} {...rest}>
|
|
265
|
+
<div class="bridge-auth-settling" data-bridge-auth-settling>
|
|
266
|
+
<Spinner size={24} />
|
|
267
|
+
<span>{t('login.submitting')}</span>
|
|
268
|
+
</div>
|
|
269
|
+
</AuthFormWrapper>
|
|
270
|
+
|
|
242
271
|
<!-- Inline forgot password -->
|
|
243
272
|
{:else if step === 'forgot-password'}
|
|
244
273
|
<AuthFormWrapper heading={fpEmailSent ? null : t('forgot.headingRequest')}>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TBP-635 — LoginForm must never draw the credentials form to somebody who has
|
|
3
|
+
* already authenticated.
|
|
4
|
+
*
|
|
5
|
+
* The top-level branch named `mfa-required`, `mfa-setup-required` and
|
|
6
|
+
* `tenant-selection`, and let everything else fall through to the password
|
|
7
|
+
* form. `authenticated` and `credentials-validated` are also "everything else",
|
|
8
|
+
* so between the token exchange resolving and the host app's router landing —
|
|
9
|
+
* LoginForm fires `onLogin` and deliberately does not navigate — the component
|
|
10
|
+
* showed a password field to a user who had just typed their password
|
|
11
|
+
* correctly. That reads as a refusal. Measured at 600ms against a local stack.
|
|
12
|
+
*
|
|
13
|
+
* ## What this file can and cannot prove
|
|
14
|
+
*
|
|
15
|
+
* The ticket's suggested test is a MutationObserver recording every DOM batch
|
|
16
|
+
* during a real login, because a sampling loop can miss the race. This
|
|
17
|
+
* workspace has no DOM (see auth-form-description.test.ts for why), so that
|
|
18
|
+
* exact test is not available here and belongs in the Playwright e2e suite.
|
|
19
|
+
*
|
|
20
|
+
* What IS available is stronger in the dimension that actually caused the bug.
|
|
21
|
+
* The defect was not a timing subtlety — it was an unhandled branch. So this
|
|
22
|
+
* enumerates EVERY member of `AuthState`, read out of auth-core's shipped
|
|
23
|
+
* type declaration rather than hardcoded, and asserts that exactly one of them
|
|
24
|
+
* renders a password field. A seventh state added to auth-core tomorrow is
|
|
25
|
+
* covered the moment it exists, which is the half a race-detector would miss.
|
|
26
|
+
*/
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TBP-635 — LoginForm must never draw the credentials form to somebody who has
|
|
3
|
+
* already authenticated.
|
|
4
|
+
*
|
|
5
|
+
* The top-level branch named `mfa-required`, `mfa-setup-required` and
|
|
6
|
+
* `tenant-selection`, and let everything else fall through to the password
|
|
7
|
+
* form. `authenticated` and `credentials-validated` are also "everything else",
|
|
8
|
+
* so between the token exchange resolving and the host app's router landing —
|
|
9
|
+
* LoginForm fires `onLogin` and deliberately does not navigate — the component
|
|
10
|
+
* showed a password field to a user who had just typed their password
|
|
11
|
+
* correctly. That reads as a refusal. Measured at 600ms against a local stack.
|
|
12
|
+
*
|
|
13
|
+
* ## What this file can and cannot prove
|
|
14
|
+
*
|
|
15
|
+
* The ticket's suggested test is a MutationObserver recording every DOM batch
|
|
16
|
+
* during a real login, because a sampling loop can miss the race. This
|
|
17
|
+
* workspace has no DOM (see auth-form-description.test.ts for why), so that
|
|
18
|
+
* exact test is not available here and belongs in the Playwright e2e suite.
|
|
19
|
+
*
|
|
20
|
+
* What IS available is stronger in the dimension that actually caused the bug.
|
|
21
|
+
* The defect was not a timing subtlety — it was an unhandled branch. So this
|
|
22
|
+
* enumerates EVERY member of `AuthState`, read out of auth-core's shipped
|
|
23
|
+
* type declaration rather than hardcoded, and asserts that exactly one of them
|
|
24
|
+
* renders a password field. A seventh state added to auth-core tomorrow is
|
|
25
|
+
* covered the moment it exists, which is the half a race-detector would miss.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
28
|
+
import { createServer } from 'vite';
|
|
29
|
+
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
|
30
|
+
import { render } from 'svelte/server';
|
|
31
|
+
import {} from 'svelte';
|
|
32
|
+
import { en, sv } from '@nebulr-group/bridge-auth-core';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
const ROOT = process.cwd();
|
|
36
|
+
const COMPONENT_DIR = 'src/lib/client/components/sdk-auth';
|
|
37
|
+
const SEED_PREFIX = '__seeded-settling-';
|
|
38
|
+
/**
|
|
39
|
+
* Every `AuthState`, parsed out of the auth-core declaration the package
|
|
40
|
+
* actually ships.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately not a literal list: a hardcoded one would still say six when
|
|
43
|
+
* auth-core says seven, and the bug being fixed is precisely a state nobody
|
|
44
|
+
* remembered to handle. Parsing throws rather than returning a short list, so a
|
|
45
|
+
* refactor that moves the type fails this suite loudly instead of quietly
|
|
46
|
+
* testing fewer states.
|
|
47
|
+
*/
|
|
48
|
+
function authStates() {
|
|
49
|
+
// Resolved from the filesystem rather than the module system: the package
|
|
50
|
+
// exports no `./package.json` subpath, and vitest's module runner supports
|
|
51
|
+
// neither `require.resolve` nor `import.meta.resolve` here. Both candidate
|
|
52
|
+
// paths are tried because bun may hoist the dep to the workspace root.
|
|
53
|
+
const candidates = [
|
|
54
|
+
path.join(ROOT, 'node_modules/@nebulr-group/bridge-auth-core/dist/types.d.ts'),
|
|
55
|
+
path.join(ROOT, '../node_modules/@nebulr-group/bridge-auth-core/dist/types.d.ts'),
|
|
56
|
+
];
|
|
57
|
+
const dts = candidates.find((c) => fs.existsSync(c));
|
|
58
|
+
if (!dts) {
|
|
59
|
+
throw new Error(`auth-core types.d.ts not found. Looked in:\n ${candidates.join('\n ')}`);
|
|
60
|
+
}
|
|
61
|
+
const source = fs.readFileSync(dts, 'utf8');
|
|
62
|
+
const match = source.match(/export type AuthState\s*=([^;]+);/);
|
|
63
|
+
if (!match) {
|
|
64
|
+
throw new Error('Could not find `export type AuthState` in auth-core types.d.ts.');
|
|
65
|
+
}
|
|
66
|
+
const states = [...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
|
67
|
+
if (states.length < 2) {
|
|
68
|
+
throw new Error(`Parsed only ${states.length} AuthState members — parser is wrong.`);
|
|
69
|
+
}
|
|
70
|
+
return states;
|
|
71
|
+
}
|
|
72
|
+
const STATES = authStates();
|
|
73
|
+
let server;
|
|
74
|
+
const seededPaths = [];
|
|
75
|
+
beforeAll(async () => {
|
|
76
|
+
for (const f of fs.readdirSync(path.join(ROOT, COMPONENT_DIR))) {
|
|
77
|
+
if (f.startsWith(SEED_PREFIX))
|
|
78
|
+
fs.unlinkSync(path.join(ROOT, COMPONENT_DIR, f));
|
|
79
|
+
}
|
|
80
|
+
server = await createServer({
|
|
81
|
+
configFile: false,
|
|
82
|
+
root: ROOT,
|
|
83
|
+
logLevel: 'error',
|
|
84
|
+
server: { middlewareMode: true, hmr: false },
|
|
85
|
+
plugins: [svelte({ compilerOptions: { dev: false } })],
|
|
86
|
+
});
|
|
87
|
+
}, 120_000);
|
|
88
|
+
afterAll(async () => {
|
|
89
|
+
await server?.close();
|
|
90
|
+
for (const p of seededPaths) {
|
|
91
|
+
if (fs.existsSync(p))
|
|
92
|
+
fs.unlinkSync(p);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
/**
|
|
96
|
+
* Render LoginForm with `currentAuthState` pinned.
|
|
97
|
+
*
|
|
98
|
+
* The seed replaces the one `$derived($authState)` line, so the template — every
|
|
99
|
+
* branch, the wrapper call, the catalogue lookups — is the real shipped source.
|
|
100
|
+
* Driving the store itself would need the network and the real state machine,
|
|
101
|
+
* and would test auth-core's transitions rather than this component's branching,
|
|
102
|
+
* which is where the defect was.
|
|
103
|
+
*/
|
|
104
|
+
async function renderAtState(state, props = {}) {
|
|
105
|
+
const file = 'LoginForm.svelte';
|
|
106
|
+
const from = 'let currentAuthState = $derived($authState);';
|
|
107
|
+
let source = fs.readFileSync(path.join(ROOT, COMPONENT_DIR, file), 'utf8');
|
|
108
|
+
if (!source.includes(from)) {
|
|
109
|
+
throw new Error(`State seed target not found in ${file}:\n ${from}\n` +
|
|
110
|
+
'The component was refactored. Update the seed so the test keeps driving ' +
|
|
111
|
+
'the auth-state branch — do not delete the assertion.');
|
|
112
|
+
}
|
|
113
|
+
source = source.replace(from, `let currentAuthState = $state('${state}');`);
|
|
114
|
+
const name = `${SEED_PREFIX}${process.pid}-${seededPaths.length}-${file}`;
|
|
115
|
+
const full = path.join(ROOT, COMPONENT_DIR, name);
|
|
116
|
+
fs.writeFileSync(full, source);
|
|
117
|
+
seededPaths.push(full);
|
|
118
|
+
const mod = await server.ssrLoadModule(`/${COMPONENT_DIR}/${name}`);
|
|
119
|
+
const Component = mod.default;
|
|
120
|
+
return render(Component, { props }).body;
|
|
121
|
+
}
|
|
122
|
+
async function setLocale(locale) {
|
|
123
|
+
const mod = await server.ssrLoadModule('/src/lib/client/stores/config.store.ts');
|
|
124
|
+
mod.bridgeConfig.initConfig({ appId: 'tbp-635-test', locale });
|
|
125
|
+
}
|
|
126
|
+
/** A password field is the thing that must never appear post-auth. */
|
|
127
|
+
function hasPasswordField(html) {
|
|
128
|
+
return /<input[^>]+type="password"/.test(html);
|
|
129
|
+
}
|
|
130
|
+
function text(html) {
|
|
131
|
+
return html
|
|
132
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
133
|
+
.replace(/<[^>]*>/g, ' ')
|
|
134
|
+
.replace(/\s+/g, ' ')
|
|
135
|
+
.trim();
|
|
136
|
+
}
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// 1. The fixture itself
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
describe('AuthState coverage (TBP-635)', () => {
|
|
141
|
+
it('reads the real state list out of the shipped auth-core types', () => {
|
|
142
|
+
// If this drifts, every assertion below silently covers less.
|
|
143
|
+
expect(STATES).toContain('unauthenticated');
|
|
144
|
+
expect(STATES).toContain('authenticated');
|
|
145
|
+
expect(STATES).toContain('credentials-validated');
|
|
146
|
+
expect(STATES.length).toBeGreaterThanOrEqual(6);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// 2. The invariant
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
describe('LoginForm never shows the credentials form post-auth (TBP-635)', () => {
|
|
153
|
+
it('renders a password field for `unauthenticated` and for nothing else', async () => {
|
|
154
|
+
await setLocale(undefined);
|
|
155
|
+
const withPassword = [];
|
|
156
|
+
for (const state of STATES) {
|
|
157
|
+
if (hasPasswordField(await renderAtState(state)))
|
|
158
|
+
withPassword.push(state);
|
|
159
|
+
}
|
|
160
|
+
// Both directions. Asserting only "authenticated has no password field"
|
|
161
|
+
// would pass for a component that rendered nothing at all, ever.
|
|
162
|
+
expect(withPassword).toEqual(['unauthenticated']);
|
|
163
|
+
});
|
|
164
|
+
for (const state of ['authenticated', 'credentials-validated']) {
|
|
165
|
+
it(`shows the settling card at "${state}"`, async () => {
|
|
166
|
+
await setLocale(undefined);
|
|
167
|
+
const html = await renderAtState(state);
|
|
168
|
+
expect(html).toContain('data-bridge-auth-settling');
|
|
169
|
+
expect(hasPasswordField(html)).toBe(false);
|
|
170
|
+
// Not a blank card: an empty box during a pause is its own bad message.
|
|
171
|
+
expect(text(html)).toContain(en['login.submitting']);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
it('translates the waiting copy', async () => {
|
|
175
|
+
await setLocale('sv');
|
|
176
|
+
const html = await renderAtState('authenticated');
|
|
177
|
+
expect(text(html)).toContain(sv['login.submitting']);
|
|
178
|
+
expect(text(html)).not.toContain(en['login.submitting']);
|
|
179
|
+
});
|
|
180
|
+
it('suppresses the heading while settling, so no stale "Log in" survives', async () => {
|
|
181
|
+
await setLocale(undefined);
|
|
182
|
+
const html = await renderAtState('authenticated', { heading: 'Log in to NorthWhistle' });
|
|
183
|
+
expect(html).not.toContain('Log in to NorthWhistle');
|
|
184
|
+
expect(html).toContain('data-bridge-auth-settling');
|
|
185
|
+
});
|
|
186
|
+
it('still hands the three delegated states to their own components', async () => {
|
|
187
|
+
// The fix must not have swallowed the branches that already worked — an
|
|
188
|
+
// over-eager `!== unauthenticated` placed above them would do exactly that.
|
|
189
|
+
await setLocale(undefined);
|
|
190
|
+
expect(await renderAtState('tenant-selection')).toContain('bridge-tenant-list');
|
|
191
|
+
expect(await renderAtState('mfa-required')).toContain(en['mfa.challengeHeading']);
|
|
192
|
+
expect(await renderAtState('mfa-setup-required')).toContain(en['mfaSetup.heading']);
|
|
193
|
+
});
|
|
194
|
+
it('leaves the unauthenticated step machine alone', async () => {
|
|
195
|
+
await setLocale(undefined);
|
|
196
|
+
const html = await renderAtState('unauthenticated');
|
|
197
|
+
expect(hasPasswordField(html)).toBe(true);
|
|
198
|
+
expect(html).toContain(en['login.submit']);
|
|
199
|
+
expect(html).not.toContain('data-bridge-auth-settling');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// 3. onLogin is untouched
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
describe('onLogin lifecycle is unchanged (TBP-635)', () => {
|
|
206
|
+
it('still fires from an effect keyed on `authenticated`', () => {
|
|
207
|
+
// Effects do not run in a server render, so this is asserted on the source.
|
|
208
|
+
// It is here because the tempting wrong fix for this bug is to move
|
|
209
|
+
// `onLogin` earlier so the consumer navigates sooner — which would fire it
|
|
210
|
+
// before the session is real.
|
|
211
|
+
const source = fs.readFileSync(path.join(ROOT, COMPONENT_DIR, 'LoginForm.svelte'), 'utf8');
|
|
212
|
+
const effect = source.match(/\$effect\(\(\) => \{[\s\S]*?\}\);/);
|
|
213
|
+
expect(effect, 'LoginForm no longer has an $effect').not.toBeNull();
|
|
214
|
+
expect(effect[0]).toContain("currentAuthState === 'authenticated'");
|
|
215
|
+
expect(effect[0]).toContain('onLogin?.()');
|
|
216
|
+
// Exactly one call site, so it cannot fire twice.
|
|
217
|
+
expect(source.match(/onLogin\?\.\(\)/g)).toHaveLength(1);
|
|
218
|
+
});
|
|
219
|
+
});
|
package/dist/styles.css
CHANGED
|
@@ -309,6 +309,19 @@
|
|
|
309
309
|
margin: 0 0 1rem;
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
/* TBP-635 — the card between a successful sign-in and the host app navigating. */
|
|
313
|
+
.bridge-auth-settling {
|
|
314
|
+
display: flex;
|
|
315
|
+
align-items: center;
|
|
316
|
+
justify-content: center;
|
|
317
|
+
gap: 0.75rem;
|
|
318
|
+
/* Roughly the height of the credentials form it replaces, so the card does
|
|
319
|
+
not collapse and then re-expand under the cursor. */
|
|
320
|
+
min-height: 8rem;
|
|
321
|
+
color: var(--bridge-muted, #6b7280);
|
|
322
|
+
font-size: 0.875rem;
|
|
323
|
+
}
|
|
324
|
+
|
|
312
325
|
.bridge-mfa-help {
|
|
313
326
|
font-size: 0.8125rem;
|
|
314
327
|
color: var(--bridge-muted, #6b7280);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebulr-group/bridge-svelte",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Bridge Svelte library, This library helps you to add bridge authentication and feature flags, and payments to your svelte application.",
|
|
5
5
|
"author": "Iman Pouya",
|
|
6
6
|
"license": "MIT",
|