@acl-identity/react 0.1.0 → 0.1.2
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/README.md +126 -0
- package/package.json +19 -15
- package/.turbo/turbo-build.log +0 -82
- package/src/components/callback-handler.tsx +0 -53
- package/src/components/sign-in-button.tsx +0 -55
- package/src/components/silent-callback-page.tsx +0 -32
- package/src/context.ts +0 -30
- package/src/hooks/use-auth.ts +0 -13
- package/src/index.ts +0 -20
- package/src/provider.tsx +0 -221
- package/tsconfig.json +0 -10
- package/tsup.config.ts +0 -11
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# @acl-identity/react
|
|
2
|
+
|
|
3
|
+
React integration for the ACL Identity SDK. Provides `AuthProvider`, the `useAuth` hook, and ready-made components for sign-in, sign-out, and OAuth2 callback handling.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @acl-identity/react
|
|
9
|
+
# or
|
|
10
|
+
pnpm add @acl-identity/react
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires React 18 or later as a peer dependency.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
### 1. Create the client and wrap your app
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { ACLBrowserClient, AuthProvider } from '@acl-identity/react';
|
|
21
|
+
|
|
22
|
+
const client = new ACLBrowserClient({
|
|
23
|
+
baseUrl: 'https://auth.example.com',
|
|
24
|
+
clientId: 'my-client-id',
|
|
25
|
+
redirectUri: 'https://app.example.com/callback',
|
|
26
|
+
orgSlug: 'my-org',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function App() {
|
|
30
|
+
return (
|
|
31
|
+
<AuthProvider
|
|
32
|
+
client={client}
|
|
33
|
+
clientId="my-client-id"
|
|
34
|
+
redirectUri="https://app.example.com/callback"
|
|
35
|
+
orgSlug="my-org"
|
|
36
|
+
onRedirectCallback={(appState) => {
|
|
37
|
+
// navigate to appState?.returnTo after login
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
<YourApp />
|
|
41
|
+
</AuthProvider>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 2. Use the `useAuth` hook
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import { useAuth } from '@acl-identity/react';
|
|
50
|
+
|
|
51
|
+
function Header() {
|
|
52
|
+
const { user, isAuthenticated, isLoading, signIn, signOut } = useAuth();
|
|
53
|
+
|
|
54
|
+
if (isLoading) return <Spinner />;
|
|
55
|
+
|
|
56
|
+
if (!isAuthenticated) {
|
|
57
|
+
return <button onClick={() => signIn()}>Sign in</button>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div>
|
|
62
|
+
<span>Welcome, {user?.name}</span>
|
|
63
|
+
<button onClick={() => signOut()}>Sign out</button>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 3. Handle the OAuth2 callback
|
|
70
|
+
|
|
71
|
+
Add a callback page that renders `<CallbackHandler />`:
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
import { CallbackHandler } from '@acl-identity/react';
|
|
75
|
+
import { useNavigate } from 'react-router-dom';
|
|
76
|
+
|
|
77
|
+
export function CallbackPage() {
|
|
78
|
+
const navigate = useNavigate();
|
|
79
|
+
return (
|
|
80
|
+
<CallbackHandler
|
|
81
|
+
onSuccess={() => navigate('/')}
|
|
82
|
+
onError={(err) => console.error(err)}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Components
|
|
89
|
+
|
|
90
|
+
| Component | Description |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `AuthProvider` | Context provider — wraps your app |
|
|
93
|
+
| `CallbackHandler` | Processes the OAuth2 redirect callback |
|
|
94
|
+
| `SignInButton` | Pre-wired sign-in button |
|
|
95
|
+
| `SignOutButton` | Pre-wired sign-out button |
|
|
96
|
+
| `SilentCallbackPage` | Placed at `silentRedirectUri` for iframe-based silent renewal |
|
|
97
|
+
|
|
98
|
+
## Silent token renewal
|
|
99
|
+
|
|
100
|
+
Mount `<SilentCallbackPage />` at a dedicated route (e.g. `/silent-callback`) and pass its URL to `AuthProvider`:
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
<AuthProvider
|
|
104
|
+
...
|
|
105
|
+
silentRedirectUri="https://app.example.com/silent-callback"
|
|
106
|
+
>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Tokens are renewed automatically before they expire without a visible redirect.
|
|
110
|
+
|
|
111
|
+
## `useAuth` return value
|
|
112
|
+
|
|
113
|
+
| Field | Type | Description |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `user` | `UserClaims \| null` | Decoded JWT claims of the current user |
|
|
116
|
+
| `isAuthenticated` | `boolean` | Whether a valid token exists |
|
|
117
|
+
| `isLoading` | `boolean` | True while the initial session check is in progress |
|
|
118
|
+
| `error` | `Error \| null` | Last auth error |
|
|
119
|
+
| `signIn(options?)` | `Promise<void>` | Redirects to the authorization server |
|
|
120
|
+
| `signOut(options?)` | `Promise<void>` | Clears tokens and optionally revokes them |
|
|
121
|
+
| `getAccessToken()` | `Promise<string \| null>` | Returns the current access token |
|
|
122
|
+
| `handleCallback(url?)` | `Promise<{ appState? }>` | Manually processes a callback URL |
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,37 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acl-identity/react",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "React hooks and provider for ACL Identity — Phase 2",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"main": "./dist/index.
|
|
7
|
-
"module": "./dist/index.
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.mjs",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
|
-
"
|
|
12
|
-
"
|
|
11
|
+
"require": "./dist/index.js",
|
|
12
|
+
"import": "./dist/index.mjs",
|
|
13
13
|
"types": "./dist/index.d.ts"
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
|
+
"files": ["dist"],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsup",
|
|
22
|
+
"dev": "tsup --watch",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"clean": "rm -rf dist"
|
|
25
|
+
},
|
|
16
26
|
"peerDependencies": {
|
|
17
27
|
"react": ">=18.0.0",
|
|
18
28
|
"react-dom": ">=18.0.0"
|
|
19
29
|
},
|
|
20
30
|
"dependencies": {
|
|
21
|
-
"@acl-identity/types": "
|
|
22
|
-
"@acl-identity/core": "
|
|
23
|
-
"@acl-identity/browser": "
|
|
31
|
+
"@acl-identity/types": "workspace:*",
|
|
32
|
+
"@acl-identity/core": "workspace:*",
|
|
33
|
+
"@acl-identity/browser": "workspace:*"
|
|
24
34
|
},
|
|
25
35
|
"devDependencies": {
|
|
26
36
|
"@types/react": "^18.3.3",
|
|
27
37
|
"@types/react-dom": "^18.3.0",
|
|
28
38
|
"tsup": "^8.2.4",
|
|
29
39
|
"typescript": "^5.5.4"
|
|
30
|
-
},
|
|
31
|
-
"scripts": {
|
|
32
|
-
"build": "tsup",
|
|
33
|
-
"dev": "tsup --watch",
|
|
34
|
-
"typecheck": "tsc --noEmit",
|
|
35
|
-
"clean": "rm -rf dist"
|
|
36
40
|
}
|
|
37
|
-
}
|
|
41
|
+
}
|
package/.turbo/turbo-build.log
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
> @acl-identity/react@0.1.0 build /Users/lanreyusuf/Developer/personal/acl-identity-sdk/packages/react
|
|
3
|
-
> tsup
|
|
4
|
-
|
|
5
|
-
[33m▲ [43;33m[[43;30mWARNING[43;33m][0m [1mThe condition "types" here will never be used as it comes after both "import" and "require"[0m [package.json]
|
|
6
|
-
|
|
7
|
-
package.json:13:6:
|
|
8
|
-
[37m 13 │ [32m"types"[37m: "./dist/index.d.ts"
|
|
9
|
-
╵ [32m~~~~~~~[0m
|
|
10
|
-
|
|
11
|
-
The "import" condition comes earlier and will be used for all "import" statements:
|
|
12
|
-
|
|
13
|
-
package.json:11:6:
|
|
14
|
-
[37m 11 │ [32m"import"[37m: "./dist/index.js",
|
|
15
|
-
╵ [32m~~~~~~~~[0m
|
|
16
|
-
|
|
17
|
-
The "require" condition comes earlier and will be used for all "require" calls:
|
|
18
|
-
|
|
19
|
-
package.json:12:6:
|
|
20
|
-
[37m 12 │ [32m"require"[37m: "./dist/index.cjs",
|
|
21
|
-
╵ [32m~~~~~~~~~[0m
|
|
22
|
-
|
|
23
|
-
[34mCLI[39m Building entry: {"index":"src/index.ts"}
|
|
24
|
-
[34mCLI[39m Using tsconfig: tsconfig.json
|
|
25
|
-
[34mCLI[39m tsup v8.5.1
|
|
26
|
-
[34mCLI[39m Using tsup config: /Users/lanreyusuf/Developer/personal/acl-identity-sdk/packages/react/tsup.config.ts
|
|
27
|
-
[34mCLI[39m Target: es2020
|
|
28
|
-
[34mCLI[39m Cleaning output folder
|
|
29
|
-
[34mESM[39m Build start
|
|
30
|
-
[34mCJS[39m Build start
|
|
31
|
-
|
|
32
|
-
[43m[30m WARN [39m[49m [33m▲ [43;33m[[43;30mWARNING[43;33m][0m [1mThe condition "types" here will never be used as it comes after both "import" and "require"[0m [package.json] [90m7:52:29 PM[39m
|
|
33
|
-
|
|
34
|
-
package.json:13:6:
|
|
35
|
-
[37m 13 │ [32m"types"[37m: "./dist/index.d.ts"
|
|
36
|
-
╵ [32m~~~~~~~[0m
|
|
37
|
-
|
|
38
|
-
The "import" condition comes earlier and will be used for all "import" statements:
|
|
39
|
-
|
|
40
|
-
package.json:11:6:
|
|
41
|
-
[37m 11 │ [32m"import"[37m: "./dist/index.js",
|
|
42
|
-
╵ [32m~~~~~~~~[0m
|
|
43
|
-
|
|
44
|
-
The "require" condition comes earlier and will be used for all "require" calls:
|
|
45
|
-
|
|
46
|
-
package.json:12:6:
|
|
47
|
-
[37m 12 │ [32m"require"[37m: "./dist/index.cjs",
|
|
48
|
-
╵ [32m~~~~~~~~~[0m
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
[43m[30m WARN [39m[49m [33m▲ [43;33m[[43;30mWARNING[43;33m][0m [1mThe condition "types" here will never be used as it comes after both "import" and "require"[0m [package.json] [90m7:52:29 PM[39m
|
|
54
|
-
|
|
55
|
-
package.json:13:6:
|
|
56
|
-
[37m 13 │ [32m"types"[37m: "./dist/index.d.ts"
|
|
57
|
-
╵ [32m~~~~~~~[0m
|
|
58
|
-
|
|
59
|
-
The "import" condition comes earlier and will be used for all "import" statements:
|
|
60
|
-
|
|
61
|
-
package.json:11:6:
|
|
62
|
-
[37m 11 │ [32m"import"[37m: "./dist/index.js",
|
|
63
|
-
╵ [32m~~~~~~~~[0m
|
|
64
|
-
|
|
65
|
-
The "require" condition comes earlier and will be used for all "require" calls:
|
|
66
|
-
|
|
67
|
-
package.json:12:6:
|
|
68
|
-
[37m 12 │ [32m"require"[37m: "./dist/index.cjs",
|
|
69
|
-
╵ [32m~~~~~~~~~[0m
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
[32mESM[39m [1mdist/index.mjs [22m[32m7.33 KB[39m
|
|
74
|
-
[32mESM[39m [1mdist/index.mjs.map [22m[32m18.09 KB[39m
|
|
75
|
-
[32mESM[39m ⚡️ Build success in 78ms
|
|
76
|
-
[32mCJS[39m [1mdist/index.js [22m[32m8.65 KB[39m
|
|
77
|
-
[32mCJS[39m [1mdist/index.js.map [22m[32m17.71 KB[39m
|
|
78
|
-
[32mCJS[39m ⚡️ Build success in 85ms
|
|
79
|
-
DTS Build start
|
|
80
|
-
DTS ⚡️ Build success in 440ms
|
|
81
|
-
DTS dist/index.d.mts 4.58 KB
|
|
82
|
-
DTS dist/index.d.ts 4.58 KB
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef } from 'react';
|
|
2
|
-
import { useAuth } from '../hooks/use-auth';
|
|
3
|
-
|
|
4
|
-
export interface CallbackHandlerProps {
|
|
5
|
-
/** Called after tokens are stored and auth state is updated. Use this to navigate away. */
|
|
6
|
-
onSuccess?: (appState?: unknown) => void;
|
|
7
|
-
/** Called if the code exchange fails. */
|
|
8
|
-
onError?: (error: Error) => void;
|
|
9
|
-
/** Rendered while the code exchange is in progress. */
|
|
10
|
-
loading?: React.ReactNode;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Place this component on the route that matches your `redirectUri`.
|
|
15
|
-
* It reads the `?code=` and `?state=` from the URL, exchanges them for
|
|
16
|
-
* tokens, then calls `onSuccess`. The URL is cleaned up automatically.
|
|
17
|
-
*
|
|
18
|
-
* @example
|
|
19
|
-
* // app/callback/page.tsx
|
|
20
|
-
* 'use client';
|
|
21
|
-
* import { CallbackHandler } from '@acl-identity/react';
|
|
22
|
-
* import { useRouter } from 'next/navigation';
|
|
23
|
-
*
|
|
24
|
-
* export default function CallbackPage() {
|
|
25
|
-
* const router = useRouter();
|
|
26
|
-
* return (
|
|
27
|
-
* <CallbackHandler
|
|
28
|
-
* onSuccess={(appState) => router.replace(appState?.returnTo ?? '/dashboard')}
|
|
29
|
-
* onError={() => router.replace('/login?error=callback_failed')}
|
|
30
|
-
* />
|
|
31
|
-
* );
|
|
32
|
-
* }
|
|
33
|
-
*/
|
|
34
|
-
export function CallbackHandler({ onSuccess, onError, loading = null }: CallbackHandlerProps) {
|
|
35
|
-
const { handleCallback } = useAuth();
|
|
36
|
-
const called = useRef(false);
|
|
37
|
-
|
|
38
|
-
useEffect(() => {
|
|
39
|
-
if (called.current) return;
|
|
40
|
-
called.current = true;
|
|
41
|
-
|
|
42
|
-
handleCallback()
|
|
43
|
-
.then(({ appState }) => {
|
|
44
|
-
window.history.replaceState({}, document.title, window.location.pathname);
|
|
45
|
-
onSuccess?.(appState);
|
|
46
|
-
})
|
|
47
|
-
.catch((err: Error) => {
|
|
48
|
-
onError?.(err);
|
|
49
|
-
});
|
|
50
|
-
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
51
|
-
|
|
52
|
-
return <>{loading}</>;
|
|
53
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { type ButtonHTMLAttributes } from 'react';
|
|
2
|
-
import { useAuth } from '../hooks/use-auth';
|
|
3
|
-
import type { SignOutOptions } from '../context';
|
|
4
|
-
|
|
5
|
-
export interface SignInButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
6
|
-
appState?: unknown;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function SignInButton({ children = 'Sign in', appState, onClick, ...props }: SignInButtonProps) {
|
|
10
|
-
const { signIn } = useAuth();
|
|
11
|
-
|
|
12
|
-
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
13
|
-
onClick?.(e);
|
|
14
|
-
if (!e.defaultPrevented) {
|
|
15
|
-
signIn({ appState });
|
|
16
|
-
}
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
return (
|
|
20
|
-
<button type="button" onClick={handleClick} {...props}>
|
|
21
|
-
{children}
|
|
22
|
-
</button>
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface SignOutButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
27
|
-
returnTo?: string;
|
|
28
|
-
revokeToken?: boolean;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function SignOutButton({
|
|
32
|
-
children = 'Sign out',
|
|
33
|
-
returnTo,
|
|
34
|
-
revokeToken,
|
|
35
|
-
onClick,
|
|
36
|
-
...props
|
|
37
|
-
}: SignOutButtonProps) {
|
|
38
|
-
const { signOut } = useAuth();
|
|
39
|
-
|
|
40
|
-
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
41
|
-
onClick?.(e);
|
|
42
|
-
if (!e.defaultPrevented) {
|
|
43
|
-
const options: SignOutOptions = {};
|
|
44
|
-
if (returnTo) options.returnTo = returnTo;
|
|
45
|
-
if (revokeToken !== undefined) options.revokeToken = revokeToken;
|
|
46
|
-
signOut(options);
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
return (
|
|
51
|
-
<button type="button" onClick={handleClick} {...props}>
|
|
52
|
-
{children}
|
|
53
|
-
</button>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { useEffect } from 'react';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Render this component (and nothing else) at the route matching your
|
|
5
|
-
* `silentRedirectUri`. It posts the current URL back to the parent window
|
|
6
|
-
* so the hidden iframe used by silent renew can complete the token exchange.
|
|
7
|
-
*
|
|
8
|
-
* The route must be registered as an allowed redirect URI on your OAuth client.
|
|
9
|
-
*
|
|
10
|
-
* @example
|
|
11
|
-
* // app/silent-callback/page.tsx (Next.js App Router)
|
|
12
|
-
* 'use client';
|
|
13
|
-
* import { SilentCallbackPage } from '@acl-identity/react';
|
|
14
|
-
* export default SilentCallbackPage;
|
|
15
|
-
*
|
|
16
|
-
* @example
|
|
17
|
-
* // pages/silent-callback.tsx (Next.js Pages Router)
|
|
18
|
-
* import { SilentCallbackPage } from '@acl-identity/react';
|
|
19
|
-
* export default SilentCallbackPage;
|
|
20
|
-
*/
|
|
21
|
-
export function SilentCallbackPage() {
|
|
22
|
-
useEffect(() => {
|
|
23
|
-
if (window.parent !== window) {
|
|
24
|
-
window.parent.postMessage(
|
|
25
|
-
{ type: 'acl:silent_callback', url: window.location.href },
|
|
26
|
-
window.location.origin
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
}, []);
|
|
30
|
-
|
|
31
|
-
return null;
|
|
32
|
-
}
|
package/src/context.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { createContext } from 'react';
|
|
2
|
-
import type { ACLBrowserClient } from '@acl-identity/browser';
|
|
3
|
-
import type { UserClaims } from '@acl-identity/types';
|
|
4
|
-
|
|
5
|
-
export interface SignInOptions {
|
|
6
|
-
appState?: unknown;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface SignOutOptions {
|
|
10
|
-
returnTo?: string;
|
|
11
|
-
revokeToken?: boolean;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface AuthContextValue {
|
|
15
|
-
user: UserClaims | null;
|
|
16
|
-
isAuthenticated: boolean;
|
|
17
|
-
isLoading: boolean;
|
|
18
|
-
error: Error | null;
|
|
19
|
-
client: ACLBrowserClient;
|
|
20
|
-
clientId: string;
|
|
21
|
-
redirectUri: string;
|
|
22
|
-
orgSlug?: string;
|
|
23
|
-
scopes?: string;
|
|
24
|
-
signIn(options?: SignInOptions): Promise<void>;
|
|
25
|
-
signOut(options?: SignOutOptions): Promise<void>;
|
|
26
|
-
getAccessToken(): Promise<string | null>;
|
|
27
|
-
handleCallback(callbackUrl?: string): Promise<{ appState?: unknown }>;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export const AuthContext = createContext<AuthContextValue | null>(null);
|
package/src/hooks/use-auth.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { useContext } from 'react';
|
|
2
|
-
import { AuthContext, type AuthContextValue } from '../context';
|
|
3
|
-
|
|
4
|
-
export function useAuth(): AuthContextValue {
|
|
5
|
-
const ctx = useContext(AuthContext);
|
|
6
|
-
if (!ctx) {
|
|
7
|
-
throw new Error(
|
|
8
|
-
'useAuth must be used within an <AuthProvider>. ' +
|
|
9
|
-
'Wrap your app (or the relevant subtree) with <AuthProvider client={...} clientId={...} redirectUri={...} />.'
|
|
10
|
-
);
|
|
11
|
-
}
|
|
12
|
-
return ctx;
|
|
13
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
export { AuthProvider } from './provider';
|
|
2
|
-
export type { AuthProviderProps } from './provider';
|
|
3
|
-
|
|
4
|
-
export { useAuth } from './hooks/use-auth';
|
|
5
|
-
|
|
6
|
-
export { SignInButton, SignOutButton } from './components/sign-in-button';
|
|
7
|
-
export type { SignInButtonProps, SignOutButtonProps } from './components/sign-in-button';
|
|
8
|
-
|
|
9
|
-
export { CallbackHandler } from './components/callback-handler';
|
|
10
|
-
export type { CallbackHandlerProps } from './components/callback-handler';
|
|
11
|
-
|
|
12
|
-
export { SilentCallbackPage } from './components/silent-callback-page';
|
|
13
|
-
|
|
14
|
-
export type { AuthContextValue, SignInOptions, SignOutOptions } from './context';
|
|
15
|
-
|
|
16
|
-
// Re-export the browser client so consumers need only one install
|
|
17
|
-
export { ACLBrowserClient } from '@acl-identity/browser';
|
|
18
|
-
export type { ACLBrowserClientConfig } from '@acl-identity/browser';
|
|
19
|
-
// Re-export useful types
|
|
20
|
-
export type { UserClaims, OrgContext } from '@acl-identity/types';
|
package/src/provider.tsx
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
useCallback,
|
|
3
|
-
useEffect,
|
|
4
|
-
useRef,
|
|
5
|
-
useState,
|
|
6
|
-
type ReactNode,
|
|
7
|
-
} from 'react';
|
|
8
|
-
import type { ACLBrowserClient } from '@acl-identity/browser';
|
|
9
|
-
import { tokenExpiresAt } from '@acl-identity/core';
|
|
10
|
-
import type { UserClaims } from '@acl-identity/types';
|
|
11
|
-
import { AuthContext, type SignInOptions, type SignOutOptions } from './context';
|
|
12
|
-
|
|
13
|
-
export interface AuthProviderProps {
|
|
14
|
-
client: ACLBrowserClient;
|
|
15
|
-
/** OAuth2 client_id — passed to buildAuthorizationUrl and silentRenew */
|
|
16
|
-
clientId: string;
|
|
17
|
-
/** Redirect URI registered with the auth server for the main auth flow */
|
|
18
|
-
redirectUri: string;
|
|
19
|
-
/** Organization slug for multi-tenant routing */
|
|
20
|
-
orgSlug?: string;
|
|
21
|
-
/** Space-separated OAuth2 scopes. Defaults to 'openid profile email' */
|
|
22
|
-
scopes?: string;
|
|
23
|
-
/**
|
|
24
|
-
* Redirect URI for silent token renewal via hidden iframe.
|
|
25
|
-
* Must be registered with the auth server. The page at this URI
|
|
26
|
-
* must render <SilentCallbackPage />.
|
|
27
|
-
*/
|
|
28
|
-
silentRedirectUri?: string;
|
|
29
|
-
/**
|
|
30
|
-
* Called after the OAuth2 redirect callback is processed.
|
|
31
|
-
* Use this to navigate the user to their intended destination.
|
|
32
|
-
*/
|
|
33
|
-
onRedirectCallback?: (appState?: unknown) => void;
|
|
34
|
-
children: ReactNode;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface AuthState {
|
|
38
|
-
user: UserClaims | null;
|
|
39
|
-
isAuthenticated: boolean;
|
|
40
|
-
isLoading: boolean;
|
|
41
|
-
error: Error | null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const PKCE_KEY_PREFIX = 'acl:pkce:';
|
|
45
|
-
|
|
46
|
-
export function AuthProvider({
|
|
47
|
-
client,
|
|
48
|
-
clientId,
|
|
49
|
-
redirectUri,
|
|
50
|
-
orgSlug,
|
|
51
|
-
scopes,
|
|
52
|
-
silentRedirectUri,
|
|
53
|
-
onRedirectCallback,
|
|
54
|
-
children,
|
|
55
|
-
}: AuthProviderProps) {
|
|
56
|
-
const [state, setState] = useState<AuthState>({
|
|
57
|
-
user: null,
|
|
58
|
-
isAuthenticated: false,
|
|
59
|
-
isLoading: true,
|
|
60
|
-
error: null,
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const initialized = useRef(false);
|
|
64
|
-
|
|
65
|
-
const syncAuthState = useCallback(async () => {
|
|
66
|
-
const [user, isAuthenticated] = await Promise.all([
|
|
67
|
-
client.auth.parseCurrentUser(),
|
|
68
|
-
client.auth.isAuthenticated(),
|
|
69
|
-
]);
|
|
70
|
-
setState({ user, isAuthenticated, isLoading: false, error: null });
|
|
71
|
-
}, [client]);
|
|
72
|
-
|
|
73
|
-
const handleSilentRenew = useCallback(async () => {
|
|
74
|
-
if (!silentRedirectUri) {
|
|
75
|
-
setState((s) => ({ ...s, isLoading: false }));
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
try {
|
|
79
|
-
await client.auth.silentRenew(silentRedirectUri, clientId, { orgSlug, scope: scopes });
|
|
80
|
-
await syncAuthState();
|
|
81
|
-
} catch {
|
|
82
|
-
setState({ user: null, isAuthenticated: false, isLoading: false, error: null });
|
|
83
|
-
}
|
|
84
|
-
}, [client, silentRedirectUri, clientId, orgSlug, scopes, syncAuthState]);
|
|
85
|
-
|
|
86
|
-
// Initial session check — runs once on mount
|
|
87
|
-
useEffect(() => {
|
|
88
|
-
if (initialized.current) return;
|
|
89
|
-
initialized.current = true;
|
|
90
|
-
|
|
91
|
-
const url = new URL(window.location.href);
|
|
92
|
-
const isCallbackUrl = url.searchParams.has('code') && url.searchParams.has('state');
|
|
93
|
-
|
|
94
|
-
if (isCallbackUrl) {
|
|
95
|
-
// Stay loading while CallbackHandler processes the code exchange
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
client.auth.isAuthenticated().then((isAuth) => {
|
|
100
|
-
if (isAuth) {
|
|
101
|
-
syncAuthState();
|
|
102
|
-
} else {
|
|
103
|
-
handleSilentRenew();
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
107
|
-
|
|
108
|
-
// Schedule proactive silent renew before the current token expires
|
|
109
|
-
useEffect(() => {
|
|
110
|
-
if (!state.user || !silentRedirectUri) return;
|
|
111
|
-
|
|
112
|
-
let timer: ReturnType<typeof setTimeout>;
|
|
113
|
-
|
|
114
|
-
client.auth.getAccessToken().then((token) => {
|
|
115
|
-
if (!token) return;
|
|
116
|
-
const expiresAtMs = tokenExpiresAt(token);
|
|
117
|
-
if (!expiresAtMs) return;
|
|
118
|
-
|
|
119
|
-
const msUntilRenew = expiresAtMs - Date.now() - 30_000;
|
|
120
|
-
if (msUntilRenew <= 0) {
|
|
121
|
-
handleSilentRenew();
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
timer = setTimeout(handleSilentRenew, msUntilRenew);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
return () => clearTimeout(timer);
|
|
128
|
-
}, [state.user, silentRedirectUri, handleSilentRenew, client]);
|
|
129
|
-
|
|
130
|
-
const signIn = useCallback(
|
|
131
|
-
async (options?: SignInOptions) => {
|
|
132
|
-
const { url, state: authState, codeVerifier } = await client.auth.buildAuthorizationUrl({
|
|
133
|
-
clientId,
|
|
134
|
-
redirectUri,
|
|
135
|
-
orgSlug,
|
|
136
|
-
scope: scopes,
|
|
137
|
-
});
|
|
138
|
-
sessionStorage.setItem(
|
|
139
|
-
`${PKCE_KEY_PREFIX}${authState}`,
|
|
140
|
-
JSON.stringify({ codeVerifier, appState: options?.appState ?? null })
|
|
141
|
-
);
|
|
142
|
-
window.location.href = url;
|
|
143
|
-
},
|
|
144
|
-
[client, clientId, redirectUri, orgSlug, scopes]
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
const signOut = useCallback(
|
|
148
|
-
async (options?: SignOutOptions) => {
|
|
149
|
-
await client.auth.logout({ revokeToken: options?.revokeToken });
|
|
150
|
-
setState({ user: null, isAuthenticated: false, isLoading: false, error: null });
|
|
151
|
-
if (options?.returnTo) {
|
|
152
|
-
window.location.href = options.returnTo;
|
|
153
|
-
}
|
|
154
|
-
},
|
|
155
|
-
[client]
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
const getAccessToken = useCallback(() => client.auth.getAccessToken(), [client]);
|
|
159
|
-
|
|
160
|
-
const handleCallback = useCallback(
|
|
161
|
-
async (callbackUrl?: string): Promise<{ appState?: unknown }> => {
|
|
162
|
-
const url = new URL(callbackUrl ?? window.location.href);
|
|
163
|
-
const error = url.searchParams.get('error');
|
|
164
|
-
|
|
165
|
-
if (error) {
|
|
166
|
-
const desc = url.searchParams.get('error_description') ?? error;
|
|
167
|
-
const err = new Error(desc);
|
|
168
|
-
setState((s) => ({ ...s, error: err, isLoading: false }));
|
|
169
|
-
throw err;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const code = url.searchParams.get('code');
|
|
173
|
-
const authState = url.searchParams.get('state');
|
|
174
|
-
|
|
175
|
-
if (!code || !authState) {
|
|
176
|
-
const err = new Error('Missing code or state in callback URL');
|
|
177
|
-
setState((s) => ({ ...s, error: err, isLoading: false }));
|
|
178
|
-
throw err;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const stored = sessionStorage.getItem(`${PKCE_KEY_PREFIX}${authState}`);
|
|
182
|
-
if (!stored) {
|
|
183
|
-
const err = new Error('Auth session expired or state mismatch. Please sign in again.');
|
|
184
|
-
setState((s) => ({ ...s, error: err, isLoading: false }));
|
|
185
|
-
throw err;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const { codeVerifier, appState } = JSON.parse(stored) as {
|
|
189
|
-
codeVerifier: string;
|
|
190
|
-
appState?: unknown;
|
|
191
|
-
};
|
|
192
|
-
sessionStorage.removeItem(`${PKCE_KEY_PREFIX}${authState}`);
|
|
193
|
-
|
|
194
|
-
await client.auth.exchangeCode(code, codeVerifier, redirectUri);
|
|
195
|
-
await syncAuthState();
|
|
196
|
-
|
|
197
|
-
onRedirectCallback?.(appState);
|
|
198
|
-
return { appState };
|
|
199
|
-
},
|
|
200
|
-
[client, redirectUri, syncAuthState, onRedirectCallback]
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
return (
|
|
204
|
-
<AuthContext.Provider
|
|
205
|
-
value={{
|
|
206
|
-
...state,
|
|
207
|
-
client,
|
|
208
|
-
clientId,
|
|
209
|
-
redirectUri,
|
|
210
|
-
orgSlug,
|
|
211
|
-
scopes,
|
|
212
|
-
signIn,
|
|
213
|
-
signOut,
|
|
214
|
-
getAccessToken,
|
|
215
|
-
handleCallback,
|
|
216
|
-
}}
|
|
217
|
-
>
|
|
218
|
-
{children}
|
|
219
|
-
</AuthContext.Provider>
|
|
220
|
-
);
|
|
221
|
-
}
|
package/tsconfig.json
DELETED
package/tsup.config.ts
DELETED