spry-apps-dropdown 2.0.3 → 3.0.0

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.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Main logout: Remove account from localStorage, then redirect to soft-logout endpoint
3
+ * Always redirects back to the app after logout
4
+ *
5
+ * @param accountId - The account ID to remove
6
+ * @param keycloakConfig - Keycloak configuration
7
+ * @param redirectUrl - (Optional) URL to redirect back to after logout (defaults to window.location.origin)
8
+ */
9
+ export declare function mainLogout(accountId: string, keycloakConfig: KeycloakConfig, redirectUrl?: string): void;
10
+ import type { KeycloakConfig, OIDCUser } from './types';
11
+ export interface LogoutResult {
12
+ success: boolean;
13
+ message: string;
14
+ remainingAccounts: number;
15
+ }
16
+ /**
17
+ * Clear Keycloak SSO session via fetch (without redirect)
18
+ * Used before signinRedirect to handle SSO collision when adding new account
19
+ *
20
+ * Important: Soft-logout clears Keycloak SSO cookies without revoking tokens.
21
+ * This preserves offline refresh tokens for multi-account switching.
22
+ *
23
+ * @param user - Current OIDC user with refresh_token
24
+ * @param keycloakConfig - Keycloak configuration
25
+ * @returns Success boolean
26
+ */
27
+ export declare function clearSSOSessionBeforeAddAccount(_user: OIDCUser | null, keycloakConfig: KeycloakConfig): Promise<boolean>;
28
+ /**
29
+ * Sign out a single account and optionally switch to another
30
+ * Uses silent token revocation (no redirect) to logout without interrupting the user.
31
+ * Removes the account from local storage after successful logout.
32
+ *
33
+ * Only redirects if this is the LAST account AND revocation fails.
34
+ *
35
+ * @param user - The OIDC user object containing id_token and refresh_token
36
+ * @param keycloakConfig - Keycloak configuration
37
+ * @param accountId - The account ID to remove
38
+ * @returns LogoutResult with success status and remaining account count
39
+ */
40
+ export declare function logoutAccount(user: OIDCUser, keycloakConfig: KeycloakConfig, accountId: string): Promise<LogoutResult>;
41
+ /**
42
+ * Sign out all accounts and clear all sessions
43
+ * This destroys the entire multi-account session
44
+ */
45
+ export declare function logoutAllAccounts(accounts: Array<{
46
+ accountId: string;
47
+ user: OIDCUser;
48
+ }>, keycloakConfig: KeycloakConfig): Promise<LogoutResult>;
49
+ /**
50
+ * Soft logout: Remove an account from local storage without calling Keycloak
51
+ * Use this when Keycloak endpoint is unavailable but you want to clear local session
52
+ */
53
+ export declare function logoutAccountLocally(accountId: string): LogoutResult;
54
+ /**
55
+ * Check if logout succeeded but we should still update the UI
56
+ * (e.g., Keycloak endpoint failed but we removed locally)
57
+ */
58
+ export declare function isLogoutPartiallySuccessful(result: LogoutResult): boolean;
@@ -0,0 +1,8 @@
1
+ import type { SyncBridgeClient } from './types';
2
+ interface SyncBridgeOptions {
3
+ bridgeUrl: string;
4
+ timeoutMs?: number;
5
+ }
6
+ export declare function createSyncBridgeClient(options: SyncBridgeOptions | null | undefined): SyncBridgeClient | null;
7
+ export declare function getBridgeStorageKey(): string;
8
+ export {};
@@ -0,0 +1,58 @@
1
+ /**
2
+ * JWT token validation and inspection utilities
3
+ * Handles checking token expiry, decoding payloads, and refresh token validation
4
+ */
5
+ import type { OIDCUser, StoredAccount } from './types';
6
+ /**
7
+ * Decode a JWT token without validation (client-side only)
8
+ * WARNING: Does not validate signature. For verification, rely on server.
9
+ */
10
+ export declare function decodeToken(token: string): Record<string, unknown> | null;
11
+ /**
12
+ * Check if an access token has expired with a buffer
13
+ * Returns true if token is expired OR expiring within buffer seconds
14
+ */
15
+ export declare function isTokenExpired(token: string | undefined, bufferSeconds?: number): boolean;
16
+ /**
17
+ * Check if a refresh token has expired with a buffer
18
+ * Similar to isTokenExpired but with longer buffer (5 minutes)
19
+ */
20
+ export declare function isRefreshTokenExpired(token: string | undefined, bufferSeconds?: number): boolean;
21
+ /**
22
+ * Get the expiry timestamp of a token (in milliseconds)
23
+ * Returns null if token is invalid or missing exp claim
24
+ */
25
+ export declare function getTokenExpiryTime(token: string | undefined): number | null;
26
+ /**
27
+ * Get the remaining lifetime of a token in seconds
28
+ * Returns 0 or negative if expired
29
+ */
30
+ export declare function getTokenTimeRemaining(token: string | undefined): number;
31
+ /**
32
+ * Extract user info from OIDC User object
33
+ * Returns email, name, and avatar for display
34
+ */
35
+ export declare function extractUserInfo(user: OIDCUser): {
36
+ email: string;
37
+ name: string;
38
+ avatar: string | undefined;
39
+ };
40
+ /**
41
+ * Check if a stored account's tokens are stale
42
+ * This checks both access token and refresh token expiry
43
+ */
44
+ export declare function isAccountStale(account: StoredAccount): boolean;
45
+ /**
46
+ * Check if we need to refresh the access token for a user
47
+ * This is different from checking if it's stale; we refresh proactively
48
+ */
49
+ export declare function shouldRefreshAccessToken(token: string | undefined): boolean;
50
+ /**
51
+ * Check if we need to refresh the refresh token
52
+ * Refresh tokens have longer buffer time
53
+ */
54
+ export declare function shouldRefreshRefreshToken(token: string | undefined): boolean;
55
+ /**
56
+ * Check if an access token is valid right now (ignoring buffer)
57
+ */
58
+ export declare function isAccessTokenValid(token: string | undefined): boolean;
package/dist/types.d.ts CHANGED
@@ -78,4 +78,60 @@ export interface TopBarProps {
78
78
  appsCacheTime?: number;
79
79
  profileRefetchInterval?: number;
80
80
  profileCacheTime?: number;
81
+ accountManager?: UseAccountManagerReturn;
82
+ }
83
+ export interface KeycloakConfig {
84
+ authority: string;
85
+ client_id: string;
86
+ redirect_uri?: string;
87
+ post_logout_redirect_uri?: string;
88
+ }
89
+ export interface OIDCUser {
90
+ access_token: string;
91
+ refresh_token?: string;
92
+ id_token?: string;
93
+ expires_in?: number;
94
+ profile?: {
95
+ sub: string;
96
+ email?: string;
97
+ name?: string;
98
+ given_name?: string;
99
+ family_name?: string;
100
+ picture?: string;
101
+ [key: string]: unknown;
102
+ };
103
+ [key: string]: unknown;
104
+ }
105
+ export interface StoredAccountMetadata {
106
+ accountId: string;
107
+ lastUsed: number;
108
+ isStale: boolean;
109
+ createdAt: number;
110
+ }
111
+ export interface StoredAccount extends StoredAccountMetadata {
112
+ user: OIDCUser;
113
+ }
114
+ export interface AccountsState {
115
+ accounts: StoredAccount[];
116
+ activeAccountId: string | null;
117
+ lastUpdated: number;
118
+ }
119
+ export interface SyncBridgeClient {
120
+ getAccountsState: () => Promise<AccountsState | null>;
121
+ setAccountsState: (state: AccountsState) => Promise<void>;
122
+ }
123
+ export interface UseAccountManagerOptions {
124
+ keycloakConfig: KeycloakConfig;
125
+ initialUser: OIDCUser | null | undefined;
126
+ onAccountSwitch: (user: OIDCUser) => void;
127
+ signinRedirect?: (args?: import('oidc-client-ts').SigninRedirectArgs) => Promise<void>;
128
+ syncBridge?: SyncBridgeClient | null;
129
+ }
130
+ export interface UseAccountManagerReturn {
131
+ accounts: StoredAccount[];
132
+ activeAccount: StoredAccount | null;
133
+ switchAccount: (accountId: string) => Promise<void>;
134
+ addNewAccount: () => Promise<void>;
135
+ removeAccount: (accountId: string) => Promise<void>;
136
+ refreshAccountToken: (accountId: string) => Promise<void>;
81
137
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * useAccountManager Hook
3
+ * Core integration point for multi-account switching with Keycloak
4
+ *
5
+ * Manages account storage, switching, and token refresh
6
+ * Handles redirect-based add account flow with state flags
7
+ *
8
+ * Redirect Flow (5 steps):
9
+ * 1. Save state before redirect (current user, flags, return URL)
10
+ * 2. Clear SSO session via fetch, then signinRedirect
11
+ * 3. Keycloak redirects back, primary auth provider (ReactKeycloak) processes callback
12
+ * 4. On mount, detect flag, wait for initialUser, add to accounts, call onAccountSwitch
13
+ * 5. Navigate to return URL
14
+ */
15
+ import type { UseAccountManagerOptions, UseAccountManagerReturn } from './types';
16
+ /**
17
+ * Hook for managing multi-account switching with redirect-based add account flow
18
+ *
19
+ * Usage:
20
+ * ```
21
+ * const accountManager = useAccountManager({
22
+ * keycloakConfig: { authority: '...', client_id: '...', redirect_uri: '...' },
23
+ * initialUser: keycloak.user, // From @react-keycloak/web
24
+ * onAccountSwitch: (user) => updateKeycloakTokens(user.access_token, user.refresh_token)
25
+ * })
26
+ * ```
27
+ */
28
+ export declare function useAccountManager(options: UseAccountManagerOptions): UseAccountManagerReturn;
@@ -0,0 +1,8 @@
1
+ export declare function useSpryAccountManager(): {
2
+ removeAccount: (accountId: string) => Promise<void>;
3
+ accounts: import("./types").StoredAccount[];
4
+ activeAccount: import("./types").StoredAccount | null;
5
+ switchAccount: (accountId: string) => Promise<void>;
6
+ addNewAccount: () => Promise<void>;
7
+ refreshAccountToken: (accountId: string) => Promise<void>;
8
+ };
@@ -0,0 +1 @@
1
+ export declare function useSpryAuth(): import("react-oidc-context").AuthContextProps;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Main logout handler: removes account from localStorage and redirects to soft-logout endpoint
3
+ * @param accountId - The account ID to remove
4
+ * @param keycloakConfig - Keycloak configuration
5
+ * @param redirectUrl - (Optional) URL to redirect back to after logout (defaults to window.location.origin)
6
+ */
7
+ export declare function handleMainLogout(accountId: string, keycloakConfig: KeycloakConfig, redirectUrl?: string): void;
8
+ /**
9
+ * UserManager pool for managing per-account Keycloak token refresh
10
+ * Each account has its own UserManager instance for isolated token refresh lifecycle
11
+ *
12
+ * Redirect Flow:
13
+ * - Uses signinRedirect for adding accounts (not popup)
14
+ * - State flags in localStorage track redirect progress
15
+ * - On callback, useAccountManager detects the flag and processes new account
16
+ */
17
+ import { UserManager } from 'oidc-client-ts';
18
+ import type { KeycloakConfig, OIDCUser } from './types';
19
+ /**
20
+ * Create a new UserManager instance for a specific Keycloak realm
21
+ */
22
+ export declare function createUserManager(keycloakConfig: KeycloakConfig, accountId: string): Promise<UserManager>;
23
+ /**
24
+ * Maintain a map of UserManager instances, one per account
25
+ * This allows each account to manage its own token refresh independently
26
+ */
27
+ declare class UserManagerPoolImpl {
28
+ private pool;
29
+ getOrCreate(keycloakConfig: KeycloakConfig, accountId: string): Promise<UserManager>;
30
+ remove(accountId: string): void;
31
+ clear(): void;
32
+ has(accountId: string): boolean;
33
+ }
34
+ export declare const userManagerPool: UserManagerPoolImpl;
35
+ /**
36
+ * Refresh the access token for a specific account using signinSilent
37
+ * Updates the stored account with new tokens on success
38
+ */
39
+ export declare function refreshTokenForAccount(accountId: string, user: OIDCUser, keycloakConfig: KeycloakConfig): Promise<OIDCUser | null>;
40
+ /**
41
+ * Handle token expiry by marking account as stale
42
+ * The next time user tries to switch to this account, they'll see the stale badge
43
+ * and can click to trigger signinPopup with prompt=login
44
+ */
45
+ export declare function handleTokenExpired(accountId: string): void;
46
+ /**
47
+ * Initiate sign-in redirect for adding a new account
48
+ *
49
+ * This is a fallback function used when signinRedirect is not available from context.
50
+ * The standard flow uses addNewAccount in useAccountManager which handles soft-logout + signinRedirect.
51
+ */
52
+ export declare function signinRedirectForNewAccount(keycloakConfig: KeycloakConfig, tempAccountId: string): Promise<void>;
53
+ /**
54
+ * Revoke a token for a specific account (silent, no redirect)
55
+ * Uses Keycloak's token revocation endpoint to invalidate the refresh token
56
+ *
57
+ * For multi-account scenarios, use this instead of signoutRedirect to avoid the logout page
58
+ */
59
+ export declare function revokeTokenForAccount(user: OIDCUser, keycloakConfig: KeycloakConfig): Promise<boolean>;
60
+ /**
61
+ * Sign out a specific account from Keycloak (in-browser redirect)
62
+ * This invalidates the Keycloak session for that account
63
+ *
64
+ * WARNING: This redirects to Keycloak logout page. Only use this when logging out the last account
65
+ * or when you want to redirect to login page. For multi-account logout, use revokeTokenForAccount instead.
66
+ */
67
+ export declare function signoutAccountFromKeycloak(user: OIDCUser, keycloakConfig: KeycloakConfig, tempAccountId: string): Promise<void>;
68
+ /**
69
+ * Validate if an account's tokens can be refreshed
70
+ * Returns true if the refresh token is still valid (not expired)
71
+ */
72
+ export declare function canRefreshAccount(user: OIDCUser): boolean;
73
+ /**
74
+ * Get a list of accounts that can be refreshed
75
+ * Useful for background refresh tasks
76
+ */
77
+ export declare function getRefreshableAccounts(accounts: Array<{
78
+ accountId: string;
79
+ user: OIDCUser;
80
+ }>): Array<{
81
+ accountId: string;
82
+ user: OIDCUser;
83
+ }>;
84
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spry-apps-dropdown",
3
- "version": "2.0.3",
3
+ "version": "3.0.0",
4
4
  "description": "React components for Spry apps dropdown and profile menu with dynamic API integration - Google-style UI",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -32,13 +32,15 @@
32
32
  "author": "",
33
33
  "license": "MIT",
34
34
  "dependencies": {
35
- "@mui/material": "^6.0.0",
36
- "@mui/icons-material": "^6.0.0",
37
- "framer-motion": "^11.0.0",
38
35
  "@emotion/react": "^11.11.0",
39
- "@emotion/styled": "^11.11.0"
36
+ "@emotion/styled": "^11.11.0",
37
+ "@mui/icons-material": "^6.0.0",
38
+ "@mui/material": "^6.0.0",
39
+ "framer-motion": "^11.0.0"
40
40
  },
41
41
  "peerDependencies": {
42
+ "oidc-client-ts": "^1.0.0",
43
+ "react-oidc-context": "^3.3.0",
42
44
  "react": "^18.0.0 || ^19.0.0",
43
45
  "react-dom": "^18.0.0 || ^19.0.0"
44
46
  },
@@ -46,6 +48,8 @@
46
48
  "@types/react": "^19.0.1",
47
49
  "@types/react-dom": "^19.0.1",
48
50
  "@vitejs/plugin-react": "^4.3.4",
51
+ "oidc-client-ts": "^3.4.1",
52
+ "react-oidc-context": "^3.3.0",
49
53
  "typescript": "^5.9.3",
50
54
  "vite": "^7.2.4"
51
55
  }