@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 +193 -105
- package/app/app.config.ts +3 -2
- package/app/components/XAuth/Handler.vue +11 -18
- package/app/components/XAuth/Login.vue +0 -32
- package/app/composables/useXAuth.ts +85 -2
- package/app/layouts/auth.vue +10 -4
- package/app/middleware/auth.global.ts +2 -0
- package/app/pages/auth/reset-password.vue +121 -0
- package/package.json +1 -1
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
|
-
##
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
67
|
+
logout,
|
|
68
|
+
loginWithProvider,
|
|
86
69
|
sendMagicLink,
|
|
87
70
|
forgotPassword,
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
84
|
+
await changePassword('oldpass', 'newpass')
|
|
85
|
+
await updateUser({ name: 'New Name' })
|
|
108
86
|
await logout()
|
|
109
87
|
</script>
|
|
110
88
|
```
|
|
111
89
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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:
|
|
119
|
-
email:
|
|
120
|
-
name:
|
|
121
|
-
avatar
|
|
122
|
-
emailVerified:
|
|
123
|
-
metadata
|
|
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
|
|
144
|
-
| `XAuthOAuthButton` | Single OAuth provider button |
|
|
145
|
-
| `XAuthOAuthButtonGroup` | All configured OAuth buttons |
|
|
146
|
-
| `XAuthHandler` | OAuth
|
|
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 (
|
|
205
|
+
## Pages (auto-registered)
|
|
149
206
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
217
|
+
All other routes are **protected** (require authentication).
|
|
158
218
|
|
|
159
|
-
|
|
219
|
+
## Environment Variables
|
|
160
220
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
225
|
+
The auth path is configurable via `runtimeConfig.public.x.auth.authPath` (default: `/auth`).
|
|
171
226
|
|
|
172
|
-
##
|
|
227
|
+
## Error Reference
|
|
173
228
|
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
40
|
+
enabled: true,
|
|
41
41
|
imageUrl: "",
|
|
42
|
-
overlayOpacity:
|
|
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
|
|
132
|
-
//
|
|
133
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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: `${
|
|
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 = `${
|
|
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
|
};
|
package/app/layouts/auth.vue
CHANGED
|
@@ -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(() =>
|
|
144
|
-
|
|
145
|
-
|
|
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) {
|
|
@@ -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>
|