@graphoria/react 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.
- package/LICENSE +201 -0
- package/NOTICE +10 -0
- package/README.md +256 -0
- package/dist/src/AppProvider.d.ts +57 -0
- package/dist/src/AppProvider.d.ts.map +1 -0
- package/dist/src/AuthContext.d.ts +19 -0
- package/dist/src/AuthContext.d.ts.map +1 -0
- package/dist/src/gates.d.ts +15 -0
- package/dist/src/gates.d.ts.map +1 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/tokenStore.d.ts +16 -0
- package/dist/src/tokenStore.d.ts.map +1 -0
- package/dist/src/transport.d.ts +19 -0
- package/dist/src/transport.d.ts.map +1 -0
- package/dist/src/types.d.ts +37 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +74 -0
- package/src/AppProvider.tsx +138 -0
- package/src/AuthContext.tsx +365 -0
- package/src/gates.tsx +33 -0
- package/src/index.ts +23 -0
- package/src/tokenStore.ts +70 -0
- package/src/transport.ts +84 -0
- package/src/types.ts +53 -0
package/src/gates.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { useAuth } from "./AuthContext";
|
|
4
|
+
|
|
5
|
+
interface AuthorizeProps<TRole extends string> {
|
|
6
|
+
roles: TRole[];
|
|
7
|
+
fallback?: ReactNode;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Authorize<TRole extends string = string>({
|
|
12
|
+
roles,
|
|
13
|
+
fallback = null,
|
|
14
|
+
children,
|
|
15
|
+
}: AuthorizeProps<TRole>) {
|
|
16
|
+
const { hasAnyRole } = useAuth<TRole>();
|
|
17
|
+
return <>{hasAnyRole(roles) ? children : fallback}</>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface GateProps {
|
|
21
|
+
fallback?: ReactNode;
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Authenticated({ fallback = null, children }: GateProps) {
|
|
26
|
+
const { isAuthenticated } = useAuth();
|
|
27
|
+
return <>{isAuthenticated ? children : fallback}</>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function Unauthenticated({ fallback = null, children }: GateProps) {
|
|
31
|
+
const { isAuthenticated } = useAuth();
|
|
32
|
+
return <>{isAuthenticated ? fallback : children}</>;
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { AuthProvider, useAuth } from "./AuthContext";
|
|
2
|
+
export {
|
|
3
|
+
AppProvider,
|
|
4
|
+
useRouteConfig,
|
|
5
|
+
useCanAccess,
|
|
6
|
+
type RouteConfigContextType,
|
|
7
|
+
} from "./AppProvider";
|
|
8
|
+
export { Authorize, Authenticated, Unauthenticated } from "./gates";
|
|
9
|
+
export {
|
|
10
|
+
getAccessToken,
|
|
11
|
+
setAccessToken,
|
|
12
|
+
subscribeAccessToken,
|
|
13
|
+
ensureFreshToken,
|
|
14
|
+
} from "./tokenStore";
|
|
15
|
+
export { GraphQLFetchError } from "./transport";
|
|
16
|
+
export type {
|
|
17
|
+
User,
|
|
18
|
+
AuthState,
|
|
19
|
+
AuthContextType,
|
|
20
|
+
TokenResponse,
|
|
21
|
+
RouteConfig,
|
|
22
|
+
AuthTransportOptions,
|
|
23
|
+
} from "./types";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Token Store - framework-agnostic, module-level state.
|
|
3
|
+
//
|
|
4
|
+
// User's GraphQL client (Apollo/urql/relay/anything) integrates via:
|
|
5
|
+
// - getAccessToken() in its auth middleware
|
|
6
|
+
// - subscribeAccessToken() to react to changes (e.g. restart WS)
|
|
7
|
+
// - ensureFreshToken() from its 401 error handler
|
|
8
|
+
// AuthProvider wires the actual refresh/logout implementations via
|
|
9
|
+
// setRefreshHandler / setLogoutHandler at mount time.
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
type RefreshHandler = () => Promise<boolean>;
|
|
13
|
+
type LogoutHandler = () => Promise<void> | void;
|
|
14
|
+
type Listener = (token: string | null) => void;
|
|
15
|
+
|
|
16
|
+
let inMemoryAccessToken: string | null = null;
|
|
17
|
+
let refreshHandler: RefreshHandler | null = null;
|
|
18
|
+
let logoutHandler: LogoutHandler | null = null;
|
|
19
|
+
let inflightRefresh: Promise<boolean> | null = null;
|
|
20
|
+
const listeners: Set<Listener> = new Set();
|
|
21
|
+
|
|
22
|
+
export function getAccessToken(): string | null {
|
|
23
|
+
return inMemoryAccessToken;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function setAccessToken(token: string | null): void {
|
|
27
|
+
if (inMemoryAccessToken === token) return;
|
|
28
|
+
inMemoryAccessToken = token;
|
|
29
|
+
for (const cb of listeners) cb(token);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Subscribe to token changes. Returns unsubscribe fn. */
|
|
33
|
+
export function subscribeAccessToken(cb: Listener): () => void {
|
|
34
|
+
listeners.add(cb);
|
|
35
|
+
return () => listeners.delete(cb);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function setRefreshHandler(fn: RefreshHandler | null): void {
|
|
39
|
+
refreshHandler = fn;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function setLogoutHandler(fn: LogoutHandler | null): void {
|
|
43
|
+
logoutHandler = fn;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Single-flight token refresh. Call from any GraphQL client's 401 handler.
|
|
48
|
+
* Concurrent calls share one in-flight refresh. On failure, triggers logout.
|
|
49
|
+
*/
|
|
50
|
+
export function ensureFreshToken(): Promise<boolean> {
|
|
51
|
+
if (inflightRefresh) return inflightRefresh;
|
|
52
|
+
if (!refreshHandler) return Promise.resolve(false);
|
|
53
|
+
|
|
54
|
+
inflightRefresh = refreshHandler()
|
|
55
|
+
.then((ok) => {
|
|
56
|
+
if (!ok && logoutHandler) {
|
|
57
|
+
void logoutHandler();
|
|
58
|
+
}
|
|
59
|
+
return ok;
|
|
60
|
+
})
|
|
61
|
+
.catch(() => {
|
|
62
|
+
if (logoutHandler) void logoutHandler();
|
|
63
|
+
return false;
|
|
64
|
+
})
|
|
65
|
+
.finally(() => {
|
|
66
|
+
inflightRefresh = null;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return inflightRefresh;
|
|
70
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Minimal GraphQL-over-fetch transport for built-in auth mutations.
|
|
3
|
+
// Not exposed publicly — users bring their own GraphQL client for app queries.
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
export interface GraphQLError {
|
|
7
|
+
message: string;
|
|
8
|
+
extensions?: Record<string, unknown>;
|
|
9
|
+
path?: ReadonlyArray<string | number>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class GraphQLFetchError extends Error {
|
|
13
|
+
status: number;
|
|
14
|
+
body: string;
|
|
15
|
+
errors?: GraphQLError[];
|
|
16
|
+
|
|
17
|
+
constructor(message: string, status: number, body: string, errors?: GraphQLError[]) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "GraphQLFetchError";
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.body = body;
|
|
22
|
+
this.errors = errors;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface GqlFetchOptions {
|
|
27
|
+
bearer?: string | null;
|
|
28
|
+
credentials?: boolean;
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface GqlResponse<T> {
|
|
33
|
+
data?: T;
|
|
34
|
+
errors?: GraphQLError[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function gqlFetch<T>(
|
|
38
|
+
uri: string,
|
|
39
|
+
query: string,
|
|
40
|
+
variables: Record<string, unknown> | undefined,
|
|
41
|
+
opts: GqlFetchOptions = {},
|
|
42
|
+
): Promise<T> {
|
|
43
|
+
const headers: Record<string, string> = {
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
Accept: "application/json",
|
|
46
|
+
};
|
|
47
|
+
if (opts.bearer) headers.Authorization = `Bearer ${opts.bearer}`;
|
|
48
|
+
|
|
49
|
+
const res = await fetch(uri, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers,
|
|
52
|
+
credentials: opts.credentials === false ? "same-origin" : "include",
|
|
53
|
+
body: JSON.stringify({ query, variables }),
|
|
54
|
+
signal: opts.signal,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const text = await res.text();
|
|
58
|
+
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
throw new GraphQLFetchError(`GraphQL request failed: ${res.status}`, res.status, text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let parsed: GqlResponse<T>;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(text) as GqlResponse<T>;
|
|
66
|
+
} catch {
|
|
67
|
+
throw new GraphQLFetchError("Invalid JSON response", res.status, text);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (parsed.errors && parsed.errors.length > 0) {
|
|
71
|
+
throw new GraphQLFetchError(
|
|
72
|
+
parsed.errors[0]?.message ?? "GraphQL error",
|
|
73
|
+
res.status,
|
|
74
|
+
text,
|
|
75
|
+
parsed.errors,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (parsed.data === undefined) {
|
|
80
|
+
throw new GraphQLFetchError("Empty GraphQL response", res.status, text);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return parsed.data;
|
|
84
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Auth Types
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
export interface TokenResponse<TRole extends string = string> {
|
|
6
|
+
access_token: string;
|
|
7
|
+
expires_in: number; // seconds until access_token expires
|
|
8
|
+
role: TRole;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface User<TRole extends string = string> {
|
|
12
|
+
role: TRole;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AuthState<TRole extends string = string> {
|
|
16
|
+
isAuthenticated: boolean;
|
|
17
|
+
isLoading: boolean;
|
|
18
|
+
user: User<TRole> | null;
|
|
19
|
+
error: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AuthContextType<TRole extends string = string> extends AuthState<TRole> {
|
|
23
|
+
login: (username: string, password: string) => Promise<User<TRole> | null>;
|
|
24
|
+
logout: () => Promise<void>;
|
|
25
|
+
hasRole: (role: TRole) => boolean;
|
|
26
|
+
hasAnyRole: (roles: TRole[]) => boolean;
|
|
27
|
+
/** Manually trigger a token refresh */
|
|
28
|
+
refreshToken: () => Promise<boolean>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Route Configuration Types
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
export interface RouteConfig<TRole extends string = string> {
|
|
36
|
+
/** Map of path to required roles (null = public) */
|
|
37
|
+
permissions: Record<string, TRole[] | null>;
|
|
38
|
+
/** Default route for each role after login */
|
|
39
|
+
defaultRoutes: Partial<Record<TRole, string>>;
|
|
40
|
+
/** Fallback route if role not in defaultRoutes */
|
|
41
|
+
fallbackRoute: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Auth Transport Options
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
export interface AuthTransportOptions {
|
|
49
|
+
/** GraphQL HTTP endpoint for auth mutations (default: "/graphql") */
|
|
50
|
+
httpUri?: string;
|
|
51
|
+
/** Include credentials (cookies) in requests (default: true) */
|
|
52
|
+
includeCredentials?: boolean;
|
|
53
|
+
}
|