@wishbone-media/spark 0.8.5 → 0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wishbone-media/spark",
3
- "version": "0.8.5",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -24,6 +24,7 @@
24
24
  "@fortawesome/fontawesome-svg-core": "^7.1.0",
25
25
  "@fortawesome/pro-regular-svg-icons": "^7.1.0",
26
26
  "@headlessui/vue": "^1.7.23",
27
+ "axios": "^1.6.0",
27
28
  "pinia": "^3.0.4",
28
29
  "pinia-plugin-persistedstate": "^4.0.0",
29
30
  "vue": "^3.5.24",
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <TransitionRoot as="template" :show="sparkModalService.state.isVisible">
3
- <Dialog class="relative z-50" @close="sparkModalService.hide">
3
+ <Dialog class="relative z-200" @close="sparkModalService.hide">
4
4
  <TransitionChild
5
5
  as="template"
6
6
  enter="ease-out duration-300"
@@ -24,7 +24,7 @@
24
24
  leave-from="opacity-100 translate-y-0 sm:scale-100"
25
25
  leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
26
26
  >
27
- <DialogPanel class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
27
+ <DialogPanel class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:min-w-lg sm:max-w-max">
28
28
  <!-- Render dynamic component -->
29
29
  <component
30
30
  :is="sparkModalService.state.content"
@@ -36,6 +36,7 @@ import {
36
36
  faStreetView,
37
37
  faTimes,
38
38
  faXmark,
39
+ faSignOut,
39
40
  } from '@fortawesome/pro-regular-svg-icons'
40
41
 
41
42
  export const Icons = {
@@ -73,6 +74,7 @@ export const Icons = {
73
74
  farStreetView: faStreetView,
74
75
  farTimes: faTimes,
75
76
  farXmark: faXmark,
77
+ farSignOut: faSignOut,
76
78
  }
77
79
 
78
80
  export function addIcons(newIcons) {
@@ -1 +1,2 @@
1
- export { Icons, addIcons, setupFontAwesome } from './fontawesome.js'
1
+ export { Icons, addIcons, setupFontAwesome } from './fontawesome.js'
2
+ export { createAuthRoutes, setupAuthGuards } from './router.js'
@@ -0,0 +1,122 @@
1
+ import { useSparkAuthStore } from '../stores/auth.js'
2
+ import {
3
+ SparkLoginView,
4
+ SparkLogoutView,
5
+ SparkForgotPasswordView,
6
+ SparkResetPasswordView,
7
+ } from '../views/index.js'
8
+
9
+ export function createAuthRoutes(options = {}) {
10
+ const {
11
+ loginPath = '/login',
12
+ logoutPath = '/logout',
13
+ forgotPasswordPath = '/forgot-password',
14
+ resetPasswordPath = '/reset-password',
15
+ logo = '',
16
+ defaultRedirect = '/dashboard',
17
+ } = options
18
+
19
+ return [
20
+ {
21
+ path: loginPath,
22
+ name: 'login',
23
+ component: SparkLoginView,
24
+ props: { logo, defaultRedirect },
25
+ meta: { auth: false },
26
+ },
27
+ {
28
+ path: logoutPath,
29
+ name: 'logout',
30
+ component: SparkLogoutView,
31
+ props: { defaultRedirect: loginPath },
32
+ meta: { auth: false },
33
+ },
34
+ {
35
+ path: forgotPasswordPath,
36
+ name: 'forgot-password',
37
+ component: SparkForgotPasswordView,
38
+ props: { logo, loginRoute: loginPath },
39
+ meta: { auth: false },
40
+ },
41
+ {
42
+ path: resetPasswordPath,
43
+ name: 'reset-password',
44
+ component: SparkResetPasswordView,
45
+ props: { logo, loginRoute: loginPath },
46
+ meta: { auth: false },
47
+ },
48
+ ]
49
+ }
50
+
51
+ export function setupAuthGuards(router, options = {}) {
52
+ const { defaultAuthenticatedRoute = '/dashboard' } = options
53
+
54
+ router.beforeEach(async (to, _from, next) => {
55
+ // Access store inside the guard to ensure Pinia is initialized
56
+ const authStore = useSparkAuthStore()
57
+
58
+ // Wait for auth to be ready
59
+ if (!authStore.state.ready) {
60
+ await authStore.fetchUser()
61
+ }
62
+
63
+ processNavigation(to, next, authStore, defaultAuthenticatedRoute)
64
+ })
65
+ }
66
+
67
+ function processNavigation(to, next, authStore, defaultAuthenticatedRoute) {
68
+ const requiresAuth = to.meta.auth !== false
69
+ const isAuthenticated = authStore.check
70
+
71
+ // Handle unauthenticated users
72
+ if (!isAuthenticated) {
73
+ // Allow access to public routes (login, forgot-password, etc.)
74
+ if (!requiresAuth) {
75
+ if (to.path === '/logout') {
76
+ next({ path: authStore.state.routes.auth })
77
+ return
78
+ }
79
+ next()
80
+ return
81
+ }
82
+
83
+ // Redirect to login page with current path for protected routes
84
+ let redirect = to.fullPath
85
+
86
+ // Don't add redirect param if going to default route after login
87
+ if (redirect === '/' || redirect === '' || redirect === defaultAuthenticatedRoute) {
88
+ next({ path: authStore.state.routes.auth })
89
+ return
90
+ }
91
+
92
+ // Prevent double encoding
93
+ if (redirect.includes('%')) {
94
+ redirect = decodeURIComponent(redirect)
95
+ }
96
+ redirect = encodeURIComponent(redirect)
97
+
98
+ next({
99
+ path: authStore.state.routes.auth,
100
+ query: { redirect }
101
+ })
102
+ return
103
+ }
104
+
105
+ // Handle authenticated users
106
+ if (isAuthenticated) {
107
+ // Redirect away from public auth pages (login, logout, etc.) to default route
108
+ if (!requiresAuth && to.path === authStore.state.routes.auth) {
109
+ next({ path: defaultAuthenticatedRoute })
110
+ return
111
+ }
112
+
113
+ // Redirect root path to default route
114
+ if (to.path === '/') {
115
+ next({ path: defaultAuthenticatedRoute })
116
+ return
117
+ }
118
+
119
+ // Allow access to protected routes
120
+ next()
121
+ }
122
+ }
@@ -0,0 +1,154 @@
1
+ import { defineStore } from 'pinia'
2
+ import { reactive, computed } from 'vue'
3
+ import axios from 'axios'
4
+ import { getCookie, setCookie, deleteCookie } from '../utils/cookies.js'
5
+
6
+ const TOKEN_NAME = 'bolt-token'
7
+
8
+ export const useSparkAuthStore = defineStore('auth', () => {
9
+ const state = reactive({
10
+ user: null,
11
+ token: null,
12
+ ready: false,
13
+ // Configurable endpoints
14
+ endpoints: {
15
+ login: '/login',
16
+ logout: '/logout',
17
+ fetch: '/user',
18
+ passwordEmail: '/password/email',
19
+ passwordReset: '/password/reset',
20
+ },
21
+ // Configurable routes
22
+ routes: {
23
+ auth: '/login',
24
+ forbidden: '/error/403',
25
+ notFound: '/error/404',
26
+ },
27
+ // Dev credentials for autofill (optional)
28
+ devCredentials: {
29
+ username: null,
30
+ password: null,
31
+ },
32
+ // Lifecycle callbacks (optional)
33
+ callbacks: {
34
+ onLoginSuccess: null,
35
+ onLoginError: null,
36
+ onLogoutSuccess: null,
37
+ onLogoutError: null,
38
+ },
39
+ })
40
+
41
+ // Initialize configuration
42
+ const initialize = (config = {}) => {
43
+ if (config.endpoints) {
44
+ Object.assign(state.endpoints, config.endpoints)
45
+ }
46
+ if (config.routes) {
47
+ Object.assign(state.routes, config.routes)
48
+ }
49
+ if (config.devCredentials) {
50
+ Object.assign(state.devCredentials, config.devCredentials)
51
+ }
52
+ if (config.callbacks) {
53
+ Object.assign(state.callbacks, config.callbacks)
54
+ }
55
+ }
56
+
57
+ // JWT Cookie Management
58
+ const setTokenCookie = (token) => {
59
+ setCookie(TOKEN_NAME, token)
60
+ state.token = token
61
+ }
62
+
63
+ const clearTokenCookie = () => {
64
+ deleteCookie(TOKEN_NAME)
65
+ state.token = null
66
+ }
67
+
68
+ const getTokenCookie = () => {
69
+ return getCookie(TOKEN_NAME)
70
+ }
71
+
72
+ // Auth Methods
73
+ const login = async (credentials) => {
74
+ const response = await axios.post(state.endpoints.login, credentials)
75
+
76
+ // Token is in Authorization header (raw token)
77
+ const token = response.headers.authorization
78
+
79
+ setTokenCookie(token)
80
+ state.user = response.data
81
+
82
+ // Call success callback if provided
83
+ if (state.callbacks.onLoginSuccess) {
84
+ await state.callbacks.onLoginSuccess(response.data)
85
+ }
86
+
87
+ return response.data
88
+ }
89
+
90
+ const logout = async () => {
91
+ try {
92
+ await axios.post(
93
+ state.endpoints.logout,
94
+ {},
95
+ {
96
+ headers: {
97
+ Authorization: `Bearer ${state.token}`,
98
+ },
99
+ },
100
+ )
101
+
102
+ // Call success callback if provided
103
+ if (state.callbacks.onLogoutSuccess) {
104
+ await state.callbacks.onLogoutSuccess()
105
+ }
106
+ } catch (error) {
107
+ // Call error callback if provided
108
+ if (state.callbacks.onLogoutError) {
109
+ await state.callbacks.onLogoutError(error)
110
+ }
111
+ throw error
112
+ } finally {
113
+ clearTokenCookie()
114
+ state.user = null
115
+ }
116
+ }
117
+
118
+ const fetchUser = async () => {
119
+ const token = getTokenCookie()
120
+ if (!token) {
121
+ state.ready = true
122
+ return null
123
+ }
124
+
125
+ try {
126
+ const { data } = await axios.get(state.endpoints.fetch, {
127
+ headers: {
128
+ Authorization: `Bearer ${token}`,
129
+ },
130
+ })
131
+
132
+ state.user = data
133
+ state.token = token
134
+ } catch (error) {
135
+ clearTokenCookie()
136
+ } finally {
137
+ state.ready = true
138
+ }
139
+ }
140
+
141
+ const check = computed(() => !!state.token && !!state.user)
142
+
143
+ return {
144
+ state,
145
+ initialize,
146
+ login,
147
+ logout,
148
+ fetchUser,
149
+ check,
150
+ setTokenCookie,
151
+ clearTokenCookie,
152
+ getTokenCookie,
153
+ }
154
+ })
@@ -1,3 +1,4 @@
1
1
  export * from './app.js'
2
+ export * from './auth.js'
2
3
  export * from './brand-filter.js'
3
4
  export * from './navigation.js'
@@ -1,6 +1,6 @@
1
1
  import { defineStore } from 'pinia'
2
- import { reactive } from 'vue'
3
- import { useRouter } from 'vue-router'
2
+ import { reactive, watch } from 'vue'
3
+ import { useRouter, useRoute } from 'vue-router'
4
4
 
5
5
  export const useSparkNavStore = defineStore('sparkNav', () => {
6
6
  const state = reactive({
@@ -10,6 +10,7 @@ export const useSparkNavStore = defineStore('sparkNav', () => {
10
10
  })
11
11
 
12
12
  const router = useRouter()
13
+ const route = useRoute()
13
14
 
14
15
  const initialize = (menuConfig = []) => {
15
16
  state.menu = menuConfig
@@ -28,24 +29,15 @@ export const useSparkNavStore = defineStore('sparkNav', () => {
28
29
 
29
30
  const goto = async (route) => {
30
31
  if (route) {
31
- const updateMenuItemCurrent = (items) => {
32
- items.forEach((item) => {
33
- item.current = item.href === route
34
- if (item.children) {
35
- updateMenuItemCurrent(item.children)
36
- }
37
- })
38
- }
39
-
40
- updateMenuItemCurrent(state.menu)
41
-
42
32
  const menuItem = findMenuItemByRoute(state.menu, route)
43
33
 
34
+ // If menu item has a custom action, execute it instead of navigating
44
35
  if (menuItem && typeof menuItem.action === 'function') {
45
36
  menuItem.action()
46
37
  return
47
38
  }
48
39
 
40
+ // Navigate to route - the route watcher will update "current" state automatically
49
41
  await router.push(route)
50
42
  }
51
43
  }
@@ -58,11 +50,36 @@ export const useSparkNavStore = defineStore('sparkNav', () => {
58
50
  state.hidden = !state.hidden
59
51
  }
60
52
 
53
+ const syncWithRoute = () => {
54
+ const routeName = route.name || route.path.replace('/', '')
55
+ if (routeName) {
56
+ const updateMenuItemCurrent = (items) => {
57
+ items.forEach((item) => {
58
+ item.current = item.href === routeName
59
+ if (item.children) {
60
+ updateMenuItemCurrent(item.children)
61
+ }
62
+ })
63
+ }
64
+ updateMenuItemCurrent(state.menu)
65
+ }
66
+ }
67
+
68
+ // Watch route changes and sync navigation state
69
+ watch(
70
+ () => route.path,
71
+ () => {
72
+ syncWithRoute()
73
+ },
74
+ { immediate: true }
75
+ )
76
+
61
77
  return {
62
78
  state,
63
79
  initialize,
64
80
  goto,
65
81
  toggleCollapsed,
66
82
  toggleHidden,
83
+ syncWithRoute,
67
84
  }
68
85
  })
@@ -0,0 +1,51 @@
1
+ export const getCookie = (name) => {
2
+ const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`))
3
+ return match ? match[2] : null
4
+ }
5
+
6
+ export const setCookie = (name, value, options = {}) => {
7
+ const {
8
+ maxAge = 31536000, // 365 days in seconds
9
+ domain = getDomain(),
10
+ secure = import.meta.env.PROD,
11
+ sameSite = 'Lax',
12
+ path = '/',
13
+ } = options
14
+
15
+ let cookie = `${name}=${value}; max-age=${maxAge}; path=${path}; samesite=${sameSite}`
16
+
17
+ if (domain) {
18
+ cookie += `; domain=${domain}`
19
+ }
20
+
21
+ if (secure) {
22
+ cookie += '; secure'
23
+ }
24
+
25
+ document.cookie = cookie
26
+ }
27
+
28
+ export const deleteCookie = (name, options = {}) => {
29
+ const { domain = getDomain(), path = '/' } = options
30
+
31
+ let cookie = `${name}=; max-age=0; path=${path}`
32
+
33
+ if (domain) {
34
+ cookie += `; domain=${domain}`
35
+ }
36
+
37
+ document.cookie = cookie
38
+ }
39
+
40
+ export const getDomain = () => {
41
+ // For PRODUCTION: returns .letsbolt.com.au
42
+ // For STAGING: returns .ENV.letsbolt.io
43
+ // For DEV: returns .letsbolt.test
44
+ let reserveSlices = -3
45
+
46
+ if (window.location.hostname.endsWith('.test')) {
47
+ reserveSlices = -2
48
+ }
49
+
50
+ return window.location.hostname.split('.').slice(reserveSlices).join('.')
51
+ }
@@ -1,75 +1,105 @@
1
1
  <template>
2
2
  <div class="h-full grid place-content-center relative">
3
3
  <div class="absolute top-8 left-8">
4
+ <img v-if="props.logo" :src="props.logo" alt="Logo" class="h-[23px] w-auto" />
4
5
  <svg
5
- width="59"
6
- height="23"
7
- viewBox="0 0 59 23"
8
- fill="none"
9
- xmlns="http://www.w3.org/2000/svg"
6
+ v-else
7
+ width="59"
8
+ height="23"
9
+ viewBox="0 0 59 23"
10
+ fill="none"
11
+ xmlns="http://www.w3.org/2000/svg"
10
12
  >
11
13
  <path
12
- d="M49.2029 17.1264V8.03835H44.0829V5.22235H58.0989V8.03835H52.9629V17.1264H49.2029Z"
13
- fill="#1C64F2"
14
+ d="M49.2029 17.1264V8.03835H44.0829V5.22235H58.0989V8.03835H52.9629V17.1264H49.2029Z"
15
+ fill="#1C64F2"
14
16
  />
15
17
  <path d="M34.5 5.22235H38.228V14.1664H46.5V17.1264H34.5V5.22235Z" fill="#1C64F2" />
16
18
  <path
17
- d="M28.3161 0C29.1499 0 29.7522 0.798785 29.5209 1.59757L27.1856 9.77748H30.9046C31.747 9.77748 32.4279 10.4584 32.4279 11.3008C32.4279 11.7504 32.2315 12.1738 31.891 12.4619L20.5989 22.0517C20.3719 22.2438 20.0839 22.3485 19.787 22.3485C18.9533 22.3485 18.351 21.5497 18.5823 20.751L20.9176 12.571H17.1463C16.33 12.571 15.6709 11.9119 15.6709 11.1001C15.6709 10.6679 15.8586 10.262 16.186 9.98263L27.5043 0.301181C27.7312 0.104759 28.0193 0 28.3161 0ZM26.7404 3.71021L18.8311 10.4759H22.3056C22.633 10.4759 22.9429 10.6286 23.1437 10.8905C23.3445 11.1524 23.4056 11.4929 23.3139 11.8072L21.3584 18.6601L29.355 11.8727H25.7976C25.4702 11.8727 25.1603 11.7199 24.9595 11.458C24.7587 11.1961 24.6976 10.8556 24.7893 10.5413L26.7404 3.71021Z"
18
- fill="#1C64F2"
19
+ d="M28.3161 0C29.1499 0 29.7522 0.798785 29.5209 1.59757L27.1856 9.77748H30.9046C31.747 9.77748 32.4279 10.4584 32.4279 11.3008C32.4279 11.7504 32.2315 12.1738 31.891 12.4619L20.5989 22.0517C20.3719 22.2438 20.0839 22.3485 19.787 22.3485C18.9533 22.3485 18.351 21.5497 18.5823 20.751L20.9176 12.571H17.1463C16.33 12.571 15.6709 11.9119 15.6709 11.1001C15.6709 10.6679 15.8586 10.262 16.186 9.98263L27.5043 0.301181C27.7312 0.104759 28.0193 0 28.3161 0ZM26.7404 3.71021L18.8311 10.4759H22.3056C22.633 10.4759 22.9429 10.6286 23.1437 10.8905C23.3445 11.1524 23.4056 11.4929 23.3139 11.8072L21.3584 18.6601L29.355 11.8727H25.7976C25.4702 11.8727 25.1603 11.7199 24.9595 11.458C24.7587 11.1961 24.6976 10.8556 24.7893 10.5413L26.7404 3.71021Z"
20
+ fill="#1C64F2"
19
21
  />
20
22
  <path
21
- d="M0 17.1264V5.22235H10.192C13.6 5.22235 14.544 6.53435 14.544 7.94235V8.16635C14.544 9.70235 13.232 10.3264 12.656 10.4864C13.472 10.6944 15.216 11.3984 15.216 13.4784V13.7024C15.216 15.5904 14.144 17.1264 10.288 17.1264H0ZM9.552 7.73435H3.728V9.67035H9.552C10.592 9.67035 10.848 9.19035 10.848 8.71035V8.67835C10.848 8.18235 10.592 7.73435 9.552 7.73435ZM9.872 12.1984H3.728V14.5344H9.872C11.12 14.5344 11.344 13.8464 11.344 13.3664V13.3024C11.344 12.7904 11.104 12.1984 9.872 12.1984Z"
22
- fill="#1C64F2"
23
+ d="M0 17.1264V5.22235H10.192C13.6 5.22235 14.544 6.53435 14.544 7.94235V8.16635C14.544 9.70235 13.232 10.3264 12.656 10.4864C13.472 10.6944 15.216 11.3984 15.216 13.4784V13.7024C15.216 15.5904 14.144 17.1264 10.288 17.1264H0ZM9.552 7.73435H3.728V9.67035H9.552C10.592 9.67035 10.848 9.19035 10.848 8.71035V8.67835C10.848 8.18235 10.592 7.73435 9.552 7.73435ZM9.872 12.1984H3.728V14.5344H9.872C11.12 14.5344 11.344 13.8464 11.344 13.3664V13.3024C11.344 12.7904 11.104 12.1984 9.872 12.1984Z"
24
+ fill="#1C64F2"
23
25
  />
24
26
  </svg>
25
27
  </div>
26
28
 
27
29
  <div class="max-w-sm grid gap-y-1 -mt-8">
28
30
  <div class="mb-7">
29
- <h1 class="text-4xl text-gray-900 semibold tracking-tight mb-3">Log in</h1>
31
+ <h1 class="text-4xl text-gray-900 semibold tracking-tight mb-3">Reset password</h1>
30
32
 
31
33
  <p class="text-gray-600">
32
- Welcome back{{ appStore.state.app ? ` to ${appStore.state.app}` : '' }}! Please enter
33
- your details.
34
+ Enter your email and we'll send you a link to reset your password.
34
35
  </p>
35
36
  </div>
36
37
 
37
- <FormKit type="form" @submit="handleLogin" :actions="false">
38
+ <FormKit type="form" @submit="handleSubmit" :actions="false">
38
39
  <FormKit
39
- label="Email"
40
- name="email"
41
- placeholder="Enter your email"
42
- type="email"
43
- validation="required|email"
44
- outer-class="max-w-full"
40
+ label="Email"
41
+ name="email"
42
+ placeholder="Enter your email"
43
+ type="email"
44
+ validation="required|email"
45
+ outer-class="max-w-full"
45
46
  />
46
47
 
47
- <FormKit
48
- label="Password"
49
- name="password"
50
- placeholder="••••••••"
51
- type="password"
52
- validation="required"
53
- outer-class="max-w-full"
54
- />
55
-
56
- <div class="grid grid-flow-col justify-between my-1">
57
- <FormKit label="Remember me" name="remember" type="checkbox" />
58
- </div>
59
-
60
48
  <div v-if="errorMessage" class="text-red-600 text-sm mb-2">
61
49
  {{ errorMessage }}
62
50
  </div>
63
51
 
52
+ <div v-if="successMessage" class="text-green-600 text-sm mb-2">
53
+ {{ successMessage }}
54
+ </div>
55
+
64
56
  <spark-button type="submit" size="xl" :disabled="isLoading" button-class="w-full mb-2">
65
- <span v-if="!isLoading">Sign in</span>
66
- <span v-else>Signing in...</span>
57
+ <span v-if="!isLoading">Send reset link</span>
58
+ <span v-else>Sending...</span>
67
59
  </spark-button>
60
+
61
+ <router-link :to="props.loginRoute" class="text-sm text-center text-primary-600 font-semibold block">
62
+ Back to login
63
+ </router-link>
68
64
  </FormKit>
69
65
  </div>
70
66
  </div>
71
67
  </template>
72
68
 
73
69
  <script setup>
74
- import { SparkButton } from '@/index.js'
70
+ import { ref } from 'vue'
71
+ import axios from 'axios'
72
+ import { SparkButton, useSparkAuthStore } from '@/index.js'
73
+
74
+ const authStore = useSparkAuthStore()
75
+
76
+ const props = defineProps({
77
+ logo: {
78
+ type: String,
79
+ default: '',
80
+ },
81
+ loginRoute: {
82
+ type: String,
83
+ default: '/login',
84
+ },
85
+ })
86
+
87
+ const isLoading = ref(false)
88
+ const errorMessage = ref('')
89
+ const successMessage = ref('')
90
+
91
+ const handleSubmit = async ({ email }) => {
92
+ isLoading.value = true
93
+ errorMessage.value = ''
94
+ successMessage.value = ''
95
+
96
+ try {
97
+ await axios.post(authStore.state.endpoints.passwordEmail, { email })
98
+ successMessage.value = 'Password reset link sent! Check your email.'
99
+ } catch (error) {
100
+ errorMessage.value = error.response?.data?.message || 'Failed to send reset link.'
101
+ } finally {
102
+ isLoading.value = false
103
+ }
104
+ }
75
105
  </script>