@xenterprises/nuxt-x-auth-better 0.2.5 → 0.3.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/README.md CHANGED
@@ -1,19 +1,8 @@
1
1
  # @xenterprises/nuxt-x-auth-better
2
2
 
3
- BetterAuth authentication layer for Nuxt 4.
3
+ BetterAuth authentication layer for Nuxt 4. Pre-built UI components, route protection, OAuth, magic link, and password reset flows — all configurable via `app.config.ts`.
4
4
 
5
- ## Features
6
-
7
- - **Better Auth integration** - Industry-standard authentication with Better Auth
8
- - **OAuth providers** - Google, GitHub, and more
9
- - **Magic link authentication** - Passwordless login via email
10
- - **Secure token storage** - HttpOnly cookies with SameSite protection
11
- - **Field normalization** - Unified user object across providers
12
- - **Pre-built UI components** - Login, Signup, Forgot Password, Magic Link, OAuth
13
- - **Route protection** - Global middleware for authenticated routes
14
- - **Responsive design** - Built with Nuxt UI v4
15
-
16
- ## Installation
5
+ ## Install
17
6
 
18
7
  ```bash
19
8
  npm install @xenterprises/nuxt-x-auth-better better-auth
@@ -26,36 +15,30 @@ npm install @xenterprises/nuxt-x-auth-better better-auth
26
15
  ```ts
27
16
  // nuxt.config.ts
28
17
  export default defineNuxtConfig({
29
- extends: ['@xenterprises/nuxt-x-auth-better']
18
+ extends: ['@xenterprises/nuxt-x-auth-better'],
30
19
  })
31
20
  ```
32
21
 
33
- ### 2. Configure your provider
22
+ ### 2. Configure
34
23
 
35
24
  ```ts
36
25
  // app.config.ts
37
26
  export default defineAppConfig({
38
27
  xAuth: {
39
- tokens: {
40
- accessCookie: 'x_auth_access',
41
- refreshCookie: 'x_auth_refresh',
42
- hasRefresh: true,
43
- },
44
- redirects: {
45
- login: '/auth/login',
46
- signup: '/auth/signup',
47
- afterLogin: '/dashboard',
48
- afterSignup: '/dashboard',
49
- afterLogout: '/auth/login',
50
- forgotPassword: '/auth/forgot-password',
51
- },
52
28
  features: {
53
29
  oauth: true,
54
30
  magicLink: true,
55
31
  forgotPassword: true,
56
32
  signup: true,
57
33
  },
58
- oauthProviders: ['google', 'github'],
34
+ redirects: {
35
+ afterLogin: '/dashboard',
36
+ afterSignup: '/dashboard',
37
+ },
38
+ oauthProviders: [
39
+ { id: 'google', label: 'Google', icon: 'i-simple-icons-google' },
40
+ { id: 'github', label: 'GitHub', icon: 'i-simple-icons-github' },
41
+ ],
59
42
  ui: {
60
43
  showLogo: true,
61
44
  logoUrl: '/logo.svg',
@@ -73,127 +56,232 @@ NUXT_PUBLIC_X_AUTH_BASE_URL=https://api.example.com
73
56
 
74
57
  ## Usage
75
58
 
76
- ### Composable
77
-
78
59
  ```vue
79
60
  <script setup>
80
61
  const {
81
62
  user,
63
+ isAuthenticated,
64
+ isLoading,
82
65
  login,
83
- logout,
84
66
  signup,
85
- loginWithOAuth,
67
+ logout,
68
+ loginWithProvider,
86
69
  sendMagicLink,
87
70
  forgotPassword,
88
- isLoading,
89
- isAuthenticated
71
+ resetPassword,
72
+ changePassword,
73
+ updateUser,
74
+ deleteAccount,
75
+ getToken,
76
+ getAuthHeaders,
90
77
  } = useXAuth()
91
78
 
92
- // Email/password login
93
79
  await login('email@example.com', 'password')
94
-
95
- // OAuth login
96
- await loginWithOAuth('google')
97
-
98
- // Magic link
80
+ await loginWithProvider('google')
99
81
  await sendMagicLink('email@example.com')
100
-
101
- // Signup
102
- await signup('email@example.com', 'password', { name: 'John' })
103
-
104
- // Password reset
82
+ await signup('email@example.com', 'password', 'John Doe')
105
83
  await forgotPassword('email@example.com')
106
-
107
- // Logout
84
+ await changePassword('oldpass', 'newpass')
85
+ await updateUser({ name: 'New Name' })
108
86
  await logout()
109
87
  </script>
110
88
  ```
111
89
 
112
- ### Normalized User Object
113
-
114
- Better Auth returns a normalized user object:
90
+ ## Options (app.config.ts)
91
+
92
+ ### `xAuth.tokens`
93
+
94
+ | Name | Type | Default | Description |
95
+ |------|------|---------|-------------|
96
+ | `accessCookie` | `string` | `"x_auth_access"` | Cookie name for access token |
97
+ | `refreshCookie` | `string` | `"x_auth_refresh"` | Cookie name for refresh token |
98
+ | `hasRefresh` | `boolean` | `true` | Enable refresh token storage |
99
+
100
+ ### `xAuth.redirects`
101
+
102
+ | Name | Type | Default | Description |
103
+ |------|------|---------|-------------|
104
+ | `login` | `string` | `"/auth/login"` | Login page path |
105
+ | `signup` | `string` | `"/auth/signup"` | Signup page path |
106
+ | `afterLogin` | `string` | `"/"` | Redirect after successful login |
107
+ | `afterSignup` | `string` | `"/"` | Redirect after successful signup |
108
+ | `afterLogout` | `string` | `"/auth/login"` | Redirect after logout |
109
+ | `forgotPassword` | `string` | `"/auth/forgot-password"` | Forgot password page path |
110
+
111
+ ### `xAuth.features`
112
+
113
+ | Name | Type | Default | Description |
114
+ |------|------|---------|-------------|
115
+ | `oauth` | `boolean` | `false` | Enable OAuth provider buttons |
116
+ | `magicLink` | `boolean` | `false` | Enable magic link authentication |
117
+ | `otp` | `boolean` | `false` | Enable OTP authentication |
118
+ | `forgotPassword` | `boolean` | `true` | Enable forgot password flow |
119
+ | `signup` | `boolean` | `true` | Enable signup form and link |
120
+
121
+ ### `xAuth.oauthProviders`
122
+
123
+ Array of `{ id, label, icon }` objects. Supported provider IDs: `google`, `github`, `microsoft`, `facebook`, `twitter`, `apple`, `linkedin`, `discord`, `spotify`.
124
+
125
+ ### `xAuth.ui`
126
+
127
+ | Name | Type | Default | Description |
128
+ |------|------|---------|-------------|
129
+ | `showLogo` | `boolean` | `true` | Show logo in auth layout |
130
+ | `showBrandName` | `boolean` | `true` | Show brand name below logo |
131
+ | `logoUrl` | `string` | `""` | URL to logo image |
132
+ | `brandName` | `string` | `""` | Brand name text |
133
+ | `tagline` | `string` | `""` | Tagline below brand name |
134
+ | `layout` | `"centered" \| "split"` | `"centered"` | Auth page layout style |
135
+ | `background.enabled` | `boolean` | `true` | Enable background image |
136
+ | `background.imageUrl` | `string` | `""` | Custom background image URL (random default if empty) |
137
+ | `background.overlayOpacity` | `number` | `55` | Overlay opacity (0-100) |
138
+ | `background.blur` | `boolean` | `true` | Blur background image |
139
+ | `card.glass` | `boolean` | `false` | Glass morphism effect on card |
140
+ | `card.logoUrl` | `string` | `""` | Logo shown inside the auth card |
141
+ | `form.showSeparator` | `boolean` | `true` | Show "or" separator between OAuth and form |
142
+ | `legal.copyright` | `string` | `""` | Footer copyright text |
143
+ | `legal.links` | `Array<{label, to}>` | `[]` | Footer legal links |
144
+
145
+ ## Composable: `useXAuth()`
146
+
147
+ ### Reactive State
148
+
149
+ | Property | Type | Description |
150
+ |----------|------|-------------|
151
+ | `user` | `Ref<AuthUser \| null>` | Current authenticated user |
152
+ | `isAuthenticated` | `Ref<boolean>` | Whether user is authenticated |
153
+ | `isLoading` | `Ref<boolean>` | Whether session is loading |
154
+ | `emailSent` | `Ref<boolean>` | Whether magic link / reset email was sent |
155
+ | `codeSent` | `Ref<boolean>` | Whether OTP code was sent |
156
+ | `session` | `Ref<SessionData>` | Raw Better Auth session data |
157
+ | `sessionError` | `Ref<Error>` | Session error if any |
158
+ | `config` | `Ref<AuthConfig>` | Current auth configuration |
159
+
160
+ ### Methods
161
+
162
+ | Method | Returns | Description |
163
+ |--------|---------|-------------|
164
+ | `login(email, password)` | `AuthUser \| null` | Email/password sign in |
165
+ | `signup(email, password, name?)` | `AuthUser \| null` | Create account |
166
+ | `logout()` | `boolean` | Sign out and redirect |
167
+ | `loginWithProvider(provider)` | `boolean` | Start OAuth flow |
168
+ | `sendMagicLink(email, options?)` | `boolean` | Send magic link email |
169
+ | `forgotPassword(email)` | `boolean` | Send password reset email |
170
+ | `resetPassword(token, newPassword)` | `true \| { error }` | Reset password with token |
171
+ | `changePassword(current, new)` | `true \| { error }` | Change password (authenticated) |
172
+ | `updateUser({ name?, image? })` | `true \| { error }` | Update user profile |
173
+ | `deleteAccount()` | `boolean` | Permanently delete account |
174
+ | `getCurrentUser()` | `AuthUser \| null` | Get current user (fetches if needed) |
175
+ | `getToken()` | `string \| null` | Get access token |
176
+ | `getAuthHeaders()` | `Record<string, string>` | Get `{ Authorization: 'Bearer ...' }` |
177
+ | `handleMagicLinkCallback(token)` | `true \| { error }` | Verify magic link token |
178
+ | `resetState()` | `void` | Reset emailSent / codeSent flags |
179
+
180
+ ### AuthUser Type
115
181
 
116
182
  ```ts
117
- {
118
- id: 'user-123',
119
- email: 'user@example.com',
120
- name: 'John Doe',
121
- avatar: 'https://example.com/avatar.jpg',
122
- emailVerified: true,
123
- metadata: { /* provider-specific data */ }
183
+ interface AuthUser {
184
+ id: string
185
+ email: string
186
+ name: string
187
+ avatar?: string
188
+ emailVerified: boolean
189
+ metadata?: Record<string, any>
124
190
  }
125
191
  ```
126
192
 
127
- ### Protected Routes
128
-
129
- Routes are automatically protected by the global middleware.
130
-
131
- **Route types:**
132
- - **Guest-only routes** - `/auth/login`, `/auth/signup`, `/auth/forgot-password`, `/auth/magic-link` (redirects logged-in users)
133
- - **Public routes** - `/auth/handler/*`, `/auth/logout` (accessible by anyone)
134
- - **Protected routes** - Everything else (requires authentication)
135
-
136
193
  ## Components
137
194
 
138
195
  | Component | Description |
139
196
  |-----------|-------------|
140
- | `XAuthLogin` | Email/password login form |
141
- | `XAuthSignup` | Registration form |
142
- | `XAuthForgotPassword` | Password reset request |
143
- | `XAuthMagicLink` | Magic link authentication |
144
- | `XAuthOAuthButton` | Single OAuth provider button |
145
- | `XAuthOAuthButtonGroup` | All configured OAuth buttons |
146
- | `XAuthHandler` | OAuth callback handler |
197
+ | `XAuthLogin` | Email/password login form with OAuth, magic link, and forgot password links |
198
+ | `XAuthSignup` | Registration form with terms/privacy links |
199
+ | `XAuthForgotPassword` | Password reset request form with success state |
200
+ | `XAuthMagicLink` | Magic link email form with success state |
201
+ | `XAuthOAuthButton` | Single OAuth provider button with brand icon |
202
+ | `XAuthOAuthButtonGroup` | All configured OAuth provider buttons |
203
+ | `XAuthHandler` | Callback handler for OAuth, magic link, password reset, email verification |
147
204
 
148
- ## Pages (Auto-registered)
205
+ ## Pages (auto-registered)
149
206
 
150
- - `/auth/login` - Login page
151
- - `/auth/signup` - Registration page
152
- - `/auth/forgot-password` - Password reset
153
- - `/auth/magic-link` - Magic link authentication
154
- - `/auth/logout` - Logout handler
155
- - `/auth/handler/*` - OAuth callback handlers
207
+ | Path | Description | Route Type |
208
+ |------|-------------|------------|
209
+ | `/auth/login` | Login page | Guest-only |
210
+ | `/auth/signup` | Registration page | Guest-only |
211
+ | `/auth/forgot-password` | Password reset request | Guest-only |
212
+ | `/auth/magic-link` | Magic link authentication | Guest-only |
213
+ | `/auth/reset-password` | Set new password (with token) | Guest-only |
214
+ | `/auth/logout` | Logout handler | Public |
215
+ | `/auth/handler/*` | OAuth / magic link / password reset callbacks | Public |
156
216
 
157
- ## OAuth Providers
217
+ All other routes are **protected** (require authentication).
158
218
 
159
- Configure which OAuth providers to show:
219
+ ## Environment Variables
160
220
 
161
- ```ts
162
- // app.config.ts
163
- export default defineAppConfig({
164
- xAuth: {
165
- oauthProviders: ['google', 'github', 'microsoft', 'apple'],
166
- },
167
- })
168
- ```
221
+ | Name | Required | Description |
222
+ |------|----------|-------------|
223
+ | `NUXT_PUBLIC_X_AUTH_BASE_URL` | Yes | Base URL of your Better Auth backend (e.g. `https://api.example.com`) |
169
224
 
170
- Available providers depend on your Better Auth backend configuration.
225
+ The auth path is configurable via `runtimeConfig.public.x.auth.authPath` (default: `/auth`).
171
226
 
172
- ## Token Storage
227
+ ## Error Reference
173
228
 
174
- Tokens are stored in secure cookies:
175
- - `HttpOnly` - Not accessible via JavaScript
176
- - `Secure` - Only sent over HTTPS in production
177
- - `SameSite=Lax` - CSRF protection
229
+ All errors are displayed as toast notifications. Methods return `null`, `false`, or `{ error: string }` on failure.
178
230
 
179
- Cookie names are configurable via `tokens.accessCookie` and `tokens.refreshCookie`.
231
+ | Context | Error | When |
232
+ |---------|-------|------|
233
+ | Login | "Please enter both email and password." | Empty email or password |
234
+ | Login | "Invalid credentials." | Wrong email/password (from API) |
235
+ | Signup | "Please enter both email and password." | Empty email or password |
236
+ | Signup | "Could not create account." | API error (e.g. email exists) |
237
+ | Forgot Password | "Please enter your email address." | Empty email |
238
+ | Reset Password | "Token and new password are required" | Missing token or password |
239
+ | Change Password | "Please enter both current and new passwords." | Empty input |
240
+ | Magic Link | "Please enter your email address." | Empty email |
241
+ | OAuth | "`{provider}` Login Failed" | OAuth flow error |
242
+ | Logout | "Failed to sign out." | API error |
243
+ | Update Profile | "Could not update profile." | API error |
244
+ | Delete Account | "Could not delete account." | API error |
245
+
246
+ ## How It Works
247
+
248
+ This layer uses Better Auth's official Vue client (`better-auth/vue`) via `createAuthClient`. The client communicates with your Better Auth backend using cookie-based sessions (`credentials: 'include'`).
249
+
250
+ 1. **Session management**: `createAuthClient` is instantiated once (singleton) and provides a reactive `useSession()` ref that auto-updates. The composable wraps this with convenience methods.
251
+
252
+ 2. **Route protection**: A global middleware (`auth.global.ts`) runs on every navigation. It classifies routes as public, guest-only, or protected, then calls `getCurrentUser()` to check auth state.
253
+
254
+ 3. **Auth layout**: All `/auth/*` pages use the `auth` layout which provides a card-style UI with background image, branding, and animations.
255
+
256
+ 4. **OAuth flow**: `loginWithProvider()` calls Better Auth's `signIn.social()` which redirects to the provider. The callback lands on `/auth/handler/oauth-callback` where the Handler component refreshes the session.
257
+
258
+ 5. **Token access**: `getToken()` and `getAuthHeaders()` extract the access token from the Better Auth session for use with your own API calls.
259
+
260
+ ## Layer Architecture
261
+
262
+ - **`nuxt.config.ts`**: Registers `@nuxt/ui` module, loads the auth CSS, and defines `runtimeConfig.public.x.auth` for base URL and auth path.
263
+ - **`app/app.config.ts`**: Defines all configurable options (tokens, redirects, features, OAuth providers, UI settings) with defaults. Consumer apps override these in their own `app.config.ts`.
264
+ - **`app/composables/useXAuth.ts`**: Core composable wrapping Better Auth's Vue client with typed methods for all auth flows.
265
+ - **`app/middleware/auth.global.ts`**: Global route middleware for auth-based route protection.
266
+ - **`app/plugins/auth-token.ts`**: Provides `$getAuthToken` for use in other plugins/composables.
267
+ - **`app/layouts/auth.vue`**: Visual auth layout with card design, background image, and branding.
268
+ - **`app/components/XAuth/*`**: Pre-built form components using Nuxt UI's `UAuthForm`.
269
+ - **`app/pages/auth/*`**: Auto-registered auth pages.
270
+ - **`app/utils/`**: Cookie storage helper and field normalization utilities.
180
271
 
181
272
  ## Testing
182
273
 
183
274
  ```bash
184
- # Unit tests
185
- npm run test
186
-
187
- # E2E tests
188
- npx playwright test
275
+ npm run test # Watch mode
276
+ npm run test:run # Single run
189
277
  ```
190
278
 
191
279
  ## Requirements
192
280
 
193
281
  - Nuxt 4+
194
282
  - Better Auth 1.0+
195
- - @nuxt/ui v4+ (included)
283
+ - @nuxt/ui v4+ (included as dependency)
196
284
 
197
285
  ## License
198
286
 
199
- MIT
287
+ UNLICENSED
package/app/app.config.ts CHANGED
@@ -37,9 +37,10 @@ export default defineAppConfig({
37
37
  tagline: "",
38
38
  layout: "centered" as "centered" | "split",
39
39
  background: {
40
- type: "gradient" as "gradient" | "image" | "solid",
40
+ enabled: true,
41
41
  imageUrl: "",
42
- overlayOpacity: 50,
42
+ overlayOpacity: 55,
43
+ blur: true,
43
44
  },
44
45
  card: {
45
46
  glass: false,
@@ -92,7 +92,7 @@ const props = defineProps({
92
92
  const config = useAppConfig();
93
93
  const router = useRouter();
94
94
  const route = useRoute();
95
- const { handleMagicLinkCallback, resetPassword } = useXAuth();
95
+ const { handleMagicLinkCallback, resetPassword, getCurrentUser } = useXAuth();
96
96
 
97
97
  const isLoading = ref(true);
98
98
  const error = ref(null);
@@ -128,12 +128,9 @@ onMounted(async () => {
128
128
  return; // Don't redirect for password reset
129
129
 
130
130
  case "oauth":
131
- // OAuth callbacks are typically handled automatically
132
- // Just check if we're authenticated after the callback
133
- const result = await handleMagicLinkCallback(url);
134
- if (result?.error) {
135
- throw new Error(result.error);
136
- }
131
+ // OAuth callbacks are handled by Better Auth automatically via cookies.
132
+ // Refresh the session to pick up the new auth state.
133
+ await getCurrentUser();
137
134
  break;
138
135
 
139
136
  case "email-verification":
@@ -153,17 +150,13 @@ onMounted(async () => {
153
150
  let redirectPath = props.redirectTo || config.xAuth?.redirects?.afterLogin || "/";
154
151
 
155
152
  // Check if there's a redirect_to in the query or state parameter
156
- if (route.query.redirect_to) {
157
- redirectPath = route.query.redirect_to;
158
- } else if (route.query.state) {
159
- try {
160
- const stateObj = JSON.parse(route.query.state);
161
- if (stateObj.redirect_to) {
162
- redirectPath = stateObj.redirect_to;
163
- }
164
- } catch {
165
- // Ignore invalid state parameter
166
- }
153
+ const candidateRedirect = route.query.redirect_to
154
+ || (() => { try { return JSON.parse(route.query.state)?.redirect_to; } catch { return null; } })();
155
+
156
+ // Validate redirect path to prevent open redirects — only allow relative paths
157
+ if (candidateRedirect && typeof candidateRedirect === 'string'
158
+ && candidateRedirect.startsWith('/') && !candidateRedirect.startsWith('//')) {
159
+ redirectPath = candidateRedirect;
167
160
  }
168
161
 
169
162
  // Redirect after delay
@@ -120,35 +120,3 @@ async function handleOAuth(providerId) {
120
120
  }
121
121
  </script>
122
122
 
123
- <style scoped>
124
- .password-strength {
125
- display: flex;
126
- gap: 0.25rem;
127
- margin-top: 0.5rem;
128
- }
129
-
130
- .password-strength__bar {
131
- flex: 1;
132
- height: 3px;
133
- border-radius: 9999px;
134
- background: rgb(var(--color-neutral-200));
135
- transition: background 0.2s ease;
136
- }
137
-
138
- .dark .password-strength__bar {
139
- background: rgb(var(--color-neutral-800));
140
- }
141
-
142
- .password-strength[data-strength="weak"] .password-strength__bar:nth-child(1) {
143
- background: rgb(var(--color-error-500));
144
- }
145
-
146
- .password-strength[data-strength="medium"] .password-strength__bar:nth-child(1),
147
- .password-strength[data-strength="medium"] .password-strength__bar:nth-child(2) {
148
- background: rgb(var(--color-warning-500));
149
- }
150
-
151
- .password-strength[data-strength="strong"] .password-strength__bar {
152
- background: rgb(var(--color-success-500));
153
- }
154
- </style>
@@ -4,6 +4,13 @@ import type { AuthUser, AuthConfig } from './types';
4
4
 
5
5
  let authClientInstance: ReturnType<typeof createAuthClient> | null = null;
6
6
 
7
+ function getOrigin(): string {
8
+ if (import.meta.client) {
9
+ return window.location.origin;
10
+ }
11
+ return '';
12
+ }
13
+
7
14
  export function useXAuth() {
8
15
  const config = useRuntimeConfig();
9
16
  const appConfig = useAppConfig();
@@ -152,7 +159,7 @@ export function useXAuth() {
152
159
  try {
153
160
  const result = await authClient.resetPassword({
154
161
  email,
155
- redirectTo: `${window.location.origin}/auth/reset-password`,
162
+ redirectTo: `${getOrigin()}/auth/handler/password-reset`,
156
163
  });
157
164
 
158
165
  emailSent.value = true;
@@ -166,8 +173,13 @@ export function useXAuth() {
166
173
  };
167
174
 
168
175
  const resetPassword = async (token: string, newPassword: string) => {
176
+ if (!token || !newPassword) {
177
+ return { error: 'Token and new password are required' };
178
+ }
179
+
169
180
  try {
170
181
  const result = await authClient.resetPassword({
182
+ token,
171
183
  newPassword,
172
184
  });
173
185
 
@@ -184,7 +196,7 @@ export function useXAuth() {
184
196
 
185
197
  const loginWithProvider = async (providerName: string) => {
186
198
  try {
187
- const callbackUrl = `${window.location.origin}/auth/handler/oauth-callback`;
199
+ const callbackUrl = `${getOrigin()}/auth/handler/oauth-callback`;
188
200
 
189
201
  await authClient.signIn.social({
190
202
  provider: providerName as any,
@@ -286,6 +298,74 @@ export function useXAuth() {
286
298
  }
287
299
  };
288
300
 
301
+ const changePassword = async (currentPassword: string, newPassword: string) => {
302
+ if (!currentPassword || !newPassword) {
303
+ showError('Change Password Failed', 'Please enter both current and new passwords.');
304
+ return { error: 'Both current and new passwords are required' };
305
+ }
306
+
307
+ try {
308
+ const result = await authClient.changePassword({
309
+ currentPassword,
310
+ newPassword,
311
+ });
312
+
313
+ if (result.error) {
314
+ showError('Change Password Failed', result.error.message || 'Could not change password.');
315
+ return { error: result.error.message || 'Could not change password' };
316
+ }
317
+
318
+ showSuccess('Password Changed', 'Your password has been updated successfully.');
319
+ return true;
320
+ } catch (err: any) {
321
+ showError('Change Password Failed', err.message || 'An error occurred.');
322
+ return { error: err.message || 'An error occurred' };
323
+ }
324
+ };
325
+
326
+ const updateUser = async (data: { name?: string; image?: string }) => {
327
+ try {
328
+ const result = await authClient.updateUser(data);
329
+
330
+ if (result.error) {
331
+ showError('Update Failed', result.error.message || 'Could not update profile.');
332
+ return { error: result.error.message || 'Could not update profile' };
333
+ }
334
+
335
+ await authClient.getSession();
336
+ showSuccess('Profile Updated', 'Your profile has been updated.');
337
+ return true;
338
+ } catch (err: any) {
339
+ showError('Update Failed', err.message || 'An error occurred.');
340
+ return { error: err.message || 'An error occurred' };
341
+ }
342
+ };
343
+
344
+ const deleteAccount = async () => {
345
+ try {
346
+ const result = await authClient.deleteUser();
347
+
348
+ if (result.error) {
349
+ showError('Delete Failed', result.error.message || 'Could not delete account.');
350
+ return false;
351
+ }
352
+
353
+ showSuccess('Account Deleted', 'Your account has been permanently deleted.');
354
+
355
+ const redirectTo = authConfig.value?.redirects?.afterLogout || '/auth/login';
356
+ if (import.meta.client) {
357
+ window.location.href = redirectTo;
358
+ } else {
359
+ await navigateTo(redirectTo, { replace: true });
360
+ }
361
+
362
+ return true;
363
+ } catch (err: any) {
364
+ showError('Delete Failed', err.message || 'An error occurred.');
365
+ return false;
366
+ }
367
+ };
368
+
289
369
  const resetState = () => {
290
370
  emailSent.value = false;
291
371
  codeSent.value = false;
@@ -311,6 +391,9 @@ export function useXAuth() {
311
391
  getToken,
312
392
  getAuthHeaders,
313
393
  handleMagicLinkCallback,
394
+ changePassword,
395
+ updateUser,
396
+ deleteAccount,
314
397
  resetState,
315
398
  authClient,
316
399
  };
@@ -139,10 +139,16 @@ const logoUrl = computed(() => config.xAuth?.ui?.logoUrl || '')
139
139
  const brandName = computed(() => config.xAuth?.ui?.brandName || '')
140
140
  const loading = computed(() => isLoading.value)
141
141
 
142
- // Background Config
143
- const bgConfig = computed(() => config.xAuth?.ui?.background || {})
144
- const isBgEnabled = computed(() => bgConfig.value.enabled !== false)
145
- const overlayBlur = computed(() => bgConfig.value.blur !== false)
142
+ // Background Config - with robust defaults that survive config overrides
143
+ const bgConfig = computed(() => ({
144
+ enabled: config.xAuth?.ui?.background?.enabled ?? true,
145
+ imageUrl: config.xAuth?.ui?.background?.imageUrl ?? '',
146
+ overlayOpacity: config.xAuth?.ui?.background?.overlayOpacity ?? 55,
147
+ blur: config.xAuth?.ui?.background?.blur ?? true,
148
+ }))
149
+
150
+ const isBgEnabled = computed(() => bgConfig.value.enabled)
151
+ const overlayBlur = computed(() => bgConfig.value.blur)
146
152
 
147
153
  const footerTextColorClass = computed(() => {
148
154
  if (backgroundImageUrl.value && isImageLoaded.value) {
@@ -10,6 +10,8 @@ export default defineNuxtRouteMiddleware(async (to) => {
10
10
  "/auth/login",
11
11
  "/auth/signup",
12
12
  "/auth/forgot-password",
13
+ "/auth/magic-link",
14
+ "/auth/reset-password",
13
15
  ];
14
16
 
15
17
  const publicRoutes = [
@@ -0,0 +1,121 @@
1
+ <template>
2
+ <div>
3
+ <Transition
4
+ enter-active-class="transition-all duration-300 ease-out"
5
+ enter-from-class="opacity-0 translate-y-2"
6
+ enter-to-class="opacity-100 translate-y-0"
7
+ leave-active-class="transition-all duration-200 ease-in"
8
+ leave-from-class="opacity-100 translate-y-0"
9
+ leave-to-class="opacity-0 -translate-y-2"
10
+ mode="out-in"
11
+ >
12
+ <UAuthForm
13
+ v-if="!resetDone"
14
+ key="form"
15
+ title="Set a new password"
16
+ description="Enter your new password below"
17
+ :fields="fields"
18
+ :schema="schema"
19
+ :loading="isSubmitting"
20
+ :submit="{ label: 'Reset password' }"
21
+ @submit="handleReset"
22
+ >
23
+ <template v-if="formError" #footer>
24
+ <div class="p-3 rounded-lg bg-error/10 ring-1 ring-error/20">
25
+ <p class="text-sm text-error">{{ formError }}</p>
26
+ </div>
27
+ </template>
28
+ </UAuthForm>
29
+
30
+ <article v-else key="success" class="text-center space-y-6">
31
+ <figure class="mx-auto flex size-16 items-center justify-center rounded-full bg-success/10 ring-1 ring-success/20">
32
+ <UIcon name="i-lucide-check" class="size-8 text-success" />
33
+ </figure>
34
+
35
+ <header class="space-y-2">
36
+ <h3 class="text-xl font-semibold tracking-tight text-highlighted">
37
+ Password reset
38
+ </h3>
39
+ <p class="text-sm text-muted">
40
+ Your password has been changed successfully.
41
+ </p>
42
+ </header>
43
+
44
+ <UButton
45
+ label="Sign in"
46
+ block
47
+ size="lg"
48
+ @click="goToLogin"
49
+ />
50
+ </article>
51
+ </Transition>
52
+ </div>
53
+ </template>
54
+
55
+ <script setup>
56
+ import { z } from "zod"
57
+
58
+ definePageMeta({
59
+ layout: 'auth'
60
+ })
61
+
62
+ const config = useAppConfig()
63
+ const route = useRoute()
64
+ const router = useRouter()
65
+ const { resetPassword } = useXAuth()
66
+
67
+ const isSubmitting = ref(false)
68
+ const resetDone = ref(false)
69
+ const formError = ref('')
70
+
71
+ const token = computed(() => route.query.token || '')
72
+
73
+ const fields = [
74
+ {
75
+ name: "password",
76
+ type: "password",
77
+ label: "New Password",
78
+ placeholder: "At least 8 characters",
79
+ required: true,
80
+ },
81
+ {
82
+ name: "confirmPassword",
83
+ type: "password",
84
+ label: "Confirm Password",
85
+ placeholder: "Re-enter your password",
86
+ required: true,
87
+ },
88
+ ]
89
+
90
+ const schema = z.object({
91
+ password: z.string().min(8, "Password must be at least 8 characters"),
92
+ confirmPassword: z.string().min(1, "Please confirm your password"),
93
+ }).refine(data => data.password === data.confirmPassword, {
94
+ message: "Passwords do not match",
95
+ path: ["confirmPassword"],
96
+ })
97
+
98
+ async function handleReset(payload) {
99
+ if (!token.value) {
100
+ formError.value = 'Invalid or missing reset token. Please request a new password reset.'
101
+ return
102
+ }
103
+
104
+ isSubmitting.value = true
105
+ formError.value = ''
106
+
107
+ const result = await resetPassword(token.value, payload.data.password)
108
+
109
+ isSubmitting.value = false
110
+
111
+ if (result === true) {
112
+ resetDone.value = true
113
+ } else {
114
+ formError.value = result?.error || 'Failed to reset password. Please try again.'
115
+ }
116
+ }
117
+
118
+ function goToLogin() {
119
+ router.push(config.xAuth?.redirects?.login || '/auth/login')
120
+ }
121
+ </script>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xenterprises/nuxt-x-auth-better",
3
3
  "type": "module",
4
- "version": "0.2.5",
4
+ "version": "0.3.0",
5
5
  "description": "BetterAuth authentication layer for Nuxt",
6
6
  "license": "UNLICENSED",
7
7
  "author": "X Enterprises",