@structured-id/ui-core 0.1.5
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 +661 -0
- package/README.md +47 -0
- package/dist/browser-vp/index.cjs +349 -0
- package/dist/browser-vp/index.d.cts +287 -0
- package/dist/browser-vp/index.d.ts +287 -0
- package/dist/browser-vp/index.js +349 -0
- package/dist/chunk-C3Q766CY.cjs +125 -0
- package/dist/chunk-J5YXRXB5.js +125 -0
- package/dist/index.cjs +19360 -0
- package/dist/index.d.cts +9536 -0
- package/dist/index.d.ts +9536 -0
- package/dist/index.js +19360 -0
- package/dist/storage-4CB2JTLW.cjs +22 -0
- package/dist/storage-THRWREZP.js +22 -0
- package/package.json +93 -0
- package/src/browser-vp/crypto.ts +254 -0
- package/src/browser-vp/index.test.ts +10 -0
- package/src/browser-vp/index.ts +118 -0
- package/src/browser-vp/prf.ts +181 -0
- package/src/browser-vp/storage.test.ts +9 -0
- package/src/browser-vp/storage.ts +165 -0
- package/src/browser-vp/types.ts +97 -0
- package/src/browser-vp/vp.test.ts +43 -0
- package/src/browser-vp/vp.ts +113 -0
- package/src/components/SidAvatar.vue +106 -0
- package/src/components/SidConfirmDialog.vue +125 -0
- package/src/components/SidStatusBadge.vue +71 -0
- package/src/components/index.ts +5 -0
- package/src/composables/index.ts +10 -0
- package/src/composables/useAuth.ts +40 -0
- package/src/composables/useBffAuth.ts +147 -0
- package/src/composables/useGrpc.test.ts +196 -0
- package/src/composables/useGrpc.ts +120 -0
- package/src/composables/useSession.ts +80 -0
- package/src/generated/google/api/http.ts +687 -0
- package/src/generated/google/protobuf/descriptor.ts +4865 -0
- package/src/generated/google/protobuf/duration.ts +230 -0
- package/src/generated/google/protobuf/empty.ts +94 -0
- package/src/generated/google/protobuf/struct.ts +484 -0
- package/src/generated/google/protobuf/timestamp.ts +287 -0
- package/src/generated/sid/v1/account/account.client.ts +527 -0
- package/src/generated/sid/v1/account/account.ts +3261 -0
- package/src/generated/sid/v1/admin/admin.client.ts +136 -0
- package/src/generated/sid/v1/admin/admin.ts +846 -0
- package/src/generated/sid/v1/admin/admin_roles.client.ts +181 -0
- package/src/generated/sid/v1/admin/admin_roles.ts +1606 -0
- package/src/generated/sid/v1/admin/analytics.client.ts +106 -0
- package/src/generated/sid/v1/admin/analytics.ts +1017 -0
- package/src/generated/sid/v1/admin/branding.client.ts +166 -0
- package/src/generated/sid/v1/admin/branding.ts +1906 -0
- package/src/generated/sid/v1/admin/enrollment.client.ts +165 -0
- package/src/generated/sid/v1/admin/enrollment.ts +1704 -0
- package/src/generated/sid/v1/admin/events.client.ts +195 -0
- package/src/generated/sid/v1/admin/events.ts +1900 -0
- package/src/generated/sid/v1/admin/flow.client.ts +183 -0
- package/src/generated/sid/v1/admin/flow.ts +1552 -0
- package/src/generated/sid/v1/admin/keys.client.ts +107 -0
- package/src/generated/sid/v1/admin/keys.ts +931 -0
- package/src/generated/sid/v1/admin/login_experience.client.ts +160 -0
- package/src/generated/sid/v1/admin/login_experience.ts +1061 -0
- package/src/generated/sid/v1/admin/migration.client.ts +127 -0
- package/src/generated/sid/v1/admin/migration.ts +1948 -0
- package/src/generated/sid/v1/admin/notifications.client.ts +128 -0
- package/src/generated/sid/v1/admin/notifications.ts +1332 -0
- package/src/generated/sid/v1/admin/operations.client.ts +241 -0
- package/src/generated/sid/v1/admin/operations.ts +2648 -0
- package/src/generated/sid/v1/admin/organizations.client.ts +57 -0
- package/src/generated/sid/v1/admin/organizations.ts +349 -0
- package/src/generated/sid/v1/admin/scopes.client.ts +171 -0
- package/src/generated/sid/v1/admin/scopes.ts +1324 -0
- package/src/generated/sid/v1/admin/security.client.ts +412 -0
- package/src/generated/sid/v1/admin/security.ts +4086 -0
- package/src/generated/sid/v1/admin/sessions.client.ts +114 -0
- package/src/generated/sid/v1/admin/sessions.ts +923 -0
- package/src/generated/sid/v1/admin/settings.client.ts +340 -0
- package/src/generated/sid/v1/admin/settings.ts +2395 -0
- package/src/generated/sid/v1/admin/user_profile.client.ts +57 -0
- package/src/generated/sid/v1/admin/user_profile.ts +730 -0
- package/src/generated/sid/v1/admin/webhooks.client.ts +269 -0
- package/src/generated/sid/v1/admin/webhooks.ts +2804 -0
- package/src/generated/sid/v1/attestation/attestation.client.ts +115 -0
- package/src/generated/sid/v1/attestation/attestation.ts +813 -0
- package/src/generated/sid/v1/authn/auth.client.ts +900 -0
- package/src/generated/sid/v1/authn/auth.ts +9287 -0
- package/src/generated/sid/v1/authn/flows.client.ts +413 -0
- package/src/generated/sid/v1/authn/flows.ts +3217 -0
- package/src/generated/sid/v1/authz/authz.client.ts +459 -0
- package/src/generated/sid/v1/authz/authz.ts +5343 -0
- package/src/generated/sid/v1/authz/governance.client.ts +102 -0
- package/src/generated/sid/v1/authz/governance.ts +818 -0
- package/src/generated/sid/v1/common/errors.ts +184 -0
- package/src/generated/sid/v1/common/pagination.ts +152 -0
- package/src/generated/sid/v1/common/transport.ts +227 -0
- package/src/generated/sid/v1/common/types.ts +151 -0
- package/src/generated/sid/v1/events/event_stream.client.ts +67 -0
- package/src/generated/sid/v1/events/event_stream.ts +374 -0
- package/src/generated/sid/v1/federation/upstream.client.ts +142 -0
- package/src/generated/sid/v1/federation/upstream.ts +1736 -0
- package/src/generated/sid/v1/identity/identity.client.ts +467 -0
- package/src/generated/sid/v1/identity/identity.ts +5613 -0
- package/src/generated/sid/v1/machine/machine_user.client.ts +219 -0
- package/src/generated/sid/v1/machine/machine_user.ts +2400 -0
- package/src/generated/sid/v1/machine/pat.client.ts +147 -0
- package/src/generated/sid/v1/machine/pat.ts +1268 -0
- package/src/generated/sid/v1/projects/projects.client.ts +286 -0
- package/src/generated/sid/v1/projects/projects.ts +2714 -0
- package/src/generated/sid/v1/scim/scim.client.ts +274 -0
- package/src/generated/sid/v1/scim/scim.ts +3043 -0
- package/src/generated/sid/v1/test/test.client.ts +232 -0
- package/src/generated/sid/v1/test/test.ts +1136 -0
- package/src/i18n/index.ts +40 -0
- package/src/index.ts +157 -0
- package/src/profile/auth.ts +15 -0
- package/src/profile/components/ConsentManager.vue +88 -0
- package/src/profile/components/ProfileEditor.vue +146 -0
- package/src/profile/components/SecuritySettings.vue +170 -0
- package/src/profile/components/SessionList.vue +100 -0
- package/src/profile/components/SidLoginForm.test.ts +80 -0
- package/src/profile/components/SidLoginForm.vue +230 -0
- package/src/profile/components/SidMfaChallenge.test.ts +127 -0
- package/src/profile/components/SidMfaChallenge.vue +243 -0
- package/src/profile/components/SidRegistrationForm.test.ts +79 -0
- package/src/profile/components/SidRegistrationForm.vue +317 -0
- package/src/profile/components/index.ts +11 -0
- package/src/profile/composables/index.ts +6 -0
- package/src/profile/composables/useAccountApi.ts +203 -0
- package/src/profile/composables/useAuthApi.opaque.test.ts +140 -0
- package/src/profile/composables/useAuthApi.ts +617 -0
- package/src/profile/composables/useIdentityApi.ts +123 -0
- package/src/profile/composables/useLoginSuccess.test.ts +112 -0
- package/src/profile/composables/useLoginSuccess.ts +65 -0
- package/src/profile/composables/useOpaqueAuth.test.ts +290 -0
- package/src/profile/composables/useOpaqueAuth.ts +191 -0
- package/src/profile/composables/useSecurityLevel.test.ts +198 -0
- package/src/profile/composables/useSecurityLevel.ts +324 -0
- package/src/profile/index.ts +18 -0
- package/src/quasar/SidPrincipalInput.vue +209 -0
- package/src/quasar/index.ts +8 -0
- package/src/stores/auth.test.ts +83 -0
- package/src/stores/auth.ts +92 -0
- package/src/stores/index.ts +1 -0
- package/src/transport/adaptive.test.ts +728 -0
- package/src/transport/adaptive.ts +308 -0
- package/src/transport/framing.test.ts +227 -0
- package/src/transport/framing.ts +98 -0
- package/src/transport/webtransport.test.ts +728 -0
- package/src/transport/webtransport.ts +323 -0
- package/src/types/auth.ts +58 -0
- package/src/types/index.ts +1 -0
- package/src/utils/formatters.test.ts +26 -0
- package/src/utils/formatters.ts +69 -0
- package/src/utils/index.ts +16 -0
- package/src/utils/normalizePrincipal.test.ts +188 -0
- package/src/utils/normalizePrincipal.ts +159 -0
- package/src/utils/validators.test.ts +75 -0
- package/src/utils/validators.ts +36 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Status badge component with semantic colors.
|
|
4
|
+
*
|
|
5
|
+
* Framework-agnostic (pure Vue 3, no Quasar dependency).
|
|
6
|
+
* Consuming apps can override styles via CSS custom properties.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type BadgeVariant =
|
|
10
|
+
| "active"
|
|
11
|
+
| "inactive"
|
|
12
|
+
| "pending"
|
|
13
|
+
| "error"
|
|
14
|
+
| "warning";
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(
|
|
17
|
+
defineProps<{
|
|
18
|
+
label: string;
|
|
19
|
+
variant?: BadgeVariant;
|
|
20
|
+
}>(),
|
|
21
|
+
{
|
|
22
|
+
variant: "active",
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const variantClasses: Record<BadgeVariant, string> = {
|
|
27
|
+
active: "sid-badge--active",
|
|
28
|
+
inactive: "sid-badge--inactive",
|
|
29
|
+
pending: "sid-badge--pending",
|
|
30
|
+
error: "sid-badge--error",
|
|
31
|
+
warning: "sid-badge--warning",
|
|
32
|
+
};
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<span class="sid-badge" :class="variantClasses[props.variant]">
|
|
37
|
+
{{ props.label }}
|
|
38
|
+
</span>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<style scoped>
|
|
42
|
+
.sid-badge {
|
|
43
|
+
display: inline-flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
padding: 2px 8px;
|
|
46
|
+
border-radius: 12px;
|
|
47
|
+
font-size: 0.75rem;
|
|
48
|
+
font-weight: 500;
|
|
49
|
+
line-height: 1.5;
|
|
50
|
+
}
|
|
51
|
+
.sid-badge--active {
|
|
52
|
+
background: var(--sid-badge-active-bg, #e8f5e9);
|
|
53
|
+
color: var(--sid-badge-active-color, #2e7d32);
|
|
54
|
+
}
|
|
55
|
+
.sid-badge--inactive {
|
|
56
|
+
background: var(--sid-badge-inactive-bg, #f5f5f5);
|
|
57
|
+
color: var(--sid-badge-inactive-color, #757575);
|
|
58
|
+
}
|
|
59
|
+
.sid-badge--pending {
|
|
60
|
+
background: var(--sid-badge-pending-bg, #fff3e0);
|
|
61
|
+
color: var(--sid-badge-pending-color, #e65100);
|
|
62
|
+
}
|
|
63
|
+
.sid-badge--error {
|
|
64
|
+
background: var(--sid-badge-error-bg, #ffebee);
|
|
65
|
+
color: var(--sid-badge-error-color, #c62828);
|
|
66
|
+
}
|
|
67
|
+
.sid-badge--warning {
|
|
68
|
+
background: var(--sid-badge-warning-bg, #fff8e1);
|
|
69
|
+
color: var(--sid-badge-warning-color, #f57f17);
|
|
70
|
+
}
|
|
71
|
+
</style>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default as SidAvatar } from "./SidAvatar.vue";
|
|
2
|
+
export type { AvatarSize } from "./SidAvatar.vue";
|
|
3
|
+
export { default as SidStatusBadge } from "./SidStatusBadge.vue";
|
|
4
|
+
export type { BadgeVariant } from "./SidStatusBadge.vue";
|
|
5
|
+
export { default as SidConfirmDialog } from "./SidConfirmDialog.vue";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import { useAuthStore } from "../stores/auth";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Vue composable for authentication state and actions.
|
|
6
|
+
*
|
|
7
|
+
* Wraps the Pinia auth store with reactive computed properties.
|
|
8
|
+
* Post BFF migration (#536i-3): no token accessors — session validity
|
|
9
|
+
* is based on httpOnly cookie checked via BFF /auth/userinfo on boot.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```vue
|
|
13
|
+
* <script setup>
|
|
14
|
+
* import { useAuth } from '@structured-id/ui-core'
|
|
15
|
+
* const { isAuthenticated, displayName, logout } = useAuth()
|
|
16
|
+
* </script>
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export function useAuth() {
|
|
20
|
+
const store = useAuthStore();
|
|
21
|
+
|
|
22
|
+
const isAuthenticated = computed(() => store.isAuthenticated);
|
|
23
|
+
const displayName = computed(() => store.displayName);
|
|
24
|
+
const email = computed(() => store.email);
|
|
25
|
+
const profileId = computed(() => store.profileId);
|
|
26
|
+
const preferredUsername = computed(() => store.preferredUsername);
|
|
27
|
+
const sessionId = computed(() => store.sessionId);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
isAuthenticated,
|
|
31
|
+
displayName,
|
|
32
|
+
email,
|
|
33
|
+
profileId,
|
|
34
|
+
preferredUsername,
|
|
35
|
+
sessionId,
|
|
36
|
+
setBffSession: store.setBffSession,
|
|
37
|
+
setProfile: store.setProfile,
|
|
38
|
+
logout: store.logout,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BFF (Backend-For-Frontend) session management composable.
|
|
3
|
+
*
|
|
4
|
+
* After migration from localStorage to cookie-based auth (#536i-3):
|
|
5
|
+
* - Tokens are stored server-side in sid-auth-proxy BFF sessions
|
|
6
|
+
* - SPA only holds a non-httpOnly CSRF cookie + httpOnly session cookie
|
|
7
|
+
* - gRPC calls go through BFF proxy which injects Bearer header
|
|
8
|
+
* - Client JS never sees the access token
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* 1. Call `initBff(bffBaseUrl)` at app boot (e.g., VITE_BFF_URL)
|
|
12
|
+
* 2. Call `checkBffSession()` to verify session + populate auth store
|
|
13
|
+
* 3. Call `startBffLogin()` to redirect user to PKCE auth flow
|
|
14
|
+
* 4. Call `bffLogout()` to clear session (sends X-CSRF-Token header)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** User info returned from BFF /auth/userinfo endpoint. */
|
|
18
|
+
export interface BffUserInfo {
|
|
19
|
+
/** Subject identifier (ProfileId for CE, PersonaId for SaaS). */
|
|
20
|
+
sub: string;
|
|
21
|
+
/** Email address (if available in session claims). */
|
|
22
|
+
email?: string | null;
|
|
23
|
+
/** Display name (full name from profile). */
|
|
24
|
+
name?: string | null;
|
|
25
|
+
/** Username or email — preferred identifier for display. */
|
|
26
|
+
preferred_username?: string | null;
|
|
27
|
+
/** Group memberships. */
|
|
28
|
+
groups?: string[] | null;
|
|
29
|
+
/** Authentication Context Class Reference (acr_values). */
|
|
30
|
+
acr?: string | null;
|
|
31
|
+
/** Role assignments. */
|
|
32
|
+
roles?: string[] | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let _bffBaseUrl: string | null = null;
|
|
36
|
+
let _bffCookieName: string = "__Host-sid-bff";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Initialize BFF configuration. Call once at app boot.
|
|
40
|
+
*
|
|
41
|
+
* @param baseUrl BFF base URL (e.g., "https://app.structured.id" or VITE_BFF_URL)
|
|
42
|
+
* @param cookieName BFF session cookie name (default: "__Host-sid-bff")
|
|
43
|
+
*/
|
|
44
|
+
export function initBff(baseUrl: string, cookieName?: string): void {
|
|
45
|
+
_bffBaseUrl = baseUrl;
|
|
46
|
+
if (cookieName) _bffCookieName = cookieName;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Get the configured BFF base URL, or null if not initialized. */
|
|
50
|
+
export function getBffBaseUrl(): string | null {
|
|
51
|
+
return _bffBaseUrl;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read the CSRF token from the non-httpOnly CSRF cookie.
|
|
56
|
+
* The BFF sets a cookie `{cookieName}_csrf=<token>; SameSite=Lax` (NOT httpOnly)
|
|
57
|
+
* so JS can read it for double-submit CSRF protection.
|
|
58
|
+
*/
|
|
59
|
+
function readCsrfToken(): string | null {
|
|
60
|
+
const name = `${_bffCookieName}_csrf=`;
|
|
61
|
+
const cookies = document.cookie.split("; ");
|
|
62
|
+
const found = cookies.find((c) => c.startsWith(name));
|
|
63
|
+
return found ? (found.split("=")[1] ?? null) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if the BFF session is valid by calling GET /auth/userinfo.
|
|
68
|
+
*
|
|
69
|
+
* Returns user info if session is active, null if unauthenticated.
|
|
70
|
+
* The browser automatically sends the httpOnly session cookie.
|
|
71
|
+
*/
|
|
72
|
+
export async function checkBffSession(
|
|
73
|
+
bffBaseUrl?: string,
|
|
74
|
+
): Promise<BffUserInfo | null> {
|
|
75
|
+
const base = bffBaseUrl ?? _bffBaseUrl;
|
|
76
|
+
if (!base) return null;
|
|
77
|
+
try {
|
|
78
|
+
const resp = await fetch(`${base}/auth/userinfo`, {
|
|
79
|
+
credentials: "include",
|
|
80
|
+
});
|
|
81
|
+
if (resp.status === 401 || resp.status === 404) return null;
|
|
82
|
+
if (!resp.ok) return null;
|
|
83
|
+
return (await resp.json()) as BffUserInfo;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Redirect user to BFF login (PKCE flow initiation).
|
|
91
|
+
*
|
|
92
|
+
* After successful login, BFF sets httpOnly session cookie and redirects
|
|
93
|
+
* back to the SPA. The SPA should call checkBffSession() on load to restore state.
|
|
94
|
+
*
|
|
95
|
+
* @param bffBaseUrl Override BFF base URL (uses configured URL if not provided)
|
|
96
|
+
* @param postLoginRedirect URL to redirect to after successful auth
|
|
97
|
+
*/
|
|
98
|
+
export function startBffLogin(
|
|
99
|
+
bffBaseUrl?: string,
|
|
100
|
+
postLoginRedirect?: string,
|
|
101
|
+
): void {
|
|
102
|
+
const base = bffBaseUrl ?? _bffBaseUrl;
|
|
103
|
+
if (!base) {
|
|
104
|
+
console.warn("[BFF] Not initialized. Call initBff() at app boot.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const url = postLoginRedirect
|
|
108
|
+
? `${base}/auth/login?post_login_redirect=${encodeURIComponent(postLoginRedirect)}`
|
|
109
|
+
: `${base}/auth/login`;
|
|
110
|
+
window.location.href = url;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Log out by calling POST /auth/logout with X-CSRF-Token header.
|
|
115
|
+
*
|
|
116
|
+
* The BFF validates the CSRF header, clears server-side session,
|
|
117
|
+
* and sends Set-Cookie headers to clear both cookies.
|
|
118
|
+
*
|
|
119
|
+
* @param bffBaseUrl Override BFF base URL
|
|
120
|
+
*/
|
|
121
|
+
export async function bffLogout(bffBaseUrl?: string): Promise<void> {
|
|
122
|
+
const base = bffBaseUrl ?? _bffBaseUrl;
|
|
123
|
+
if (!base) return;
|
|
124
|
+
const csrf = readCsrfToken();
|
|
125
|
+
try {
|
|
126
|
+
await fetch(`${base}/auth/logout`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
credentials: "include",
|
|
129
|
+
headers: csrf ? { "X-CSRF-Token": csrf } : {},
|
|
130
|
+
});
|
|
131
|
+
} catch {
|
|
132
|
+
// Best-effort — local state is cleared by caller regardless
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Vue composable for BFF auth actions.
|
|
138
|
+
*/
|
|
139
|
+
export function useBffAuth() {
|
|
140
|
+
return {
|
|
141
|
+
checkBffSession,
|
|
142
|
+
startBffLogin,
|
|
143
|
+
bffLogout,
|
|
144
|
+
getBffBaseUrl,
|
|
145
|
+
initBff,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mock modules BEFORE importing the module under test
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const mockAdaptiveInit = vi
|
|
8
|
+
.fn<[], Promise<void>>()
|
|
9
|
+
.mockResolvedValue(undefined);
|
|
10
|
+
const mockAdaptiveClose = vi.fn();
|
|
11
|
+
const mockAdaptiveActiveTransport = vi
|
|
12
|
+
.fn<[], string>()
|
|
13
|
+
.mockReturnValue("grpc-web");
|
|
14
|
+
|
|
15
|
+
vi.mock("../transport/adaptive", () => ({
|
|
16
|
+
AdaptiveRpcTransport: vi.fn().mockImplementation(() => ({
|
|
17
|
+
init: mockAdaptiveInit,
|
|
18
|
+
close: mockAdaptiveClose,
|
|
19
|
+
get activeTransport() {
|
|
20
|
+
return mockAdaptiveActiveTransport();
|
|
21
|
+
},
|
|
22
|
+
})),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock("@protobuf-ts/grpcweb-transport", () => ({
|
|
26
|
+
GrpcWebFetchTransport: vi
|
|
27
|
+
.fn()
|
|
28
|
+
.mockImplementation(() => ({ type: "grpc-web-transport" })),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// Import AFTER mocks
|
|
32
|
+
import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
|
|
33
|
+
import { AdaptiveRpcTransport } from "../transport/adaptive";
|
|
34
|
+
import {
|
|
35
|
+
initGrpc,
|
|
36
|
+
getTransport,
|
|
37
|
+
closeTransport,
|
|
38
|
+
getTransportType,
|
|
39
|
+
useGrpc,
|
|
40
|
+
} from "./useGrpc";
|
|
41
|
+
import type { GrpcConfig } from "../types/auth";
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Tests
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
describe("useGrpc", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
// Reset transport state by calling close between tests
|
|
51
|
+
closeTransport();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("initGrpc — without WebTransport URL", () => {
|
|
55
|
+
it("creates GrpcWebFetchTransport with baseUrl and credentials", () => {
|
|
56
|
+
const config: GrpcConfig = { baseUrl: "https://api.sid.example.com" };
|
|
57
|
+
initGrpc(config);
|
|
58
|
+
|
|
59
|
+
expect(GrpcWebFetchTransport).toHaveBeenCalledWith({
|
|
60
|
+
baseUrl: "https://api.sid.example.com",
|
|
61
|
+
fetchInit: { credentials: "include" },
|
|
62
|
+
});
|
|
63
|
+
expect(AdaptiveRpcTransport).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("getTransportType returns grpc-web", () => {
|
|
67
|
+
initGrpc({ baseUrl: "https://api.sid.example.com" });
|
|
68
|
+
expect(getTransportType()).toBe("grpc-web");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("initGrpc — with WebTransport URL", () => {
|
|
73
|
+
it("creates AdaptiveRpcTransport with fetchInit credentials", () => {
|
|
74
|
+
const config: GrpcConfig = {
|
|
75
|
+
baseUrl: "https://api.sid.example.com",
|
|
76
|
+
webTransportUrl: "https://wt.api.sid.example.com",
|
|
77
|
+
};
|
|
78
|
+
initGrpc(config);
|
|
79
|
+
|
|
80
|
+
expect(AdaptiveRpcTransport).toHaveBeenCalledWith(
|
|
81
|
+
expect.objectContaining({
|
|
82
|
+
grpcWebUrl: "https://api.sid.example.com",
|
|
83
|
+
webTransportUrl: "https://wt.api.sid.example.com",
|
|
84
|
+
fetchInit: { credentials: "include" },
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
expect(GrpcWebFetchTransport).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("calls adaptive.init() non-blocking on creation", async () => {
|
|
91
|
+
initGrpc({
|
|
92
|
+
baseUrl: "https://api.sid.example.com",
|
|
93
|
+
webTransportUrl: "https://wt.api.sid.example.com",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// init() should be called (non-blocking fire-and-forget)
|
|
97
|
+
// Give microtasks time to run
|
|
98
|
+
await Promise.resolve();
|
|
99
|
+
expect(mockAdaptiveInit).toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("passes all WebTransport options to AdaptiveRpcTransport", () => {
|
|
103
|
+
const onChange = vi.fn();
|
|
104
|
+
initGrpc({
|
|
105
|
+
baseUrl: "https://api.sid.example.com",
|
|
106
|
+
webTransportUrl: "https://wt.api.sid.example.com",
|
|
107
|
+
webTransportCertHash: "abcdef1234567890",
|
|
108
|
+
webTransportReconnectDelay: 3000,
|
|
109
|
+
onTransportChange: onChange,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(AdaptiveRpcTransport).toHaveBeenCalledWith(
|
|
113
|
+
expect.objectContaining({
|
|
114
|
+
grpcWebUrl: "https://api.sid.example.com",
|
|
115
|
+
webTransportUrl: "https://wt.api.sid.example.com",
|
|
116
|
+
webTransportCertHash: "abcdef1234567890",
|
|
117
|
+
reconnectDelay: 3000,
|
|
118
|
+
onTransportChange: onChange,
|
|
119
|
+
fetchInit: { credentials: "include" },
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("getTransportType delegates to AdaptiveRpcTransport.activeTransport", () => {
|
|
125
|
+
mockAdaptiveActiveTransport.mockReturnValue("webtransport");
|
|
126
|
+
initGrpc({
|
|
127
|
+
baseUrl: "https://api.sid.example.com",
|
|
128
|
+
webTransportUrl: "https://wt.api.sid.example.com",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(getTransportType()).toBe("webtransport");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("getTransport", () => {
|
|
136
|
+
it("throws before initGrpc is called", () => {
|
|
137
|
+
expect(() => getTransport()).toThrow("gRPC transport not initialized");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("returns transport after initGrpc", () => {
|
|
141
|
+
initGrpc({ baseUrl: "https://api.sid.example.com" });
|
|
142
|
+
expect(() => getTransport()).not.toThrow();
|
|
143
|
+
expect(getTransport()).toBeDefined();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("useGrpc composable", () => {
|
|
148
|
+
it("transport.value is null before initGrpc", () => {
|
|
149
|
+
const { transport } = useGrpc();
|
|
150
|
+
expect(transport.value).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("transport.value is set when initGrpc was called before useGrpc", () => {
|
|
154
|
+
initGrpc({ baseUrl: "https://api.sid.example.com" });
|
|
155
|
+
const { transport } = useGrpc();
|
|
156
|
+
expect(transport.value).not.toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("returns getTransport and initGrpc functions", () => {
|
|
160
|
+
const composable = useGrpc();
|
|
161
|
+
expect(typeof composable.getTransport).toBe("function");
|
|
162
|
+
expect(typeof composable.initGrpc).toBe("function");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("transport.value is null after closeTransport", () => {
|
|
166
|
+
initGrpc({ baseUrl: "https://api.sid.example.com" });
|
|
167
|
+
closeTransport();
|
|
168
|
+
const { transport } = useGrpc();
|
|
169
|
+
expect(transport.value).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("closeTransport", () => {
|
|
174
|
+
it("calls close() on AdaptiveRpcTransport", () => {
|
|
175
|
+
initGrpc({
|
|
176
|
+
baseUrl: "https://api.sid.example.com",
|
|
177
|
+
webTransportUrl: "https://wt.api.sid.example.com",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
closeTransport();
|
|
181
|
+
expect(mockAdaptiveClose).toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("resets transport to null (getTransport throws after close)", () => {
|
|
185
|
+
initGrpc({ baseUrl: "https://api.sid.example.com" });
|
|
186
|
+
closeTransport();
|
|
187
|
+
expect(() => getTransport()).toThrow("gRPC transport not initialized");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("does not call close() on plain GrpcWebFetchTransport (no close method)", () => {
|
|
191
|
+
initGrpc({ baseUrl: "https://api.sid.example.com" });
|
|
192
|
+
// Should not throw even though GrpcWebFetchTransport has no close()
|
|
193
|
+
expect(() => closeTransport()).not.toThrow();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
|
|
2
|
+
import type { RpcTransport } from "@protobuf-ts/runtime-rpc";
|
|
3
|
+
import { ref, type Ref } from "vue";
|
|
4
|
+
import type { GrpcConfig } from "../types/auth";
|
|
5
|
+
import { AdaptiveRpcTransport } from "../transport/adaptive";
|
|
6
|
+
import type { TransportType } from "../transport/adaptive";
|
|
7
|
+
|
|
8
|
+
/** Shared RPC transport instance (gRPC-web or AdaptiveRpcTransport). */
|
|
9
|
+
let _transport: RpcTransport | null = null;
|
|
10
|
+
/** Separate reference to AdaptiveRpcTransport when active (avoids instanceof). */
|
|
11
|
+
let _adaptive: AdaptiveRpcTransport | null = null;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Initialize the gRPC transport. Call once at app startup.
|
|
15
|
+
*
|
|
16
|
+
* When `config.webTransportUrl` is provided, creates an AdaptiveRpcTransport
|
|
17
|
+
* with WebTransport as the primary and gRPC-web as the fallback. The initial
|
|
18
|
+
* WebTransport connection is attempted asynchronously (non-blocking).
|
|
19
|
+
*
|
|
20
|
+
* Without `config.webTransportUrl`, falls back to plain gRPC-web (same as before).
|
|
21
|
+
*
|
|
22
|
+
* After BFF migration (#536i-3): `credentials: "include"` is always set
|
|
23
|
+
* so the httpOnly BFF session cookie is sent with every gRPC-web request.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* // gRPC-web only
|
|
28
|
+
* initGrpc({ baseUrl: 'https://api.example.com' })
|
|
29
|
+
*
|
|
30
|
+
* // With WebTransport failover
|
|
31
|
+
* initGrpc({
|
|
32
|
+
* baseUrl: 'https://api.example.com',
|
|
33
|
+
* webTransportUrl: 'https://wt.api.example.com',
|
|
34
|
+
* })
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function initGrpc(config: GrpcConfig): void {
|
|
38
|
+
if (config.webTransportUrl) {
|
|
39
|
+
const adaptive = new AdaptiveRpcTransport({
|
|
40
|
+
grpcWebUrl: config.baseUrl,
|
|
41
|
+
webTransportUrl: config.webTransportUrl,
|
|
42
|
+
webTransportCertHash: config.webTransportCertHash,
|
|
43
|
+
reconnectDelay: config.webTransportReconnectDelay,
|
|
44
|
+
onTransportChange: config.onTransportChange,
|
|
45
|
+
// Always include credentials so the BFF session cookie is attached.
|
|
46
|
+
// The BFF proxy exchanges the cookie for a Bearer token server-side.
|
|
47
|
+
fetchInit: { credentials: "include" },
|
|
48
|
+
});
|
|
49
|
+
// Attempt WebTransport connection in background — non-blocking.
|
|
50
|
+
adaptive.init().catch(() => {
|
|
51
|
+
// Silently falls back to gRPC-web if WebTransport is unavailable.
|
|
52
|
+
});
|
|
53
|
+
_adaptive = adaptive;
|
|
54
|
+
_transport = adaptive;
|
|
55
|
+
} else {
|
|
56
|
+
_adaptive = null;
|
|
57
|
+
_transport = new GrpcWebFetchTransport({
|
|
58
|
+
baseUrl: config.baseUrl,
|
|
59
|
+
// Always include credentials so the BFF session cookie is attached.
|
|
60
|
+
// The BFF proxy exchanges the cookie for a Bearer token server-side.
|
|
61
|
+
fetchInit: { credentials: "include" },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get the shared RPC transport. Throws if not initialized.
|
|
68
|
+
*/
|
|
69
|
+
export function getTransport(): RpcTransport {
|
|
70
|
+
if (!_transport) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"gRPC transport not initialized. Call initGrpc() at app startup.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return _transport;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Close the transport and release resources (timers, connections).
|
|
80
|
+
* Call during app teardown if using WebTransport.
|
|
81
|
+
*/
|
|
82
|
+
export function closeTransport(): void {
|
|
83
|
+
_adaptive?.close();
|
|
84
|
+
_adaptive = null;
|
|
85
|
+
_transport = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get the currently active transport type.
|
|
90
|
+
* Returns "grpc-web" if AdaptiveRpcTransport is not used or WebTransport
|
|
91
|
+
* is not available.
|
|
92
|
+
*/
|
|
93
|
+
export function getTransportType(): TransportType {
|
|
94
|
+
return _adaptive?.activeTransport ?? "grpc-web";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Vue composable for gRPC transport.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```ts
|
|
102
|
+
* const { transport } = useGrpc()
|
|
103
|
+
* const client = new AuthServiceClient(transport.value)
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export function useGrpc() {
|
|
107
|
+
const transport: Ref<RpcTransport | null> = ref(null);
|
|
108
|
+
|
|
109
|
+
if (_transport) {
|
|
110
|
+
transport.value = _transport;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
transport,
|
|
115
|
+
getTransport,
|
|
116
|
+
initGrpc,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type { TransportType };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { ref, onMounted, onUnmounted } from "vue";
|
|
2
|
+
import type { SessionInfo } from "../types/auth";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Vue composable for session management.
|
|
6
|
+
*
|
|
7
|
+
* Provides reactive session list, auto-refresh, and revocation.
|
|
8
|
+
* The actual gRPC calls are delegated to a callback to keep
|
|
9
|
+
* ui-core independent of specific proto client versions.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```vue
|
|
13
|
+
* <script setup>
|
|
14
|
+
* import { useSession } from '@structured-id/ui-core'
|
|
15
|
+
*
|
|
16
|
+
* const { sessions, loading, refresh } = useSession({
|
|
17
|
+
* fetchSessions: async () => { ... },
|
|
18
|
+
* revokeSession: async (id) => { ... },
|
|
19
|
+
* })
|
|
20
|
+
* </script>
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export interface SessionCallbacks {
|
|
24
|
+
fetchSessions: () => Promise<SessionInfo[]>;
|
|
25
|
+
revokeSession: (sessionId: string) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useSession(callbacks: SessionCallbacks) {
|
|
29
|
+
const sessions = ref<SessionInfo[]>([]);
|
|
30
|
+
const loading = ref(false);
|
|
31
|
+
const error = ref<string | null>(null);
|
|
32
|
+
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
33
|
+
|
|
34
|
+
async function refresh(): Promise<void> {
|
|
35
|
+
loading.value = true;
|
|
36
|
+
error.value = null;
|
|
37
|
+
try {
|
|
38
|
+
sessions.value = await callbacks.fetchSessions();
|
|
39
|
+
} catch (e) {
|
|
40
|
+
error.value = e instanceof Error ? e.message : String(e);
|
|
41
|
+
} finally {
|
|
42
|
+
loading.value = false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function revoke(sessionId: string): Promise<void> {
|
|
47
|
+
await callbacks.revokeSession(sessionId);
|
|
48
|
+
sessions.value = sessions.value.filter((s) => s.id !== sessionId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function startAutoRefresh(intervalMs = 30_000): void {
|
|
52
|
+
stopAutoRefresh();
|
|
53
|
+
refreshTimer = setInterval(refresh, intervalMs);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stopAutoRefresh(): void {
|
|
57
|
+
if (refreshTimer) {
|
|
58
|
+
clearInterval(refreshTimer);
|
|
59
|
+
refreshTimer = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
onMounted(() => {
|
|
64
|
+
refresh();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
onUnmounted(() => {
|
|
68
|
+
stopAutoRefresh();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
sessions,
|
|
73
|
+
loading,
|
|
74
|
+
error,
|
|
75
|
+
refresh,
|
|
76
|
+
revoke,
|
|
77
|
+
startAutoRefresh,
|
|
78
|
+
stopAutoRefresh,
|
|
79
|
+
};
|
|
80
|
+
}
|