@payload-auth-link/react 0.0.0-stage → 0.1.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.
Files changed (38) hide show
  1. package/dist/__tests__/components.test.d.ts +2 -0
  2. package/dist/__tests__/components.test.d.ts.map +1 -0
  3. package/dist/__tests__/components.test.js +163 -0
  4. package/dist/__tests__/components.test.js.map +1 -0
  5. package/dist/__tests__/index.test.d.ts +2 -0
  6. package/dist/__tests__/index.test.d.ts.map +1 -0
  7. package/dist/__tests__/index.test.js +10 -0
  8. package/dist/__tests__/index.test.js.map +1 -0
  9. package/dist/__tests__/provider.test.d.ts +2 -0
  10. package/dist/__tests__/provider.test.d.ts.map +1 -0
  11. package/dist/__tests__/provider.test.js +177 -0
  12. package/dist/__tests__/provider.test.js.map +1 -0
  13. package/dist/__tests__/session.test.d.ts +2 -0
  14. package/dist/__tests__/session.test.d.ts.map +1 -0
  15. package/dist/__tests__/session.test.js +70 -0
  16. package/dist/__tests__/session.test.js.map +1 -0
  17. package/dist/components.d.ts +54 -0
  18. package/dist/components.d.ts.map +1 -0
  19. package/dist/components.js +49 -0
  20. package/dist/components.js.map +1 -0
  21. package/dist/index.d.ts +12 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +10 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/provider.d.ts +46 -0
  26. package/dist/provider.d.ts.map +1 -0
  27. package/dist/provider.js +94 -0
  28. package/dist/provider.js.map +1 -0
  29. package/dist/session.d.ts +17 -0
  30. package/dist/session.d.ts.map +1 -0
  31. package/dist/session.js +46 -0
  32. package/dist/session.js.map +1 -0
  33. package/package.json +41 -4
  34. package/src/components.tsx +127 -0
  35. package/src/index.ts +17 -0
  36. package/src/provider.tsx +161 -0
  37. package/src/session.ts +57 -0
  38. package/README.md +0 -3
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,WAAW,6BAA6B,CAAA;AAErD,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AACrF,YAAY,EACV,iBAAiB,EACjB,iBAAiB,EACjB,kBAAkB,EAClB,aAAa,GACd,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAC1E,YAAY,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAChG,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Provider-agnostic React UI: session context, sign-in components, user
3
+ * menu, role gates. No Next, no Payload imports — framework work lives in
4
+ * `@payload-auth-link/next`, server work in `@payload-auth-link/plugin`.
5
+ */
6
+ export const packageName = '@payload-auth-link/react';
7
+ export { RequireRoles, SignInButton, SignOutButton, UserMenu } from './components.js';
8
+ export { AuthLinkProvider, useAuthLink, useHasRoles } from './provider.js';
9
+ export { fetchSessionUser, revokeSession } from './session.js';
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,0BAA0B,CAAA;AAErD,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAOrF,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,eAAe,CAAA;AAE1E,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA"}
@@ -0,0 +1,46 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type HttpFetch, type SessionUser } from '@payload-auth-link/core';
3
+ /** Session state: unknown until the first load settles. */
4
+ export type AuthLinkStatus = 'loading' | 'authenticated' | 'anonymous';
5
+ /** What `useAuthLink` returns: user, status, refresh, honest sign-out. */
6
+ export type AuthLinkContextValue = {
7
+ readonly user: SessionUser | null;
8
+ readonly status: AuthLinkStatus;
9
+ /** Re-read the session endpoint (call after sign-in or role changes). */
10
+ readonly refresh: () => Promise<void>;
11
+ /**
12
+ * Revoke the session and drop local state. Resolves `false` when the
13
+ * revocation failed — local state is kept so the UI stays honest.
14
+ */
15
+ readonly signOut: () => Promise<boolean>;
16
+ };
17
+ /** Client provider wiring; `initialUser` is the hydration contract. */
18
+ export type AuthLinkProviderProps = {
19
+ readonly children: ReactNode;
20
+ /** Session endpoint path. Defaults to the plugin's endpoint. */
21
+ readonly sessionEndpoint?: string | undefined;
22
+ /** Injectible fetch. Defaults to the global `fetch`. */
23
+ readonly fetchImpl?: HttpFetch | undefined;
24
+ /**
25
+ * Hydration contract: `undefined` (default) fetches on mount;
26
+ * `SessionUser` starts authenticated without a fetch; `null` starts
27
+ * anonymous without a fetch. The Next adapter's server provider
28
+ * supplies this from full server-side validation.
29
+ */
30
+ readonly initialUser?: SessionUser | null | undefined;
31
+ };
32
+ /**
33
+ * Client session provider: loads once on mount (unless hydrated),
34
+ * exposes refresh/sign-out. Fail-closed — fetch errors mean anonymous.
35
+ *
36
+ * A later `initialUser` (a re-run server loader, a new RSC payload) is
37
+ * synced into context when its value changes; re-renders passing the
38
+ * same user as a fresh object change nothing, so a client-side sign-out
39
+ * is never undone by a stale snapshot. Refresh stays explicit.
40
+ */
41
+ export declare function AuthLinkProvider({ children, sessionEndpoint, fetchImpl, initialUser, }: AuthLinkProviderProps): ReactNode;
42
+ /** Read the auth context. Throws outside `AuthLinkProvider` — fail loud. */
43
+ export declare function useAuthLink(): AuthLinkContextValue;
44
+ /** `true` when the current user holds every listed role. Fails closed. */
45
+ export declare function useHasRoles(...roles: readonly string[]): boolean;
46
+ //# sourceMappingURL=provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAEA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAA;AACd,OAAO,EAEL,KAAK,SAAS,EACd,KAAK,WAAW,EACjB,MAAM,yBAAyB,CAAA;AAGhC,2DAA2D;AAC3D,MAAM,MAAM,cAAc,GAAG,SAAS,GAAG,eAAe,GAAG,WAAW,CAAA;AAEtE,0EAA0E;AAC1E,MAAM,MAAM,oBAAoB,GAAG;IACjC,QAAQ,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CAAA;IACjC,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAA;IAC/B,yEAAyE;IACzE,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IACrC;;;OAGG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;CACzC,CAAA;AAID,uEAAuE;AACvE,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAA;IAC5B,gEAAgE;IAChE,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC7C,wDAAwD;IACxD,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;IAC1C;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,CAAA;CACtD,CAAA;AAiBD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,QAAQ,EACR,eAA4C,EAC5C,SAAiB,EACjB,WAAuB,GACxB,EAAE,qBAAqB,GAAG,SAAS,CA6DnC;AAED,4EAA4E;AAC5E,wBAAgB,WAAW,IAAI,oBAAoB,CAMlD;AAED,0EAA0E;AAC1E,wBAAgB,WAAW,CAAC,GAAG,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAMhE"}
@@ -0,0 +1,94 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
4
+ import { defaultSessionEndpointPath, } from '@payload-auth-link/core';
5
+ import { fetchSessionUser, revokeSession } from './session.js';
6
+ const AuthLinkContext = createContext(null);
7
+ /** Value identity for hydration snapshots (fresh objects, same user). */
8
+ function snapshotKey(user) {
9
+ if (user === undefined) {
10
+ return 'undefined';
11
+ }
12
+ if (user === null) {
13
+ return 'null';
14
+ }
15
+ return JSON.stringify([user.id, user.collection, user.email, [...user.roles].sort()]);
16
+ }
17
+ function statusFor(user) {
18
+ return user === null ? 'anonymous' : 'authenticated';
19
+ }
20
+ /**
21
+ * Client session provider: loads once on mount (unless hydrated),
22
+ * exposes refresh/sign-out. Fail-closed — fetch errors mean anonymous.
23
+ *
24
+ * A later `initialUser` (a re-run server loader, a new RSC payload) is
25
+ * synced into context when its value changes; re-renders passing the
26
+ * same user as a fresh object change nothing, so a client-side sign-out
27
+ * is never undone by a stale snapshot. Refresh stays explicit.
28
+ */
29
+ export function AuthLinkProvider({ children, sessionEndpoint = defaultSessionEndpointPath, fetchImpl = fetch, initialUser = undefined, }) {
30
+ const [user, setUser] = useState(initialUser ?? null);
31
+ const [status, setStatus] = useState(initialUser === undefined ? 'loading' : statusFor(initialUser));
32
+ // React's "adjust state when a prop changes" pattern (during render,
33
+ // not in an effect): no stale frame, no set-state-in-effect.
34
+ const [syncedKey, setSyncedKey] = useState(() => snapshotKey(initialUser));
35
+ const incomingKey = snapshotKey(initialUser);
36
+ if (incomingKey !== syncedKey) {
37
+ setSyncedKey(incomingKey);
38
+ if (initialUser !== undefined) {
39
+ setUser(initialUser);
40
+ setStatus(statusFor(initialUser));
41
+ }
42
+ }
43
+ const loadSession = useCallback(() => fetchSessionUser(sessionEndpoint, fetchImpl), [sessionEndpoint, fetchImpl]);
44
+ const refresh = useCallback(async () => {
45
+ const current = await loadSession();
46
+ setUser(current);
47
+ setStatus(current === null ? 'anonymous' : 'authenticated');
48
+ }, [loadSession]);
49
+ const signOut = useCallback(async () => {
50
+ const revoked = await revokeSession(sessionEndpoint, fetchImpl);
51
+ if (revoked) {
52
+ setUser(null);
53
+ setStatus('anonymous');
54
+ }
55
+ return revoked;
56
+ }, [sessionEndpoint, fetchImpl]);
57
+ useEffect(() => {
58
+ if (initialUser !== undefined) {
59
+ return;
60
+ }
61
+ let cancelled = false;
62
+ // Mount-only initial load (the async boundary keeps this out of the
63
+ // sync-setState-in-effect rule); `refresh` stays the public re-read.
64
+ void (async () => {
65
+ const current = await loadSession();
66
+ if (!cancelled) {
67
+ setUser(current);
68
+ setStatus(current === null ? 'anonymous' : 'authenticated');
69
+ }
70
+ })();
71
+ return () => {
72
+ cancelled = true;
73
+ };
74
+ }, [initialUser, loadSession]);
75
+ const value = useMemo(() => ({ user, status, refresh, signOut }), [user, status, refresh, signOut]);
76
+ return _jsx(AuthLinkContext.Provider, { value: value, children: children });
77
+ }
78
+ /** Read the auth context. Throws outside `AuthLinkProvider` — fail loud. */
79
+ export function useAuthLink() {
80
+ const value = useContext(AuthLinkContext);
81
+ if (!value) {
82
+ throw new Error('useAuthLink must be used within AuthLinkProvider');
83
+ }
84
+ return value;
85
+ }
86
+ /** `true` when the current user holds every listed role. Fails closed. */
87
+ export function useHasRoles(...roles) {
88
+ const { user } = useAuthLink();
89
+ if (!user) {
90
+ return false;
91
+ }
92
+ return roles.every((role) => user.roles.includes(role));
93
+ }
94
+ //# sourceMappingURL=provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider.js","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ import { type HttpFetch, type SessionUser } from '@payload-auth-link/core';
2
+ /**
3
+ * Read the current session user from the plugin's session endpoint
4
+ * (`{ user }` envelope). Fails closed: transport errors, non-2xx
5
+ * statuses, invalid JSON, and malformed envelopes all yield `null`,
6
+ * never an exception and never a partial user.
7
+ */
8
+ export declare function fetchSessionUser(sessionEndpoint?: string, fetchImpl?: HttpFetch): Promise<SessionUser | null>;
9
+ /**
10
+ * Revoke the current session through the plugin's session endpoint.
11
+ * Sends the CSRF header the endpoint requires. Returns `true` when the
12
+ * session is gone afterwards: `2xx`, or `401` (already signed out —
13
+ * logout is idempotent). Transport errors and other statuses return
14
+ * `false` so the caller can report instead of lying.
15
+ */
16
+ export declare function revokeSession(sessionEndpoint?: string, fetchImpl?: HttpFetch): Promise<boolean>;
17
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,SAAS,EACd,KAAK,WAAW,EACjB,MAAM,yBAAyB,CAAA;AAEhC;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,eAAe,GAAE,MAAmC,EACpD,SAAS,GAAE,SAAiB,GAC3B,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAe7B;AAED;;;;;;GAMG;AACH,wBAAsB,aAAa,CACjC,eAAe,GAAE,MAAmC,EACpD,SAAS,GAAE,SAAiB,GAC3B,OAAO,CAAC,OAAO,CAAC,CAYlB"}
@@ -0,0 +1,46 @@
1
+ import { csrfHeaderName, defaultSessionEndpointPath, parseSessionResponse, } from '@payload-auth-link/core';
2
+ /**
3
+ * Read the current session user from the plugin's session endpoint
4
+ * (`{ user }` envelope). Fails closed: transport errors, non-2xx
5
+ * statuses, invalid JSON, and malformed envelopes all yield `null`,
6
+ * never an exception and never a partial user.
7
+ */
8
+ export async function fetchSessionUser(sessionEndpoint = defaultSessionEndpointPath, fetchImpl = fetch) {
9
+ let response;
10
+ try {
11
+ response = await fetchImpl(sessionEndpoint, {
12
+ method: 'GET',
13
+ credentials: 'same-origin',
14
+ // Belt and suspenders with the endpoint's `Cache-Control:
15
+ // no-store`: never serve this read from any cache.
16
+ cache: 'no-store',
17
+ headers: { accept: 'application/json' },
18
+ });
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ return parseSessionResponse(response);
24
+ }
25
+ /**
26
+ * Revoke the current session through the plugin's session endpoint.
27
+ * Sends the CSRF header the endpoint requires. Returns `true` when the
28
+ * session is gone afterwards: `2xx`, or `401` (already signed out —
29
+ * logout is idempotent). Transport errors and other statuses return
30
+ * `false` so the caller can report instead of lying.
31
+ */
32
+ export async function revokeSession(sessionEndpoint = defaultSessionEndpointPath, fetchImpl = fetch) {
33
+ let response;
34
+ try {
35
+ response = await fetchImpl(sessionEndpoint, {
36
+ method: 'DELETE',
37
+ credentials: 'same-origin',
38
+ headers: { [csrfHeaderName]: '1' },
39
+ });
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ return response.ok || response.status === 401;
45
+ }
46
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,0BAA0B,EAC1B,oBAAoB,GAGrB,MAAM,yBAAyB,CAAA;AAEhC;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,kBAA0B,0BAA0B,EACpD,YAAuB,KAAK;IAE5B,IAAI,QAAkB,CAAA;IACtB,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,SAAS,CAAC,eAAe,EAAE;YAC1C,MAAM,EAAE,KAAK;YACb,WAAW,EAAE,aAAa;YAC1B,0DAA0D;YAC1D,mDAAmD;YACnD,KAAK,EAAE,UAAU;YACjB,OAAO,EAAE,EAAE,MAAM,EAAE,kBAAkB,EAAE;SACxC,CAAC,CAAA;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA;IACb,CAAC;IACD,OAAO,oBAAoB,CAAC,QAAQ,CAAC,CAAA;AACvC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,kBAA0B,0BAA0B,EACpD,YAAuB,KAAK;IAE5B,IAAI,QAAkB,CAAA;IACtB,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,SAAS,CAAC,eAAe,EAAE;YAC1C,MAAM,EAAE,QAAQ;YAChB,WAAW,EAAE,aAAa;YAC1B,OAAO,EAAE,EAAE,CAAC,cAAc,CAAC,EAAE,GAAG,EAAE;SACnC,CAAC,CAAA;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;IACD,OAAO,QAAQ,CAAC,EAAE,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,CAAA;AAC/C,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@payload-auth-link/react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "payload-auth-link React UI: provider-agnostic sign-in components and user menu.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "files": [
17
+ "dist",
18
+ "!dist/*.tsbuildinfo",
19
+ "src",
20
+ "!src/**/__tests__"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -b",
24
+ "typecheck": "tsc -b",
25
+ "clean": "rm -rf dist *.tsbuildinfo"
26
+ },
27
+ "dependencies": {
28
+ "@payload-auth-link/core": "0.1.0"
29
+ },
30
+ "peerDependencies": {
31
+ "react": "^19.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "react": "19.2.6"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "provenance": true
39
+ },
40
+ "engines": {
41
+ "node": ">=20.9.0"
42
+ }
43
+ }
@@ -0,0 +1,127 @@
1
+ 'use client'
2
+
3
+ import { safeRedirectPath } from '@payload-auth-link/core'
4
+ import type { ReactNode } from 'react'
5
+ import { useAuthLink, useHasRoles } from './provider.js'
6
+
7
+ /** {@link SignInButton} wiring; unstyled by design. */
8
+ export type SignInButtonProps = {
9
+ /** Where the sign-in page lives. Defaults to `/sign-in`. */
10
+ readonly href?: string | undefined
11
+ readonly className?: string | undefined
12
+ readonly children?: ReactNode | undefined
13
+ }
14
+
15
+ /** Unstyled link to the builder's sign-in page. */
16
+ export function SignInButton({
17
+ href = '/sign-in',
18
+ className,
19
+ children = 'Sign in',
20
+ }: SignInButtonProps): ReactNode {
21
+ return (
22
+ <a href={href} className={className} data-auth-link="sign-in">
23
+ {children}
24
+ </a>
25
+ )
26
+ }
27
+
28
+ /** {@link SignOutButton} wiring; navigation is injectable. */
29
+ export type SignOutButtonProps = {
30
+ readonly className?: string | undefined
31
+ readonly children?: ReactNode | undefined
32
+ /**
33
+ * Where to go after a successful sign-out. Stays put by default.
34
+ * Narrowed by `safeRedirectPath` (an off-origin or API destination
35
+ * falls back to `/`), so a prop fed from a query param cannot turn
36
+ * sign-out into an open redirect.
37
+ */
38
+ readonly redirectTo?: string | undefined
39
+ /** Navigation injection for tests; defaults to a full page load. */
40
+ readonly navigate?: ((url: string) => void) | undefined
41
+ /** Called after a successful sign-out, before navigation. */
42
+ readonly onSignedOut?: (() => void) | undefined
43
+ }
44
+
45
+ /**
46
+ * Unstyled sign-out button. Revokes through the provider; on failure it
47
+ * does nothing (the session is still live, so navigating away would lie).
48
+ */
49
+ export function SignOutButton({
50
+ className,
51
+ children = 'Sign out',
52
+ redirectTo,
53
+ navigate = (url: string): void => {
54
+ window.location.assign(url)
55
+ },
56
+ onSignedOut,
57
+ }: SignOutButtonProps): ReactNode {
58
+ const { signOut } = useAuthLink()
59
+ return (
60
+ <button
61
+ type="button"
62
+ className={className}
63
+ data-auth-link="sign-out"
64
+ onClick={() => {
65
+ void (async () => {
66
+ if (await signOut()) {
67
+ onSignedOut?.()
68
+ if (redirectTo !== undefined) {
69
+ // Sign-in is a fine post-sign-out destination: disable
70
+ // that rule, keep the off-origin and API ones.
71
+ navigate(safeRedirectPath(redirectTo, '/', { signInPath: null }))
72
+ }
73
+ }
74
+ })()
75
+ }}
76
+ >
77
+ {children}
78
+ </button>
79
+ )
80
+ }
81
+
82
+ /** {@link UserMenu} wiring; renders nothing while loading. */
83
+ export type UserMenuProps = {
84
+ readonly className?: string | undefined
85
+ /** Sign-in page for the anonymous state. Defaults to `/sign-in`. */
86
+ readonly signInHref?: string | undefined
87
+ /** Where to go after sign-out. Stays put by default. */
88
+ readonly signOutRedirectTo?: string | undefined
89
+ }
90
+
91
+ /**
92
+ * Minimal session menu: nothing while loading, a sign-in link when
93
+ * anonymous, the email plus a sign-out button when authenticated.
94
+ */
95
+ export function UserMenu({
96
+ className,
97
+ signInHref = '/sign-in',
98
+ signOutRedirectTo,
99
+ }: UserMenuProps): ReactNode {
100
+ const { user, status } = useAuthLink()
101
+ if (status === 'loading') {
102
+ return null
103
+ }
104
+ if (!user) {
105
+ return <SignInButton href={signInHref} />
106
+ }
107
+ return (
108
+ <div className={className} data-auth-link="user-menu" data-auth-status={status}>
109
+ <span data-auth-link="user-email">{user.email}</span>{' '}
110
+ <SignOutButton redirectTo={signOutRedirectTo} />
111
+ </div>
112
+ )
113
+ }
114
+
115
+ /** {@link RequireRoles} wiring: all roles required, fail closed. */
116
+ export type RequireRolesProps = {
117
+ /** Every listed role is required (fail closed while loading). */
118
+ readonly roles: readonly string[]
119
+ readonly children: ReactNode
120
+ readonly fallback?: ReactNode | undefined
121
+ }
122
+
123
+ /** Render-gate for role-restricted UI. Hides — it never authorizes. */
124
+ export function RequireRoles({ roles, children, fallback = null }: RequireRolesProps): ReactNode {
125
+ const allowed = useHasRoles(...roles)
126
+ return allowed ? children : fallback
127
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Provider-agnostic React UI: session context, sign-in components, user
3
+ * menu, role gates. No Next, no Payload imports — framework work lives in
4
+ * `@payload-auth-link/next`, server work in `@payload-auth-link/plugin`.
5
+ */
6
+ export const packageName = '@payload-auth-link/react'
7
+
8
+ export { RequireRoles, SignInButton, SignOutButton, UserMenu } from './components.js'
9
+ export type {
10
+ RequireRolesProps,
11
+ SignInButtonProps,
12
+ SignOutButtonProps,
13
+ UserMenuProps,
14
+ } from './components.js'
15
+ export { AuthLinkProvider, useAuthLink, useHasRoles } from './provider.js'
16
+ export type { AuthLinkContextValue, AuthLinkProviderProps, AuthLinkStatus } from './provider.js'
17
+ export { fetchSessionUser, revokeSession } from './session.js'
@@ -0,0 +1,161 @@
1
+ 'use client'
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useState,
10
+ type ReactNode,
11
+ } from 'react'
12
+ import {
13
+ defaultSessionEndpointPath,
14
+ type HttpFetch,
15
+ type SessionUser,
16
+ } from '@payload-auth-link/core'
17
+ import { fetchSessionUser, revokeSession } from './session.js'
18
+
19
+ /** Session state: unknown until the first load settles. */
20
+ export type AuthLinkStatus = 'loading' | 'authenticated' | 'anonymous'
21
+
22
+ /** What `useAuthLink` returns: user, status, refresh, honest sign-out. */
23
+ export type AuthLinkContextValue = {
24
+ readonly user: SessionUser | null
25
+ readonly status: AuthLinkStatus
26
+ /** Re-read the session endpoint (call after sign-in or role changes). */
27
+ readonly refresh: () => Promise<void>
28
+ /**
29
+ * Revoke the session and drop local state. Resolves `false` when the
30
+ * revocation failed — local state is kept so the UI stays honest.
31
+ */
32
+ readonly signOut: () => Promise<boolean>
33
+ }
34
+
35
+ const AuthLinkContext = createContext<AuthLinkContextValue | null>(null)
36
+
37
+ /** Client provider wiring; `initialUser` is the hydration contract. */
38
+ export type AuthLinkProviderProps = {
39
+ readonly children: ReactNode
40
+ /** Session endpoint path. Defaults to the plugin's endpoint. */
41
+ readonly sessionEndpoint?: string | undefined
42
+ /** Injectible fetch. Defaults to the global `fetch`. */
43
+ readonly fetchImpl?: HttpFetch | undefined
44
+ /**
45
+ * Hydration contract: `undefined` (default) fetches on mount;
46
+ * `SessionUser` starts authenticated without a fetch; `null` starts
47
+ * anonymous without a fetch. The Next adapter's server provider
48
+ * supplies this from full server-side validation.
49
+ */
50
+ readonly initialUser?: SessionUser | null | undefined
51
+ }
52
+
53
+ /** Value identity for hydration snapshots (fresh objects, same user). */
54
+ function snapshotKey(user: SessionUser | null | undefined): string {
55
+ if (user === undefined) {
56
+ return 'undefined'
57
+ }
58
+ if (user === null) {
59
+ return 'null'
60
+ }
61
+ return JSON.stringify([user.id, user.collection, user.email, [...user.roles].sort()])
62
+ }
63
+
64
+ function statusFor(user: SessionUser | null): AuthLinkStatus {
65
+ return user === null ? 'anonymous' : 'authenticated'
66
+ }
67
+
68
+ /**
69
+ * Client session provider: loads once on mount (unless hydrated),
70
+ * exposes refresh/sign-out. Fail-closed — fetch errors mean anonymous.
71
+ *
72
+ * A later `initialUser` (a re-run server loader, a new RSC payload) is
73
+ * synced into context when its value changes; re-renders passing the
74
+ * same user as a fresh object change nothing, so a client-side sign-out
75
+ * is never undone by a stale snapshot. Refresh stays explicit.
76
+ */
77
+ export function AuthLinkProvider({
78
+ children,
79
+ sessionEndpoint = defaultSessionEndpointPath,
80
+ fetchImpl = fetch,
81
+ initialUser = undefined,
82
+ }: AuthLinkProviderProps): ReactNode {
83
+ const [user, setUser] = useState<SessionUser | null>(initialUser ?? null)
84
+ const [status, setStatus] = useState<AuthLinkStatus>(
85
+ initialUser === undefined ? 'loading' : statusFor(initialUser)
86
+ )
87
+ // React's "adjust state when a prop changes" pattern (during render,
88
+ // not in an effect): no stale frame, no set-state-in-effect.
89
+ const [syncedKey, setSyncedKey] = useState(() => snapshotKey(initialUser))
90
+ const incomingKey = snapshotKey(initialUser)
91
+ if (incomingKey !== syncedKey) {
92
+ setSyncedKey(incomingKey)
93
+ if (initialUser !== undefined) {
94
+ setUser(initialUser)
95
+ setStatus(statusFor(initialUser))
96
+ }
97
+ }
98
+
99
+ const loadSession = useCallback(
100
+ () => fetchSessionUser(sessionEndpoint, fetchImpl),
101
+ [sessionEndpoint, fetchImpl]
102
+ )
103
+
104
+ const refresh = useCallback(async () => {
105
+ const current = await loadSession()
106
+ setUser(current)
107
+ setStatus(current === null ? 'anonymous' : 'authenticated')
108
+ }, [loadSession])
109
+
110
+ const signOut = useCallback(async () => {
111
+ const revoked = await revokeSession(sessionEndpoint, fetchImpl)
112
+ if (revoked) {
113
+ setUser(null)
114
+ setStatus('anonymous')
115
+ }
116
+ return revoked
117
+ }, [sessionEndpoint, fetchImpl])
118
+
119
+ useEffect(() => {
120
+ if (initialUser !== undefined) {
121
+ return
122
+ }
123
+ let cancelled = false
124
+ // Mount-only initial load (the async boundary keeps this out of the
125
+ // sync-setState-in-effect rule); `refresh` stays the public re-read.
126
+ void (async () => {
127
+ const current = await loadSession()
128
+ if (!cancelled) {
129
+ setUser(current)
130
+ setStatus(current === null ? 'anonymous' : 'authenticated')
131
+ }
132
+ })()
133
+ return () => {
134
+ cancelled = true
135
+ }
136
+ }, [initialUser, loadSession])
137
+
138
+ const value = useMemo<AuthLinkContextValue>(
139
+ () => ({ user, status, refresh, signOut }),
140
+ [user, status, refresh, signOut]
141
+ )
142
+ return <AuthLinkContext.Provider value={value}>{children}</AuthLinkContext.Provider>
143
+ }
144
+
145
+ /** Read the auth context. Throws outside `AuthLinkProvider` — fail loud. */
146
+ export function useAuthLink(): AuthLinkContextValue {
147
+ const value = useContext(AuthLinkContext)
148
+ if (!value) {
149
+ throw new Error('useAuthLink must be used within AuthLinkProvider')
150
+ }
151
+ return value
152
+ }
153
+
154
+ /** `true` when the current user holds every listed role. Fails closed. */
155
+ export function useHasRoles(...roles: readonly string[]): boolean {
156
+ const { user } = useAuthLink()
157
+ if (!user) {
158
+ return false
159
+ }
160
+ return roles.every((role) => user.roles.includes(role))
161
+ }
package/src/session.ts ADDED
@@ -0,0 +1,57 @@
1
+ import {
2
+ csrfHeaderName,
3
+ defaultSessionEndpointPath,
4
+ parseSessionResponse,
5
+ type HttpFetch,
6
+ type SessionUser,
7
+ } from '@payload-auth-link/core'
8
+
9
+ /**
10
+ * Read the current session user from the plugin's session endpoint
11
+ * (`{ user }` envelope). Fails closed: transport errors, non-2xx
12
+ * statuses, invalid JSON, and malformed envelopes all yield `null`,
13
+ * never an exception and never a partial user.
14
+ */
15
+ export async function fetchSessionUser(
16
+ sessionEndpoint: string = defaultSessionEndpointPath,
17
+ fetchImpl: HttpFetch = fetch
18
+ ): Promise<SessionUser | null> {
19
+ let response: Response
20
+ try {
21
+ response = await fetchImpl(sessionEndpoint, {
22
+ method: 'GET',
23
+ credentials: 'same-origin',
24
+ // Belt and suspenders with the endpoint's `Cache-Control:
25
+ // no-store`: never serve this read from any cache.
26
+ cache: 'no-store',
27
+ headers: { accept: 'application/json' },
28
+ })
29
+ } catch {
30
+ return null
31
+ }
32
+ return parseSessionResponse(response)
33
+ }
34
+
35
+ /**
36
+ * Revoke the current session through the plugin's session endpoint.
37
+ * Sends the CSRF header the endpoint requires. Returns `true` when the
38
+ * session is gone afterwards: `2xx`, or `401` (already signed out —
39
+ * logout is idempotent). Transport errors and other statuses return
40
+ * `false` so the caller can report instead of lying.
41
+ */
42
+ export async function revokeSession(
43
+ sessionEndpoint: string = defaultSessionEndpointPath,
44
+ fetchImpl: HttpFetch = fetch
45
+ ): Promise<boolean> {
46
+ let response: Response
47
+ try {
48
+ response = await fetchImpl(sessionEndpoint, {
49
+ method: 'DELETE',
50
+ credentials: 'same-origin',
51
+ headers: { [csrfHeaderName]: '1' },
52
+ })
53
+ } catch {
54
+ return false
55
+ }
56
+ return response.ok || response.status === 401
57
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.