@nebulr-group/bridge-svelte 0.4.0-beta.0 → 0.4.0-beta.10
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/dist/client/BridgeBootstrap.js +30 -29
- package/dist/client/BridgeBootstrap.svelte +44 -2
- package/dist/client/components/sdk-auth/ForgotPassword.svelte +15 -1
- package/dist/client/components/sdk-auth/ForgotPassword.svelte.d.ts +5 -0
- package/dist/client/components/sdk-auth/LoginForm.svelte +2 -2
- package/dist/client/components/sdk-auth/MagicLink.svelte +4 -1
- package/dist/client/components/sdk-auth/MagicLink.svelte.d.ts +2 -0
- package/dist/client/components/sdk-auth/PasskeyLogin.svelte +1 -1
- package/dist/client/components/sdk-auth/PasskeySetup.svelte +1 -1
- package/dist/client/components/sdk-auth/SignupForm.svelte +19 -3
- package/dist/client/components/sdk-auth/SignupForm.svelte.d.ts +8 -1
- package/dist/client/components/sdk-auth/shared/AuthFormWrapper.svelte +2 -1
- package/dist/client/components/sdk-auth/shared/AuthFormWrapper.svelte.d.ts +2 -1
- package/dist/client/components/subscription/BridgeBillingNotice.svelte +18 -16
- package/dist/client/components/subscription/BridgeBillingNotice.svelte.d.ts +3 -0
- package/dist/client/components/subscription/BridgeQuotaBanner.svelte +87 -16
- package/dist/client/components/subscription/BridgeQuotaBanner.svelte.d.ts +3 -0
- package/dist/client/components/subscription/PlanSelector.svelte +199 -10
- package/dist/client/components/subscription/PlanSelector.svelte.d.ts +21 -0
- package/dist/client/components/subscription/billing-manage-route.test.d.ts +1 -0
- package/dist/client/components/subscription/billing-manage-route.test.js +258 -0
- package/dist/client/components/subscription/plan-selector.test.d.ts +1 -0
- package/dist/client/components/subscription/plan-selector.test.js +391 -0
- package/dist/core/bridge-fetch.d.ts +18 -0
- package/dist/core/bridge-fetch.js +59 -0
- package/dist/core/bridge-instance.d.ts +0 -3
- package/dist/core/bridge-instance.js +4 -10
- package/dist/core/bridge-runtime.d.ts +11 -0
- package/dist/core/bridge-runtime.js +55 -0
- package/dist/core/bridge.d.ts +13 -6
- package/dist/core/bridge.js +90 -8
- package/dist/core/bridge.test.js +19 -11
- package/dist/flags/bootstrap.js +2 -3
- package/dist/flags/bootstrap.test.js +12 -7
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -3
- package/dist/styles.css +91 -0
- package/package.json +4 -7
- package/dist/client/components/FeatureFlag.svelte +0 -37
- package/dist/client/components/FeatureFlag.svelte.d.ts +0 -15
- package/dist/shared/feature-flag.d.ts +0 -6
- package/dist/shared/feature-flag.js +0 -13
|
@@ -3,8 +3,8 @@ import { redirect, isRedirect } from '@sveltejs/kit';
|
|
|
3
3
|
import { get } from 'svelte/store';
|
|
4
4
|
import { createRouteGuard } from '../auth/route-guard.js';
|
|
5
5
|
import { getBridgeAuth, bridgeReadyStore, markReady, waitForBridge as _waitForBridge, } from '../core/bridge-instance.js';
|
|
6
|
+
import { installBridgeAuthFetch } from '../core/bridge-runtime.js';
|
|
6
7
|
import { useBridge } from '@nebulr-group/bridge-auth-core';
|
|
7
|
-
import { featureFlags } from '../shared/feature-flag.js';
|
|
8
8
|
import { logger } from '../shared/logger.js';
|
|
9
9
|
import { bridgeConfig, getConfig } from './stores/config.store.js';
|
|
10
10
|
export async function bridgeBootstrap(url, config, routeConfig = { rules: [], defaultAccess: 'protected' }, kitFetch) {
|
|
@@ -21,6 +21,11 @@ export async function bridgeBootstrap(url, config, routeConfig = { rules: [], de
|
|
|
21
21
|
}
|
|
22
22
|
// 1. Initialize configuration (synchronously) — this also calls initBridge()
|
|
23
23
|
bridgeConfig.initConfig(finalConfig, routeConfig);
|
|
24
|
+
// 1b. Patch globalThis.fetch early so GraphQL/HTTP calls made in this load()
|
|
25
|
+
// function (before any component mounts) already carry the Bearer token.
|
|
26
|
+
// installBridgeAuthFetch() is idempotent — startBridgeRuntime() (onMount)
|
|
27
|
+
// is a no-op when it finds the patch already in place.
|
|
28
|
+
installBridgeAuthFetch();
|
|
24
29
|
// 1a. Unified callback handler — detects what is calling back and routes accordingly
|
|
25
30
|
try {
|
|
26
31
|
const resolvedCallbackUrl = getConfig().callbackUrl;
|
|
@@ -62,22 +67,15 @@ export async function bridgeBootstrap(url, config, routeConfig = { rules: [], de
|
|
|
62
67
|
}
|
|
63
68
|
}
|
|
64
69
|
else if (stripeSuccess && sessionId) {
|
|
65
|
-
// Stripe payment success —
|
|
66
|
-
//
|
|
70
|
+
// Stripe payment success — auth-core's confirmStripeCheckout() verifies the session
|
|
71
|
+
// with bridge-api (server calls Stripe directly) and refreshes tokens so the new JWT
|
|
72
|
+
// has shouldSelectPlan: false before we redirect. It throws on a non-OK response or
|
|
73
|
+
// network error → we fall through to the payment-error redirect. (TBP-369: the HTTP +
|
|
74
|
+
// token-refresh logic now lives in auth-core so every plugin port can reuse it.)
|
|
67
75
|
logger.debug('[bridgeBootstrap] Stripe success callback — confirming with bridge-api');
|
|
68
76
|
const bridge = getBridgeAuth();
|
|
69
|
-
const ctx = bridge.getApiContext();
|
|
70
77
|
try {
|
|
71
|
-
|
|
72
|
-
method: 'POST',
|
|
73
|
-
headers: { 'Content-Type': 'application/json' },
|
|
74
|
-
body: JSON.stringify({ sessionId, appId: ctx.appId }),
|
|
75
|
-
});
|
|
76
|
-
if (!res.ok) {
|
|
77
|
-
logger.warn('[bridgeBootstrap] confirm-checkout failed', res.status);
|
|
78
|
-
redirect(303, getConfig().billing?.paymentErrorRoute ?? '/payment-error');
|
|
79
|
-
}
|
|
80
|
-
await bridge.refreshTokens();
|
|
78
|
+
await bridge.confirmStripeCheckout(sessionId, kitFetch);
|
|
81
79
|
redirect(303, redirectTo);
|
|
82
80
|
}
|
|
83
81
|
catch (err) {
|
|
@@ -112,22 +110,19 @@ export async function bridgeBootstrap(url, config, routeConfig = { rules: [], de
|
|
|
112
110
|
catch {
|
|
113
111
|
// Non-fatal — stale tokens will be caught by route guard
|
|
114
112
|
}
|
|
115
|
-
// 2b. Paywall redirect — fires before any page renders.
|
|
116
|
-
//
|
|
113
|
+
// 2b. Paywall redirect — fires before any page renders. The framework-agnostic
|
|
114
|
+
// decision (authenticated + shouldSelectPlan + not opted out via
|
|
115
|
+
// paymentsAutoRedirect) now lives in auth-core's shouldRedirectToPaywall()
|
|
116
|
+
// (TBP-369). We only own the route/config guards here:
|
|
117
117
|
// - billing.paywallRoute is configured
|
|
118
118
|
// - the current path is not already the paywall route (no redirect loop)
|
|
119
|
-
// - the tenant is authenticated but has not selected a plan
|
|
120
|
-
// - the app has not opted out via paymentsAutoRedirect: false
|
|
121
119
|
try {
|
|
122
120
|
const paywallRoute = getConfig().billing?.paywallRoute;
|
|
123
121
|
if (paywallRoute && url.pathname !== paywallRoute) {
|
|
124
122
|
const bridge = getBridgeAuth();
|
|
125
|
-
if (bridge.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
logger.debug('[bridgeBootstrap] paywall redirect', paywallRoute);
|
|
129
|
-
redirect(303, paywallRoute);
|
|
130
|
-
}
|
|
123
|
+
if (await bridge.shouldRedirectToPaywall()) {
|
|
124
|
+
logger.debug('[bridgeBootstrap] paywall redirect', paywallRoute);
|
|
125
|
+
redirect(303, paywallRoute);
|
|
131
126
|
}
|
|
132
127
|
}
|
|
133
128
|
}
|
|
@@ -152,11 +147,17 @@ export async function bridgeBootstrap(url, config, routeConfig = { rules: [], de
|
|
|
152
147
|
catch {
|
|
153
148
|
// Non-fatal — gate fails open; the notice hydrates lazily on first render.
|
|
154
149
|
}
|
|
155
|
-
// 3.
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
150
|
+
// 3. Warm the route-guard's flag cache without awaiting so GQL queries can
|
|
151
|
+
// start in parallel. `loadFeatureFlags()` populates the same auth-core
|
|
152
|
+
// FeatureFlagService the route guard evaluates `featureFlag` rules
|
|
153
|
+
// against; the flagsReady promise is passed to the guard and returned to
|
|
154
|
+
// callers so routes that gate on flags can await before rendering.
|
|
155
|
+
// (Component/programmatic flag reads use FF 2.0 `useFlag` / <FeatureFlag>.)
|
|
156
|
+
// Errors are logged, never thrown — flag failures must not crash bootstrap.
|
|
157
|
+
const flagsReady = getBridgeAuth()
|
|
158
|
+
.loadFeatureFlags()
|
|
159
|
+
.then(() => { })
|
|
160
|
+
.catch((err) => {
|
|
160
161
|
logger.warn('[bridgeBootstrap] Feature flags failed to load:', err);
|
|
161
162
|
});
|
|
162
163
|
// 4. Handle route guarding and redirects
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { beforeNavigate, goto } from '$app/navigation';
|
|
3
|
+
import { page } from '$app/stores';
|
|
3
4
|
import { onMount, onDestroy } from 'svelte';
|
|
4
5
|
import { createRouteGuard } from '../auth/route-guard.js';
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getBridgeAuth,
|
|
8
|
+
isAuthenticated,
|
|
9
|
+
subscriptionStore,
|
|
10
|
+
loadSubscription,
|
|
11
|
+
ensureAppConfig,
|
|
12
|
+
} from '../core/bridge-instance.js';
|
|
6
13
|
import { bridge as bridgeSurface } from '../core/bridge.js';
|
|
7
14
|
import { setBridgeContext } from '../core/use-bridge.js';
|
|
8
15
|
import { getConfig } from './stores/config.store.js';
|
|
@@ -11,7 +18,6 @@
|
|
|
11
18
|
stopBridgeRuntime,
|
|
12
19
|
type StartBridgeRuntimeOptions,
|
|
13
20
|
} from '../core/bridge-runtime.js';
|
|
14
|
-
import { loadSubscription, ensureAppConfig } from '../core/bridge-instance.js';
|
|
15
21
|
|
|
16
22
|
// Props: optional `runtime` overrides for advanced/debug use (websocketFactory,
|
|
17
23
|
// reconnect overrides, etc.); `onBootstrapComplete` callback fires after the
|
|
@@ -30,6 +36,42 @@
|
|
|
30
36
|
|
|
31
37
|
const guard = createRouteGuard();
|
|
32
38
|
|
|
39
|
+
// Reactive paywall enforcement.
|
|
40
|
+
//
|
|
41
|
+
// The one-shot redirect in bridgeBootstrap() (load) only fires on the very
|
|
42
|
+
// first load — it short-circuits on every later navigation (bridgeReadyStore),
|
|
43
|
+
// so it misses the post-login case where auth + subscription state resolve a
|
|
44
|
+
// beat AFTER load() ran. This guard owns correctness: it actively loads the
|
|
45
|
+
// subscription once known-authenticated, then redirects reactively whenever
|
|
46
|
+
// `shouldSelectPlan` resolves true. Same data source as <BridgePaywall>, so the
|
|
47
|
+
// overlay and the redirect agree. The load() redirect remains a no-flash
|
|
48
|
+
// fast-path for direct loads/refreshes only.
|
|
49
|
+
$effect(() => {
|
|
50
|
+
const paywallRoute = getConfig().billing?.paywallRoute;
|
|
51
|
+
if (!paywallRoute || !$isAuthenticated) return;
|
|
52
|
+
|
|
53
|
+
const { status, loading, error } = $subscriptionStore;
|
|
54
|
+
|
|
55
|
+
// Status unknown → trigger a single load. Guarding on `!error` avoids a
|
|
56
|
+
// tight refetch loop on persistent failure (fail-pending, not fail-open);
|
|
57
|
+
// a workspace switch resets the store and re-attempts.
|
|
58
|
+
if (!status && !loading && !error) {
|
|
59
|
+
loadSubscription().catch(() => { /* surfaced via store.error */ });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Status known → enforce. `$page.url.pathname` makes this re-run on
|
|
64
|
+
// navigation too, so manual nav to a protected page while plan-less is
|
|
65
|
+
// also caught. Path guard prevents a redirect loop on the paywall itself.
|
|
66
|
+
if (
|
|
67
|
+
status?.shouldSelectPlan === true &&
|
|
68
|
+
status?.paymentsAutoRedirect !== false &&
|
|
69
|
+
$page.url.pathname !== paywallRoute
|
|
70
|
+
) {
|
|
71
|
+
goto(paywallRoute);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
33
75
|
async function handleRoute(pathname: string, cancel?: () => void) {
|
|
34
76
|
const decision = await guard.getNavigationDecision(pathname);
|
|
35
77
|
if (decision.type === 'login') {
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
onComplete?: () => void;
|
|
11
11
|
onError?: (error: Error) => void;
|
|
12
12
|
loginHref?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Override the built-in heading ("Set new password" / "Reset your password").
|
|
15
|
+
* Pass `null`/`''` to render no heading and use your own page title.
|
|
16
|
+
*/
|
|
17
|
+
heading?: string | null;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
let {
|
|
@@ -17,6 +22,7 @@
|
|
|
17
22
|
onComplete,
|
|
18
23
|
onError,
|
|
19
24
|
loginHref = '/login',
|
|
25
|
+
heading = undefined,
|
|
20
26
|
class: className,
|
|
21
27
|
style,
|
|
22
28
|
...rest
|
|
@@ -33,6 +39,14 @@
|
|
|
33
39
|
|
|
34
40
|
const isSetMode = $derived(!!token);
|
|
35
41
|
|
|
42
|
+
// In a success state ("Password set" / the email-sent alert) the form heading
|
|
43
|
+
// is redundant, so suppress it. Otherwise use the override (if provided) or
|
|
44
|
+
// the built-in, state-appropriate heading.
|
|
45
|
+
const builtInHeading = $derived(isSetMode ? 'Set new password' : 'Reset your password');
|
|
46
|
+
const wrapperHeading = $derived(
|
|
47
|
+
passwordReset || emailSent ? null : heading !== undefined ? heading : builtInHeading,
|
|
48
|
+
);
|
|
49
|
+
|
|
36
50
|
async function handleSendLink() {
|
|
37
51
|
if (loading) return;
|
|
38
52
|
error = null;
|
|
@@ -77,7 +91,7 @@
|
|
|
77
91
|
</script>
|
|
78
92
|
|
|
79
93
|
<AuthFormWrapper
|
|
80
|
-
heading={
|
|
94
|
+
heading={wrapperHeading}
|
|
81
95
|
class={className}
|
|
82
96
|
{style}
|
|
83
97
|
{...rest}
|
|
@@ -4,6 +4,11 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
|
4
4
|
onComplete?: () => void;
|
|
5
5
|
onError?: (error: Error) => void;
|
|
6
6
|
loginHref?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Override the built-in heading ("Set new password" / "Reset your password").
|
|
9
|
+
* Pass `null`/`''` to render no heading and use your own page title.
|
|
10
|
+
*/
|
|
11
|
+
heading?: string | null;
|
|
7
12
|
}
|
|
8
13
|
declare const ForgotPassword: import("svelte").Component<Props, {}, "">;
|
|
9
14
|
type ForgotPassword = ReturnType<typeof ForgotPassword>;
|
|
@@ -219,7 +219,7 @@
|
|
|
219
219
|
|
|
220
220
|
<!-- Inline forgot password -->
|
|
221
221
|
{:else if step === 'forgot-password'}
|
|
222
|
-
<AuthFormWrapper heading=
|
|
222
|
+
<AuthFormWrapper heading={fpEmailSent ? null : 'Reset your password'}>
|
|
223
223
|
{#if error}
|
|
224
224
|
<Alert variant="error">{error}</Alert>
|
|
225
225
|
{/if}
|
|
@@ -257,7 +257,7 @@
|
|
|
257
257
|
|
|
258
258
|
<!-- Inline magic link -->
|
|
259
259
|
{:else if step === 'magic-link'}
|
|
260
|
-
<AuthFormWrapper heading=
|
|
260
|
+
<AuthFormWrapper heading={mlSent ? null : 'Sign in with email link'}>
|
|
261
261
|
{#if error}
|
|
262
262
|
<Alert variant="error">{error}</Alert>
|
|
263
263
|
{/if}
|
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
onSent?: () => void;
|
|
10
10
|
onError?: (error: Error) => void;
|
|
11
11
|
loginHref?: string;
|
|
12
|
+
/** Heading text. Pass `null`/`''` to render no heading and use your own page title. */
|
|
13
|
+
heading?: string | null;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
let {
|
|
15
17
|
onSent,
|
|
16
18
|
onError,
|
|
17
19
|
loginHref = '/login',
|
|
20
|
+
heading = 'Sign in with email link',
|
|
18
21
|
class: className,
|
|
19
22
|
style,
|
|
20
23
|
...rest
|
|
@@ -51,7 +54,7 @@
|
|
|
51
54
|
}
|
|
52
55
|
</script>
|
|
53
56
|
|
|
54
|
-
<AuthFormWrapper heading=
|
|
57
|
+
<AuthFormWrapper heading={sent ? null : heading} class={className} {style} {...rest}>
|
|
55
58
|
{#if error}
|
|
56
59
|
<Alert variant="error">{error}</Alert>
|
|
57
60
|
{/if}
|
|
@@ -3,6 +3,8 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
|
3
3
|
onSent?: () => void;
|
|
4
4
|
onError?: (error: Error) => void;
|
|
5
5
|
loginHref?: string;
|
|
6
|
+
/** Heading text. Pass `null`/`''` to render no heading and use your own page title. */
|
|
7
|
+
heading?: string | null;
|
|
6
8
|
}
|
|
7
9
|
declare const MagicLink: import("svelte").Component<Props, {}, "">;
|
|
8
10
|
type MagicLink = ReturnType<typeof MagicLink>;
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
let authResponse: any;
|
|
42
42
|
if (typeof window !== 'undefined' && (window as any).__simpleWebAuthn?.startAuthentication) {
|
|
43
|
-
authResponse = await (window as any).__simpleWebAuthn.startAuthentication(options, autofill);
|
|
43
|
+
authResponse = await (window as any).__simpleWebAuthn.startAuthentication({ optionsJSON: options, useBrowserAutofill: autofill });
|
|
44
44
|
} else {
|
|
45
45
|
// @ts-ignore — @simplewebauthn/browser is an optional peer dependency
|
|
46
46
|
const mod = await import('@simplewebauthn/browser');
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
|
|
55
55
|
let regResponse: any;
|
|
56
56
|
if (typeof window !== 'undefined' && (window as any).__simpleWebAuthn?.startRegistration) {
|
|
57
|
-
regResponse = await (window as any).__simpleWebAuthn.startRegistration(options);
|
|
57
|
+
regResponse = await (window as any).__simpleWebAuthn.startRegistration({ optionsJSON: options });
|
|
58
58
|
} else {
|
|
59
59
|
// @ts-ignore — @simplewebauthn/browser is an optional peer dependency
|
|
60
60
|
const mod = await import('@simplewebauthn/browser');
|
|
@@ -13,7 +13,14 @@
|
|
|
13
13
|
showLoginLink?: boolean;
|
|
14
14
|
/** Override login page URL. Default: derived from BridgeConfig loginRoute. */
|
|
15
15
|
loginHref?: string | undefined;
|
|
16
|
-
heading
|
|
16
|
+
/** Heading text. Pass `null`/`''` to render no heading and use your own page title. */
|
|
17
|
+
heading?: string | null;
|
|
18
|
+
/** TBP-36 — preselected plan applied at tenant creation (from ?signupPlan= links). */
|
|
19
|
+
plan?: string | null;
|
|
20
|
+
/** TBP-36 — currency of the preselected plan's price offer. */
|
|
21
|
+
currency?: string | null;
|
|
22
|
+
/** TBP-36 — recurrence interval of the preselected plan's price offer. */
|
|
23
|
+
recurrenceInterval?: string | null;
|
|
17
24
|
footer?: Snippet;
|
|
18
25
|
}
|
|
19
26
|
|
|
@@ -23,6 +30,9 @@
|
|
|
23
30
|
showLoginLink = true,
|
|
24
31
|
loginHref = undefined,
|
|
25
32
|
heading = 'Create your account',
|
|
33
|
+
plan = null,
|
|
34
|
+
currency = null,
|
|
35
|
+
recurrenceInterval = null,
|
|
26
36
|
footer,
|
|
27
37
|
class: className,
|
|
28
38
|
style,
|
|
@@ -43,7 +53,11 @@
|
|
|
43
53
|
error = null;
|
|
44
54
|
loading = true;
|
|
45
55
|
try {
|
|
46
|
-
await getBridgeAuth().signup(email, firstName, lastName
|
|
56
|
+
await getBridgeAuth().signup(email, firstName, lastName, plan ? {
|
|
57
|
+
plan,
|
|
58
|
+
currency: currency ?? undefined,
|
|
59
|
+
recurrenceInterval: recurrenceInterval ?? undefined,
|
|
60
|
+
} : undefined);
|
|
47
61
|
success = true;
|
|
48
62
|
onSignup?.();
|
|
49
63
|
} catch (err: any) {
|
|
@@ -55,7 +69,9 @@
|
|
|
55
69
|
}
|
|
56
70
|
</script>
|
|
57
71
|
|
|
58
|
-
|
|
72
|
+
<!-- In the success state, the "Check your email" heading below is the title,
|
|
73
|
+
so suppress the form heading to avoid two stacked headings. -->
|
|
74
|
+
<AuthFormWrapper heading={success ? null : heading} class={className} {style} {...rest}>
|
|
59
75
|
{#if success}
|
|
60
76
|
<h2 class="bridge-success-heading">Check your email</h2>
|
|
61
77
|
<p class="bridge-step-desc">We sent a verification link to <strong>{email}</strong>. Check your inbox to activate your account.</p>
|
|
@@ -6,7 +6,14 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
|
6
6
|
showLoginLink?: boolean;
|
|
7
7
|
/** Override login page URL. Default: derived from BridgeConfig loginRoute. */
|
|
8
8
|
loginHref?: string | undefined;
|
|
9
|
-
heading
|
|
9
|
+
/** Heading text. Pass `null`/`''` to render no heading and use your own page title. */
|
|
10
|
+
heading?: string | null;
|
|
11
|
+
/** TBP-36 — preselected plan applied at tenant creation (from ?signupPlan= links). */
|
|
12
|
+
plan?: string | null;
|
|
13
|
+
/** TBP-36 — currency of the preselected plan's price offer. */
|
|
14
|
+
currency?: string | null;
|
|
15
|
+
/** TBP-36 — recurrence interval of the preselected plan's price offer. */
|
|
16
|
+
recurrenceInterval?: string | null;
|
|
10
17
|
footer?: Snippet;
|
|
11
18
|
}
|
|
12
19
|
declare const SignupForm: import("svelte").Component<Props, {}, "">;
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
4
|
|
|
5
5
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
-
heading
|
|
6
|
+
/** Heading text. Pass `null` or `''` to render no heading. */
|
|
7
|
+
heading?: string | null;
|
|
7
8
|
headingSnippet?: Snippet;
|
|
8
9
|
children?: Snippet;
|
|
9
10
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
3
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
4
|
-
heading
|
|
4
|
+
/** Heading text. Pass `null` or `''` to render no heading. */
|
|
5
|
+
heading?: string | null;
|
|
5
6
|
headingSnippet?: Snippet;
|
|
6
7
|
children?: Snippet;
|
|
7
8
|
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
type BillingSubscriptionSnapshot,
|
|
25
25
|
} from '@nebulr-group/bridge-auth-core';
|
|
26
26
|
import { getBridgeAuth } from '../../../core/bridge-instance.js';
|
|
27
|
+
import { getConfig } from '../../stores/config.store.js';
|
|
27
28
|
|
|
28
29
|
type Chassis = 'bar' | 'rail' | 'card';
|
|
29
30
|
|
|
@@ -40,6 +41,9 @@
|
|
|
40
41
|
class?: string;
|
|
41
42
|
/** Override the default CTA click handler (links to billing surface). */
|
|
42
43
|
onActionClick?: (state: BillingNoticeState) => void;
|
|
44
|
+
/** CTA destination for this instance. Overrides `billing.manageRoute`
|
|
45
|
+
* config; `onActionClick` takes precedence over both. */
|
|
46
|
+
actionHref?: string;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
let {
|
|
@@ -47,14 +51,15 @@
|
|
|
47
51
|
mode = 'soft',
|
|
48
52
|
class: className = '',
|
|
49
53
|
onActionClick,
|
|
54
|
+
actionHref,
|
|
50
55
|
}: Props = $props();
|
|
51
56
|
|
|
52
57
|
let snapshot = $state<BillingSubscriptionSnapshot>(useBridge().subscription.snapshot());
|
|
53
58
|
let unsubscribe: (() => void) | undefined;
|
|
54
59
|
|
|
55
|
-
// Admin/member variant
|
|
56
|
-
//
|
|
57
|
-
//
|
|
60
|
+
// Admin/member variant. v1 policy: workspace owner only, via
|
|
61
|
+
// canManageBilling() (immutable OWNER role key). Defaults to non-admin if
|
|
62
|
+
// unavailable.
|
|
58
63
|
let isBillingAdmin = $state(false);
|
|
59
64
|
|
|
60
65
|
onMount(() => {
|
|
@@ -65,15 +70,7 @@
|
|
|
65
70
|
const auth = getBridgeAuth();
|
|
66
71
|
const ctx = auth.getApiContext();
|
|
67
72
|
if (ctx.accessToken) {
|
|
68
|
-
|
|
69
|
-
// defensive cast so the component never crashes on missing fields.
|
|
70
|
-
const tokenManager = (auth as unknown as { tokenManager?: { getTokens?: () => { accessToken?: string } } }).tokenManager;
|
|
71
|
-
void tokenManager; // intentionally unused — placeholder until role API is finalized
|
|
72
|
-
// For US-5 default: assume admin if a JWT is present. Subsequent
|
|
73
|
-
// stories will read the exact privilege claim. Keeping permissive so
|
|
74
|
-
// the CTA shows by default; member variant lands when the role check
|
|
75
|
-
// is wired through.
|
|
76
|
-
isBillingAdmin = true;
|
|
73
|
+
isBillingAdmin = auth.canManageBilling();
|
|
77
74
|
}
|
|
78
75
|
|
|
79
76
|
if (!ctx.accessToken) {
|
|
@@ -202,11 +199,16 @@
|
|
|
202
199
|
onActionClick(noticeState);
|
|
203
200
|
return;
|
|
204
201
|
}
|
|
205
|
-
// Default: open the
|
|
206
|
-
//
|
|
207
|
-
// current origin. Apps can override via `onActionClick`.
|
|
202
|
+
// Default: open the app's billing surface. Destination priority:
|
|
203
|
+
// `actionHref` prop → `billing.manageRoute` config → '/billing'.
|
|
208
204
|
if (typeof window !== 'undefined') {
|
|
209
|
-
|
|
205
|
+
let manageRoute: string | undefined;
|
|
206
|
+
try {
|
|
207
|
+
manageRoute = getConfig().billing?.manageRoute;
|
|
208
|
+
} catch {
|
|
209
|
+
// Config not initialized — fall through to the default.
|
|
210
|
+
}
|
|
211
|
+
window.location.href = actionHref ?? manageRoute ?? '/billing';
|
|
210
212
|
}
|
|
211
213
|
}
|
|
212
214
|
</script>
|
|
@@ -13,6 +13,9 @@ interface Props {
|
|
|
13
13
|
class?: string;
|
|
14
14
|
/** Override the default CTA click handler (links to billing surface). */
|
|
15
15
|
onActionClick?: (state: BillingNoticeState) => void;
|
|
16
|
+
/** CTA destination for this instance. Overrides `billing.manageRoute`
|
|
17
|
+
* config; `onActionClick` takes precedence over both. */
|
|
18
|
+
actionHref?: string;
|
|
16
19
|
}
|
|
17
20
|
declare const BridgeBillingNotice: import("svelte").Component<Props, {}, "">;
|
|
18
21
|
type BridgeBillingNotice = ReturnType<typeof BridgeBillingNotice>;
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
type QuotaSnapshot,
|
|
28
28
|
} from '@nebulr-group/bridge-auth-core';
|
|
29
29
|
import { getBridgeAuth } from '../../../core/bridge-instance.js';
|
|
30
|
+
import { getConfig } from '../../stores/config.store.js';
|
|
30
31
|
|
|
31
32
|
type Chassis = 'rail';
|
|
32
33
|
type Severity = 'warn' | 'critical';
|
|
@@ -43,6 +44,9 @@
|
|
|
43
44
|
class?: string;
|
|
44
45
|
/** Override the default Upgrade CTA click handler. */
|
|
45
46
|
onActionClick?: (snap: QuotaSnapshot) => void;
|
|
47
|
+
/** CTA destination for this instance. Overrides `billing.manageRoute`
|
|
48
|
+
* config; `onActionClick` takes precedence over both. */
|
|
49
|
+
actionHref?: string;
|
|
46
50
|
/**
|
|
47
51
|
* Optional display label override. Defaults to the snapshot's `.label`
|
|
48
52
|
* (raw metric key for US-11; the framework wrapper will eventually
|
|
@@ -56,6 +60,7 @@
|
|
|
56
60
|
chassis = 'rail',
|
|
57
61
|
class: className = '',
|
|
58
62
|
onActionClick,
|
|
63
|
+
actionHref,
|
|
59
64
|
label,
|
|
60
65
|
}: Props = $props();
|
|
61
66
|
|
|
@@ -64,9 +69,8 @@
|
|
|
64
69
|
let snapshot = $state<QuotaSnapshot | undefined>(useBridge().quota(metric));
|
|
65
70
|
let unsubscribe: (() => void) | undefined;
|
|
66
71
|
|
|
67
|
-
// Role variant mirrors BridgeBillingNotice:
|
|
68
|
-
//
|
|
69
|
-
// finalized. Member variant lands when the role probe is wired through.
|
|
72
|
+
// Role variant mirrors BridgeBillingNotice: CTA only for the workspace
|
|
73
|
+
// owner (v1 canManageBilling() policy). Member variant renders otherwise.
|
|
70
74
|
let isBillingAdmin = $state(false);
|
|
71
75
|
|
|
72
76
|
onMount(() => {
|
|
@@ -78,11 +82,7 @@
|
|
|
78
82
|
snapshot = useBridge().quota(metric);
|
|
79
83
|
|
|
80
84
|
try {
|
|
81
|
-
|
|
82
|
-
const ctx = auth.getApiContext();
|
|
83
|
-
if (ctx.accessToken) {
|
|
84
|
-
isBillingAdmin = true;
|
|
85
|
-
}
|
|
85
|
+
isBillingAdmin = getBridgeAuth().canManageBilling();
|
|
86
86
|
} catch {
|
|
87
87
|
// No BridgeAuth instance — render the member variant.
|
|
88
88
|
}
|
|
@@ -96,12 +96,28 @@
|
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
const warningLevel = $derived(snapshot?.warningLevel ?? null);
|
|
99
|
+
// TBP-275 — `metered` quotas bill overage instead of blocking. Prefer the
|
|
100
|
+
// server-authoritative `overcap` flag (handles limit === 0 pure-per-unit)
|
|
101
|
+
// over a UI-derived `used > limit`.
|
|
102
|
+
const isMetered = $derived(snapshot?.policy === 'metered');
|
|
99
103
|
const overCap = $derived(
|
|
100
|
-
snapshot
|
|
104
|
+
snapshot
|
|
105
|
+
? (snapshot.overcap ?? snapshot.used > snapshot.limit)
|
|
106
|
+
: false,
|
|
107
|
+
);
|
|
108
|
+
// Hard caps show only at the warning thresholds. Metered quotas also show
|
|
109
|
+
// once billing has engaged (overCap), even with no warningLevel — that's the
|
|
110
|
+
// live "you're now being billed for overage" state.
|
|
111
|
+
const visible = $derived(
|
|
112
|
+
snapshot !== undefined && (warningLevel !== null || (isMetered && overCap)),
|
|
101
113
|
);
|
|
102
|
-
|
|
114
|
+
// Metered never blocks, so it never renders as 'critical' — it's informational.
|
|
103
115
|
const severity = $derived<Severity>(
|
|
104
|
-
|
|
116
|
+
isMetered
|
|
117
|
+
? 'warn'
|
|
118
|
+
: warningLevel === 'critical' || overCap
|
|
119
|
+
? 'critical'
|
|
120
|
+
: 'warn',
|
|
105
121
|
);
|
|
106
122
|
const displayLabel = $derived(label ?? snapshot?.label ?? metric);
|
|
107
123
|
const percent = $derived(
|
|
@@ -109,13 +125,58 @@
|
|
|
109
125
|
? Math.min(100, Math.round((snapshot.used / snapshot.limit) * 100))
|
|
110
126
|
: 0,
|
|
111
127
|
);
|
|
128
|
+
// Meter bar is meaningless for pure per-unit metered (limit 0) — hide it there.
|
|
129
|
+
const showMeter = $derived(!!snapshot && snapshot.limit > 0);
|
|
130
|
+
|
|
131
|
+
/** Format an estimated cost like "$1.00" / "1.00 SEK". */
|
|
132
|
+
function formatCost(amount: number | undefined, currency: string | undefined): string {
|
|
133
|
+
if (amount === undefined) return '';
|
|
134
|
+
const cur = (currency ?? '').toUpperCase();
|
|
135
|
+
try {
|
|
136
|
+
return new Intl.NumberFormat(undefined, {
|
|
137
|
+
style: 'currency',
|
|
138
|
+
currency: cur || 'USD',
|
|
139
|
+
}).format(amount);
|
|
140
|
+
} catch {
|
|
141
|
+
// Unknown currency code → fall back to "<amount> <CUR>".
|
|
142
|
+
return `${amount.toFixed(2)}${cur ? ` ${cur}` : ''}`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
112
145
|
|
|
113
146
|
function getCopy(
|
|
114
147
|
snap: QuotaSnapshot | undefined,
|
|
115
148
|
admin: boolean,
|
|
116
149
|
): { title: string; body: string; cta?: string } {
|
|
117
150
|
if (!snap) return { title: '', body: '' };
|
|
118
|
-
|
|
151
|
+
|
|
152
|
+
// TBP-275 — metered: live usage + projected cost, never a blocking message.
|
|
153
|
+
if (snap.policy === 'metered') {
|
|
154
|
+
const overUnits = snap.limit > 0 ? Math.max(0, snap.used - snap.limit) : snap.used;
|
|
155
|
+
const cost = formatCost(snap.overageEstimate, snap.currency);
|
|
156
|
+
const costSuffix = cost ? ` · ~${cost} estimated this period` : '';
|
|
157
|
+
if (snap.limit > 0) {
|
|
158
|
+
// included allotment + overage
|
|
159
|
+
if (overUnits > 0) {
|
|
160
|
+
return {
|
|
161
|
+
title: `${displayLabel} overage`,
|
|
162
|
+
body: `${overUnits.toLocaleString()} over your ${snap.limit.toLocaleString()} included${costSuffix}.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// approaching the included allotment (warningLevel drove visibility)
|
|
166
|
+
const unit = formatCost(snap.unitAmount, snap.currency);
|
|
167
|
+
return {
|
|
168
|
+
title: `${displayLabel} approaching included limit`,
|
|
169
|
+
body: `You've used ${snap.used.toLocaleString()} of ${snap.limit.toLocaleString()} included${unit ? ` — extra usage is billed at ${unit}/unit` : ''}.`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// pure per-unit (limit 0) — billed from unit 1
|
|
173
|
+
return {
|
|
174
|
+
title: `${displayLabel} usage`,
|
|
175
|
+
body: `${snap.used.toLocaleString()} ${displayLabel}${costSuffix}.`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const over = snap.overcap ?? snap.used > snap.limit;
|
|
119
180
|
const remaining = Math.max(0, snap.remaining);
|
|
120
181
|
if (over) {
|
|
121
182
|
return admin
|
|
@@ -162,8 +223,16 @@
|
|
|
162
223
|
onActionClick(snapshot);
|
|
163
224
|
return;
|
|
164
225
|
}
|
|
226
|
+
// Destination priority: `actionHref` prop → `billing.manageRoute` config
|
|
227
|
+
// → '/billing'.
|
|
165
228
|
if (typeof window !== 'undefined') {
|
|
166
|
-
|
|
229
|
+
let manageRoute: string | undefined;
|
|
230
|
+
try {
|
|
231
|
+
manageRoute = getConfig().billing?.manageRoute;
|
|
232
|
+
} catch {
|
|
233
|
+
// Config not initialized — fall through to the default.
|
|
234
|
+
}
|
|
235
|
+
window.location.href = actionHref ?? manageRoute ?? '/billing';
|
|
167
236
|
}
|
|
168
237
|
}
|
|
169
238
|
</script>
|
|
@@ -178,9 +247,11 @@
|
|
|
178
247
|
<div class="bqb-content">
|
|
179
248
|
<strong class="bqb-title">{copy.title}</strong>
|
|
180
249
|
<span class="bqb-body">{copy.body}</span>
|
|
181
|
-
|
|
182
|
-
<div class="bqb-meter
|
|
183
|
-
|
|
250
|
+
{#if showMeter}
|
|
251
|
+
<div class="bqb-meter" aria-hidden="true">
|
|
252
|
+
<div class="bqb-meter-fill" style={`width: ${Math.min(100, percent)}%`}></div>
|
|
253
|
+
</div>
|
|
254
|
+
{/if}
|
|
184
255
|
</div>
|
|
185
256
|
{#if copy.cta && isBillingAdmin}
|
|
186
257
|
<button type="button" class="bqb-cta" onclick={handleAction}>{copy.cta}</button>
|