next-supa-utils 0.1.2 → 0.1.4
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 +25 -0
- package/dist/server/index.cjs +50 -16
- package/dist/server/index.cjs.map +1 -1
- package/dist/server/index.d.cts +58 -17
- package/dist/server/index.d.ts +58 -17
- package/dist/server/index.js +50 -16
- package/dist/server/index.js.map +1 -1
- package/dist/shared/index.cjs.map +1 -1
- package/dist/shared/index.d.cts +49 -12
- package/dist/shared/index.d.ts +49 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -272,6 +272,31 @@ src/
|
|
|
272
272
|
| `next-supa-utils/server` | Server Components, Middleware, Server Actions | `withSupaAuth`, `createAction` |
|
|
273
273
|
| `next-supa-utils` | Anywhere | `handleSupaError`, all types |
|
|
274
274
|
|
|
275
|
+
## Changelog
|
|
276
|
+
|
|
277
|
+
### v0.1.3
|
|
278
|
+
|
|
279
|
+
- **🔐 RBAC Support** — `withSupaAuth` now supports **role-based access control**.
|
|
280
|
+
- New `RouteConfig` type: define `allowedRoles` per route (e.g. `["admin", "editor"]`).
|
|
281
|
+
- New `MiddlewareOptions` replaces the old `SupaAuthConfig`.
|
|
282
|
+
- New `roleExtractor` option: read roles from `user_metadata`, `app_metadata`, or a custom function.
|
|
283
|
+
- Wildcard path matching supported (e.g. `"/admin/:path*"`).
|
|
284
|
+
- Unauthorized users are redirected with `?error=forbidden&next=<path>`.
|
|
285
|
+
- **⚠️ Breaking:** `SupaAuthConfig` type removed. Use `MiddlewareOptions` with `routes: RouteConfig[]` instead.
|
|
286
|
+
|
|
287
|
+
### v0.1.0
|
|
288
|
+
|
|
289
|
+
- 🎉 **Initial release**
|
|
290
|
+
- `withSupaAuth` — Middleware helper for route protection with session refresh.
|
|
291
|
+
- `createAction` — Type-safe server action wrapper with automatic `{ data, error }` responses.
|
|
292
|
+
- `useSupaUser` — React hook for real-time user state.
|
|
293
|
+
- `useSupaSession` — React hook for real-time session state.
|
|
294
|
+
- `handleSupaError` — Universal error normalizer.
|
|
295
|
+
- Multi-entry package: `next-supa-utils/client`, `next-supa-utils/server`.
|
|
296
|
+
- Dual format output (ESM + CJS) with TypeScript declarations.
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
275
300
|
## Contributing
|
|
276
301
|
|
|
277
302
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
package/dist/server/index.cjs
CHANGED
|
@@ -28,13 +28,41 @@ module.exports = __toCommonJS(server_exports);
|
|
|
28
28
|
// src/server/middleware/withSupaAuth.ts
|
|
29
29
|
var import_ssr = require("@supabase/ssr");
|
|
30
30
|
var import_server = require("next/server");
|
|
31
|
-
function
|
|
31
|
+
function matchPath(pattern, pathname) {
|
|
32
|
+
if (pattern.includes(":path*")) {
|
|
33
|
+
const prefix = pattern.replace(/:path\*$/, "").replace(/\/$/, "");
|
|
34
|
+
return pathname === prefix || pathname.startsWith(prefix + "/");
|
|
35
|
+
}
|
|
36
|
+
return pathname === pattern || pathname.startsWith(pattern + "/");
|
|
37
|
+
}
|
|
38
|
+
function findMatchingRoute(routes, pathname) {
|
|
39
|
+
return routes.find((route) => matchPath(route.path, pathname));
|
|
40
|
+
}
|
|
41
|
+
function extractRole(user, extractor) {
|
|
42
|
+
if (typeof extractor === "function") {
|
|
43
|
+
return extractor(user);
|
|
44
|
+
}
|
|
45
|
+
const source = extractor === "app_metadata" ? user.app_metadata : user.user_metadata;
|
|
46
|
+
const raw = source?.role;
|
|
47
|
+
if (typeof raw === "string") return raw;
|
|
48
|
+
if (Array.isArray(raw) && raw.every((r) => typeof r === "string")) {
|
|
49
|
+
return raw;
|
|
50
|
+
}
|
|
51
|
+
return void 0;
|
|
52
|
+
}
|
|
53
|
+
function hasRequiredRole(userRole, allowedRoles) {
|
|
54
|
+
if (!allowedRoles || allowedRoles.length === 0) return true;
|
|
55
|
+
if (!userRole) return false;
|
|
56
|
+
const roles = Array.isArray(userRole) ? userRole : [userRole];
|
|
57
|
+
return roles.some((r) => allowedRoles.includes(r));
|
|
58
|
+
}
|
|
59
|
+
function withSupaAuth(options) {
|
|
32
60
|
const {
|
|
33
|
-
|
|
61
|
+
routes,
|
|
34
62
|
redirectTo = "/login",
|
|
35
|
-
|
|
63
|
+
roleExtractor = "user_metadata",
|
|
36
64
|
onAuthSuccess
|
|
37
|
-
} =
|
|
65
|
+
} = options;
|
|
38
66
|
return async function middleware(request) {
|
|
39
67
|
let response = import_server.NextResponse.next({
|
|
40
68
|
request: { headers: request.headers }
|
|
@@ -57,8 +85,8 @@ function withSupaAuth(config) {
|
|
|
57
85
|
request.cookies.set(name, value);
|
|
58
86
|
});
|
|
59
87
|
response = import_server.NextResponse.next({ request });
|
|
60
|
-
cookiesToSet.forEach(({ name, value, options }) => {
|
|
61
|
-
response.cookies.set(name, value,
|
|
88
|
+
cookiesToSet.forEach(({ name, value, options: options2 }) => {
|
|
89
|
+
response.cookies.set(name, value, options2);
|
|
62
90
|
});
|
|
63
91
|
}
|
|
64
92
|
}
|
|
@@ -67,22 +95,28 @@ function withSupaAuth(config) {
|
|
|
67
95
|
data: { user }
|
|
68
96
|
} = await supabase.auth.getUser();
|
|
69
97
|
const { pathname } = request.nextUrl;
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
);
|
|
73
|
-
if (isPublicRoute) {
|
|
98
|
+
const matchedRoute = findMatchingRoute(routes, pathname);
|
|
99
|
+
if (!matchedRoute) {
|
|
74
100
|
return response;
|
|
75
101
|
}
|
|
76
|
-
|
|
77
|
-
(route) => pathname.startsWith(route)
|
|
78
|
-
);
|
|
79
|
-
if (isProtectedRoute && !user) {
|
|
102
|
+
if (!user) {
|
|
80
103
|
const loginUrl = new URL(redirectTo, request.url);
|
|
81
104
|
loginUrl.searchParams.set("next", pathname);
|
|
82
105
|
return import_server.NextResponse.redirect(loginUrl);
|
|
83
106
|
}
|
|
84
|
-
|
|
85
|
-
|
|
107
|
+
const userRole = extractRole(user, roleExtractor);
|
|
108
|
+
if (!hasRequiredRole(userRole, matchedRoute.allowedRoles)) {
|
|
109
|
+
const forbiddenUrl = new URL(redirectTo, request.url);
|
|
110
|
+
forbiddenUrl.searchParams.set("error", "forbidden");
|
|
111
|
+
forbiddenUrl.searchParams.set("next", pathname);
|
|
112
|
+
return import_server.NextResponse.redirect(forbiddenUrl);
|
|
113
|
+
}
|
|
114
|
+
if (onAuthSuccess) {
|
|
115
|
+
await onAuthSuccess({
|
|
116
|
+
id: user.id,
|
|
117
|
+
email: user.email ?? void 0,
|
|
118
|
+
role: userRole
|
|
119
|
+
});
|
|
86
120
|
}
|
|
87
121
|
return response;
|
|
88
122
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/server/index.ts","../../src/server/middleware/withSupaAuth.ts","../../src/server/actions/actionWrapper.ts","../../src/shared/utils/error-handler.ts"],"sourcesContent":["// ── Server entry point ──────────────────────────────────────────────\n// This module should ONLY be imported in server-side contexts:\n// middleware.ts, Server Components, Server Actions, Route Handlers.\n\nexport { withSupaAuth } from \"./middleware/withSupaAuth\";\nexport { createAction } from \"./actions/actionWrapper\";\n\n// Re-export types consumers commonly need alongside server helpers.\nexport type { SupaAuthConfig, ActionResponse, SupaError } from \"../types\";\n","import { createServerClient, type CookieOptions } from \"@supabase/ssr\";\nimport { NextResponse, type NextRequest } from \"next/server\";\n\nimport type { SupaAuthConfig } from \"../../types\";\n\n/**\n * Create a Next.js middleware handler that protects routes based on\n * Supabase authentication state.\n *\n * @example\n * ```ts\n * // middleware.ts\n * import { withSupaAuth } from \"next-supa-utils/server\";\n *\n * export default withSupaAuth({\n * protectedRoutes: [\"/dashboard\", \"/admin\"],\n * redirectTo: \"/login\",\n * publicRoutes: [\"/admin/login\"],\n * });\n *\n * export const config = {\n * matcher: [\"/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)\"],\n * };\n * ```\n */\nexport function withSupaAuth(config: SupaAuthConfig) {\n const {\n protectedRoutes,\n redirectTo = \"/login\",\n publicRoutes = [],\n onAuthSuccess,\n } = config;\n\n return async function middleware(request: NextRequest): Promise<NextResponse> {\n // ── 1. Create a mutable response so Supabase can set cookies ──\n let response = NextResponse.next({\n request: { headers: request.headers },\n });\n\n const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\n const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\n if (!supabaseUrl || !supabaseAnonKey) {\n console.error(\n \"[next-supa-utils] Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.\",\n );\n return response;\n }\n\n // ── 2. Initialize server client with middleware cookie helpers ─\n const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return request.cookies.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n // Forward cookies to the request so downstream server\n // components can read the updated session.\n cookiesToSet.forEach(({ name, value }) => {\n request.cookies.set(name, value);\n });\n\n // Re-create the response so it carries the updated request.\n response = NextResponse.next({ request });\n\n // Set cookies on the outgoing response so the browser\n // stores the refreshed tokens.\n cookiesToSet.forEach(({ name, value, options }) => {\n response.cookies.set(name, value, options);\n });\n },\n },\n });\n\n // ── 3. Refresh session (required to keep tokens alive) ────────\n const {\n data: { user },\n } = await supabase.auth.getUser();\n\n const { pathname } = request.nextUrl;\n\n // ── 4. Check if current path matches a public override ────────\n const isPublicRoute = publicRoutes.some((route) =>\n pathname.startsWith(route),\n );\n\n if (isPublicRoute) {\n return response;\n }\n\n // ── 5. Check if current path requires authentication ──────────\n const isProtectedRoute = protectedRoutes.some((route) =>\n pathname.startsWith(route),\n );\n\n if (isProtectedRoute && !user) {\n const loginUrl = new URL(redirectTo, request.url);\n // Preserve the originally-requested URL so the app can redirect\n // back after login.\n loginUrl.searchParams.set(\"next\", pathname);\n return NextResponse.redirect(loginUrl);\n }\n\n // ── 6. Optional success callback ──────────────────────────────\n if (user && onAuthSuccess) {\n await onAuthSuccess({ id: user.id, email: user.email ?? undefined });\n }\n\n return response;\n };\n}\n","import { createServerClient, type CookieOptions } from \"@supabase/ssr\";\nimport { cookies } from \"next/headers\";\nimport type { SupabaseClient } from \"@supabase/supabase-js\";\n\nimport type { ActionResponse, SupaError } from \"../../types\";\nimport { handleSupaError } from \"../../shared/utils/error-handler\";\n\n/**\n * Create a type-safe Server Action that automatically:\n * 1. Initialises a Supabase server client (with cookies)\n * 2. Wraps execution in try/catch\n * 3. Returns a standardised `{ data, error }` response\n *\n * @example\n * ```ts\n * // app/actions/profile.ts\n * \"use server\";\n * import { createAction } from \"next-supa-utils/server\";\n *\n * export const getProfile = createAction(async (supabase, userId: string) => {\n * const { data, error } = await supabase\n * .from(\"profiles\")\n * .select(\"*\")\n * .eq(\"id\", userId)\n * .single();\n *\n * if (error) throw error;\n * return data;\n * });\n *\n * // Usage in a Server Component or Client Component:\n * const result = await getProfile(\"user-uuid\");\n * if (result.error) { ... }\n * ```\n */\nexport function createAction<TArgs extends unknown[], TResult>(\n fn: (supabase: SupabaseClient, ...args: TArgs) => Promise<TResult>,\n): (...args: TArgs) => Promise<ActionResponse<TResult>> {\n return async (...args: TArgs): Promise<ActionResponse<TResult>> => {\n try {\n const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\n const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\n if (!supabaseUrl || !supabaseAnonKey) {\n return {\n data: null,\n error: {\n message:\n \"Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.\",\n code: \"CONFIG_ERROR\",\n },\n };\n }\n\n const cookieStore = await cookies();\n\n const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return cookieStore.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n try {\n cookiesToSet.forEach(({ name, value, options }) => {\n cookieStore.set(name, value, options);\n });\n } catch {\n // `cookies().set()` throws when called from a Server Component.\n // In that context we only need read access — the middleware\n // handles token refresh.\n }\n },\n },\n });\n\n const data = await fn(supabase, ...args);\n\n return { data, error: null };\n } catch (caught: unknown) {\n const error: SupaError = handleSupaError(caught);\n return { data: null, error };\n }\n };\n}\n","import type { SupaError } from \"../../types\";\n\n/**\n * Normalize any thrown value into a consistent `SupaError` shape.\n *\n * Handles:\n * - Supabase `AuthError` / `PostgrestError` (has `.message` and optional `.code` / `.status`)\n * - Standard `Error` instances\n * - Plain strings\n * - Unknown values (fallback)\n */\nexport function handleSupaError(error: unknown): SupaError {\n // ── Supabase errors & standard Error instances ──────────────────\n if (error instanceof Error) {\n const record = error as unknown as Record<string, unknown>;\n return {\n message: error.message,\n code: typeof record.code === \"string\" ? record.code : undefined,\n status: typeof record.status === \"number\" ? record.status : undefined,\n };\n }\n\n // ── Plain object with a message property ────────────────────────\n if (\n typeof error === \"object\" &&\n error !== null &&\n \"message\" in error &&\n typeof (error as Record<string, unknown>).message === \"string\"\n ) {\n const err = error as Record<string, unknown>;\n return {\n message: err.message as string,\n code: typeof err.code === \"string\" ? err.code : undefined,\n status: typeof err.status === \"number\" ? err.status : undefined,\n };\n }\n\n // ── String ──────────────────────────────────────────────────────\n if (typeof error === \"string\") {\n return { message: error };\n }\n\n // ── Fallback ────────────────────────────────────────────────────\n return { message: \"An unknown error occurred\" };\n}\n"],"mappings":"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","names":["import_ssr"]}
|
|
1
|
+
{"version":3,"sources":["../../src/server/index.ts","../../src/server/middleware/withSupaAuth.ts","../../src/server/actions/actionWrapper.ts","../../src/shared/utils/error-handler.ts"],"sourcesContent":["// ── Server entry point ──────────────────────────────────────────────\n// This module should ONLY be imported in server-side contexts:\n// middleware.ts, Server Components, Server Actions, Route Handlers.\n\nexport { withSupaAuth } from \"./middleware/withSupaAuth\";\nexport { createAction } from \"./actions/actionWrapper\";\n\n// Re-export types consumers commonly need alongside server helpers.\nexport type { MiddlewareOptions, RouteConfig, ActionResponse, SupaError } from \"../types\";\n","import { createServerClient, type CookieOptions } from \"@supabase/ssr\";\nimport { NextResponse, type NextRequest } from \"next/server\";\n\nimport type { MiddlewareOptions, RouteConfig } from \"../../types\";\n\n// ── Path matching utility ───────────────────────────────────────────\n\n/**\n * Converts a route pattern into a RegExp for matching.\n *\n * Supports:\n * - Exact: `\"/settings\"` → matches only `/settings`\n * - Prefix: `\"/dashboard\"` → matches `/dashboard`, `/dashboard/stats`\n * - Wildcard: `\"/admin/:path*\"` → matches `/admin`, `/admin/users`, `/admin/a/b/c`\n */\nfunction matchPath(pattern: string, pathname: string): boolean {\n // Wildcard pattern: \"/admin/:path*\" → match \"/admin\" and everything below\n if (pattern.includes(\":path*\")) {\n const prefix = pattern.replace(/:path\\*$/, \"\").replace(/\\/$/, \"\");\n return pathname === prefix || pathname.startsWith(prefix + \"/\");\n }\n\n // Prefix matching: \"/dashboard\" matches \"/dashboard/anything\"\n return pathname === pattern || pathname.startsWith(pattern + \"/\");\n}\n\n/**\n * Finds the first `RouteConfig` whose pattern matches `pathname`, or `undefined`.\n */\nfunction findMatchingRoute(\n routes: RouteConfig[],\n pathname: string,\n): RouteConfig | undefined {\n return routes.find((route) => matchPath(route.path, pathname));\n}\n\n// ── Role extraction utility ─────────────────────────────────────────\n\ntype UserMeta = {\n user_metadata: Record<string, unknown>;\n app_metadata: Record<string, unknown>;\n};\n\nfunction extractRole(\n user: UserMeta,\n extractor: MiddlewareOptions[\"roleExtractor\"],\n): string | string[] | undefined {\n if (typeof extractor === \"function\") {\n return extractor(user);\n }\n\n const source =\n extractor === \"app_metadata\" ? user.app_metadata : user.user_metadata;\n\n const raw = source?.role;\n\n if (typeof raw === \"string\") return raw;\n if (Array.isArray(raw) && raw.every((r) => typeof r === \"string\")) {\n return raw as string[];\n }\n\n return undefined;\n}\n\n/**\n * Check whether the user's role(s) satisfy the route's `allowedRoles`.\n */\nfunction hasRequiredRole(\n userRole: string | string[] | undefined,\n allowedRoles: string[] | undefined,\n): boolean {\n // No role restriction → any authenticated user is allowed.\n if (!allowedRoles || allowedRoles.length === 0) return true;\n\n if (!userRole) return false;\n\n const roles = Array.isArray(userRole) ? userRole : [userRole];\n return roles.some((r) => allowedRoles.includes(r));\n}\n\n// ── Main middleware factory ─────────────────────────────────────────\n\n/**\n * Create a Next.js middleware handler that protects routes with\n * authentication **and** optional role-based access control (RBAC).\n *\n * @example\n * ```ts\n * // middleware.ts\n * import { withSupaAuth } from \"next-supa-utils/server\";\n *\n * export default withSupaAuth({\n * routes: [\n * { path: \"/dashboard\" }, // any authed user\n * { path: \"/admin/:path*\", allowedRoles: [\"admin\"] }, // admin only\n * { path: \"/editor/:path*\", allowedRoles: [\"admin\", \"editor\"] },\n * ],\n * redirectTo: \"/login\",\n * roleExtractor: \"user_metadata\", // or \"app_metadata\" or a custom fn\n * });\n *\n * export const config = {\n * matcher: [\"/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)\"],\n * };\n * ```\n */\nexport function withSupaAuth(options: MiddlewareOptions) {\n const {\n routes,\n redirectTo = \"/login\",\n roleExtractor = \"user_metadata\",\n onAuthSuccess,\n } = options;\n\n return async function middleware(request: NextRequest): Promise<NextResponse> {\n // ── 1. Create a mutable response so Supabase can set cookies ──\n let response = NextResponse.next({\n request: { headers: request.headers },\n });\n\n const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\n const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\n if (!supabaseUrl || !supabaseAnonKey) {\n console.error(\n \"[next-supa-utils] Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.\",\n );\n return response;\n }\n\n // ── 2. Initialize server client with middleware cookie helpers ─\n const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return request.cookies.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n cookiesToSet.forEach(({ name, value }) => {\n request.cookies.set(name, value);\n });\n\n response = NextResponse.next({ request });\n\n cookiesToSet.forEach(({ name, value, options }) => {\n response.cookies.set(name, value, options);\n });\n },\n },\n });\n\n // ── 3. Refresh session (required to keep tokens alive) ────────\n const {\n data: { user },\n } = await supabase.auth.getUser();\n\n const { pathname } = request.nextUrl;\n\n // ── 4. Find the matching route config ─────────────────────────\n const matchedRoute = findMatchingRoute(routes, pathname);\n\n // No matching route → not a protected path, pass through.\n if (!matchedRoute) {\n return response;\n }\n\n // ── 5. Check authentication ───────────────────────────────────\n if (!user) {\n const loginUrl = new URL(redirectTo, request.url);\n loginUrl.searchParams.set(\"next\", pathname);\n return NextResponse.redirect(loginUrl);\n }\n\n // ── 6. Check role-based access ────────────────────────────────\n const userRole = extractRole(user, roleExtractor);\n\n if (!hasRequiredRole(userRole, matchedRoute.allowedRoles)) {\n // User is logged in but lacks the required role.\n const forbiddenUrl = new URL(redirectTo, request.url);\n forbiddenUrl.searchParams.set(\"error\", \"forbidden\");\n forbiddenUrl.searchParams.set(\"next\", pathname);\n return NextResponse.redirect(forbiddenUrl);\n }\n\n // ── 7. Success callback ───────────────────────────────────────\n if (onAuthSuccess) {\n await onAuthSuccess({\n id: user.id,\n email: user.email ?? undefined,\n role: userRole,\n });\n }\n\n return response;\n };\n}\n","import { createServerClient, type CookieOptions } from \"@supabase/ssr\";\nimport { cookies } from \"next/headers\";\nimport type { SupabaseClient } from \"@supabase/supabase-js\";\n\nimport type { ActionResponse, SupaError } from \"../../types\";\nimport { handleSupaError } from \"../../shared/utils/error-handler\";\n\n/**\n * Create a type-safe Server Action that automatically:\n * 1. Initialises a Supabase server client (with cookies)\n * 2. Wraps execution in try/catch\n * 3. Returns a standardised `{ data, error }` response\n *\n * @example\n * ```ts\n * // app/actions/profile.ts\n * \"use server\";\n * import { createAction } from \"next-supa-utils/server\";\n *\n * export const getProfile = createAction(async (supabase, userId: string) => {\n * const { data, error } = await supabase\n * .from(\"profiles\")\n * .select(\"*\")\n * .eq(\"id\", userId)\n * .single();\n *\n * if (error) throw error;\n * return data;\n * });\n *\n * // Usage in a Server Component or Client Component:\n * const result = await getProfile(\"user-uuid\");\n * if (result.error) { ... }\n * ```\n */\nexport function createAction<TArgs extends unknown[], TResult>(\n fn: (supabase: SupabaseClient, ...args: TArgs) => Promise<TResult>,\n): (...args: TArgs) => Promise<ActionResponse<TResult>> {\n return async (...args: TArgs): Promise<ActionResponse<TResult>> => {\n try {\n const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\n const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\n if (!supabaseUrl || !supabaseAnonKey) {\n return {\n data: null,\n error: {\n message:\n \"Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.\",\n code: \"CONFIG_ERROR\",\n },\n };\n }\n\n const cookieStore = await cookies();\n\n const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return cookieStore.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n try {\n cookiesToSet.forEach(({ name, value, options }) => {\n cookieStore.set(name, value, options);\n });\n } catch {\n // `cookies().set()` throws when called from a Server Component.\n // In that context we only need read access — the middleware\n // handles token refresh.\n }\n },\n },\n });\n\n const data = await fn(supabase, ...args);\n\n return { data, error: null };\n } catch (caught: unknown) {\n const error: SupaError = handleSupaError(caught);\n return { data: null, error };\n }\n };\n}\n","import type { SupaError } from \"../../types\";\n\n/**\n * Normalize any thrown value into a consistent `SupaError` shape.\n *\n * Handles:\n * - Supabase `AuthError` / `PostgrestError` (has `.message` and optional `.code` / `.status`)\n * - Standard `Error` instances\n * - Plain strings\n * - Unknown values (fallback)\n */\nexport function handleSupaError(error: unknown): SupaError {\n // ── Supabase errors & standard Error instances ──────────────────\n if (error instanceof Error) {\n const record = error as unknown as Record<string, unknown>;\n return {\n message: error.message,\n code: typeof record.code === \"string\" ? record.code : undefined,\n status: typeof record.status === \"number\" ? record.status : undefined,\n };\n }\n\n // ── Plain object with a message property ────────────────────────\n if (\n typeof error === \"object\" &&\n error !== null &&\n \"message\" in error &&\n typeof (error as Record<string, unknown>).message === \"string\"\n ) {\n const err = error as Record<string, unknown>;\n return {\n message: err.message as string,\n code: typeof err.code === \"string\" ? err.code : undefined,\n status: typeof err.status === \"number\" ? err.status : undefined,\n };\n }\n\n // ── String ──────────────────────────────────────────────────────\n if (typeof error === \"string\") {\n return { message: error };\n }\n\n // ── Fallback ────────────────────────────────────────────────────\n return { message: \"An unknown error occurred\" };\n}\n"],"mappings":"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","names":["options","import_ssr"]}
|
package/dist/server/index.d.cts
CHANGED
|
@@ -15,38 +15,75 @@ type ActionResponse<T> = {
|
|
|
15
15
|
data: null;
|
|
16
16
|
error: SupaError;
|
|
17
17
|
};
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Defines a protected route with optional role-based access control.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* { path: "/admin/:path*", allowedRoles: ["admin", "super_admin"] }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
interface RouteConfig {
|
|
27
|
+
/**
|
|
28
|
+
* Route pattern to match.
|
|
29
|
+
*
|
|
30
|
+
* Supports:
|
|
31
|
+
* - Exact paths: `"/settings"`
|
|
32
|
+
* - Prefix matching: `"/dashboard"` matches `/dashboard/anything`
|
|
33
|
+
* - Wildcards: `"/admin/:path*"` matches `/admin/users`, `/admin/settings/edit`, etc.
|
|
34
|
+
*/
|
|
35
|
+
path: string;
|
|
19
36
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
37
|
+
* Roles that are allowed to access this route.
|
|
38
|
+
*
|
|
39
|
+
* - If **omitted or empty**, any *authenticated* user can access the route.
|
|
40
|
+
* - If **provided**, only users whose role is in this list are allowed.
|
|
22
41
|
*
|
|
23
|
-
* @example ["
|
|
42
|
+
* @example ["admin", "editor"]
|
|
43
|
+
*/
|
|
44
|
+
allowedRoles?: string[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Configuration for the `withSupaAuth` middleware.
|
|
48
|
+
*/
|
|
49
|
+
interface MiddlewareOptions {
|
|
50
|
+
/**
|
|
51
|
+
* Protected route definitions. Each entry can optionally restrict access
|
|
52
|
+
* to specific roles via `allowedRoles`.
|
|
24
53
|
*/
|
|
25
|
-
|
|
54
|
+
routes: RouteConfig[];
|
|
26
55
|
/**
|
|
27
|
-
* Where to redirect unauthenticated users.
|
|
56
|
+
* Where to redirect unauthenticated or unauthorized users.
|
|
28
57
|
* @default "/login"
|
|
29
58
|
*/
|
|
30
59
|
redirectTo?: string;
|
|
31
60
|
/**
|
|
32
|
-
*
|
|
61
|
+
* Strategy for extracting the user's role from the Supabase user object.
|
|
62
|
+
*
|
|
63
|
+
* - `"user_metadata"` — reads `user.user_metadata.role`
|
|
64
|
+
* - `"app_metadata"` — reads `user.app_metadata.role`
|
|
65
|
+
* - A custom function for advanced scenarios (e.g. multiple roles, JWT claims).
|
|
33
66
|
*
|
|
34
|
-
* @
|
|
67
|
+
* @default "user_metadata"
|
|
35
68
|
*/
|
|
36
|
-
|
|
69
|
+
roleExtractor?: "user_metadata" | "app_metadata" | ((user: {
|
|
70
|
+
user_metadata: Record<string, unknown>;
|
|
71
|
+
app_metadata: Record<string, unknown>;
|
|
72
|
+
}) => string | string[] | undefined);
|
|
37
73
|
/**
|
|
38
|
-
* Optional callback invoked
|
|
39
|
-
*
|
|
74
|
+
* Optional callback invoked when a user is authenticated and authorized.
|
|
75
|
+
* Useful for logging, analytics, or injecting custom response headers.
|
|
40
76
|
*/
|
|
41
77
|
onAuthSuccess?: (user: {
|
|
42
78
|
id: string;
|
|
43
79
|
email?: string;
|
|
80
|
+
role?: string | string[];
|
|
44
81
|
}) => void | Promise<void>;
|
|
45
82
|
}
|
|
46
83
|
|
|
47
84
|
/**
|
|
48
|
-
* Create a Next.js middleware handler that protects routes
|
|
49
|
-
*
|
|
85
|
+
* Create a Next.js middleware handler that protects routes with
|
|
86
|
+
* authentication **and** optional role-based access control (RBAC).
|
|
50
87
|
*
|
|
51
88
|
* @example
|
|
52
89
|
* ```ts
|
|
@@ -54,9 +91,13 @@ interface SupaAuthConfig {
|
|
|
54
91
|
* import { withSupaAuth } from "next-supa-utils/server";
|
|
55
92
|
*
|
|
56
93
|
* export default withSupaAuth({
|
|
57
|
-
*
|
|
94
|
+
* routes: [
|
|
95
|
+
* { path: "/dashboard" }, // any authed user
|
|
96
|
+
* { path: "/admin/:path*", allowedRoles: ["admin"] }, // admin only
|
|
97
|
+
* { path: "/editor/:path*", allowedRoles: ["admin", "editor"] },
|
|
98
|
+
* ],
|
|
58
99
|
* redirectTo: "/login",
|
|
59
|
-
*
|
|
100
|
+
* roleExtractor: "user_metadata", // or "app_metadata" or a custom fn
|
|
60
101
|
* });
|
|
61
102
|
*
|
|
62
103
|
* export const config = {
|
|
@@ -64,7 +105,7 @@ interface SupaAuthConfig {
|
|
|
64
105
|
* };
|
|
65
106
|
* ```
|
|
66
107
|
*/
|
|
67
|
-
declare function withSupaAuth(
|
|
108
|
+
declare function withSupaAuth(options: MiddlewareOptions): (request: NextRequest) => Promise<NextResponse>;
|
|
68
109
|
|
|
69
110
|
/**
|
|
70
111
|
* Create a type-safe Server Action that automatically:
|
|
@@ -96,4 +137,4 @@ declare function withSupaAuth(config: SupaAuthConfig): (request: NextRequest) =>
|
|
|
96
137
|
*/
|
|
97
138
|
declare function createAction<TArgs extends unknown[], TResult>(fn: (supabase: SupabaseClient, ...args: TArgs) => Promise<TResult>): (...args: TArgs) => Promise<ActionResponse<TResult>>;
|
|
98
139
|
|
|
99
|
-
export { type ActionResponse, type
|
|
140
|
+
export { type ActionResponse, type MiddlewareOptions, type RouteConfig, type SupaError, createAction, withSupaAuth };
|
package/dist/server/index.d.ts
CHANGED
|
@@ -15,38 +15,75 @@ type ActionResponse<T> = {
|
|
|
15
15
|
data: null;
|
|
16
16
|
error: SupaError;
|
|
17
17
|
};
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Defines a protected route with optional role-based access control.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* { path: "/admin/:path*", allowedRoles: ["admin", "super_admin"] }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
interface RouteConfig {
|
|
27
|
+
/**
|
|
28
|
+
* Route pattern to match.
|
|
29
|
+
*
|
|
30
|
+
* Supports:
|
|
31
|
+
* - Exact paths: `"/settings"`
|
|
32
|
+
* - Prefix matching: `"/dashboard"` matches `/dashboard/anything`
|
|
33
|
+
* - Wildcards: `"/admin/:path*"` matches `/admin/users`, `/admin/settings/edit`, etc.
|
|
34
|
+
*/
|
|
35
|
+
path: string;
|
|
19
36
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
37
|
+
* Roles that are allowed to access this route.
|
|
38
|
+
*
|
|
39
|
+
* - If **omitted or empty**, any *authenticated* user can access the route.
|
|
40
|
+
* - If **provided**, only users whose role is in this list are allowed.
|
|
22
41
|
*
|
|
23
|
-
* @example ["
|
|
42
|
+
* @example ["admin", "editor"]
|
|
43
|
+
*/
|
|
44
|
+
allowedRoles?: string[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Configuration for the `withSupaAuth` middleware.
|
|
48
|
+
*/
|
|
49
|
+
interface MiddlewareOptions {
|
|
50
|
+
/**
|
|
51
|
+
* Protected route definitions. Each entry can optionally restrict access
|
|
52
|
+
* to specific roles via `allowedRoles`.
|
|
24
53
|
*/
|
|
25
|
-
|
|
54
|
+
routes: RouteConfig[];
|
|
26
55
|
/**
|
|
27
|
-
* Where to redirect unauthenticated users.
|
|
56
|
+
* Where to redirect unauthenticated or unauthorized users.
|
|
28
57
|
* @default "/login"
|
|
29
58
|
*/
|
|
30
59
|
redirectTo?: string;
|
|
31
60
|
/**
|
|
32
|
-
*
|
|
61
|
+
* Strategy for extracting the user's role from the Supabase user object.
|
|
62
|
+
*
|
|
63
|
+
* - `"user_metadata"` — reads `user.user_metadata.role`
|
|
64
|
+
* - `"app_metadata"` — reads `user.app_metadata.role`
|
|
65
|
+
* - A custom function for advanced scenarios (e.g. multiple roles, JWT claims).
|
|
33
66
|
*
|
|
34
|
-
* @
|
|
67
|
+
* @default "user_metadata"
|
|
35
68
|
*/
|
|
36
|
-
|
|
69
|
+
roleExtractor?: "user_metadata" | "app_metadata" | ((user: {
|
|
70
|
+
user_metadata: Record<string, unknown>;
|
|
71
|
+
app_metadata: Record<string, unknown>;
|
|
72
|
+
}) => string | string[] | undefined);
|
|
37
73
|
/**
|
|
38
|
-
* Optional callback invoked
|
|
39
|
-
*
|
|
74
|
+
* Optional callback invoked when a user is authenticated and authorized.
|
|
75
|
+
* Useful for logging, analytics, or injecting custom response headers.
|
|
40
76
|
*/
|
|
41
77
|
onAuthSuccess?: (user: {
|
|
42
78
|
id: string;
|
|
43
79
|
email?: string;
|
|
80
|
+
role?: string | string[];
|
|
44
81
|
}) => void | Promise<void>;
|
|
45
82
|
}
|
|
46
83
|
|
|
47
84
|
/**
|
|
48
|
-
* Create a Next.js middleware handler that protects routes
|
|
49
|
-
*
|
|
85
|
+
* Create a Next.js middleware handler that protects routes with
|
|
86
|
+
* authentication **and** optional role-based access control (RBAC).
|
|
50
87
|
*
|
|
51
88
|
* @example
|
|
52
89
|
* ```ts
|
|
@@ -54,9 +91,13 @@ interface SupaAuthConfig {
|
|
|
54
91
|
* import { withSupaAuth } from "next-supa-utils/server";
|
|
55
92
|
*
|
|
56
93
|
* export default withSupaAuth({
|
|
57
|
-
*
|
|
94
|
+
* routes: [
|
|
95
|
+
* { path: "/dashboard" }, // any authed user
|
|
96
|
+
* { path: "/admin/:path*", allowedRoles: ["admin"] }, // admin only
|
|
97
|
+
* { path: "/editor/:path*", allowedRoles: ["admin", "editor"] },
|
|
98
|
+
* ],
|
|
58
99
|
* redirectTo: "/login",
|
|
59
|
-
*
|
|
100
|
+
* roleExtractor: "user_metadata", // or "app_metadata" or a custom fn
|
|
60
101
|
* });
|
|
61
102
|
*
|
|
62
103
|
* export const config = {
|
|
@@ -64,7 +105,7 @@ interface SupaAuthConfig {
|
|
|
64
105
|
* };
|
|
65
106
|
* ```
|
|
66
107
|
*/
|
|
67
|
-
declare function withSupaAuth(
|
|
108
|
+
declare function withSupaAuth(options: MiddlewareOptions): (request: NextRequest) => Promise<NextResponse>;
|
|
68
109
|
|
|
69
110
|
/**
|
|
70
111
|
* Create a type-safe Server Action that automatically:
|
|
@@ -96,4 +137,4 @@ declare function withSupaAuth(config: SupaAuthConfig): (request: NextRequest) =>
|
|
|
96
137
|
*/
|
|
97
138
|
declare function createAction<TArgs extends unknown[], TResult>(fn: (supabase: SupabaseClient, ...args: TArgs) => Promise<TResult>): (...args: TArgs) => Promise<ActionResponse<TResult>>;
|
|
98
139
|
|
|
99
|
-
export { type ActionResponse, type
|
|
140
|
+
export { type ActionResponse, type MiddlewareOptions, type RouteConfig, type SupaError, createAction, withSupaAuth };
|
package/dist/server/index.js
CHANGED
|
@@ -1,13 +1,41 @@
|
|
|
1
1
|
// src/server/middleware/withSupaAuth.ts
|
|
2
2
|
import { createServerClient } from "@supabase/ssr";
|
|
3
3
|
import { NextResponse } from "next/server";
|
|
4
|
-
function
|
|
4
|
+
function matchPath(pattern, pathname) {
|
|
5
|
+
if (pattern.includes(":path*")) {
|
|
6
|
+
const prefix = pattern.replace(/:path\*$/, "").replace(/\/$/, "");
|
|
7
|
+
return pathname === prefix || pathname.startsWith(prefix + "/");
|
|
8
|
+
}
|
|
9
|
+
return pathname === pattern || pathname.startsWith(pattern + "/");
|
|
10
|
+
}
|
|
11
|
+
function findMatchingRoute(routes, pathname) {
|
|
12
|
+
return routes.find((route) => matchPath(route.path, pathname));
|
|
13
|
+
}
|
|
14
|
+
function extractRole(user, extractor) {
|
|
15
|
+
if (typeof extractor === "function") {
|
|
16
|
+
return extractor(user);
|
|
17
|
+
}
|
|
18
|
+
const source = extractor === "app_metadata" ? user.app_metadata : user.user_metadata;
|
|
19
|
+
const raw = source?.role;
|
|
20
|
+
if (typeof raw === "string") return raw;
|
|
21
|
+
if (Array.isArray(raw) && raw.every((r) => typeof r === "string")) {
|
|
22
|
+
return raw;
|
|
23
|
+
}
|
|
24
|
+
return void 0;
|
|
25
|
+
}
|
|
26
|
+
function hasRequiredRole(userRole, allowedRoles) {
|
|
27
|
+
if (!allowedRoles || allowedRoles.length === 0) return true;
|
|
28
|
+
if (!userRole) return false;
|
|
29
|
+
const roles = Array.isArray(userRole) ? userRole : [userRole];
|
|
30
|
+
return roles.some((r) => allowedRoles.includes(r));
|
|
31
|
+
}
|
|
32
|
+
function withSupaAuth(options) {
|
|
5
33
|
const {
|
|
6
|
-
|
|
34
|
+
routes,
|
|
7
35
|
redirectTo = "/login",
|
|
8
|
-
|
|
36
|
+
roleExtractor = "user_metadata",
|
|
9
37
|
onAuthSuccess
|
|
10
|
-
} =
|
|
38
|
+
} = options;
|
|
11
39
|
return async function middleware(request) {
|
|
12
40
|
let response = NextResponse.next({
|
|
13
41
|
request: { headers: request.headers }
|
|
@@ -30,8 +58,8 @@ function withSupaAuth(config) {
|
|
|
30
58
|
request.cookies.set(name, value);
|
|
31
59
|
});
|
|
32
60
|
response = NextResponse.next({ request });
|
|
33
|
-
cookiesToSet.forEach(({ name, value, options }) => {
|
|
34
|
-
response.cookies.set(name, value,
|
|
61
|
+
cookiesToSet.forEach(({ name, value, options: options2 }) => {
|
|
62
|
+
response.cookies.set(name, value, options2);
|
|
35
63
|
});
|
|
36
64
|
}
|
|
37
65
|
}
|
|
@@ -40,22 +68,28 @@ function withSupaAuth(config) {
|
|
|
40
68
|
data: { user }
|
|
41
69
|
} = await supabase.auth.getUser();
|
|
42
70
|
const { pathname } = request.nextUrl;
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
);
|
|
46
|
-
if (isPublicRoute) {
|
|
71
|
+
const matchedRoute = findMatchingRoute(routes, pathname);
|
|
72
|
+
if (!matchedRoute) {
|
|
47
73
|
return response;
|
|
48
74
|
}
|
|
49
|
-
|
|
50
|
-
(route) => pathname.startsWith(route)
|
|
51
|
-
);
|
|
52
|
-
if (isProtectedRoute && !user) {
|
|
75
|
+
if (!user) {
|
|
53
76
|
const loginUrl = new URL(redirectTo, request.url);
|
|
54
77
|
loginUrl.searchParams.set("next", pathname);
|
|
55
78
|
return NextResponse.redirect(loginUrl);
|
|
56
79
|
}
|
|
57
|
-
|
|
58
|
-
|
|
80
|
+
const userRole = extractRole(user, roleExtractor);
|
|
81
|
+
if (!hasRequiredRole(userRole, matchedRoute.allowedRoles)) {
|
|
82
|
+
const forbiddenUrl = new URL(redirectTo, request.url);
|
|
83
|
+
forbiddenUrl.searchParams.set("error", "forbidden");
|
|
84
|
+
forbiddenUrl.searchParams.set("next", pathname);
|
|
85
|
+
return NextResponse.redirect(forbiddenUrl);
|
|
86
|
+
}
|
|
87
|
+
if (onAuthSuccess) {
|
|
88
|
+
await onAuthSuccess({
|
|
89
|
+
id: user.id,
|
|
90
|
+
email: user.email ?? void 0,
|
|
91
|
+
role: userRole
|
|
92
|
+
});
|
|
59
93
|
}
|
|
60
94
|
return response;
|
|
61
95
|
};
|
package/dist/server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/server/middleware/withSupaAuth.ts","../../src/server/actions/actionWrapper.ts","../../src/shared/utils/error-handler.ts"],"sourcesContent":["import { createServerClient, type CookieOptions } from \"@supabase/ssr\";\nimport { NextResponse, type NextRequest } from \"next/server\";\n\nimport type { SupaAuthConfig } from \"../../types\";\n\n/**\n * Create a Next.js middleware handler that protects routes based on\n * Supabase authentication state.\n *\n * @example\n * ```ts\n * // middleware.ts\n * import { withSupaAuth } from \"next-supa-utils/server\";\n *\n * export default withSupaAuth({\n * protectedRoutes: [\"/dashboard\", \"/admin\"],\n * redirectTo: \"/login\",\n * publicRoutes: [\"/admin/login\"],\n * });\n *\n * export const config = {\n * matcher: [\"/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)\"],\n * };\n * ```\n */\nexport function withSupaAuth(config: SupaAuthConfig) {\n const {\n protectedRoutes,\n redirectTo = \"/login\",\n publicRoutes = [],\n onAuthSuccess,\n } = config;\n\n return async function middleware(request: NextRequest): Promise<NextResponse> {\n // ── 1. Create a mutable response so Supabase can set cookies ──\n let response = NextResponse.next({\n request: { headers: request.headers },\n });\n\n const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\n const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\n if (!supabaseUrl || !supabaseAnonKey) {\n console.error(\n \"[next-supa-utils] Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.\",\n );\n return response;\n }\n\n // ── 2. Initialize server client with middleware cookie helpers ─\n const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return request.cookies.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n // Forward cookies to the request so downstream server\n // components can read the updated session.\n cookiesToSet.forEach(({ name, value }) => {\n request.cookies.set(name, value);\n });\n\n // Re-create the response so it carries the updated request.\n response = NextResponse.next({ request });\n\n // Set cookies on the outgoing response so the browser\n // stores the refreshed tokens.\n cookiesToSet.forEach(({ name, value, options }) => {\n response.cookies.set(name, value, options);\n });\n },\n },\n });\n\n // ── 3. Refresh session (required to keep tokens alive) ────────\n const {\n data: { user },\n } = await supabase.auth.getUser();\n\n const { pathname } = request.nextUrl;\n\n // ── 4. Check if current path matches a public override ────────\n const isPublicRoute = publicRoutes.some((route) =>\n pathname.startsWith(route),\n );\n\n if (isPublicRoute) {\n return response;\n }\n\n // ── 5. Check if current path requires authentication ──────────\n const isProtectedRoute = protectedRoutes.some((route) =>\n pathname.startsWith(route),\n );\n\n if (isProtectedRoute && !user) {\n const loginUrl = new URL(redirectTo, request.url);\n // Preserve the originally-requested URL so the app can redirect\n // back after login.\n loginUrl.searchParams.set(\"next\", pathname);\n return NextResponse.redirect(loginUrl);\n }\n\n // ── 6. Optional success callback ──────────────────────────────\n if (user && onAuthSuccess) {\n await onAuthSuccess({ id: user.id, email: user.email ?? undefined });\n }\n\n return response;\n };\n}\n","import { createServerClient, type CookieOptions } from \"@supabase/ssr\";\nimport { cookies } from \"next/headers\";\nimport type { SupabaseClient } from \"@supabase/supabase-js\";\n\nimport type { ActionResponse, SupaError } from \"../../types\";\nimport { handleSupaError } from \"../../shared/utils/error-handler\";\n\n/**\n * Create a type-safe Server Action that automatically:\n * 1. Initialises a Supabase server client (with cookies)\n * 2. Wraps execution in try/catch\n * 3. Returns a standardised `{ data, error }` response\n *\n * @example\n * ```ts\n * // app/actions/profile.ts\n * \"use server\";\n * import { createAction } from \"next-supa-utils/server\";\n *\n * export const getProfile = createAction(async (supabase, userId: string) => {\n * const { data, error } = await supabase\n * .from(\"profiles\")\n * .select(\"*\")\n * .eq(\"id\", userId)\n * .single();\n *\n * if (error) throw error;\n * return data;\n * });\n *\n * // Usage in a Server Component or Client Component:\n * const result = await getProfile(\"user-uuid\");\n * if (result.error) { ... }\n * ```\n */\nexport function createAction<TArgs extends unknown[], TResult>(\n fn: (supabase: SupabaseClient, ...args: TArgs) => Promise<TResult>,\n): (...args: TArgs) => Promise<ActionResponse<TResult>> {\n return async (...args: TArgs): Promise<ActionResponse<TResult>> => {\n try {\n const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\n const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\n if (!supabaseUrl || !supabaseAnonKey) {\n return {\n data: null,\n error: {\n message:\n \"Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.\",\n code: \"CONFIG_ERROR\",\n },\n };\n }\n\n const cookieStore = await cookies();\n\n const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return cookieStore.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n try {\n cookiesToSet.forEach(({ name, value, options }) => {\n cookieStore.set(name, value, options);\n });\n } catch {\n // `cookies().set()` throws when called from a Server Component.\n // In that context we only need read access — the middleware\n // handles token refresh.\n }\n },\n },\n });\n\n const data = await fn(supabase, ...args);\n\n return { data, error: null };\n } catch (caught: unknown) {\n const error: SupaError = handleSupaError(caught);\n return { data: null, error };\n }\n };\n}\n","import type { SupaError } from \"../../types\";\n\n/**\n * Normalize any thrown value into a consistent `SupaError` shape.\n *\n * Handles:\n * - Supabase `AuthError` / `PostgrestError` (has `.message` and optional `.code` / `.status`)\n * - Standard `Error` instances\n * - Plain strings\n * - Unknown values (fallback)\n */\nexport function handleSupaError(error: unknown): SupaError {\n // ── Supabase errors & standard Error instances ──────────────────\n if (error instanceof Error) {\n const record = error as unknown as Record<string, unknown>;\n return {\n message: error.message,\n code: typeof record.code === \"string\" ? record.code : undefined,\n status: typeof record.status === \"number\" ? record.status : undefined,\n };\n }\n\n // ── Plain object with a message property ────────────────────────\n if (\n typeof error === \"object\" &&\n error !== null &&\n \"message\" in error &&\n typeof (error as Record<string, unknown>).message === \"string\"\n ) {\n const err = error as Record<string, unknown>;\n return {\n message: err.message as string,\n code: typeof err.code === \"string\" ? err.code : undefined,\n status: typeof err.status === \"number\" ? err.status : undefined,\n };\n }\n\n // ── String ──────────────────────────────────────────────────────\n if (typeof error === \"string\") {\n return { message: error };\n }\n\n // ── Fallback ────────────────────────────────────────────────────\n return { message: \"An unknown error occurred\" };\n}\n"],"mappings":"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","names":["createServerClient","createServerClient"]}
|
|
1
|
+
{"version":3,"sources":["../../src/server/middleware/withSupaAuth.ts","../../src/server/actions/actionWrapper.ts","../../src/shared/utils/error-handler.ts"],"sourcesContent":["import { createServerClient, type CookieOptions } from \"@supabase/ssr\";\nimport { NextResponse, type NextRequest } from \"next/server\";\n\nimport type { MiddlewareOptions, RouteConfig } from \"../../types\";\n\n// ── Path matching utility ───────────────────────────────────────────\n\n/**\n * Converts a route pattern into a RegExp for matching.\n *\n * Supports:\n * - Exact: `\"/settings\"` → matches only `/settings`\n * - Prefix: `\"/dashboard\"` → matches `/dashboard`, `/dashboard/stats`\n * - Wildcard: `\"/admin/:path*\"` → matches `/admin`, `/admin/users`, `/admin/a/b/c`\n */\nfunction matchPath(pattern: string, pathname: string): boolean {\n // Wildcard pattern: \"/admin/:path*\" → match \"/admin\" and everything below\n if (pattern.includes(\":path*\")) {\n const prefix = pattern.replace(/:path\\*$/, \"\").replace(/\\/$/, \"\");\n return pathname === prefix || pathname.startsWith(prefix + \"/\");\n }\n\n // Prefix matching: \"/dashboard\" matches \"/dashboard/anything\"\n return pathname === pattern || pathname.startsWith(pattern + \"/\");\n}\n\n/**\n * Finds the first `RouteConfig` whose pattern matches `pathname`, or `undefined`.\n */\nfunction findMatchingRoute(\n routes: RouteConfig[],\n pathname: string,\n): RouteConfig | undefined {\n return routes.find((route) => matchPath(route.path, pathname));\n}\n\n// ── Role extraction utility ─────────────────────────────────────────\n\ntype UserMeta = {\n user_metadata: Record<string, unknown>;\n app_metadata: Record<string, unknown>;\n};\n\nfunction extractRole(\n user: UserMeta,\n extractor: MiddlewareOptions[\"roleExtractor\"],\n): string | string[] | undefined {\n if (typeof extractor === \"function\") {\n return extractor(user);\n }\n\n const source =\n extractor === \"app_metadata\" ? user.app_metadata : user.user_metadata;\n\n const raw = source?.role;\n\n if (typeof raw === \"string\") return raw;\n if (Array.isArray(raw) && raw.every((r) => typeof r === \"string\")) {\n return raw as string[];\n }\n\n return undefined;\n}\n\n/**\n * Check whether the user's role(s) satisfy the route's `allowedRoles`.\n */\nfunction hasRequiredRole(\n userRole: string | string[] | undefined,\n allowedRoles: string[] | undefined,\n): boolean {\n // No role restriction → any authenticated user is allowed.\n if (!allowedRoles || allowedRoles.length === 0) return true;\n\n if (!userRole) return false;\n\n const roles = Array.isArray(userRole) ? userRole : [userRole];\n return roles.some((r) => allowedRoles.includes(r));\n}\n\n// ── Main middleware factory ─────────────────────────────────────────\n\n/**\n * Create a Next.js middleware handler that protects routes with\n * authentication **and** optional role-based access control (RBAC).\n *\n * @example\n * ```ts\n * // middleware.ts\n * import { withSupaAuth } from \"next-supa-utils/server\";\n *\n * export default withSupaAuth({\n * routes: [\n * { path: \"/dashboard\" }, // any authed user\n * { path: \"/admin/:path*\", allowedRoles: [\"admin\"] }, // admin only\n * { path: \"/editor/:path*\", allowedRoles: [\"admin\", \"editor\"] },\n * ],\n * redirectTo: \"/login\",\n * roleExtractor: \"user_metadata\", // or \"app_metadata\" or a custom fn\n * });\n *\n * export const config = {\n * matcher: [\"/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)\"],\n * };\n * ```\n */\nexport function withSupaAuth(options: MiddlewareOptions) {\n const {\n routes,\n redirectTo = \"/login\",\n roleExtractor = \"user_metadata\",\n onAuthSuccess,\n } = options;\n\n return async function middleware(request: NextRequest): Promise<NextResponse> {\n // ── 1. Create a mutable response so Supabase can set cookies ──\n let response = NextResponse.next({\n request: { headers: request.headers },\n });\n\n const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\n const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\n if (!supabaseUrl || !supabaseAnonKey) {\n console.error(\n \"[next-supa-utils] Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.\",\n );\n return response;\n }\n\n // ── 2. Initialize server client with middleware cookie helpers ─\n const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return request.cookies.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n cookiesToSet.forEach(({ name, value }) => {\n request.cookies.set(name, value);\n });\n\n response = NextResponse.next({ request });\n\n cookiesToSet.forEach(({ name, value, options }) => {\n response.cookies.set(name, value, options);\n });\n },\n },\n });\n\n // ── 3. Refresh session (required to keep tokens alive) ────────\n const {\n data: { user },\n } = await supabase.auth.getUser();\n\n const { pathname } = request.nextUrl;\n\n // ── 4. Find the matching route config ─────────────────────────\n const matchedRoute = findMatchingRoute(routes, pathname);\n\n // No matching route → not a protected path, pass through.\n if (!matchedRoute) {\n return response;\n }\n\n // ── 5. Check authentication ───────────────────────────────────\n if (!user) {\n const loginUrl = new URL(redirectTo, request.url);\n loginUrl.searchParams.set(\"next\", pathname);\n return NextResponse.redirect(loginUrl);\n }\n\n // ── 6. Check role-based access ────────────────────────────────\n const userRole = extractRole(user, roleExtractor);\n\n if (!hasRequiredRole(userRole, matchedRoute.allowedRoles)) {\n // User is logged in but lacks the required role.\n const forbiddenUrl = new URL(redirectTo, request.url);\n forbiddenUrl.searchParams.set(\"error\", \"forbidden\");\n forbiddenUrl.searchParams.set(\"next\", pathname);\n return NextResponse.redirect(forbiddenUrl);\n }\n\n // ── 7. Success callback ───────────────────────────────────────\n if (onAuthSuccess) {\n await onAuthSuccess({\n id: user.id,\n email: user.email ?? undefined,\n role: userRole,\n });\n }\n\n return response;\n };\n}\n","import { createServerClient, type CookieOptions } from \"@supabase/ssr\";\nimport { cookies } from \"next/headers\";\nimport type { SupabaseClient } from \"@supabase/supabase-js\";\n\nimport type { ActionResponse, SupaError } from \"../../types\";\nimport { handleSupaError } from \"../../shared/utils/error-handler\";\n\n/**\n * Create a type-safe Server Action that automatically:\n * 1. Initialises a Supabase server client (with cookies)\n * 2. Wraps execution in try/catch\n * 3. Returns a standardised `{ data, error }` response\n *\n * @example\n * ```ts\n * // app/actions/profile.ts\n * \"use server\";\n * import { createAction } from \"next-supa-utils/server\";\n *\n * export const getProfile = createAction(async (supabase, userId: string) => {\n * const { data, error } = await supabase\n * .from(\"profiles\")\n * .select(\"*\")\n * .eq(\"id\", userId)\n * .single();\n *\n * if (error) throw error;\n * return data;\n * });\n *\n * // Usage in a Server Component or Client Component:\n * const result = await getProfile(\"user-uuid\");\n * if (result.error) { ... }\n * ```\n */\nexport function createAction<TArgs extends unknown[], TResult>(\n fn: (supabase: SupabaseClient, ...args: TArgs) => Promise<TResult>,\n): (...args: TArgs) => Promise<ActionResponse<TResult>> {\n return async (...args: TArgs): Promise<ActionResponse<TResult>> => {\n try {\n const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;\n const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;\n\n if (!supabaseUrl || !supabaseAnonKey) {\n return {\n data: null,\n error: {\n message:\n \"Missing NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.\",\n code: \"CONFIG_ERROR\",\n },\n };\n }\n\n const cookieStore = await cookies();\n\n const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {\n cookies: {\n getAll() {\n return cookieStore.getAll();\n },\n setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {\n try {\n cookiesToSet.forEach(({ name, value, options }) => {\n cookieStore.set(name, value, options);\n });\n } catch {\n // `cookies().set()` throws when called from a Server Component.\n // In that context we only need read access — the middleware\n // handles token refresh.\n }\n },\n },\n });\n\n const data = await fn(supabase, ...args);\n\n return { data, error: null };\n } catch (caught: unknown) {\n const error: SupaError = handleSupaError(caught);\n return { data: null, error };\n }\n };\n}\n","import type { SupaError } from \"../../types\";\n\n/**\n * Normalize any thrown value into a consistent `SupaError` shape.\n *\n * Handles:\n * - Supabase `AuthError` / `PostgrestError` (has `.message` and optional `.code` / `.status`)\n * - Standard `Error` instances\n * - Plain strings\n * - Unknown values (fallback)\n */\nexport function handleSupaError(error: unknown): SupaError {\n // ── Supabase errors & standard Error instances ──────────────────\n if (error instanceof Error) {\n const record = error as unknown as Record<string, unknown>;\n return {\n message: error.message,\n code: typeof record.code === \"string\" ? record.code : undefined,\n status: typeof record.status === \"number\" ? record.status : undefined,\n };\n }\n\n // ── Plain object with a message property ────────────────────────\n if (\n typeof error === \"object\" &&\n error !== null &&\n \"message\" in error &&\n typeof (error as Record<string, unknown>).message === \"string\"\n ) {\n const err = error as Record<string, unknown>;\n return {\n message: err.message as string,\n code: typeof err.code === \"string\" ? err.code : undefined,\n status: typeof err.status === \"number\" ? err.status : undefined,\n };\n }\n\n // ── String ──────────────────────────────────────────────────────\n if (typeof error === \"string\") {\n return { message: error };\n }\n\n // ── Fallback ────────────────────────────────────────────────────\n return { message: \"An unknown error occurred\" };\n}\n"],"mappings":"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","names":["options","createServerClient","createServerClient"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shared/index.ts","../../src/shared/utils/error-handler.ts"],"sourcesContent":["// ── Shared entry point ──────────────────────────────────────────────\n// Re-exports types and utilities used across client & server modules.\n\nexport { handleSupaError } from \"./utils/error-handler\";\n\nexport type {\n SupaError,\n ActionResponse,\n
|
|
1
|
+
{"version":3,"sources":["../../src/shared/index.ts","../../src/shared/utils/error-handler.ts"],"sourcesContent":["// ── Shared entry point ──────────────────────────────────────────────\n// Re-exports types and utilities used across client & server modules.\n\nexport { handleSupaError } from \"./utils/error-handler\";\n\nexport type {\n SupaError,\n ActionResponse,\n MiddlewareOptions,\n RouteConfig,\n UseSupaUserReturn,\n UseSupaSessionReturn,\n} from \"../types\";\n","import type { SupaError } from \"../../types\";\n\n/**\n * Normalize any thrown value into a consistent `SupaError` shape.\n *\n * Handles:\n * - Supabase `AuthError` / `PostgrestError` (has `.message` and optional `.code` / `.status`)\n * - Standard `Error` instances\n * - Plain strings\n * - Unknown values (fallback)\n */\nexport function handleSupaError(error: unknown): SupaError {\n // ── Supabase errors & standard Error instances ──────────────────\n if (error instanceof Error) {\n const record = error as unknown as Record<string, unknown>;\n return {\n message: error.message,\n code: typeof record.code === \"string\" ? record.code : undefined,\n status: typeof record.status === \"number\" ? record.status : undefined,\n };\n }\n\n // ── Plain object with a message property ────────────────────────\n if (\n typeof error === \"object\" &&\n error !== null &&\n \"message\" in error &&\n typeof (error as Record<string, unknown>).message === \"string\"\n ) {\n const err = error as Record<string, unknown>;\n return {\n message: err.message as string,\n code: typeof err.code === \"string\" ? err.code : undefined,\n status: typeof err.status === \"number\" ? err.status : undefined,\n };\n }\n\n // ── String ──────────────────────────────────────────────────────\n if (typeof error === \"string\") {\n return { message: error };\n }\n\n // ── Fallback ────────────────────────────────────────────────────\n return { message: \"An unknown error occurred\" };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACWO,SAAS,gBAAgB,OAA2B;AAEzD,MAAI,iBAAiB,OAAO;AAC1B,UAAM,SAAS;AACf,WAAO;AAAA,MACL,SAAS,MAAM;AAAA,MACf,MAAM,OAAO,OAAO,SAAS,WAAW,OAAO,OAAO;AAAA,MACtD,QAAQ,OAAO,OAAO,WAAW,WAAW,OAAO,SAAS;AAAA,IAC9D;AAAA,EACF;AAGA,MACE,OAAO,UAAU,YACjB,UAAU,QACV,aAAa,SACb,OAAQ,MAAkC,YAAY,UACtD;AACA,UAAM,MAAM;AACZ,WAAO;AAAA,MACL,SAAS,IAAI;AAAA,MACb,MAAM,OAAO,IAAI,SAAS,WAAW,IAAI,OAAO;AAAA,MAChD,QAAQ,OAAO,IAAI,WAAW,WAAW,IAAI,SAAS;AAAA,IACxD;AAAA,EACF;AAGA,MAAI,OAAO,UAAU,UAAU;AAC7B,WAAO,EAAE,SAAS,MAAM;AAAA,EAC1B;AAGA,SAAO,EAAE,SAAS,4BAA4B;AAChD;","names":[]}
|
package/dist/shared/index.d.cts
CHANGED
|
@@ -14,32 +14,69 @@ type ActionResponse<T> = {
|
|
|
14
14
|
data: null;
|
|
15
15
|
error: SupaError;
|
|
16
16
|
};
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Defines a protected route with optional role-based access control.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* { path: "/admin/:path*", allowedRoles: ["admin", "super_admin"] }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
interface RouteConfig {
|
|
18
26
|
/**
|
|
19
|
-
* Route
|
|
20
|
-
* Supports simple prefix matching.
|
|
27
|
+
* Route pattern to match.
|
|
21
28
|
*
|
|
22
|
-
*
|
|
29
|
+
* Supports:
|
|
30
|
+
* - Exact paths: `"/settings"`
|
|
31
|
+
* - Prefix matching: `"/dashboard"` matches `/dashboard/anything`
|
|
32
|
+
* - Wildcards: `"/admin/:path*"` matches `/admin/users`, `/admin/settings/edit`, etc.
|
|
23
33
|
*/
|
|
24
|
-
|
|
34
|
+
path: string;
|
|
25
35
|
/**
|
|
26
|
-
*
|
|
36
|
+
* Roles that are allowed to access this route.
|
|
37
|
+
*
|
|
38
|
+
* - If **omitted or empty**, any *authenticated* user can access the route.
|
|
39
|
+
* - If **provided**, only users whose role is in this list are allowed.
|
|
40
|
+
*
|
|
41
|
+
* @example ["admin", "editor"]
|
|
42
|
+
*/
|
|
43
|
+
allowedRoles?: string[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Configuration for the `withSupaAuth` middleware.
|
|
47
|
+
*/
|
|
48
|
+
interface MiddlewareOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Protected route definitions. Each entry can optionally restrict access
|
|
51
|
+
* to specific roles via `allowedRoles`.
|
|
52
|
+
*/
|
|
53
|
+
routes: RouteConfig[];
|
|
54
|
+
/**
|
|
55
|
+
* Where to redirect unauthenticated or unauthorized users.
|
|
27
56
|
* @default "/login"
|
|
28
57
|
*/
|
|
29
58
|
redirectTo?: string;
|
|
30
59
|
/**
|
|
31
|
-
*
|
|
60
|
+
* Strategy for extracting the user's role from the Supabase user object.
|
|
61
|
+
*
|
|
62
|
+
* - `"user_metadata"` — reads `user.user_metadata.role`
|
|
63
|
+
* - `"app_metadata"` — reads `user.app_metadata.role`
|
|
64
|
+
* - A custom function for advanced scenarios (e.g. multiple roles, JWT claims).
|
|
32
65
|
*
|
|
33
|
-
* @
|
|
66
|
+
* @default "user_metadata"
|
|
34
67
|
*/
|
|
35
|
-
|
|
68
|
+
roleExtractor?: "user_metadata" | "app_metadata" | ((user: {
|
|
69
|
+
user_metadata: Record<string, unknown>;
|
|
70
|
+
app_metadata: Record<string, unknown>;
|
|
71
|
+
}) => string | string[] | undefined);
|
|
36
72
|
/**
|
|
37
|
-
* Optional callback invoked
|
|
38
|
-
*
|
|
73
|
+
* Optional callback invoked when a user is authenticated and authorized.
|
|
74
|
+
* Useful for logging, analytics, or injecting custom response headers.
|
|
39
75
|
*/
|
|
40
76
|
onAuthSuccess?: (user: {
|
|
41
77
|
id: string;
|
|
42
78
|
email?: string;
|
|
79
|
+
role?: string | string[];
|
|
43
80
|
}) => void | Promise<void>;
|
|
44
81
|
}
|
|
45
82
|
interface UseSupaUserReturn {
|
|
@@ -64,4 +101,4 @@ interface UseSupaSessionReturn {
|
|
|
64
101
|
*/
|
|
65
102
|
declare function handleSupaError(error: unknown): SupaError;
|
|
66
103
|
|
|
67
|
-
export { type ActionResponse, type
|
|
104
|
+
export { type ActionResponse, type MiddlewareOptions, type RouteConfig, type SupaError, type UseSupaSessionReturn, type UseSupaUserReturn, handleSupaError };
|
package/dist/shared/index.d.ts
CHANGED
|
@@ -14,32 +14,69 @@ type ActionResponse<T> = {
|
|
|
14
14
|
data: null;
|
|
15
15
|
error: SupaError;
|
|
16
16
|
};
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Defines a protected route with optional role-based access control.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* { path: "/admin/:path*", allowedRoles: ["admin", "super_admin"] }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
interface RouteConfig {
|
|
18
26
|
/**
|
|
19
|
-
* Route
|
|
20
|
-
* Supports simple prefix matching.
|
|
27
|
+
* Route pattern to match.
|
|
21
28
|
*
|
|
22
|
-
*
|
|
29
|
+
* Supports:
|
|
30
|
+
* - Exact paths: `"/settings"`
|
|
31
|
+
* - Prefix matching: `"/dashboard"` matches `/dashboard/anything`
|
|
32
|
+
* - Wildcards: `"/admin/:path*"` matches `/admin/users`, `/admin/settings/edit`, etc.
|
|
23
33
|
*/
|
|
24
|
-
|
|
34
|
+
path: string;
|
|
25
35
|
/**
|
|
26
|
-
*
|
|
36
|
+
* Roles that are allowed to access this route.
|
|
37
|
+
*
|
|
38
|
+
* - If **omitted or empty**, any *authenticated* user can access the route.
|
|
39
|
+
* - If **provided**, only users whose role is in this list are allowed.
|
|
40
|
+
*
|
|
41
|
+
* @example ["admin", "editor"]
|
|
42
|
+
*/
|
|
43
|
+
allowedRoles?: string[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Configuration for the `withSupaAuth` middleware.
|
|
47
|
+
*/
|
|
48
|
+
interface MiddlewareOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Protected route definitions. Each entry can optionally restrict access
|
|
51
|
+
* to specific roles via `allowedRoles`.
|
|
52
|
+
*/
|
|
53
|
+
routes: RouteConfig[];
|
|
54
|
+
/**
|
|
55
|
+
* Where to redirect unauthenticated or unauthorized users.
|
|
27
56
|
* @default "/login"
|
|
28
57
|
*/
|
|
29
58
|
redirectTo?: string;
|
|
30
59
|
/**
|
|
31
|
-
*
|
|
60
|
+
* Strategy for extracting the user's role from the Supabase user object.
|
|
61
|
+
*
|
|
62
|
+
* - `"user_metadata"` — reads `user.user_metadata.role`
|
|
63
|
+
* - `"app_metadata"` — reads `user.app_metadata.role`
|
|
64
|
+
* - A custom function for advanced scenarios (e.g. multiple roles, JWT claims).
|
|
32
65
|
*
|
|
33
|
-
* @
|
|
66
|
+
* @default "user_metadata"
|
|
34
67
|
*/
|
|
35
|
-
|
|
68
|
+
roleExtractor?: "user_metadata" | "app_metadata" | ((user: {
|
|
69
|
+
user_metadata: Record<string, unknown>;
|
|
70
|
+
app_metadata: Record<string, unknown>;
|
|
71
|
+
}) => string | string[] | undefined);
|
|
36
72
|
/**
|
|
37
|
-
* Optional callback invoked
|
|
38
|
-
*
|
|
73
|
+
* Optional callback invoked when a user is authenticated and authorized.
|
|
74
|
+
* Useful for logging, analytics, or injecting custom response headers.
|
|
39
75
|
*/
|
|
40
76
|
onAuthSuccess?: (user: {
|
|
41
77
|
id: string;
|
|
42
78
|
email?: string;
|
|
79
|
+
role?: string | string[];
|
|
43
80
|
}) => void | Promise<void>;
|
|
44
81
|
}
|
|
45
82
|
interface UseSupaUserReturn {
|
|
@@ -64,4 +101,4 @@ interface UseSupaSessionReturn {
|
|
|
64
101
|
*/
|
|
65
102
|
declare function handleSupaError(error: unknown): SupaError;
|
|
66
103
|
|
|
67
|
-
export { type ActionResponse, type
|
|
104
|
+
export { type ActionResponse, type MiddlewareOptions, type RouteConfig, type SupaError, type UseSupaSessionReturn, type UseSupaUserReturn, handleSupaError };
|
package/package.json
CHANGED