@nuxt-customer-portal/authentication 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/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/app/middleware/check-verification-error.ts +16 -0
- package/app/pages/forgot-password.vue +317 -0
- package/app/pages/login.vue +212 -0
- package/app/pages/signup.vue +359 -0
- package/app/pages/verify-email.vue +301 -0
- package/i18n/locales/en.json +115 -0
- package/i18n/locales/nl.json +115 -0
- package/nuxt.config.ts +11 -0
- package/package.json +42 -0
- package/portal.manifest.mjs +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @nuxt-customer-portal/authentication
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
Declare the Nuxt UI dependency used by the package's public components and types,
|
|
6
|
+
so npm installations do not depend on another package's dependency layout.
|
|
7
|
+
|
|
8
|
+
### Patch Changes
|
|
9
|
+
|
|
10
|
+
- @nuxt-customer-portal/core@0.3.0
|
|
11
|
+
- @nuxt-customer-portal/ui@0.3.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nuxt Customer Portal contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export default defineNuxtRouteMiddleware((to, _from) => {
|
|
2
|
+
// Check if navigating to the email verified page and if there's an error query param
|
|
3
|
+
if (to.path === '/email-verified' && to.query.error) {
|
|
4
|
+
console.log(`Middleware: Found error (${to.query.error}) on /email-verified, redirecting to /verification-error`)
|
|
5
|
+
// Redirect to the verification error page, preserving the error query param
|
|
6
|
+
// Use replace: true to avoid adding the intermediate /email-verified page to history
|
|
7
|
+
return navigateTo(
|
|
8
|
+
{
|
|
9
|
+
path: '/verification-error',
|
|
10
|
+
query: { error: to.query.error }
|
|
11
|
+
},
|
|
12
|
+
{ replace: true }
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
// Otherwise, allow navigation to proceed
|
|
16
|
+
})
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import * as z from 'zod'
|
|
3
|
+
import type { FormSubmitEvent } from '@nuxt/ui'
|
|
4
|
+
import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
|
|
5
|
+
|
|
6
|
+
definePageMeta({
|
|
7
|
+
layout: 'centerform',
|
|
8
|
+
public: true
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const { t } = useI18n()
|
|
12
|
+
const router = useRouter()
|
|
13
|
+
|
|
14
|
+
useSeoMeta({
|
|
15
|
+
title: t('forgotPassword.title'),
|
|
16
|
+
description: t('forgotPassword.description')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// State management
|
|
20
|
+
const currentStep = ref(1) // 1: email entry, 2: OTP + password
|
|
21
|
+
const email = ref('')
|
|
22
|
+
const otpCode = ref('')
|
|
23
|
+
const newPassword = ref('')
|
|
24
|
+
const confirmPassword = ref('')
|
|
25
|
+
const isLoading = ref(false)
|
|
26
|
+
const error = ref('')
|
|
27
|
+
const success = ref('')
|
|
28
|
+
const resendCooldown = ref(0)
|
|
29
|
+
let cooldownTimer: NodeJS.Timeout | null = null
|
|
30
|
+
|
|
31
|
+
// Auto-focus email input on mount
|
|
32
|
+
onMounted(() => {
|
|
33
|
+
const input = document.querySelector('input[type="email"]') as HTMLInputElement
|
|
34
|
+
if (input) {
|
|
35
|
+
input.focus()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Clean up timer on unmount
|
|
40
|
+
onUnmounted(() => {
|
|
41
|
+
if (cooldownTimer) {
|
|
42
|
+
clearInterval(cooldownTimer)
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Start cooldown timer
|
|
47
|
+
const startCooldownTimer = () => {
|
|
48
|
+
resendCooldown.value = 60
|
|
49
|
+
if (cooldownTimer) {
|
|
50
|
+
clearInterval(cooldownTimer)
|
|
51
|
+
}
|
|
52
|
+
cooldownTimer = setInterval(() => {
|
|
53
|
+
resendCooldown.value--
|
|
54
|
+
if (resendCooldown.value <= 0) {
|
|
55
|
+
clearInterval(cooldownTimer!)
|
|
56
|
+
cooldownTimer = null
|
|
57
|
+
}
|
|
58
|
+
}, 1000)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Handle OTP input formatting (6 digits only)
|
|
62
|
+
const handleOtpInput = (event: Event) => {
|
|
63
|
+
const target = event.target as HTMLInputElement
|
|
64
|
+
const value = target.value.replace(/\D/g, '').slice(0, 6)
|
|
65
|
+
otpCode.value = value
|
|
66
|
+
target.value = value
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Email validation schema
|
|
70
|
+
const emailSchema = computed(() =>
|
|
71
|
+
z.object({
|
|
72
|
+
email: z.email(t('forgotPassword.validation.invalidEmail'))
|
|
73
|
+
})
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
// Password reset validation schema
|
|
77
|
+
const resetSchema = computed(() =>
|
|
78
|
+
z
|
|
79
|
+
.object({
|
|
80
|
+
otp: z.string().min(6, t('forgotPassword.validation.otpLength')),
|
|
81
|
+
newPassword: z.string().min(8, t('forgotPassword.validation.passwordMinLength')),
|
|
82
|
+
confirmPassword: z.string().min(8, t('forgotPassword.validation.passwordMinLength'))
|
|
83
|
+
})
|
|
84
|
+
.refine((data) => data.newPassword === data.confirmPassword, {
|
|
85
|
+
message: t('forgotPassword.validation.passwordsMatch'),
|
|
86
|
+
path: ['confirmPassword']
|
|
87
|
+
})
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// Step 1: Send verification code
|
|
91
|
+
const sendVerificationCode = async (payload: FormSubmitEvent<{ email: string }>) => {
|
|
92
|
+
isLoading.value = true
|
|
93
|
+
error.value = ''
|
|
94
|
+
success.value = ''
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const result = await authClient.emailOtp.sendVerificationOtp({
|
|
98
|
+
email: payload.data.email,
|
|
99
|
+
type: 'forget-password'
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
if (result.data?.success) {
|
|
103
|
+
email.value = payload.data.email
|
|
104
|
+
currentStep.value = 2
|
|
105
|
+
success.value = t('forgotPassword.messages.codeSent')
|
|
106
|
+
startCooldownTimer()
|
|
107
|
+
|
|
108
|
+
// Auto-focus OTP input
|
|
109
|
+
await nextTick()
|
|
110
|
+
const otpInput = document.querySelector('input[type="text"]') as HTMLInputElement
|
|
111
|
+
if (otpInput) {
|
|
112
|
+
otpInput.focus()
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
error.value = result.error?.message || t('forgotPassword.messages.userNotFound')
|
|
116
|
+
}
|
|
117
|
+
} catch (err: unknown) {
|
|
118
|
+
const errorMessage = err instanceof Error ? err.message : String(err)
|
|
119
|
+
error.value = errorMessage || t('forgotPassword.messages.userNotFound')
|
|
120
|
+
} finally {
|
|
121
|
+
isLoading.value = false
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Resend verification code
|
|
126
|
+
const resendCode = async () => {
|
|
127
|
+
if (resendCooldown.value > 0) {
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
isLoading.value = true
|
|
132
|
+
error.value = ''
|
|
133
|
+
success.value = ''
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const result = await authClient.emailOtp.sendVerificationOtp({
|
|
137
|
+
email: email.value,
|
|
138
|
+
type: 'forget-password'
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
if (result.data?.success) {
|
|
142
|
+
success.value = t('forgotPassword.messages.codeSent')
|
|
143
|
+
startCooldownTimer()
|
|
144
|
+
} else {
|
|
145
|
+
error.value = result.error?.message || t('forgotPassword.messages.resendError')
|
|
146
|
+
}
|
|
147
|
+
} catch (err: unknown) {
|
|
148
|
+
const errorMessage = err instanceof Error ? err.message : String(err)
|
|
149
|
+
error.value = errorMessage || t('forgotPassword.messages.resendError')
|
|
150
|
+
} finally {
|
|
151
|
+
isLoading.value = false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Step 2: Reset password with OTP
|
|
156
|
+
const resetPassword = async (
|
|
157
|
+
payload: FormSubmitEvent<{ otp: string; newPassword: string; confirmPassword: string }>
|
|
158
|
+
) => {
|
|
159
|
+
isLoading.value = true
|
|
160
|
+
error.value = ''
|
|
161
|
+
success.value = ''
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const resetResult = await authClient.emailOtp.resetPassword({
|
|
165
|
+
email: email.value,
|
|
166
|
+
otp: payload.data.otp,
|
|
167
|
+
password: payload.data.newPassword
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
if (resetResult.data?.success) {
|
|
171
|
+
router.push('/dashboard')
|
|
172
|
+
} else {
|
|
173
|
+
console.error(resetResult.error)
|
|
174
|
+
error.value = t('forgotPassword.messages.resetError')
|
|
175
|
+
}
|
|
176
|
+
} catch (err: unknown) {
|
|
177
|
+
const errorMessage = err instanceof Error ? err.message : String(err)
|
|
178
|
+
console.error('Error resetting password:', errorMessage)
|
|
179
|
+
error.value = errorMessage || t('forgotPassword.messages.resetError')
|
|
180
|
+
} finally {
|
|
181
|
+
isLoading.value = false
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Auto-submit when 6 digits are entered
|
|
186
|
+
watch(otpCode, async (newValue) => {
|
|
187
|
+
if (newValue.length === 6 && currentStep.value === 2) {
|
|
188
|
+
// Only auto-submit if passwords are also filled
|
|
189
|
+
if (newPassword.value && confirmPassword.value) {
|
|
190
|
+
const formData = {
|
|
191
|
+
otp: newValue,
|
|
192
|
+
newPassword: newPassword.value,
|
|
193
|
+
confirmPassword: confirmPassword.value
|
|
194
|
+
}
|
|
195
|
+
await resetPassword({ data: formData } as FormSubmitEvent<{
|
|
196
|
+
otp: string
|
|
197
|
+
newPassword: string
|
|
198
|
+
confirmPassword: string
|
|
199
|
+
}>)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
</script>
|
|
204
|
+
|
|
205
|
+
<template>
|
|
206
|
+
<CustomPageCard
|
|
207
|
+
:title="t('forgotPassword.title')"
|
|
208
|
+
:description="currentStep === 1 ? t('forgotPassword.description') : t('forgotPassword.step2Description', { email })"
|
|
209
|
+
:success="success"
|
|
210
|
+
:error="error"
|
|
211
|
+
>
|
|
212
|
+
<!-- Step 1: Email Entry -->
|
|
213
|
+
<div v-if="currentStep === 1">
|
|
214
|
+
<UForm :schema="emailSchema" :state="{ email }" class="space-y-4" @submit="sendVerificationCode">
|
|
215
|
+
<UFormField :label="t('forgotPassword.fields.email')" name="email" required>
|
|
216
|
+
<UInput
|
|
217
|
+
v-model="email"
|
|
218
|
+
class="mb-4 w-full"
|
|
219
|
+
type="email"
|
|
220
|
+
:placeholder="t('forgotPassword.fields.emailPlaceholder')"
|
|
221
|
+
:disabled="isLoading"
|
|
222
|
+
size="lg"
|
|
223
|
+
/>
|
|
224
|
+
</UFormField>
|
|
225
|
+
|
|
226
|
+
<UButton :loading="isLoading" :disabled="!email || isLoading" color="primary" size="lg" block type="submit">
|
|
227
|
+
{{ t('forgotPassword.buttons.sendCode') }}
|
|
228
|
+
</UButton>
|
|
229
|
+
</UForm>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<!-- Step 2: OTP + Password Reset -->
|
|
233
|
+
<div v-if="currentStep === 2" class="space-y-4">
|
|
234
|
+
<UForm
|
|
235
|
+
:schema="resetSchema"
|
|
236
|
+
:state="{ otp: otpCode, newPassword, confirmPassword }"
|
|
237
|
+
class="space-y-4"
|
|
238
|
+
@submit="resetPassword"
|
|
239
|
+
>
|
|
240
|
+
<!-- OTP Input -->
|
|
241
|
+
<div class="flex flex-col items-center w-full">
|
|
242
|
+
<UFormField
|
|
243
|
+
name="otp"
|
|
244
|
+
required
|
|
245
|
+
class="w-full flex flex-col items-center"
|
|
246
|
+
:label="t('forgotPassword.fields.otp')"
|
|
247
|
+
>
|
|
248
|
+
<UInput
|
|
249
|
+
id="otp"
|
|
250
|
+
v-model="otpCode"
|
|
251
|
+
size="xl"
|
|
252
|
+
type="text"
|
|
253
|
+
maxlength="6"
|
|
254
|
+
inputmode="numeric"
|
|
255
|
+
pattern="[0-9]*"
|
|
256
|
+
class="w-32 text-center"
|
|
257
|
+
:placeholder="$t('verify.codePlaceholder')"
|
|
258
|
+
:disabled="isLoading"
|
|
259
|
+
@input="handleOtpInput"
|
|
260
|
+
/>
|
|
261
|
+
</UFormField>
|
|
262
|
+
</div>
|
|
263
|
+
<USeparator />
|
|
264
|
+
<!-- New Password -->
|
|
265
|
+
<UFormField :label="t('forgotPassword.fields.newPassword')" name="newPassword" required>
|
|
266
|
+
<UInput
|
|
267
|
+
v-model="newPassword"
|
|
268
|
+
type="password"
|
|
269
|
+
:placeholder="t('forgotPassword.fields.newPasswordPlaceholder')"
|
|
270
|
+
:disabled="isLoading"
|
|
271
|
+
class="w-full"
|
|
272
|
+
/>
|
|
273
|
+
</UFormField>
|
|
274
|
+
|
|
275
|
+
<!-- Confirm Password -->
|
|
276
|
+
<UFormField :label="t('forgotPassword.fields.confirmPassword')" name="confirmPassword" required>
|
|
277
|
+
<UInput
|
|
278
|
+
v-model="confirmPassword"
|
|
279
|
+
type="password"
|
|
280
|
+
:placeholder="t('forgotPassword.fields.confirmPasswordPlaceholder')"
|
|
281
|
+
:disabled="isLoading"
|
|
282
|
+
class="w-full"
|
|
283
|
+
/>
|
|
284
|
+
</UFormField>
|
|
285
|
+
|
|
286
|
+
<UButton
|
|
287
|
+
:loading="isLoading"
|
|
288
|
+
:disabled="otpCode.length !== 6 || !newPassword || !confirmPassword || isLoading"
|
|
289
|
+
color="primary"
|
|
290
|
+
size="lg"
|
|
291
|
+
block
|
|
292
|
+
type="submit"
|
|
293
|
+
>
|
|
294
|
+
{{ t('forgotPassword.buttons.resetPassword') }}
|
|
295
|
+
</UButton>
|
|
296
|
+
</UForm>
|
|
297
|
+
|
|
298
|
+
<!-- Resend Code -->
|
|
299
|
+
<div class="text-center">
|
|
300
|
+
<UButton :disabled="resendCooldown > 0 || isLoading" variant="ghost" size="sm" @click="resendCode">
|
|
301
|
+
{{
|
|
302
|
+
resendCooldown > 0
|
|
303
|
+
? t('forgotPassword.messages.resendIn', { seconds: resendCooldown })
|
|
304
|
+
: t('forgotPassword.buttons.resendCode')
|
|
305
|
+
}}
|
|
306
|
+
</UButton>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
<!-- Back to Login -->
|
|
311
|
+
<div class="text-center mt-10">
|
|
312
|
+
<NuxtLink to="/login" class="text-sm text-primary hover:text-primary/80">
|
|
313
|
+
{{ t('forgotPassword.buttons.backToLogin') }}
|
|
314
|
+
</NuxtLink>
|
|
315
|
+
</div>
|
|
316
|
+
</CustomPageCard>
|
|
317
|
+
</template>
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import * as z from 'zod'
|
|
3
|
+
import type { FormSubmitEvent } from '@nuxt/ui'
|
|
4
|
+
import { signIn } from '@nuxt-customer-portal/core/app/utils/auth-client'
|
|
5
|
+
|
|
6
|
+
definePageMeta({
|
|
7
|
+
layout: 'auth',
|
|
8
|
+
public: true
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const { t } = useI18n()
|
|
12
|
+
|
|
13
|
+
useSeoMeta({
|
|
14
|
+
title: t('login.title'),
|
|
15
|
+
description: t('login.title')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const router = useRouter()
|
|
19
|
+
const route = useRoute()
|
|
20
|
+
const portalAuth = useRuntimeConfig().public.portalAuth
|
|
21
|
+
const fields = computed(() => [
|
|
22
|
+
{
|
|
23
|
+
name: 'email',
|
|
24
|
+
type: 'text' as const,
|
|
25
|
+
label: t('login.fields.email'),
|
|
26
|
+
placeholder: t('login.fields.emailPlaceholder'),
|
|
27
|
+
required: true
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'password',
|
|
31
|
+
label: t('login.fields.password'),
|
|
32
|
+
type: 'password' as const,
|
|
33
|
+
placeholder: t('login.fields.passwordPlaceholder')
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'remember',
|
|
37
|
+
label: t('login.fields.remember'),
|
|
38
|
+
type: 'checkbox' as const
|
|
39
|
+
}
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
const providers = computed(() =>
|
|
43
|
+
[
|
|
44
|
+
{
|
|
45
|
+
enabled: portalAuth.googleEnabled,
|
|
46
|
+
label: t('login.providers.google'),
|
|
47
|
+
icon: 'i-simple-icons-google',
|
|
48
|
+
onClick: async () => {
|
|
49
|
+
await handleGoogleLogin()
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
enabled: portalAuth.githubEnabled,
|
|
54
|
+
label: t('login.providers.github'),
|
|
55
|
+
icon: 'i-simple-icons-github',
|
|
56
|
+
onClick: async () => {
|
|
57
|
+
await handleGitHubLogin()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
].filter((provider) => provider.enabled)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const schema = computed(() =>
|
|
64
|
+
z.object({
|
|
65
|
+
email: z.email(t('login.validation.invalidEmail')),
|
|
66
|
+
password: z.string().min(8, t('login.validation.passwordMinLength'))
|
|
67
|
+
})
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const loading = ref(false)
|
|
71
|
+
const errorMessage = ref<string | null>(null)
|
|
72
|
+
const successMessage = ref<string | null>(null)
|
|
73
|
+
type Schema = {
|
|
74
|
+
email: string
|
|
75
|
+
password: string
|
|
76
|
+
remember?: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const onSubmit = async (payload: FormSubmitEvent<Schema>) => {
|
|
80
|
+
console.log('Submitted', payload)
|
|
81
|
+
|
|
82
|
+
loading.value = true
|
|
83
|
+
try {
|
|
84
|
+
await signIn.email(
|
|
85
|
+
{
|
|
86
|
+
email: payload.data.email,
|
|
87
|
+
password: payload.data.password
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
onRequest: () => {
|
|
91
|
+
loading.value = true
|
|
92
|
+
errorMessage.value = null
|
|
93
|
+
},
|
|
94
|
+
onResponse: (_ctx) => {
|
|
95
|
+
// Typically handled by onError or onSuccess
|
|
96
|
+
},
|
|
97
|
+
onSuccess: async (_ctx) => {
|
|
98
|
+
// Check for pending invitation and accept it after successful login
|
|
99
|
+
const pendingInvitationId = localStorage.getItem('pendingInvitationId')
|
|
100
|
+
if (pendingInvitationId) {
|
|
101
|
+
try {
|
|
102
|
+
const { authClient } = await import('@nuxt-customer-portal/core/app/utils/auth-client')
|
|
103
|
+
const result = await authClient.organization.acceptInvitation({
|
|
104
|
+
invitationId: pendingInvitationId
|
|
105
|
+
})
|
|
106
|
+
if (result.error) {
|
|
107
|
+
console.error('Failed to accept invitation after login:', result.error)
|
|
108
|
+
} else {
|
|
109
|
+
console.log('Invitation accepted successfully after login')
|
|
110
|
+
localStorage.removeItem('pendingInvitationId')
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error('Error accepting invitation after login:', err)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const redirectTo = route.query.redirect?.toString() || '/dashboard'
|
|
117
|
+
window.location.href = redirectTo
|
|
118
|
+
},
|
|
119
|
+
onError: (ctx) => {
|
|
120
|
+
const error = ctx.error
|
|
121
|
+
console.error('Credentials login error:', error)
|
|
122
|
+
|
|
123
|
+
// Check if the error indicates email needs verification
|
|
124
|
+
if (error?.message?.includes('Email not verified')) {
|
|
125
|
+
// Redirect to OTP verification page with login context
|
|
126
|
+
const redirectTo = route.query.redirect?.toString() || '/dashboard'
|
|
127
|
+
router.push(
|
|
128
|
+
`/verify-email?email=${encodeURIComponent(payload.data.email)}&redirect=${encodeURIComponent(redirectTo)}&from=login`
|
|
129
|
+
)
|
|
130
|
+
} else {
|
|
131
|
+
errorMessage.value = error?.message || t('login.errors.invalidCredentials')
|
|
132
|
+
}
|
|
133
|
+
loading.value = false
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error('Unexpected login error:', error)
|
|
139
|
+
errorMessage.value = t('login.errors.unexpectedError')
|
|
140
|
+
} finally {
|
|
141
|
+
loading.value = false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const handleGitHubLogin = async () => {
|
|
146
|
+
loading.value = true
|
|
147
|
+
errorMessage.value = null
|
|
148
|
+
try {
|
|
149
|
+
const redirectTo = route.query.redirect?.toString() || '/dashboard'
|
|
150
|
+
await signIn.social({ provider: 'github', callbackURL: redirectTo })
|
|
151
|
+
} catch (error) {
|
|
152
|
+
console.error('GitHub sign in initiation failed:', error)
|
|
153
|
+
errorMessage.value = t('login.errors.githubError')
|
|
154
|
+
loading.value = false
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const handleGoogleLogin = async () => {
|
|
159
|
+
loading.value = true
|
|
160
|
+
errorMessage.value = null
|
|
161
|
+
try {
|
|
162
|
+
const redirectTo = route.query.redirect?.toString() || '/dashboard'
|
|
163
|
+
await signIn.social({ provider: 'google', callbackURL: redirectTo })
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error('Google sign in initiation failed:', error)
|
|
166
|
+
errorMessage.value = t('login.errors.googleError')
|
|
167
|
+
loading.value = false
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
</script>
|
|
171
|
+
|
|
172
|
+
<template>
|
|
173
|
+
<div>
|
|
174
|
+
<UAlert v-if="successMessage" color="success" :description="successMessage" variant="outline" />
|
|
175
|
+
<UAlert v-if="errorMessage" color="error" :description="errorMessage" variant="outline" />
|
|
176
|
+
|
|
177
|
+
<!-- Company Logo -->
|
|
178
|
+
<div class="flex justify-center mb-8">
|
|
179
|
+
<AppLogo class="w-auto h-8 shrink-0" />
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<UAuthForm
|
|
183
|
+
:fields="fields"
|
|
184
|
+
:schema="schema"
|
|
185
|
+
:providers="providers"
|
|
186
|
+
:title="t('login.title')"
|
|
187
|
+
icon="i-lucide-lock"
|
|
188
|
+
:loading="loading"
|
|
189
|
+
:submit="{ label: t('login.submitButton') }"
|
|
190
|
+
@submit="onSubmit"
|
|
191
|
+
>
|
|
192
|
+
<template #description>
|
|
193
|
+
<template v-if="portalAuth.registrationMode === 'open'">
|
|
194
|
+
{{ t('login.description') }}
|
|
195
|
+
<ULink to="/signup" class="text-primary font-medium">{{ t('login.signupLink') }} </ULink>.
|
|
196
|
+
</template>
|
|
197
|
+
</template>
|
|
198
|
+
|
|
199
|
+
<template #password-hint>
|
|
200
|
+
<ULink to="/forgot-password" class="text-primary font-medium" tabindex="-1">{{
|
|
201
|
+
t('login.forgotPassword')
|
|
202
|
+
}}</ULink>
|
|
203
|
+
</template>
|
|
204
|
+
|
|
205
|
+
<template #footer>
|
|
206
|
+
{{ t('login.footer') }}
|
|
207
|
+
<ULink :to="portalAuth.termsUrl" class="text-primary font-medium">{{ t('login.termsLink') }}</ULink
|
|
208
|
+
>.
|
|
209
|
+
</template>
|
|
210
|
+
</UAuthForm>
|
|
211
|
+
</div>
|
|
212
|
+
</template>
|