@nebulr-group/bridge-svelte 0.1.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/LICENSE +23 -0
- package/README.md +61 -0
- package/dist/auth/route-guard.d.ts +32 -0
- package/dist/auth/route-guard.js +100 -0
- package/dist/client/BridgeBootstrap.d.ts +3 -0
- package/dist/client/BridgeBootstrap.js +49 -0
- package/dist/client/BridgeBootstrap.svelte +41 -0
- package/dist/client/BridgeBootstrap.svelte.d.ts +6 -0
- package/dist/client/components/FeatureFlag.svelte +21 -0
- package/dist/client/components/FeatureFlag.svelte.d.ts +10 -0
- package/dist/client/components/auth/Login.svelte +20 -0
- package/dist/client/components/auth/Login.svelte.d.ts +18 -0
- package/dist/client/components/team/TeamManagement.svelte +131 -0
- package/dist/client/components/team/TeamManagement.svelte.d.ts +18 -0
- package/dist/client/stores/config.store.d.ts +6 -0
- package/dist/client/stores/config.store.js +60 -0
- package/dist/client/stores/profile.store.d.ts +10 -0
- package/dist/client/stores/profile.store.js +29 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +19 -0
- package/dist/shared/feature-flag.d.ts +6 -0
- package/dist/shared/feature-flag.js +64 -0
- package/dist/shared/logger.d.ts +17 -0
- package/dist/shared/logger.js +38 -0
- package/dist/shared/profile.d.ts +47 -0
- package/dist/shared/profile.js +98 -0
- package/dist/shared/services/auth.service.d.ts +24 -0
- package/dist/shared/services/auth.service.js +209 -0
- package/dist/shared/types/config.d.ts +47 -0
- package/dist/shared/types/config.js +1 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 thebridgedev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
|
|
2
|
+
## @nebulr-group/bridge-svelte
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
Bridge Svelte library. Add Bridge auth, feature flags, and payments to your SvelteKit 2 + Svelte 5 apps.
|
|
6
|
+
|
|
7
|
+
### Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i @nebulr-group/bridge-svelte
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Usage
|
|
14
|
+
|
|
15
|
+
See the `demo/` app in the monorepo for end-to-end wiring.
|
|
16
|
+
|
|
17
|
+
### Build
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm run build
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Artifacts are emitted to `dist/` via `svelte-package`.
|
|
25
|
+
|
|
26
|
+
### Release (branch-protected main)
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# 1) Create release branch
|
|
30
|
+
git checkout -b release/v0.1.0-beta.1
|
|
31
|
+
git push -u origin release/v0.1.0-beta.1
|
|
32
|
+
|
|
33
|
+
# 2) Open a PR: release/v0.1.0-beta.1 -> main, approve and merge
|
|
34
|
+
|
|
35
|
+
# 3) After merge to main, tag and push
|
|
36
|
+
git checkout main && git pull
|
|
37
|
+
git tag v0.1.0-beta.1
|
|
38
|
+
git push origin v0.1.0-beta.1
|
|
39
|
+
|
|
40
|
+
# 4) Monitor GitHub Actions "Publish to npm"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Commit signing (required)
|
|
44
|
+
|
|
45
|
+
Ensure your commits are verified before opening PRs:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Option A: SSH signing (recommended)
|
|
49
|
+
git config --global gpg.format ssh
|
|
50
|
+
git config --global user.signingkey ~/.ssh/id_ed25519.pub
|
|
51
|
+
git config --global commit.gpgsign true
|
|
52
|
+
|
|
53
|
+
# Option B: GPG signing
|
|
54
|
+
gpg --full-generate-key
|
|
55
|
+
gpg --list-secret-keys --keyid-format=long
|
|
56
|
+
git config --global user.signingkey <KEY_ID>
|
|
57
|
+
git config --global commit.gpgsign true
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### License
|
|
61
|
+
MIT © thebridgedev
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type PublicRoutePattern = string | RegExp;
|
|
2
|
+
export type FlagRequirement = string | {
|
|
3
|
+
any: string[];
|
|
4
|
+
} | {
|
|
5
|
+
all: string[];
|
|
6
|
+
};
|
|
7
|
+
export type RouteRule = {
|
|
8
|
+
match: string | RegExp;
|
|
9
|
+
public?: boolean;
|
|
10
|
+
featureFlag?: FlagRequirement;
|
|
11
|
+
redirectTo?: string;
|
|
12
|
+
};
|
|
13
|
+
export interface RouteGuardConfig {
|
|
14
|
+
rules: RouteRule[];
|
|
15
|
+
defaultAccess?: 'public' | 'protected';
|
|
16
|
+
}
|
|
17
|
+
export declare function createRouteGuard(): {
|
|
18
|
+
isPublicRoute: (pathname: string) => boolean;
|
|
19
|
+
isProtectedRoute: (pathname: string) => boolean;
|
|
20
|
+
shouldRedirectToLogin: (pathname: string) => boolean;
|
|
21
|
+
checkRouteRestrictions: (pathname: string) => Promise<string | null>;
|
|
22
|
+
getLoginRedirect: () => string;
|
|
23
|
+
getNavigationDecision(pathname: string): Promise<{
|
|
24
|
+
type: "allow";
|
|
25
|
+
} | {
|
|
26
|
+
type: "login";
|
|
27
|
+
loginUrl: string;
|
|
28
|
+
} | {
|
|
29
|
+
type: "redirect";
|
|
30
|
+
to: string;
|
|
31
|
+
}>;
|
|
32
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { get } from 'svelte/store';
|
|
2
|
+
import { getRouteGuardConfig } from '../client/stores/config.store.js';
|
|
3
|
+
import { isFeatureEnabled } from '../shared/feature-flag.js';
|
|
4
|
+
import { logger } from '../shared/logger.js';
|
|
5
|
+
import { auth } from '../shared/services/auth.service.js';
|
|
6
|
+
const { isAubridgenticated, createLoginUrl } = auth;
|
|
7
|
+
function escapeRegex(text) {
|
|
8
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
9
|
+
}
|
|
10
|
+
function toRegExp(pattern) {
|
|
11
|
+
if (pattern instanceof RegExp)
|
|
12
|
+
return pattern;
|
|
13
|
+
// Support simple wildcard '*'
|
|
14
|
+
const hasWildcard = pattern.includes('*');
|
|
15
|
+
if (!hasWildcard) {
|
|
16
|
+
return new RegExp(`^${escapeRegex(pattern)}$`);
|
|
17
|
+
}
|
|
18
|
+
const escaped = escapeRegex(pattern).replace(/\\\*/g, '.*');
|
|
19
|
+
return new RegExp(`^${escaped}$`);
|
|
20
|
+
}
|
|
21
|
+
function findMatchingRule(pathname, config) {
|
|
22
|
+
for (const rule of config.rules) {
|
|
23
|
+
if (toRegExp(rule.match).test(pathname)) {
|
|
24
|
+
return rule;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
async function evaluateFlagRequirement(req) {
|
|
30
|
+
if (typeof req === 'string') {
|
|
31
|
+
return isFeatureEnabled(req);
|
|
32
|
+
}
|
|
33
|
+
if ('any' in req) {
|
|
34
|
+
const results = await Promise.all(req.any.map((f) => isFeatureEnabled(f)));
|
|
35
|
+
return results.some(Boolean);
|
|
36
|
+
}
|
|
37
|
+
if ('all' in req) {
|
|
38
|
+
const results = await Promise.all(req.all.map((f) => isFeatureEnabled(f)));
|
|
39
|
+
return results.every(Boolean);
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
export function createRouteGuard() {
|
|
44
|
+
const config = getRouteGuardConfig();
|
|
45
|
+
function isPublicRoute(pathname) {
|
|
46
|
+
const rule = findMatchingRule(pathname, config);
|
|
47
|
+
if (rule) {
|
|
48
|
+
logger.debug(`[route-guard] path ${pathname} is ${rule.public ? 'public' : 'protected'} by bridge rule ${rule.match}`);
|
|
49
|
+
return !!rule.public;
|
|
50
|
+
}
|
|
51
|
+
logger.debug(`[route-guard] path ${pathname} is ${config.defaultAccess === 'public' ? 'public' : 'protected'} by bridge default access ${config.defaultAccess}`);
|
|
52
|
+
const isPublicByDefault = (config.defaultAccess ?? 'protected') === 'public';
|
|
53
|
+
return isPublicByDefault;
|
|
54
|
+
}
|
|
55
|
+
function isProtectedRoute(pathname) {
|
|
56
|
+
return !isPublicRoute(pathname);
|
|
57
|
+
}
|
|
58
|
+
function shouldRedirectToLogin(pathname) {
|
|
59
|
+
const isProtected = isProtectedRoute(pathname);
|
|
60
|
+
const aubridgenticated = get(isAubridgenticated);
|
|
61
|
+
if (isProtectedRoute(pathname) && !get(isAubridgenticated)) {
|
|
62
|
+
logger.debug(`[route-guard] path ${pathname} is protected and user is not aubridgenticated`);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
logger.debug(`[route-guard] path ${pathname} is ${isProtected ? 'protected' : 'public'} and user ${aubridgenticated ? 'aubridgenticated' : 'not aubridgenticated'}`);
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
async function checkRouteRestrictions(pathname) {
|
|
69
|
+
const rule = findMatchingRule(pathname, config);
|
|
70
|
+
if (!rule)
|
|
71
|
+
return null;
|
|
72
|
+
if (rule.featureFlag) {
|
|
73
|
+
const ok = await evaluateFlagRequirement(rule.featureFlag);
|
|
74
|
+
logger.debug(`[route-guard] path ${pathname} is restricted by bridge feature flag ${rule.featureFlag} and flag requirment evaluated to ${ok}`);
|
|
75
|
+
if (!ok)
|
|
76
|
+
return rule.redirectTo ?? '/';
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
function getLoginRedirect() {
|
|
81
|
+
return createLoginUrl();
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
isPublicRoute,
|
|
85
|
+
isProtectedRoute,
|
|
86
|
+
shouldRedirectToLogin,
|
|
87
|
+
checkRouteRestrictions,
|
|
88
|
+
getLoginRedirect,
|
|
89
|
+
async getNavigationDecision(pathname) {
|
|
90
|
+
if (shouldRedirectToLogin(pathname)) {
|
|
91
|
+
return { type: 'login', loginUrl: getLoginRedirect() };
|
|
92
|
+
}
|
|
93
|
+
const redirectTo = await checkRouteRestrictions(pathname);
|
|
94
|
+
if (redirectTo) {
|
|
95
|
+
return { type: 'redirect', to: redirectTo };
|
|
96
|
+
}
|
|
97
|
+
return { type: 'allow' };
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// src/lib/bridge/bootstrap.ts
|
|
2
|
+
import { redirect } from '@sveltejs/kit';
|
|
3
|
+
import { createRouteGuard } from '../auth/route-guard.js';
|
|
4
|
+
import { featureFlags } from '../shared/feature-flag.js';
|
|
5
|
+
import { logger } from '../shared/logger.js';
|
|
6
|
+
import { auth, maybeRefreshNow } from '../shared/services/auth.service.js';
|
|
7
|
+
import { bridgeConfig } from './stores/config.store.js';
|
|
8
|
+
export async function bridgeBootstrap(url, config, routeConfig = { rules: [], defaultAccess: 'protected' }) {
|
|
9
|
+
const finalConfig = typeof config === 'string' ? { appId: config } : config;
|
|
10
|
+
// 1. Initialize configuration (synchronously)
|
|
11
|
+
bridgeConfig.initConfig(finalConfig, routeConfig);
|
|
12
|
+
// 1a. If we're on bridge OAuth callback route, let bridge dedicated route handle it
|
|
13
|
+
try {
|
|
14
|
+
const callbackPath = finalConfig.callbackUrl ? new URL(finalConfig.callbackUrl).pathname : null;
|
|
15
|
+
if (callbackPath && url.pathname === callbackPath) {
|
|
16
|
+
logger.debug('[bridgeBootstrap] callback route detected, skipping bootstrap flow');
|
|
17
|
+
const { handleCallback } = auth;
|
|
18
|
+
const code = url.searchParams.get('code');
|
|
19
|
+
if (code) {
|
|
20
|
+
try {
|
|
21
|
+
await handleCallback(code);
|
|
22
|
+
// Redirect bridge user to bridge home page after bridge callback is handled
|
|
23
|
+
window.location.href = '/';
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
logger.error('Auth callback error:', err);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
logger.warn('[bridgeBootstrap] failed parsing callbackUrl', e);
|
|
33
|
+
}
|
|
34
|
+
// 2. Ensure tokens are fresh if needed
|
|
35
|
+
await maybeRefreshNow();
|
|
36
|
+
// 3. Load feature flags before guard if your guard depends on bridgem
|
|
37
|
+
await featureFlags.refresh();
|
|
38
|
+
// 4. Handle route guarding and redirects
|
|
39
|
+
const guard = createRouteGuard();
|
|
40
|
+
const decision = await guard.getNavigationDecision(url.pathname);
|
|
41
|
+
logger.debug('[bridgeBootstrap] navigation decision', decision);
|
|
42
|
+
if (decision.type === 'login') {
|
|
43
|
+
throw redirect(303, auth.createLoginUrl());
|
|
44
|
+
}
|
|
45
|
+
if (decision.type === 'redirect' && url.pathname !== decision.to) {
|
|
46
|
+
throw redirect(303, decision.to);
|
|
47
|
+
}
|
|
48
|
+
logger.debug('[bridgeBootstrap] in bridge end');
|
|
49
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { beforeNavigate } from '$app/navigation';
|
|
3
|
+
import { onMount } from 'svelte';
|
|
4
|
+
import { createRouteGuard } from '../auth/route-guard.js';
|
|
5
|
+
import { auth, startAutoRefresh } from '../shared/services/auth.service.js';
|
|
6
|
+
|
|
7
|
+
// Props: require config to be passed by consumer, now including onBootstrapComplete
|
|
8
|
+
let {onBootstrapComplete }: {
|
|
9
|
+
|
|
10
|
+
onBootstrapComplete?: () => void
|
|
11
|
+
} = $props();
|
|
12
|
+
|
|
13
|
+
const { login } = auth;
|
|
14
|
+
const guard = createRouteGuard();
|
|
15
|
+
|
|
16
|
+
async function handleRoute(pathname: string, cancel?: () => void) {
|
|
17
|
+
const decision = await guard.getNavigationDecision(pathname);
|
|
18
|
+
if (decision.type === 'login') {
|
|
19
|
+
if (cancel) cancel();
|
|
20
|
+
login();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (decision.type === 'redirect' && window.location.pathname !== decision.to) {
|
|
24
|
+
if (cancel) cancel();
|
|
25
|
+
window.location.href = decision.to;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onMount(async () => {
|
|
31
|
+
startAutoRefresh();
|
|
32
|
+
|
|
33
|
+
// Signal completion
|
|
34
|
+
if (onBootstrapComplete) onBootstrapComplete();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
beforeNavigate(async ({ to, cancel }) => {
|
|
38
|
+
if (!to) return;
|
|
39
|
+
await handleRoute(to.url.pathname, cancel);
|
|
40
|
+
});
|
|
41
|
+
</script>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { onMount } from 'svelte';
|
|
4
|
+
import { isFeatureEnabled } from '../../shared/feature-flag.js';
|
|
5
|
+
|
|
6
|
+
let { flagName, forceLive = false, negate = false, children }: { flagName: string; forceLive?: boolean; negate?: boolean; children?: Snippet } = $props();
|
|
7
|
+
|
|
8
|
+
let enabled = $state(false);
|
|
9
|
+
|
|
10
|
+
let shouldRender = $derived(() => negate ? !enabled : enabled);
|
|
11
|
+
|
|
12
|
+
onMount(async () => {
|
|
13
|
+
enabled = await isFeatureEnabled(flagName, forceLive);
|
|
14
|
+
});
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
{#if shouldRender}
|
|
18
|
+
{#if children}
|
|
19
|
+
{@render children()}
|
|
20
|
+
{/if}
|
|
21
|
+
{/if}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
flagName: string;
|
|
4
|
+
forceLive?: boolean;
|
|
5
|
+
negate?: boolean;
|
|
6
|
+
children?: Snippet;
|
|
7
|
+
};
|
|
8
|
+
declare const FeatureFlag: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
9
|
+
type FeatureFlag = ReturnType<typeof FeatureFlag>;
|
|
10
|
+
export default FeatureFlag;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { auth } from '../../../shared/services/auth.service';
|
|
3
|
+
const { isAubridgenticated, login } = auth;
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<button onclick={() => login()} class="login-button">
|
|
7
|
+
Login with Bridge
|
|
8
|
+
</button>
|
|
9
|
+
|
|
10
|
+
<style>
|
|
11
|
+
.login-button {
|
|
12
|
+
display: inline-block;
|
|
13
|
+
padding: 0.5rem 1rem;
|
|
14
|
+
background-color: #3b82f6;
|
|
15
|
+
color: white;
|
|
16
|
+
border-radius: 0.25rem;
|
|
17
|
+
border: none;
|
|
18
|
+
cursor: pointer;
|
|
19
|
+
}
|
|
20
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
2
|
+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
3
|
+
$$bindings?: Bindings;
|
|
4
|
+
} & Exports;
|
|
5
|
+
(internal: unknown, props: {
|
|
6
|
+
$$events?: Events;
|
|
7
|
+
$$slots?: Slots;
|
|
8
|
+
}): Exports & {
|
|
9
|
+
$set?: any;
|
|
10
|
+
$on?: any;
|
|
11
|
+
};
|
|
12
|
+
z_$$bindings?: Bindings;
|
|
13
|
+
}
|
|
14
|
+
declare const Login: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
15
|
+
[evt: string]: CustomEvent<any>;
|
|
16
|
+
}, {}, {}, string>;
|
|
17
|
+
type Login = InstanceType<typeof Login>;
|
|
18
|
+
export default Login;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { logger } from '../../../shared/logger.js';
|
|
4
|
+
import { auth } from '../../../shared/services/auth.service.js';
|
|
5
|
+
import { getConfig } from '../../stores/config.store.js';
|
|
6
|
+
|
|
7
|
+
let iframeUrl: string | null = null;
|
|
8
|
+
let error: string | null = null;
|
|
9
|
+
let isLoading = true;
|
|
10
|
+
|
|
11
|
+
async function getHandoverCode(accessToken: string) {
|
|
12
|
+
const config = getConfig();
|
|
13
|
+
const authBaseUrl = config.authBaseUrl;
|
|
14
|
+
const appId = config.appId;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(
|
|
18
|
+
`${authBaseUrl}/handover/code/${appId}`,
|
|
19
|
+
{
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: {
|
|
22
|
+
'Content-Type': 'application/json',
|
|
23
|
+
},
|
|
24
|
+
body: JSON.stringify({ accessToken }),
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
const errorText = await response.text();
|
|
30
|
+
logger.error(`Failed to get handover code: ${response.status} ${response.statusText}`, errorText);
|
|
31
|
+
throw new Error(`Failed to get handover code: ${response.statusText}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const data = await response.json();
|
|
35
|
+
if (!data.code) {
|
|
36
|
+
logger.error('No handover code in response:', data);
|
|
37
|
+
throw new Error('Failed to get handover code: No code in response');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Create bridge team management URL with bridge handover code
|
|
41
|
+
const baseUrl = config.teamManagementUrl;
|
|
42
|
+
return `${baseUrl}?code=${data.code}`;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
logger.error('Error getting handover code:', err);
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
onMount(async () => {
|
|
50
|
+
logger.debug('TeamManagement onMount');
|
|
51
|
+
try {
|
|
52
|
+
if (!auth.isAubridgenticated) {
|
|
53
|
+
logger.debug('TeamManagement onMount: User is not aubridgenticated');
|
|
54
|
+
throw new Error('User must be aubridgenticated to access team management');
|
|
55
|
+
}
|
|
56
|
+
const token = auth.getToken();
|
|
57
|
+
// Get bridge access token from your auth store
|
|
58
|
+
const accessToken = token?.accessToken;
|
|
59
|
+
if (!accessToken) {
|
|
60
|
+
throw new Error('No access token available');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
iframeUrl = await getHandoverCode(accessToken);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
error = err instanceof Error ? err.message : 'Failed to load team management';
|
|
66
|
+
} finally {
|
|
67
|
+
isLoading = false;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<div class="team-management-container">
|
|
73
|
+
{#if isLoading}
|
|
74
|
+
<div class="loading">Loading team management...</div>
|
|
75
|
+
{:else if error}
|
|
76
|
+
<div class="error">
|
|
77
|
+
<h3>Error</h3>
|
|
78
|
+
<p>{error}</p>
|
|
79
|
+
</div>
|
|
80
|
+
{:else if iframeUrl}
|
|
81
|
+
<iframe
|
|
82
|
+
src={iframeUrl}
|
|
83
|
+
title="Team Management"
|
|
84
|
+
class="team-management-iframe"
|
|
85
|
+
allow="clipboard-read; clipboard-write"
|
|
86
|
+
></iframe>
|
|
87
|
+
{/if}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<style>
|
|
91
|
+
.team-management-container {
|
|
92
|
+
width: 100%;
|
|
93
|
+
height: 100%;
|
|
94
|
+
min-height: 600px;
|
|
95
|
+
position: relative;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.team-management-iframe {
|
|
99
|
+
width: 100%;
|
|
100
|
+
height: 100%;
|
|
101
|
+
border: none;
|
|
102
|
+
min-height: 600px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.loading {
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
justify-content: center;
|
|
109
|
+
height: 100%;
|
|
110
|
+
min-height: 600px;
|
|
111
|
+
color: #4b5563;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.error {
|
|
115
|
+
padding: 1rem;
|
|
116
|
+
background-color: #fee2e2;
|
|
117
|
+
border: 1px solid #ef4444;
|
|
118
|
+
border-radius: 0.25rem;
|
|
119
|
+
color: #dc2626;
|
|
120
|
+
margin: 1rem;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.error h3 {
|
|
124
|
+
margin: 0 0 0.5rem 0;
|
|
125
|
+
font-size: 1.125rem;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.error p {
|
|
129
|
+
margin: 0;
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
2
|
+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
3
|
+
$$bindings?: Bindings;
|
|
4
|
+
} & Exports;
|
|
5
|
+
(internal: unknown, props: {
|
|
6
|
+
$$events?: Events;
|
|
7
|
+
$$slots?: Slots;
|
|
8
|
+
}): Exports & {
|
|
9
|
+
$set?: any;
|
|
10
|
+
$on?: any;
|
|
11
|
+
};
|
|
12
|
+
z_$$bindings?: Bindings;
|
|
13
|
+
}
|
|
14
|
+
declare const TeamManagement: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
15
|
+
[evt: string]: CustomEvent<any>;
|
|
16
|
+
}, {}, {}, string>;
|
|
17
|
+
type TeamManagement = InstanceType<typeof TeamManagement>;
|
|
18
|
+
export default TeamManagement;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BridgeConfig } from '../../shared/types/config';
|
|
2
|
+
export declare const bridgeConfig: import("svelte/store").Writable<BridgeConfig | null>;
|
|
3
|
+
export declare const readonlyConfig: import("svelte/store").Readable<BridgeConfig | null>;
|
|
4
|
+
export declare const configReady: import("svelte/store").Readable<boolean>;
|
|
5
|
+
export declare function initConfig(config: BridgeConfig): void;
|
|
6
|
+
export declare function getConfig(): BridgeConfig;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// src/lib/config/bridgeConfig.ts
|
|
2
|
+
import { derived, writable } from 'svelte/store';
|
|
3
|
+
import { logger } from '../../shared/logger.js';
|
|
4
|
+
const DEFAULT_CONFIG = {
|
|
5
|
+
authBaseUrl: 'https://auth.nblocks.cloud',
|
|
6
|
+
backendlessBaseUrl: 'https://backendless.nblocks.cloud',
|
|
7
|
+
teamManagementUrl: 'https://backendless.nblocks.cloud/user-management-portal/users',
|
|
8
|
+
defaultRedirectRoute: '/',
|
|
9
|
+
loginRoute: '/login',
|
|
10
|
+
debug: false
|
|
11
|
+
};
|
|
12
|
+
const { subscribe, set, update } = writable({
|
|
13
|
+
config: null,
|
|
14
|
+
routeConfig: null,
|
|
15
|
+
loaded: false
|
|
16
|
+
});
|
|
17
|
+
export const bridgeConfig = {
|
|
18
|
+
subscribe,
|
|
19
|
+
initConfig: (config, routeConfig) => {
|
|
20
|
+
if (!config?.appId) {
|
|
21
|
+
throw new Error('Bridge appId is required but was not provided in bridge configuration.');
|
|
22
|
+
}
|
|
23
|
+
const merged = {
|
|
24
|
+
...DEFAULT_CONFIG,
|
|
25
|
+
...config
|
|
26
|
+
};
|
|
27
|
+
// Set bridge full config and mark it as loaded
|
|
28
|
+
set({
|
|
29
|
+
config: merged,
|
|
30
|
+
routeConfig: routeConfig || null,
|
|
31
|
+
loaded: true
|
|
32
|
+
});
|
|
33
|
+
// This will only print when debug is true
|
|
34
|
+
logger.debug('[config] initialized', merged);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
// Convenience derived stores
|
|
38
|
+
export const configReady = derived(bridgeConfig, ($state) => $state.loaded);
|
|
39
|
+
export const readonlyConfig = derived(bridgeConfig, ($state) => $state.config);
|
|
40
|
+
// Synchronous access to latest config value
|
|
41
|
+
let _currentConfig = null;
|
|
42
|
+
let _currentRouteConfig = null;
|
|
43
|
+
bridgeConfig.subscribe(($state) => {
|
|
44
|
+
if ($state.loaded) {
|
|
45
|
+
_currentConfig = $state.config;
|
|
46
|
+
_currentRouteConfig = $state.routeConfig;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
export function getConfig() {
|
|
50
|
+
if (!_currentConfig) {
|
|
51
|
+
throw new Error('Config has not been initialized. Call initConfig(...) early in app startup.');
|
|
52
|
+
}
|
|
53
|
+
return _currentConfig;
|
|
54
|
+
}
|
|
55
|
+
export function getRouteGuardConfig() {
|
|
56
|
+
if (!_currentRouteConfig) {
|
|
57
|
+
throw new Error('RouteGuardConfig has not been initialized. Call initConfig(...) early in app startup.');
|
|
58
|
+
}
|
|
59
|
+
return _currentRouteConfig;
|
|
60
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface Profile {
|
|
2
|
+
username: string;
|
|
3
|
+
email: string;
|
|
4
|
+
}
|
|
5
|
+
export declare const profileState: import("svelte/store").Writable<{
|
|
6
|
+
profile: Profile | null;
|
|
7
|
+
isLoading: boolean;
|
|
8
|
+
error: string | null;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function fetchProfile(): Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { writable } from 'svelte/store';
|
|
2
|
+
import { bridgeConfig } from './config.store.js';
|
|
3
|
+
// Create a writable store for profile state
|
|
4
|
+
export const profileState = writable({
|
|
5
|
+
profile: null,
|
|
6
|
+
isLoading: true,
|
|
7
|
+
error: null
|
|
8
|
+
});
|
|
9
|
+
// Initialize profile when config changes
|
|
10
|
+
bridgeConfig.subscribe((config) => {
|
|
11
|
+
if (config) {
|
|
12
|
+
// TODO: Initialize profile service
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
export async function fetchProfile() {
|
|
16
|
+
profileState.update(state => ({ ...state, isLoading: true, error: null }));
|
|
17
|
+
try {
|
|
18
|
+
// TODO: Implement profile fetching
|
|
19
|
+
const profile = null; // Replace with actual profile fetch
|
|
20
|
+
profileState.update(state => ({ ...state, profile }));
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch profile';
|
|
24
|
+
profileState.update(state => ({ ...state, error: errorMessage }));
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
profileState.update(state => ({ ...state, isLoading: false }));
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './client/BridgeBootstrap.js';
|
|
2
|
+
export * from './client/stores/config.store.js';
|
|
3
|
+
export * from './client/stores/profile.store.js';
|
|
4
|
+
export { default as BridgeBootstrap, default as BridgeProvider } from './client/BridgeBootstrap.svelte';
|
|
5
|
+
export { default as Login } from './client/components/auth/Login.svelte';
|
|
6
|
+
export { default as FeatureFlag } from './client/components/FeatureFlag.svelte';
|
|
7
|
+
export { default as TeamManagement } from './client/components/team/TeamManagement.svelte';
|
|
8
|
+
export * from './shared/feature-flag.js';
|
|
9
|
+
export * from './auth/route-guard.js';
|
|
10
|
+
export * from './shared/profile.js';
|
|
11
|
+
export * from './shared/services/auth.service.js';
|
|
12
|
+
export * from './shared/types/config.js';
|
|
13
|
+
export { logger } from './shared/logger.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Core stores and setup
|
|
2
|
+
export * from './client/BridgeBootstrap.js';
|
|
3
|
+
export * from './client/stores/config.store.js';
|
|
4
|
+
export * from './client/stores/profile.store.js';
|
|
5
|
+
// Components (Svelte components must have `export default`)
|
|
6
|
+
export { default as BridgeBootstrap, default as BridgeProvider } from './client/BridgeBootstrap.svelte';
|
|
7
|
+
export { default as Login } from './client/components/auth/Login.svelte';
|
|
8
|
+
export { default as FeatureFlag } from './client/components/FeatureFlag.svelte';
|
|
9
|
+
export { default as TeamManagement } from './client/components/team/TeamManagement.svelte';
|
|
10
|
+
// Feature flags
|
|
11
|
+
export * from './shared/feature-flag.js';
|
|
12
|
+
// Auth route guards
|
|
13
|
+
export * from './auth/route-guard.js';
|
|
14
|
+
// Types
|
|
15
|
+
export * from './shared/profile.js'; // If this exists
|
|
16
|
+
export * from './shared/services/auth.service.js';
|
|
17
|
+
export * from './shared/types/config.js';
|
|
18
|
+
// Logger
|
|
19
|
+
export { logger } from './shared/logger.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function loadFeatureFlags(): Promise<void>;
|
|
2
|
+
export declare function isFeatureEnabled(flag: string, forceLive?: boolean): Promise<boolean>;
|
|
3
|
+
export declare const featureFlags: {
|
|
4
|
+
flags: import("svelte/store").Writable<Record<string, boolean>>;
|
|
5
|
+
refresh: typeof loadFeatureFlags;
|
|
6
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { get, writable } from 'svelte/store';
|
|
2
|
+
import { getConfig } from '../client/stores/config.store.js';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
import { auth } from './services/auth.service.js';
|
|
5
|
+
const cacheValidityMs = 5 * 60 * 1000;
|
|
6
|
+
const cachedFlags = writable({});
|
|
7
|
+
let lastFetchTime = 0;
|
|
8
|
+
export async function loadFeatureFlags() {
|
|
9
|
+
const tokens = get(auth.token);
|
|
10
|
+
const appId = tokens?.appId ?? getConfig().appId;
|
|
11
|
+
const accessToken = tokens?.accessToken;
|
|
12
|
+
const backendlessBaseUrl = getConfig().backendlessBaseUrl;
|
|
13
|
+
if (!appId)
|
|
14
|
+
return;
|
|
15
|
+
const url = `${backendlessBaseUrl}/flags/bulkEvaluate/${appId}`;
|
|
16
|
+
const body = accessToken ? { accessToken } : {};
|
|
17
|
+
const res = await fetch(url, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'Content-Type': 'application/json' },
|
|
20
|
+
body: JSON.stringify(body)
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok)
|
|
23
|
+
throw new Error('Failed to load feature flags');
|
|
24
|
+
const data = await res.json();
|
|
25
|
+
const flags = data.flags.reduce((acc, { flag, evaluation }) => {
|
|
26
|
+
acc[flag] = evaluation?.enabled ?? false;
|
|
27
|
+
return acc;
|
|
28
|
+
}, {});
|
|
29
|
+
cachedFlags.set(flags);
|
|
30
|
+
lastFetchTime = Date.now();
|
|
31
|
+
}
|
|
32
|
+
export async function isFeatureEnabled(flag, forceLive = false) {
|
|
33
|
+
const tokens = get(auth.token);
|
|
34
|
+
const appId = tokens?.appId ?? getConfig().appId;
|
|
35
|
+
const accessToken = tokens?.accessToken;
|
|
36
|
+
const backendlessBaseUrl = getConfig().backendlessBaseUrl;
|
|
37
|
+
if (!appId)
|
|
38
|
+
return false;
|
|
39
|
+
logger.debug(`[feature-flag] is flag:${flag}: enabled: ${get(cachedFlags)[flag]}`);
|
|
40
|
+
if (!forceLive && Date.now() - lastFetchTime < cacheValidityMs) {
|
|
41
|
+
return get(cachedFlags)[flag] ?? false;
|
|
42
|
+
}
|
|
43
|
+
if (!forceLive)
|
|
44
|
+
await loadFeatureFlags();
|
|
45
|
+
if (forceLive) {
|
|
46
|
+
const url = `${backendlessBaseUrl}/flags/evaluate/${appId}/${flag}`;
|
|
47
|
+
const body = accessToken ? { accessToken } : {};
|
|
48
|
+
const res = await fetch(url, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify(body)
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok)
|
|
54
|
+
return get(cachedFlags)[flag] ?? false;
|
|
55
|
+
const { enabled } = await res.json();
|
|
56
|
+
cachedFlags.update(f => ({ ...f, [flag]: enabled }));
|
|
57
|
+
return enabled ?? false;
|
|
58
|
+
}
|
|
59
|
+
return get(cachedFlags)[flag] ?? false;
|
|
60
|
+
}
|
|
61
|
+
export const featureFlags = {
|
|
62
|
+
flags: cachedFlags,
|
|
63
|
+
refresh: loadFeatureFlags
|
|
64
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type LogMethod = (...args: unknown[]) => void;
|
|
2
|
+
export declare const logger: {
|
|
3
|
+
readonly debug: (...args: unknown[]) => void;
|
|
4
|
+
readonly log: (...args: unknown[]) => void;
|
|
5
|
+
readonly info: (...args: unknown[]) => void;
|
|
6
|
+
readonly warn: (...args: unknown[]) => void;
|
|
7
|
+
readonly error: (...args: unknown[]) => void;
|
|
8
|
+
readonly withPrefix: (prefix: string) => {
|
|
9
|
+
readonly debug: (...args: unknown[]) => void;
|
|
10
|
+
readonly log: (...args: unknown[]) => void;
|
|
11
|
+
readonly info: (...args: unknown[]) => void;
|
|
12
|
+
readonly warn: LogMethod;
|
|
13
|
+
readonly error: LogMethod;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
export type Logger = typeof logger;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getConfig } from '../client/stores/config.store.js';
|
|
2
|
+
function createPrefixed(method, prefix) {
|
|
3
|
+
return (...args) => method(prefix, ...args);
|
|
4
|
+
}
|
|
5
|
+
function isDebugEnabled() {
|
|
6
|
+
try {
|
|
7
|
+
const cfg = getConfig();
|
|
8
|
+
return !!cfg.debug;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export const logger = {
|
|
15
|
+
debug: (...args) => {
|
|
16
|
+
if (isDebugEnabled())
|
|
17
|
+
console.log(...args);
|
|
18
|
+
},
|
|
19
|
+
log: (...args) => {
|
|
20
|
+
if (isDebugEnabled())
|
|
21
|
+
console.log(...args);
|
|
22
|
+
},
|
|
23
|
+
info: (...args) => {
|
|
24
|
+
if (isDebugEnabled())
|
|
25
|
+
console.info(...args);
|
|
26
|
+
},
|
|
27
|
+
warn: (...args) => console.warn(...args),
|
|
28
|
+
error: (...args) => console.error(...args),
|
|
29
|
+
withPrefix(prefix) {
|
|
30
|
+
return {
|
|
31
|
+
debug: (...args) => logger.debug(prefix, ...args),
|
|
32
|
+
log: (...args) => logger.log(prefix, ...args),
|
|
33
|
+
info: (...args) => logger.info(prefix, ...args),
|
|
34
|
+
warn: createPrefixed(console.warn, prefix),
|
|
35
|
+
error: createPrefixed(console.error, prefix)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface IDToken {
|
|
2
|
+
sub: string;
|
|
3
|
+
preferred_username: string;
|
|
4
|
+
email: string;
|
|
5
|
+
email_verified: boolean;
|
|
6
|
+
name: string;
|
|
7
|
+
family_name?: string;
|
|
8
|
+
given_name?: string;
|
|
9
|
+
locale?: string;
|
|
10
|
+
onboarded?: boolean;
|
|
11
|
+
multi_tenant?: boolean;
|
|
12
|
+
tenant_id?: string;
|
|
13
|
+
tenant_name?: string;
|
|
14
|
+
tenant_locale?: string;
|
|
15
|
+
tenant_logo?: string;
|
|
16
|
+
tenant_onboarded?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface Profile {
|
|
19
|
+
id: string;
|
|
20
|
+
username: string;
|
|
21
|
+
email: string;
|
|
22
|
+
emailVerified: boolean;
|
|
23
|
+
fullName: string;
|
|
24
|
+
familyName?: string;
|
|
25
|
+
givenName?: string;
|
|
26
|
+
locale?: string;
|
|
27
|
+
onboarded?: boolean;
|
|
28
|
+
multiTenantAccess?: boolean;
|
|
29
|
+
tenant?: {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
locale?: string;
|
|
33
|
+
logo?: string;
|
|
34
|
+
onboarded?: boolean;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
declare function updateProfile(idToken: string | null): Promise<void>;
|
|
38
|
+
export declare const profileStore: {
|
|
39
|
+
profile: import("svelte/store").Writable<Profile | null | undefined>;
|
|
40
|
+
error: import("svelte/store").Writable<string | null>;
|
|
41
|
+
updateProfile: typeof updateProfile;
|
|
42
|
+
isOnboarded: import("svelte/store").Readable<boolean>;
|
|
43
|
+
hasMultiTenantAccess: import("svelte/store").Readable<boolean>;
|
|
44
|
+
getProfile: () => Profile | null | undefined;
|
|
45
|
+
clear: () => void;
|
|
46
|
+
};
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// src/lib/auth/profile.ts
|
|
2
|
+
import { createRemoteJWKSet, errors as joseErrors, jwtVerify } from 'jose';
|
|
3
|
+
import { derived, get, writable } from 'svelte/store';
|
|
4
|
+
import { getConfig } from '../client/stores/config.store.js';
|
|
5
|
+
import { auth } from './services/auth.service.js';
|
|
6
|
+
const profile = writable(undefined);
|
|
7
|
+
const error = writable(null);
|
|
8
|
+
function transformIDToken(payload) {
|
|
9
|
+
return {
|
|
10
|
+
id: payload.sub,
|
|
11
|
+
username: payload.preferred_username,
|
|
12
|
+
email: payload.email,
|
|
13
|
+
emailVerified: payload.email_verified,
|
|
14
|
+
fullName: payload.name,
|
|
15
|
+
familyName: payload.family_name,
|
|
16
|
+
givenName: payload.given_name,
|
|
17
|
+
locale: payload.locale,
|
|
18
|
+
onboarded: payload.onboarded,
|
|
19
|
+
multiTenantAccess: payload.multi_tenant,
|
|
20
|
+
tenant: payload.tenant_id
|
|
21
|
+
? {
|
|
22
|
+
id: payload.tenant_id,
|
|
23
|
+
name: payload.tenant_name || '',
|
|
24
|
+
locale: payload.tenant_locale,
|
|
25
|
+
logo: payload.tenant_logo,
|
|
26
|
+
onboarded: payload.tenant_onboarded,
|
|
27
|
+
}
|
|
28
|
+
: undefined,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
let jwks = null;
|
|
32
|
+
let expectedIssuer = null;
|
|
33
|
+
let expectedAudience = null;
|
|
34
|
+
function ensureVerifier() {
|
|
35
|
+
const config = getConfig();
|
|
36
|
+
if (!jwks || expectedIssuer !== config.authBaseUrl || expectedAudience !== config.appId) {
|
|
37
|
+
jwks = createRemoteJWKSet(new URL(`${config.authBaseUrl}/.well-known/jwks.json`));
|
|
38
|
+
expectedIssuer = config.authBaseUrl;
|
|
39
|
+
expectedAudience = config.appId;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async function verifyToken(idToken) {
|
|
43
|
+
try {
|
|
44
|
+
ensureVerifier();
|
|
45
|
+
const { payload } = await jwtVerify(idToken, jwks, {
|
|
46
|
+
issuer: expectedIssuer,
|
|
47
|
+
audience: expectedAudience,
|
|
48
|
+
});
|
|
49
|
+
return transformIDToken(payload);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
if (err instanceof joseErrors.JWTExpired) {
|
|
53
|
+
error.set('Token expired');
|
|
54
|
+
}
|
|
55
|
+
else if (err instanceof joseErrors.JWTInvalid) {
|
|
56
|
+
error.set('Invalid token');
|
|
57
|
+
}
|
|
58
|
+
else if (err instanceof joseErrors.JWKSNoMatchingKey) {
|
|
59
|
+
error.set('JWKS error');
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
error.set('Token verification failed');
|
|
63
|
+
}
|
|
64
|
+
profile.set(null);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function updateProfile(idToken) {
|
|
69
|
+
profile.set(undefined);
|
|
70
|
+
if (!idToken) {
|
|
71
|
+
profile.set(null);
|
|
72
|
+
error.set(null);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const result = await verifyToken(idToken);
|
|
76
|
+
profile.set(result);
|
|
77
|
+
if (result)
|
|
78
|
+
error.set(null);
|
|
79
|
+
}
|
|
80
|
+
// 🔁 Auto-sync profile with token
|
|
81
|
+
auth.token.subscribe(($token) => {
|
|
82
|
+
const idToken = $token?.idToken || null;
|
|
83
|
+
updateProfile(idToken);
|
|
84
|
+
});
|
|
85
|
+
const isOnboarded = derived(profile, ($profile) => $profile?.onboarded ?? false);
|
|
86
|
+
const hasMultiTenantAccess = derived(profile, ($profile) => $profile?.multiTenantAccess ?? false);
|
|
87
|
+
export const profileStore = {
|
|
88
|
+
profile,
|
|
89
|
+
error,
|
|
90
|
+
updateProfile,
|
|
91
|
+
isOnboarded,
|
|
92
|
+
hasMultiTenantAccess,
|
|
93
|
+
getProfile: () => get(profile),
|
|
94
|
+
clear: () => {
|
|
95
|
+
profile.set(null);
|
|
96
|
+
error.set(null);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TokenSet } from './types/config';
|
|
2
|
+
declare function clearTokens(): void;
|
|
3
|
+
declare function login(options?: {
|
|
4
|
+
redirectUri?: string;
|
|
5
|
+
}): Promise<void>;
|
|
6
|
+
declare function createLoginUrl(options?: {
|
|
7
|
+
redirectUri?: string;
|
|
8
|
+
}): string;
|
|
9
|
+
declare function handleCallback(code: string): Promise<void>;
|
|
10
|
+
declare function refreshToken(refreshToken: string): Promise<TokenSet | null>;
|
|
11
|
+
export declare const auth: {
|
|
12
|
+
token: import("svelte/store").Writable<any>;
|
|
13
|
+
isAubridgenticated: import("svelte/store").Readable<boolean>;
|
|
14
|
+
isLoading: import("svelte/store").Writable<boolean>;
|
|
15
|
+
error: import("svelte/store").Writable<string | null>;
|
|
16
|
+
login: typeof login;
|
|
17
|
+
logout: typeof clearTokens;
|
|
18
|
+
handleCallback: typeof handleCallback;
|
|
19
|
+
refreshToken: typeof refreshToken;
|
|
20
|
+
createLoginUrl: typeof createLoginUrl;
|
|
21
|
+
getToken: () => any;
|
|
22
|
+
};
|
|
23
|
+
export { };
|
|
24
|
+
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// src/lib/auth.ts
|
|
2
|
+
import { browser } from '$app/environment';
|
|
3
|
+
import { derived, get, writable } from 'svelte/store';
|
|
4
|
+
import { getConfig } from '../../client/stores/config.store.js';
|
|
5
|
+
import { logger } from '../logger.js';
|
|
6
|
+
const TOKEN_KEY = 'bridge_tokens';
|
|
7
|
+
const tokenStore = writable(null);
|
|
8
|
+
const isLoading = writable(true);
|
|
9
|
+
const error = writable(null);
|
|
10
|
+
let refreshInterval = null;
|
|
11
|
+
const REFRESH_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
12
|
+
// Load from localStorage on first load
|
|
13
|
+
if (browser) {
|
|
14
|
+
try {
|
|
15
|
+
const raw = localStorage.getItem(TOKEN_KEY);
|
|
16
|
+
if (raw)
|
|
17
|
+
tokenStore.set(JSON.parse(raw));
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
logger.error('Failed to load tokens from storage', e);
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
isLoading.set(false);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// --- API ---
|
|
27
|
+
function setTokens(tokens) {
|
|
28
|
+
tokenStore.set(tokens);
|
|
29
|
+
if (browser)
|
|
30
|
+
localStorage.setItem(TOKEN_KEY, JSON.stringify(tokens));
|
|
31
|
+
scheduleTokenRefresh();
|
|
32
|
+
}
|
|
33
|
+
function clearTokens() {
|
|
34
|
+
tokenStore.set(null);
|
|
35
|
+
if (browser)
|
|
36
|
+
localStorage.removeItem(TOKEN_KEY);
|
|
37
|
+
stopAutoRefresh();
|
|
38
|
+
}
|
|
39
|
+
async function login(options = {}) {
|
|
40
|
+
const loginUrl = createLoginUrl(options);
|
|
41
|
+
if (browser) {
|
|
42
|
+
window.location.href = loginUrl;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
throw new Error('Login not supported in this environment');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function createLoginUrl(options = {}) {
|
|
49
|
+
const config = getConfig();
|
|
50
|
+
const redirectUri = options.redirectUri ?? config.callbackUrl;
|
|
51
|
+
const base = `${config.authBaseUrl}/url/login/${config.appId}`;
|
|
52
|
+
return redirectUri ? `${base}?redirect_uri=${encodeURIComponent(redirectUri)}` : base;
|
|
53
|
+
}
|
|
54
|
+
async function handleCallback(code) {
|
|
55
|
+
const config = getConfig();
|
|
56
|
+
const url = `${config.authBaseUrl}/token/code/${config.appId}`;
|
|
57
|
+
const response = await fetch(url, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
code,
|
|
62
|
+
...(config.callbackUrl ? { redirect_uri: config.callbackUrl } : {})
|
|
63
|
+
})
|
|
64
|
+
});
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
let errorMessage = 'Failed to exchange code for tokens';
|
|
67
|
+
try {
|
|
68
|
+
const errorData = await response.json();
|
|
69
|
+
if (errorData && errorData.message) {
|
|
70
|
+
errorMessage = `Failed to exchange code for tokens: ${errorData.message}`;
|
|
71
|
+
}
|
|
72
|
+
else if (errorData && typeof errorData === 'string') {
|
|
73
|
+
errorMessage = `Failed to exchange code for tokens: ${errorData}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
// If parsing JSON fails, use bridge generic message or response status text
|
|
78
|
+
errorMessage = `Failed to exchange code for tokens: ${response.statusText || 'Unknown error'}`;
|
|
79
|
+
}
|
|
80
|
+
throw new Error(errorMessage);
|
|
81
|
+
}
|
|
82
|
+
const data = await response.json();
|
|
83
|
+
setTokens({
|
|
84
|
+
accessToken: data.access_token,
|
|
85
|
+
refreshToken: data.refresh_token,
|
|
86
|
+
idToken: data.id_token
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async function refreshToken(refreshToken) {
|
|
90
|
+
const config = getConfig();
|
|
91
|
+
try {
|
|
92
|
+
const url = `${config.authBaseUrl}/token`;
|
|
93
|
+
const response = await fetch(url, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'Content-Type': 'application/json' },
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
client_id: config.appId,
|
|
98
|
+
grant_type: 'refresh_token',
|
|
99
|
+
refresh_token: refreshToken
|
|
100
|
+
})
|
|
101
|
+
});
|
|
102
|
+
if (!response.ok)
|
|
103
|
+
return null;
|
|
104
|
+
const data = await response.json();
|
|
105
|
+
const tokens = {
|
|
106
|
+
accessToken: data.access_token,
|
|
107
|
+
refreshToken: data.refresh_token,
|
|
108
|
+
idToken: data.id_token
|
|
109
|
+
};
|
|
110
|
+
setTokens(tokens);
|
|
111
|
+
return tokens;
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
logger.error('Failed to refresh token', e);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function getTokenExpiry(token) {
|
|
119
|
+
try {
|
|
120
|
+
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
121
|
+
return payload.exp * 1000;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function shouldRefreshNow(accessToken) {
|
|
128
|
+
if (!accessToken)
|
|
129
|
+
return false;
|
|
130
|
+
const exp = getTokenExpiry(accessToken);
|
|
131
|
+
if (!exp)
|
|
132
|
+
return false;
|
|
133
|
+
const timeUntilExpiry = exp - Date.now();
|
|
134
|
+
return timeUntilExpiry <= REFRESH_THRESHOLD_MS;
|
|
135
|
+
}
|
|
136
|
+
function scheduleTokenRefresh() {
|
|
137
|
+
if (!browser)
|
|
138
|
+
return;
|
|
139
|
+
const current = get(tokenStore);
|
|
140
|
+
const accessToken = current?.accessToken ?? null;
|
|
141
|
+
const exp = accessToken ? getTokenExpiry(accessToken) : null;
|
|
142
|
+
if (!exp)
|
|
143
|
+
return;
|
|
144
|
+
const timeUntilExpiry = exp - Date.now();
|
|
145
|
+
logger.debug('[auth] timeUntilExpiry and refresh threshold', timeUntilExpiry, REFRESH_THRESHOLD_MS);
|
|
146
|
+
if (shouldRefreshNow(accessToken)) {
|
|
147
|
+
logger.debug('[auth] refreshing now');
|
|
148
|
+
refreshNow();
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
const checkIn = Math.max(timeUntilExpiry - REFRESH_THRESHOLD_MS, 10000);
|
|
152
|
+
refreshInterval = setTimeout(refreshNow, checkIn);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function refreshNow() {
|
|
156
|
+
const current = get(tokenStore);
|
|
157
|
+
if (!current?.refreshToken)
|
|
158
|
+
return;
|
|
159
|
+
logger.debug('🔄 Attempting token refresh...');
|
|
160
|
+
const newTokens = await refreshToken(current.refreshToken);
|
|
161
|
+
if (newTokens) {
|
|
162
|
+
logger.debug('✅ Token refreshed');
|
|
163
|
+
setTokens(newTokens);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
logger.warn('❌ Token refresh failed');
|
|
167
|
+
clearTokens();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function startAutoRefresh() {
|
|
171
|
+
stopAutoRefresh();
|
|
172
|
+
scheduleTokenRefresh();
|
|
173
|
+
}
|
|
174
|
+
function stopAutoRefresh() {
|
|
175
|
+
if (refreshInterval)
|
|
176
|
+
clearTimeout(refreshInterval);
|
|
177
|
+
refreshInterval = null;
|
|
178
|
+
}
|
|
179
|
+
export async function maybeRefreshNow() {
|
|
180
|
+
const current = get(tokenStore);
|
|
181
|
+
const accessToken = current?.accessToken ?? null;
|
|
182
|
+
const refresh = current?.refreshToken ?? null;
|
|
183
|
+
if (!accessToken || !refresh)
|
|
184
|
+
return !!accessToken;
|
|
185
|
+
if (shouldRefreshNow(accessToken)) {
|
|
186
|
+
const newTokens = await refreshToken(refresh);
|
|
187
|
+
if (newTokens)
|
|
188
|
+
return true;
|
|
189
|
+
clearTokens();
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
// --- Derived values ---
|
|
195
|
+
const isAubridgenticated = derived(tokenStore, $t => !!$t?.accessToken);
|
|
196
|
+
// --- Exports ---
|
|
197
|
+
export const auth = {
|
|
198
|
+
token: tokenStore,
|
|
199
|
+
isAubridgenticated,
|
|
200
|
+
isLoading,
|
|
201
|
+
error,
|
|
202
|
+
login,
|
|
203
|
+
logout: clearTokens,
|
|
204
|
+
handleCallback,
|
|
205
|
+
refreshToken,
|
|
206
|
+
createLoginUrl,
|
|
207
|
+
getToken: () => get(tokenStore)
|
|
208
|
+
};
|
|
209
|
+
export { startAutoRefresh, stopAutoRefresh };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface BridgeConfig {
|
|
2
|
+
/**
|
|
3
|
+
* Your Bridge application ID
|
|
4
|
+
* @required
|
|
5
|
+
*/
|
|
6
|
+
appId: string;
|
|
7
|
+
/**
|
|
8
|
+
* The URL to redirect to after successful login
|
|
9
|
+
* @default The current origin + '/auth/callback'
|
|
10
|
+
*/
|
|
11
|
+
callbackUrl?: string;
|
|
12
|
+
/**
|
|
13
|
+
* The base URL for Bridge auth services
|
|
14
|
+
* @default 'https://auth.nblocks.cloud'
|
|
15
|
+
*/
|
|
16
|
+
authBaseUrl?: string;
|
|
17
|
+
/**
|
|
18
|
+
* The base URL for Bridge backendless services
|
|
19
|
+
* @default 'https://backendless.nblocks.cloud'
|
|
20
|
+
*/
|
|
21
|
+
backendlessBaseUrl?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Route to redirect to after login
|
|
24
|
+
* @default '/'
|
|
25
|
+
*/
|
|
26
|
+
defaultRedirectRoute?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Route to redirect to when aubridgentication fails
|
|
29
|
+
* @default '/login'
|
|
30
|
+
*/
|
|
31
|
+
loginRoute?: string;
|
|
32
|
+
/**
|
|
33
|
+
* URL for bridge team management portal
|
|
34
|
+
* @default 'https://backendless.nblocks.cloud'
|
|
35
|
+
*/
|
|
36
|
+
teamManagementUrl?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Debug mode
|
|
39
|
+
* @default false
|
|
40
|
+
*/
|
|
41
|
+
debug?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface TokenSet {
|
|
44
|
+
accessToken: string;
|
|
45
|
+
refreshToken: string;
|
|
46
|
+
idToken: string;
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nebulr-group/bridge-svelte",
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
|
|
5
|
+
"description": "Bridge Svelte library, This library helps you to add bridge authentication and feature flags, and payments to your svelte application.",
|
|
6
|
+
"author": "Iman Pouya",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/thebridgedev/bridge-svelte.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/thebridgedev/bridge-svelte/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/thebridgedev/bridge-svelte#readme",
|
|
16
|
+
"scripts": {
|
|
17
|
+
"dev": "vite dev",
|
|
18
|
+
"build": "bun run prepack",
|
|
19
|
+
"preview": "vite preview",
|
|
20
|
+
"prepare": "svelte-kit sync || echo ''",
|
|
21
|
+
"prepack": "svelte-kit sync && svelte-package && publint",
|
|
22
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
23
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
24
|
+
"package": "bun run prepack && bun pm pack --destination ../"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"sideEffects": [
|
|
32
|
+
"**/*.css"
|
|
33
|
+
],
|
|
34
|
+
"type": "module",
|
|
35
|
+
"svelte": "./dist/index.js",
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"exports": {
|
|
38
|
+
".": {
|
|
39
|
+
"types": "./dist/index.d.ts",
|
|
40
|
+
"svelte": "./dist/index.js",
|
|
41
|
+
"import": "./dist/index.js"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"jose": "^6.0.10"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"svelte": "^5.0.0",
|
|
49
|
+
"@sveltejs/kit": "^2.0.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@sveltejs/kit": "^2.16.0",
|
|
53
|
+
"@sveltejs/package": "^2.0.0",
|
|
54
|
+
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
|
55
|
+
"publint": "^0.3.2",
|
|
56
|
+
"svelte": "^5.0.0",
|
|
57
|
+
"svelte-check": "^4.0.0",
|
|
58
|
+
"typescript": "^5.0.0",
|
|
59
|
+
"vite": "^6.2.6"
|
|
60
|
+
},
|
|
61
|
+
"keywords": ["svelte", "bridge", "nebulr", "auth", "feature-flags","payments","stripe","saas control center"]
|
|
62
|
+
}
|
|
63
|
+
|