@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/README.md +0 -11
- package/dist/index.js +2781 -561
- package/package.json +2 -1
- package/src/components/SparkModalContainer.vue +2 -2
- package/src/plugins/fontawesome.js +2 -0
- package/src/plugins/index.js +2 -1
- package/src/plugins/router.js +122 -0
- package/src/stores/auth.js +154 -0
- package/src/stores/index.js +1 -0
- package/src/stores/navigation.js +30 -13
- package/src/utils/cookies.js +51 -0
- package/src/views/SparkForgotPasswordView.vue +67 -37
- package/src/views/SparkLoginView.vue +29 -13
- package/src/views/SparkLogoutView.vue +19 -4
- package/src/views/SparkResetPasswordView.vue +113 -0
- package/src/views/index.js +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wishbone-media/spark",
|
|
3
|
-
"version": "0.
|
|
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-
|
|
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-
|
|
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) {
|
package/src/plugins/index.js
CHANGED
|
@@ -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
|
+
})
|
package/src/stores/index.js
CHANGED
package/src/stores/navigation.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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">
|
|
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
|
-
|
|
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="
|
|
38
|
+
<FormKit type="form" @submit="handleSubmit" :actions="false">
|
|
38
39
|
<FormKit
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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">
|
|
66
|
-
<span v-else>
|
|
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 {
|
|
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>
|