@lenne.tech/nuxt-extensions 1.8.3 → 1.9.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/CLAUDE.md +19 -7
- package/README.md +37 -0
- package/dist/runtime/composables/auth/use-lt-auth.js +14 -2
- package/dist/runtime/plugins/auth-interceptor.client.d.ts +8 -1
- package/dist/runtime/plugins/auth-interceptor.client.js +21 -0
- package/dist/runtime/types/auth.d.ts +58 -0
- package/package.json +6 -22
package/CLAUDE.md
CHANGED
|
@@ -13,7 +13,7 @@ src/
|
|
|
13
13
|
│ ├── lib/ # Library exports (auth-client, auth-state, ai)
|
|
14
14
|
│ ├── middleware/ # Route middleware (system setup)
|
|
15
15
|
│ ├── plugins/ # Nuxt plugins (auth interceptor, error translation, config check)
|
|
16
|
-
│ ├── server/ #
|
|
16
|
+
│ ├── server/ # server-side tsconfig scope (no runtime routes; auth talks to the backend directly)
|
|
17
17
|
│ ├── testing/ # Playwright test helpers
|
|
18
18
|
│ ├── types/ # TypeScript type definitions
|
|
19
19
|
│ └── utils/ # Auto-imported utility functions
|
|
@@ -85,17 +85,17 @@ The API base URL is resolved at runtime from `NUXT_PUBLIC_API_URL` (client + SSR
|
|
|
85
85
|
## Authentication (Better Auth)
|
|
86
86
|
|
|
87
87
|
- SSR-safe session management via `useLtAuth()`
|
|
88
|
-
- Auth
|
|
89
|
-
- Middleware `
|
|
88
|
+
- Auth requests go **directly** to the backend Better-Auth endpoints (`basePath: '/iam'`) via `ltAuthFetch` (Cookie/JWT dual-mode) — the module ships **no** local Nitro `/api/auth/**` proxy
|
|
89
|
+
- Protected-route gating is the consumer's own middleware (see "Custom Auth Middleware Pattern" below); the module only registers the optional `lt-system-setup` middleware
|
|
90
90
|
- Passkey (WebAuthn) support via `@better-auth/passkey` peer dependency
|
|
91
91
|
- 2FA (TOTP) support built-in
|
|
92
92
|
|
|
93
93
|
### Auth Flow
|
|
94
94
|
|
|
95
95
|
1. Frontend calls `useLtAuth().signIn()` / `.signUp()`
|
|
96
|
-
2.
|
|
97
|
-
3.
|
|
98
|
-
4.
|
|
96
|
+
2. The Better-Auth client sends the request straight to the backend Better-Auth endpoints (`basePath: '/iam'`), with the base URL resolved by `buildLtApiUrl` (`NUXT_PUBLIC_API_URL` / SSR `NUXT_API_URL`)
|
|
97
|
+
3. The backend sets the session as an `httpOnly` cookie (`iam.session_token`); `lt-auth-state` is the non-httpOnly client-side cache used by middleware
|
|
98
|
+
4. Subsequent authenticated requests use `ltAuthFetch` (Cookie mode by default, JWT fallback)
|
|
99
99
|
|
|
100
100
|
## AI Assistant Flow
|
|
101
101
|
|
|
@@ -137,9 +137,21 @@ The `useLtAuth()` composable manages the `lt-auth-state` cookie for authenticati
|
|
|
137
137
|
On session re-validation (`validateSession()` at app init / hard reload, and the passkey get-session fallback), `useLtAuth()` **merges** the Better-Auth session user onto the cached user of the SAME identity via the private `mergeSessionUser()`, instead of overwriting it. Consequences you must know:
|
|
138
138
|
|
|
139
139
|
- **nest-server-only fields persist across reloads.** Better-Auth's get-session returns only Better-Auth-owned fields (id/email/name + registered additionalFields). Fields it does not own (e.g. custom preferences like `leadTableColumns`) previously vanished on every reload; they now survive. If you built a workaround that re-fetched such fields after each reload, you can retire it.
|
|
140
|
-
- **Authorization fields are fail-closed.** `role`, `banned`, `banExpires`, `banReason`, `emailVerified`, `twoFactorEnabled` always reflect the session — any of these the session omits is dropped from the merge (never kept stale), so a backend downgrade is reflected client-side. If your project adds its OWN authorization-relevant user field, either register it as a Better-Auth additionalField (so get-session returns it) or add it to the `AUTHZ_KEYS` list in `use-lt-auth.ts` — otherwise it could persist stale in the client cache.
|
|
140
|
+
- **Authorization fields are fail-closed.** `role`, `roles`, `banned`, `banExpires`, `banReason`, `emailVerified`, `twoFactorEnabled` always reflect the session — any of these the session omits is dropped from the merge (never kept stale), so a backend downgrade is reflected client-side. If your project adds its OWN authorization-relevant user field, either register it as a Better-Auth additionalField (so get-session returns it) or add it to the `AUTHZ_KEYS` list in `use-lt-auth.ts` — otherwise it could persist stale in the client cache.
|
|
141
141
|
- **Keep the cached user lean.** `lt-auth-state` is a non-httpOnly cookie sent on every same-site request and capped at ~4 KB by browsers. Because the merge keeps preference fields across reloads, large blobs on the user object can push the cookie over the limit — the browser then silently rejects the write and the next reload reads as a logout. `setUser` emits a dev-mode warning above ~3.5 KB; move large preference data off the user object and fetch it from an authenticated API.
|
|
142
142
|
|
|
143
|
+
### Admin Detection (`role` vs `roles`):
|
|
144
|
+
`useLtAuth().isAdmin` accepts BOTH user shapes, so one frontend works against either backend:
|
|
145
|
+
|
|
146
|
+
- **`@lenne.tech/nest-server`** registers `roles` as a *core* Better-Auth additionalField (`type: 'string[]'`, `defaultValue: []`). Its users carry `roles: ['admin']` and no singular `role`.
|
|
147
|
+
- **Better-Auth's admin plugin** uses the singular `role: 'admin'`.
|
|
148
|
+
|
|
149
|
+
`isAdmin` is `true` when `role === 'admin'` **or** `roles` contains `'admin'` (a union — either shape granting admin is sufficient). A malformed non-array `roles` (the `lt-auth-state` cookie is client-writable) degrades to `false` — it never throws and never fail-opens via `String.prototype.includes` substring matching. Matching is case-sensitive (`'admin'`, not `'Admin'`), identical to nest-server's own `hasRole(RoleEnum.ADMIN)`.
|
|
150
|
+
|
|
151
|
+
- `roles` is in `AUTHZ_KEYS`, so a backend-side admin revocation closes the admin UI on the next session re-validation instead of being masked by a stale cached array.
|
|
152
|
+
- The `auth.enableAdmin` module option toggles the Better-Auth **admin client plugin** (user-management calls like `admin.listUsers()`). It does NOT control `isAdmin` — role detection works regardless of that flag.
|
|
153
|
+
- **`isAdmin` is a UX gate, not an authorization boundary.** It reads the non-httpOnly `lt-auth-state` cache, which a user can edit. Render admin UI behind it; enforce admin rights server-side (`@Restricted(RoleEnum.ADMIN)`).
|
|
154
|
+
|
|
143
155
|
### Custom Auth Middleware Pattern:
|
|
144
156
|
```typescript
|
|
145
157
|
// CORRECT — read-only check, no cookie mutation
|
package/README.md
CHANGED
|
@@ -166,6 +166,7 @@ export default defineNuxtConfig({
|
|
|
166
166
|
const {
|
|
167
167
|
user,
|
|
168
168
|
isAuthenticated,
|
|
169
|
+
isAdmin,
|
|
169
170
|
isLoading,
|
|
170
171
|
signIn,
|
|
171
172
|
signOut,
|
|
@@ -199,6 +200,42 @@ async function handlePasskeyLogin() {
|
|
|
199
200
|
</template>
|
|
200
201
|
```
|
|
201
202
|
|
|
203
|
+
#### Admin detection (`role` vs `roles`)
|
|
204
|
+
|
|
205
|
+
`isAdmin` accepts **both** user shapes, so the same frontend works against either backend:
|
|
206
|
+
|
|
207
|
+
| Backend | User shape | `isAdmin` is `true` when |
|
|
208
|
+
|---------|------------|-------------------------|
|
|
209
|
+
| `@lenne.tech/nest-server` | `roles: ['admin']` — core Better-Auth additionalField (`type: 'string[]'`), no singular `role` | `roles` contains `'admin'` |
|
|
210
|
+
| Better-Auth admin plugin | `role: 'admin'` — single role | `role === 'admin'` |
|
|
211
|
+
|
|
212
|
+
```vue
|
|
213
|
+
<script setup>
|
|
214
|
+
const { isAdmin } = useLtAuth();
|
|
215
|
+
</script>
|
|
216
|
+
|
|
217
|
+
<template>
|
|
218
|
+
<NuxtLink v-if="isAdmin" to="/admin">Admin</NuxtLink>
|
|
219
|
+
</template>
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
> **`isAdmin` is a UX gate, not an authorization boundary.** It reads the client-side `lt-auth-state` cookie cache, which is not httpOnly and therefore user-writable. Use it to decide what to *render*; always enforce admin rights server-side (`@Restricted(RoleEnum.ADMIN)` in nest-server).
|
|
223
|
+
>
|
|
224
|
+
> The `auth.enableAdmin` option toggles the Better-Auth **admin client plugin** (user-management calls such as `admin.listUsers()`). It does **not** control `isAdmin` — role detection works regardless of that flag.
|
|
225
|
+
|
|
226
|
+
For roles other than admin, use `hasRole(role)` / `hasAnyRole(...roles)`. They apply the exact same both-shapes union and `Array.isArray` guard as `isAdmin` (which is itself just `hasRole('admin')`), so you never need the unguarded `user.value?.roles?.includes(...)`:
|
|
227
|
+
|
|
228
|
+
```vue
|
|
229
|
+
<script setup>
|
|
230
|
+
const { hasRole, hasAnyRole } = useLtAuth();
|
|
231
|
+
</script>
|
|
232
|
+
|
|
233
|
+
<template>
|
|
234
|
+
<button v-if="hasRole('editor')">Edit</button>
|
|
235
|
+
<NuxtLink v-if="hasAnyRole('admin', 'editor')" to="/manage">Manage</NuxtLink>
|
|
236
|
+
</template>
|
|
237
|
+
```
|
|
238
|
+
|
|
202
239
|
### Custom Better Auth Plugins
|
|
203
240
|
|
|
204
241
|
You can extend the auth client with additional [Better Auth plugins](https://www.better-auth.com/docs/plugins):
|
|
@@ -46,7 +46,17 @@ export function useLtAuth() {
|
|
|
46
46
|
const isJwtMode = computed(() => authMode.value === "jwt");
|
|
47
47
|
const user = computed(() => resolvedAuthState.value?.user ?? null);
|
|
48
48
|
const isAuthenticated = computed(() => !!user.value);
|
|
49
|
-
|
|
49
|
+
function hasRole(role) {
|
|
50
|
+
const current = user.value;
|
|
51
|
+
if (!current) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return current.role === role || Array.isArray(current.roles) && current.roles.includes(role);
|
|
55
|
+
}
|
|
56
|
+
function hasAnyRole(...roles) {
|
|
57
|
+
return roles.some((role) => hasRole(role));
|
|
58
|
+
}
|
|
59
|
+
const isAdmin = computed(() => hasRole("admin"));
|
|
50
60
|
const is2FAEnabled = computed(() => user.value?.twoFactorEnabled ?? false);
|
|
51
61
|
const features = useState("lt-auth-features", () => ({}));
|
|
52
62
|
const featuresFetched = useState("lt-auth-features-fetched", () => false);
|
|
@@ -129,7 +139,7 @@ export function useLtAuth() {
|
|
|
129
139
|
}
|
|
130
140
|
return response;
|
|
131
141
|
}
|
|
132
|
-
const AUTHZ_KEYS = ["banExpires", "banReason", "banned", "emailVerified", "role", "twoFactorEnabled"];
|
|
142
|
+
const AUTHZ_KEYS = ["banExpires", "banReason", "banned", "emailVerified", "role", "roles", "twoFactorEnabled"];
|
|
133
143
|
function mergeSessionUser(sessionUser) {
|
|
134
144
|
const cached = resolvedAuthState.value?.user;
|
|
135
145
|
if (!cached?.id || !sessionUser?.id || cached.id !== sessionUser.id) {
|
|
@@ -457,6 +467,8 @@ export function useLtAuth() {
|
|
|
457
467
|
isLoading: computed(() => isLoading.value),
|
|
458
468
|
user,
|
|
459
469
|
// User properties
|
|
470
|
+
hasAnyRole,
|
|
471
|
+
hasRole,
|
|
460
472
|
is2FAEnabled,
|
|
461
473
|
isAdmin,
|
|
462
474
|
// Feature detection
|
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
* Auth Interceptor Plugin
|
|
3
3
|
*
|
|
4
4
|
* This plugin intercepts all API responses and handles session expiration.
|
|
5
|
-
* When a 401 (Unauthorized) response is received, it
|
|
5
|
+
* When a 401 (Unauthorized) response is received, it verifies against the
|
|
6
|
+
* session endpoint that the session is genuinely dead and then automatically:
|
|
6
7
|
* 1. Clears the user session state
|
|
7
8
|
* 2. Redirects to the login page
|
|
8
9
|
*
|
|
10
|
+
* The verification step exists because a 401 from a domain endpoint is not
|
|
11
|
+
* proof of an expired session: backends may mislabel permission errors
|
|
12
|
+
* (authenticated user, missing rights — semantically 403) as 401. Logging out
|
|
13
|
+
* on those would kick a logged-in user out of the app for a mere missing
|
|
14
|
+
* right. Only a dead session may clear state.
|
|
15
|
+
*
|
|
9
16
|
* Note: This is a client-only plugin (.client.ts) since auth state
|
|
10
17
|
* management only makes sense in the browser context.
|
|
11
18
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useLtAuth } from "../composables/auth/use-lt-auth.js";
|
|
2
|
+
import { getLtApiBase } from "../lib/auth-state.js";
|
|
2
3
|
export default (nuxtApp) => {
|
|
3
4
|
if (import.meta.server) return;
|
|
4
5
|
let _authInstance = null;
|
|
@@ -42,6 +43,19 @@ export default (nuxtApp) => {
|
|
|
42
43
|
];
|
|
43
44
|
return authEndpoints.some((endpoint) => url.includes(endpoint));
|
|
44
45
|
}
|
|
46
|
+
async function isSessionStillAlive() {
|
|
47
|
+
try {
|
|
48
|
+
const { fetchWithAuth } = getAuth();
|
|
49
|
+
const response = await fetchWithAuth(`${getLtApiBase()}/get-session`, { method: "GET" });
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const data = await response.json().catch(() => null);
|
|
54
|
+
return Boolean(data && (data.user || data.session));
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
45
59
|
async function handleUnauthorized(requestUrl) {
|
|
46
60
|
if (isHandling401) {
|
|
47
61
|
return;
|
|
@@ -56,6 +70,13 @@ export default (nuxtApp) => {
|
|
|
56
70
|
try {
|
|
57
71
|
const { clearUser, isAuthenticated } = getAuth();
|
|
58
72
|
if (isAuthenticated.value) {
|
|
73
|
+
const sessionAlive = await isSessionStillAlive();
|
|
74
|
+
if (sessionAlive !== false) {
|
|
75
|
+
console.debug(
|
|
76
|
+
sessionAlive ? `[LtAuth Interceptor] 401 from ${requestUrl ?? "unknown URL"} but session is still valid \u2014 treating it as a permission error, not logging out` : "[LtAuth Interceptor] 401 received but session state could not be verified \u2014 not logging out"
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
59
80
|
console.debug("[LtAuth Interceptor] Session expired, logging out...");
|
|
60
81
|
clearUser();
|
|
61
82
|
const router = nuxtApp.$router;
|
|
@@ -12,7 +12,22 @@ export interface LtUser {
|
|
|
12
12
|
id: string;
|
|
13
13
|
image?: string;
|
|
14
14
|
name?: string;
|
|
15
|
+
/** Single-role shape (Better-Auth admin plugin). See also {@link LtUser.roles}. */
|
|
15
16
|
role?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Multi-role shape, and the canonical home for this fact: `@lenne.tech/nest-server`
|
|
19
|
+
* registers `roles` as a core Better-Auth additionalField (`type: 'string[]'`,
|
|
20
|
+
* `defaultValue: []`), so its users carry `roles: ['admin']` and usually NO
|
|
21
|
+
* singular `role`.
|
|
22
|
+
*
|
|
23
|
+
* NOT a source of authorization truth: this lives in the non-httpOnly
|
|
24
|
+
* `lt-auth-state` cookie and is therefore client-writable. It is kept
|
|
25
|
+
* fail-closed on session merge (see `AUTHZ_KEYS` in `use-lt-auth.ts`) so a
|
|
26
|
+
* backend downgrade is never masked by a stale value — that is a *staleness*
|
|
27
|
+
* guarantee, not a security guarantee. `isAdmin` reads it for UI gating;
|
|
28
|
+
* authorization is enforced server-side (`@Restricted(RoleEnum.ADMIN)`).
|
|
29
|
+
*/
|
|
30
|
+
roles?: string[];
|
|
16
31
|
twoFactorEnabled?: boolean;
|
|
17
32
|
}
|
|
18
33
|
/**
|
|
@@ -94,7 +109,50 @@ export interface UseLtAuthReturn {
|
|
|
94
109
|
isLoading: ComputedRef<boolean>;
|
|
95
110
|
jwtToken: Ref<string | null>;
|
|
96
111
|
user: ComputedRef<LtUser | null>;
|
|
112
|
+
/**
|
|
113
|
+
* Returns `true` when the current user has ANY of the given roles (union).
|
|
114
|
+
* Same shape-tolerance and UX-gate-only caveats as {@link UseLtAuthReturn.hasRole}.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* // <NuxtLink v-if="hasAnyRole('admin', 'editor')" to="/manage">Manage</NuxtLink>
|
|
118
|
+
*/
|
|
119
|
+
hasAnyRole: (...roles: string[]) => boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Returns `true` when the current user has `role` in EITHER supported shape:
|
|
122
|
+
* `role === role` (Better-Auth admin plugin) or `roles` containing it
|
|
123
|
+
* (`@lenne.tech/nest-server`; see {@link LtUser.roles}). A malformed non-array
|
|
124
|
+
* `roles` degrades to `false` — never throws, never fail-opens via
|
|
125
|
+
* `String.prototype.includes` substring matching. Reads `user` live, so call it
|
|
126
|
+
* from a template or `computed` for reactivity.
|
|
127
|
+
*
|
|
128
|
+
* Prefer this over the raw `user.value?.roles?.includes(x)`, which is unguarded
|
|
129
|
+
* against the string-`roles` substring-confusion this method closes.
|
|
130
|
+
*
|
|
131
|
+
* UX gate only — the `lt-auth-state` cache is client-writable; enforce rights
|
|
132
|
+
* server-side.
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* // <button v-if="hasRole('editor')">Edit</button>
|
|
136
|
+
* @see {@link LtUser.role}
|
|
137
|
+
* @see {@link LtUser.roles}
|
|
138
|
+
*/
|
|
139
|
+
hasRole: (role: string) => boolean;
|
|
97
140
|
is2FAEnabled: ComputedRef<boolean>;
|
|
141
|
+
/**
|
|
142
|
+
* `true` when the current user is an admin in EITHER supported shape:
|
|
143
|
+
* `role === 'admin'` (Better-Auth admin plugin) or `roles` containing `'admin'`
|
|
144
|
+
* (`@lenne.tech/nest-server`; see {@link LtUser.roles}). Equivalent to
|
|
145
|
+
* `hasRole('admin')`. A malformed non-array `roles` degrades to `false` (never
|
|
146
|
+
* throws, never fail-opens). Independent of the `enableAdmin` module option,
|
|
147
|
+
* which only toggles the Better-Auth admin *client plugin*.
|
|
148
|
+
*
|
|
149
|
+
* UX gate only: the `lt-auth-state` cache it reads is client-writable, so treat
|
|
150
|
+
* this as "what to render", never as the gate itself. Enforce admin rights
|
|
151
|
+
* server-side.
|
|
152
|
+
*
|
|
153
|
+
* @see {@link UseLtAuthReturn.hasRole}
|
|
154
|
+
* @see {@link LtUser.roles}
|
|
155
|
+
*/
|
|
98
156
|
isAdmin: ComputedRef<boolean>;
|
|
99
157
|
features: ComputedRef<Record<string, boolean | number | string[]>>;
|
|
100
158
|
fetchFeatures: () => Promise<Record<string, boolean | number | string[]>>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nuxt-extensions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.tech projects",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"author": "lenne.Tech <info@lenne.tech> (https://lenne.tech)",
|
|
14
14
|
"license": "MIT",
|
|
15
|
-
"packageManager": "pnpm@
|
|
15
|
+
"packageManager": "pnpm@11.13.0",
|
|
16
16
|
"type": "module",
|
|
17
17
|
"sideEffects": false,
|
|
18
18
|
"exports": {
|
|
@@ -113,25 +113,6 @@
|
|
|
113
113
|
"vitest": "4.1.7",
|
|
114
114
|
"vue-tsc": "3.3.3"
|
|
115
115
|
},
|
|
116
|
-
"pnpm": {
|
|
117
|
-
"onlyBuiltDependencies": [
|
|
118
|
-
"@parcel/watcher",
|
|
119
|
-
"esbuild"
|
|
120
|
-
],
|
|
121
|
-
"overrides": {
|
|
122
|
-
"brace-expansion@>=5.0.0 <5.0.6": "5.0.6",
|
|
123
|
-
"devalue@>=5.6.3 <=5.8.0": "5.8.1",
|
|
124
|
-
"kysely@>=0.26.0 <0.28.17": "0.28.17",
|
|
125
|
-
"ws@>=8.0.0 <8.20.1": "8.21.0",
|
|
126
|
-
"shell-quote@<1.8.4": "1.8.4",
|
|
127
|
-
"nuxt@<4.4.7": "4.4.7",
|
|
128
|
-
"vite@>=7.0.0 <7.3.5": "7.3.5",
|
|
129
|
-
"tar@<7.5.16": "7.5.16",
|
|
130
|
-
"launch-editor@<2.14.1": "2.14.1",
|
|
131
|
-
"esbuild@<0.28.1": "0.28.1",
|
|
132
|
-
"@babel/core@<7.29.6": "7.29.6"
|
|
133
|
-
}
|
|
134
|
-
},
|
|
135
116
|
"keywords": [
|
|
136
117
|
"nuxt",
|
|
137
118
|
"nuxt-module",
|
|
@@ -144,5 +125,8 @@
|
|
|
144
125
|
"tus-upload",
|
|
145
126
|
"file-upload",
|
|
146
127
|
"lenne-tech"
|
|
147
|
-
]
|
|
128
|
+
],
|
|
129
|
+
"engines": {
|
|
130
|
+
"node": ">= 22"
|
|
131
|
+
}
|
|
148
132
|
}
|