@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
|
@@ -0,0 +1,359 @@
|
|
|
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
|
+
import type { ApiError } from '@nuxt-customer-portal/core/shared/types/index'
|
|
6
|
+
|
|
7
|
+
definePageMeta({
|
|
8
|
+
layout: 'auth',
|
|
9
|
+
public: true
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const { t } = useI18n()
|
|
13
|
+
|
|
14
|
+
useSeoMeta({
|
|
15
|
+
title: t('signup.title'),
|
|
16
|
+
description: t('signup.title')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const toast = useToast()
|
|
20
|
+
const portalAuth = useRuntimeConfig().public.portalAuth
|
|
21
|
+
|
|
22
|
+
const invitationId = useRoute().query.invitationId
|
|
23
|
+
if (
|
|
24
|
+
portalAuth.registrationMode === 'disabled' ||
|
|
25
|
+
(portalAuth.registrationMode === 'invitation-only' && !invitationId)
|
|
26
|
+
) {
|
|
27
|
+
await navigateTo('/login')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const fields = computed(() => [
|
|
31
|
+
{
|
|
32
|
+
name: 'name',
|
|
33
|
+
type: 'text' as const,
|
|
34
|
+
label: t('signup.fields.name'),
|
|
35
|
+
placeholder: t('signup.fields.namePlaceholder')
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'email',
|
|
39
|
+
type: 'text' as const,
|
|
40
|
+
label: t('signup.fields.email'),
|
|
41
|
+
placeholder: t('signup.fields.emailPlaceholder')
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'password',
|
|
45
|
+
label: t('signup.fields.password'),
|
|
46
|
+
type: 'password' as const,
|
|
47
|
+
placeholder: t('signup.fields.passwordPlaceholder')
|
|
48
|
+
}
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
const providers = computed(() =>
|
|
52
|
+
[
|
|
53
|
+
{
|
|
54
|
+
enabled: portalAuth.googleEnabled,
|
|
55
|
+
label: t('signup.providers.google'),
|
|
56
|
+
icon: 'i-simple-icons-google',
|
|
57
|
+
onClick: async () => {
|
|
58
|
+
await handleGoogleLogin()
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
enabled: portalAuth.githubEnabled,
|
|
63
|
+
label: t('signup.providers.github'),
|
|
64
|
+
icon: 'i-simple-icons-github',
|
|
65
|
+
onClick: async () => {
|
|
66
|
+
await handleGitHubLogin()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
].filter((provider) => provider.enabled)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const schema = computed(() =>
|
|
73
|
+
z.object({
|
|
74
|
+
name: z.string().min(1, t('signup.validation.nameRequired')),
|
|
75
|
+
email: z.email(t('signup.validation.invalidEmail')),
|
|
76
|
+
password: z.string().min(8, t('signup.validation.passwordMinLength'))
|
|
77
|
+
})
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
type Schema = {
|
|
81
|
+
name: string
|
|
82
|
+
email: string
|
|
83
|
+
password: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const route = useRoute()
|
|
87
|
+
const error = ref<string | null>(null)
|
|
88
|
+
const isLoading = ref(false)
|
|
89
|
+
const invitationInfo = ref<{ organizationName?: string; role?: string; email?: string } | null>(null)
|
|
90
|
+
const acceptingInvitation = ref(false)
|
|
91
|
+
|
|
92
|
+
// User store for checking authentication
|
|
93
|
+
const userStore = useUserStore()
|
|
94
|
+
const { isAuthenticated, currentUser } = storeToRefs(userStore)
|
|
95
|
+
|
|
96
|
+
const fetchInvitationDetails = async (id: string) => {
|
|
97
|
+
try {
|
|
98
|
+
// Use custom endpoint to bypass inviter membership check
|
|
99
|
+
const data = await $fetch<{
|
|
100
|
+
organizationName?: string
|
|
101
|
+
role?: string
|
|
102
|
+
email?: string
|
|
103
|
+
}>(`/api/organizations/get-invitation?id=${encodeURIComponent(id)}`)
|
|
104
|
+
|
|
105
|
+
if (data) {
|
|
106
|
+
invitationInfo.value = {
|
|
107
|
+
organizationName: data.organizationName || undefined,
|
|
108
|
+
role: data.role,
|
|
109
|
+
email: data.email
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error('Failed to fetch invitation details:', err)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Handle invitation acceptance for logged-in users
|
|
118
|
+
const handleLoggedInInvitation = async (invId: string) => {
|
|
119
|
+
acceptingInvitation.value = true
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// Fetch invitation details using custom endpoint to bypass inviter membership check
|
|
123
|
+
let invitationData: {
|
|
124
|
+
email?: string
|
|
125
|
+
role?: string
|
|
126
|
+
organizationName?: string
|
|
127
|
+
status?: string
|
|
128
|
+
expiresAt?: Date | string
|
|
129
|
+
} | null = null
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
invitationData = await $fetch<{
|
|
133
|
+
email?: string
|
|
134
|
+
role?: string
|
|
135
|
+
organizationName?: string
|
|
136
|
+
status?: string
|
|
137
|
+
expiresAt?: Date | string
|
|
138
|
+
}>(`/api/organizations/get-invitation?id=${encodeURIComponent(invId)}`)
|
|
139
|
+
} catch (fetchErr: unknown) {
|
|
140
|
+
const apiError = fetchErr as { data?: { message?: string }; message?: string }
|
|
141
|
+
const errorMessage =
|
|
142
|
+
apiError?.data?.message ||
|
|
143
|
+
apiError?.message ||
|
|
144
|
+
t('signup.invitation.loggedIn.error', { error: 'Invitation not found' })
|
|
145
|
+
error.value = errorMessage
|
|
146
|
+
toast.add({
|
|
147
|
+
title: t('common.error'),
|
|
148
|
+
description: errorMessage,
|
|
149
|
+
color: 'error'
|
|
150
|
+
})
|
|
151
|
+
acceptingInvitation.value = false
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!invitationData) {
|
|
156
|
+
const errorMessage = t('signup.invitation.loggedIn.error', { error: 'Invitation not found' })
|
|
157
|
+
error.value = errorMessage
|
|
158
|
+
toast.add({
|
|
159
|
+
title: t('common.error'),
|
|
160
|
+
description: errorMessage,
|
|
161
|
+
color: 'error'
|
|
162
|
+
})
|
|
163
|
+
acceptingInvitation.value = false
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check if invitation email matches logged-in user's email
|
|
168
|
+
const userEmail = currentUser.value?.email?.toLowerCase()
|
|
169
|
+
const invitationEmail = invitationData.email?.toLowerCase()
|
|
170
|
+
|
|
171
|
+
if (userEmail !== invitationEmail) {
|
|
172
|
+
const errorMessage = t('signup.invitation.loggedIn.emailMismatch')
|
|
173
|
+
error.value = errorMessage
|
|
174
|
+
toast.add({
|
|
175
|
+
title: t('common.error'),
|
|
176
|
+
description: errorMessage,
|
|
177
|
+
color: 'error'
|
|
178
|
+
})
|
|
179
|
+
acceptingInvitation.value = false
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Accept the invitation using custom endpoint to bypass inviter membership check
|
|
184
|
+
// This is necessary because admins who create organizations are removed as members
|
|
185
|
+
try {
|
|
186
|
+
const result = await $fetch<{ success: boolean; organization?: { id: string; name: string } }>(
|
|
187
|
+
'/api/organizations/accept-invitation',
|
|
188
|
+
{
|
|
189
|
+
method: 'POST',
|
|
190
|
+
body: { invitationId: invId }
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if (!result || !result.success) {
|
|
195
|
+
throw new Error('Failed to accept invitation')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Set the organization as active if user doesn't have an active organization
|
|
199
|
+
const userStore = useUserStore()
|
|
200
|
+
if (!userStore.activeOrganizationId && result.organization) {
|
|
201
|
+
await userStore.setActiveOrganizationId(result.organization.id)
|
|
202
|
+
}
|
|
203
|
+
} catch (err: unknown) {
|
|
204
|
+
const apiError = err as { data?: { message?: string }; message?: string }
|
|
205
|
+
const errorMessage =
|
|
206
|
+
apiError?.data?.message ||
|
|
207
|
+
apiError?.message ||
|
|
208
|
+
t('signup.invitation.loggedIn.error', { error: 'Unknown error' })
|
|
209
|
+
error.value = errorMessage
|
|
210
|
+
toast.add({
|
|
211
|
+
title: t('common.error'),
|
|
212
|
+
description: errorMessage,
|
|
213
|
+
color: 'error'
|
|
214
|
+
})
|
|
215
|
+
acceptingInvitation.value = false
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Success - show success message and redirect
|
|
220
|
+
const successMessage = t('signup.invitation.loggedIn.success', {
|
|
221
|
+
organizationName: invitationData.organizationName || 'the organization',
|
|
222
|
+
role: invitationData.role || 'member'
|
|
223
|
+
})
|
|
224
|
+
toast.add({
|
|
225
|
+
title: t('common.success'),
|
|
226
|
+
description: successMessage,
|
|
227
|
+
color: 'success'
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
localStorage.removeItem('pendingInvitationId')
|
|
231
|
+
|
|
232
|
+
// Redirect to dashboard after short delay
|
|
233
|
+
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
234
|
+
await navigateTo('/dashboard')
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.error('Error handling logged-in invitation:', err)
|
|
237
|
+
const errorMessage = t('signup.invitation.loggedIn.error', {
|
|
238
|
+
error: err instanceof Error ? err.message : 'Unknown error'
|
|
239
|
+
})
|
|
240
|
+
error.value = errorMessage
|
|
241
|
+
toast.add({
|
|
242
|
+
title: t('common.error'),
|
|
243
|
+
description: errorMessage,
|
|
244
|
+
color: 'error'
|
|
245
|
+
})
|
|
246
|
+
} finally {
|
|
247
|
+
acceptingInvitation.value = false
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const onSubmit = async (payload: FormSubmitEvent<Schema>) => {
|
|
252
|
+
console.log('Submitted', payload)
|
|
253
|
+
error.value = null
|
|
254
|
+
isLoading.value = true
|
|
255
|
+
|
|
256
|
+
// Store invitation ID if present
|
|
257
|
+
const invId = route.query.invitationId as string | undefined
|
|
258
|
+
if (invId) {
|
|
259
|
+
localStorage.setItem('pendingInvitationId', invId)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const response = await authClient.signUp.email({
|
|
264
|
+
name: payload.data.name,
|
|
265
|
+
email: payload.data.email,
|
|
266
|
+
password: payload.data.password
|
|
267
|
+
})
|
|
268
|
+
if (response.error) {
|
|
269
|
+
const errorMessage = response.error.message || t('signup.errors.unknownError')
|
|
270
|
+
error.value = errorMessage
|
|
271
|
+
toast.add({ title: t('signup.errors.errorTitle'), description: errorMessage, color: 'error' })
|
|
272
|
+
} else {
|
|
273
|
+
// Redirect to OTP verification page with email parameter and invitation ID if present
|
|
274
|
+
const verifyUrl = `/verify-email?email=${encodeURIComponent(payload.data.email)}${invId ? `&invitationId=${encodeURIComponent(invId)}` : ''}`
|
|
275
|
+
navigateTo(verifyUrl)
|
|
276
|
+
}
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.error('Email signup failed:', err)
|
|
279
|
+
const apiError = err as ApiError
|
|
280
|
+
const errorMessage = apiError.message || t('signup.errors.unknownError')
|
|
281
|
+
error.value = errorMessage
|
|
282
|
+
toast.add({ title: t('signup.errors.errorTitle'), description: errorMessage, color: 'error' })
|
|
283
|
+
} finally {
|
|
284
|
+
isLoading.value = false
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const invId = route.query.invitationId as string | undefined
|
|
288
|
+
if (invId) {
|
|
289
|
+
// Check if user is already logged in
|
|
290
|
+
if (isAuthenticated.value && currentUser.value) {
|
|
291
|
+
// User is logged in, try to accept invitation automatically
|
|
292
|
+
await handleLoggedInInvitation(invId)
|
|
293
|
+
} else {
|
|
294
|
+
// User is not logged in, store for later use
|
|
295
|
+
localStorage.setItem('pendingInvitationId', invId)
|
|
296
|
+
// Try to fetch invitation details to show context
|
|
297
|
+
fetchInvitationDetails(invId)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const loading = ref(false)
|
|
302
|
+
const errorMessage = ref<string | null>(null)
|
|
303
|
+
const handleGitHubLogin = async () => {
|
|
304
|
+
loading.value = true
|
|
305
|
+
errorMessage.value = null
|
|
306
|
+
try {
|
|
307
|
+
const redirectTo = route.query.redirect?.toString() || '/dashboard'
|
|
308
|
+
await signIn.social({ provider: 'github', callbackURL: redirectTo })
|
|
309
|
+
} catch (error) {
|
|
310
|
+
console.error('GitHub sign in initiation failed:', error)
|
|
311
|
+
errorMessage.value = t('login.errors.githubError')
|
|
312
|
+
loading.value = false
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const handleGoogleLogin = async () => {
|
|
317
|
+
loading.value = true
|
|
318
|
+
errorMessage.value = null
|
|
319
|
+
try {
|
|
320
|
+
const redirectTo = route.query.redirect?.toString() || '/dashboard'
|
|
321
|
+
await signIn.social({ provider: 'google', callbackURL: redirectTo })
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.error('Google sign in initiation failed:', error)
|
|
324
|
+
errorMessage.value = t('login.errors.googleError')
|
|
325
|
+
loading.value = false
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
</script>
|
|
329
|
+
|
|
330
|
+
<template>
|
|
331
|
+
<div>
|
|
332
|
+
<UAlert v-if="errorMessage" color="error" :description="errorMessage" variant="outline" />
|
|
333
|
+
<!-- Company Logo -->
|
|
334
|
+
<div class="flex justify-center mb-8">
|
|
335
|
+
<AppLogo class="w-auto h-8 shrink-0" />
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
<UAuthForm
|
|
339
|
+
:fields="fields"
|
|
340
|
+
:schema="schema"
|
|
341
|
+
:providers="providers"
|
|
342
|
+
:title="t('signup.title')"
|
|
343
|
+
:loading="loading"
|
|
344
|
+
:submit="{ label: t('signup.submitButton') }"
|
|
345
|
+
@submit="onSubmit"
|
|
346
|
+
>
|
|
347
|
+
<template #description>
|
|
348
|
+
{{ t('signup.description') }}
|
|
349
|
+
<ULink to="/login" class="text-primary font-medium">{{ t('signup.loginLink') }} </ULink>.
|
|
350
|
+
</template>
|
|
351
|
+
|
|
352
|
+
<template #footer>
|
|
353
|
+
{{ t('signup.footer') }}
|
|
354
|
+
<ULink :to="portalAuth.termsUrl" class="text-primary font-medium">{{ t('signup.termsLink') }}</ULink
|
|
355
|
+
>.
|
|
356
|
+
</template>
|
|
357
|
+
</UAuthForm>
|
|
358
|
+
</div>
|
|
359
|
+
</template>
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
|
|
3
|
+
|
|
4
|
+
const route = useRoute()
|
|
5
|
+
const { t } = useI18n()
|
|
6
|
+
|
|
7
|
+
const email = ref(decodeURIComponent((route.query.email as string) || ''))
|
|
8
|
+
const otpCode = ref('')
|
|
9
|
+
const isLoading = ref(false)
|
|
10
|
+
const error = ref('')
|
|
11
|
+
const success = ref('')
|
|
12
|
+
const resendCooldown = ref(0)
|
|
13
|
+
let cooldownTimer: NodeJS.Timeout | null = null
|
|
14
|
+
const emailRef = ref<{ input: HTMLInputElement } | null>(null)
|
|
15
|
+
const invitationId = ref<string | null>(null)
|
|
16
|
+
const acceptingInvitation = ref(false)
|
|
17
|
+
onMounted(async () => {
|
|
18
|
+
emailRef.value?.input?.focus()
|
|
19
|
+
|
|
20
|
+
// Check for invitation ID in query params or localStorage
|
|
21
|
+
const invIdFromQuery = route.query.invitationId as string | undefined
|
|
22
|
+
const invIdFromStorage = import.meta.client ? localStorage.getItem('pendingInvitationId') : null
|
|
23
|
+
if (invIdFromQuery || invIdFromStorage) {
|
|
24
|
+
invitationId.value = invIdFromQuery || invIdFromStorage || null
|
|
25
|
+
if (import.meta.client && invIdFromQuery) {
|
|
26
|
+
localStorage.setItem('pendingInvitationId', invIdFromQuery)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Only auto-send OTP for login verification, not signup verification
|
|
31
|
+
// Signup verification already sends an OTP during the signup process
|
|
32
|
+
const isLoginVerification = route.query.redirect || route.query.from === 'login'
|
|
33
|
+
if (email.value && isLoginVerification) {
|
|
34
|
+
console.log('Login verification detected, sending OTP...')
|
|
35
|
+
// Reset cooldown to ensure resendCode can run
|
|
36
|
+
resendCooldown.value = 0
|
|
37
|
+
await resendCode()
|
|
38
|
+
} else if (email.value) {
|
|
39
|
+
console.log('Signup verification detected, starting cooldown timer...')
|
|
40
|
+
// For signup verification, start the cooldown timer since an OTP was already sent
|
|
41
|
+
// This prevents immediate resend after page load
|
|
42
|
+
startCooldownTimer()
|
|
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
|
+
// Clean up timer on unmount
|
|
62
|
+
onUnmounted(() => {
|
|
63
|
+
if (cooldownTimer) {
|
|
64
|
+
clearInterval(cooldownTimer)
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
// Handle OTP input formatting (6 digits only)
|
|
68
|
+
const handleOtpInput = (event: Event) => {
|
|
69
|
+
const target = event.target as HTMLInputElement
|
|
70
|
+
const value = target.value.replace(/\D/g, '').slice(0, 6)
|
|
71
|
+
otpCode.value = value
|
|
72
|
+
target.value = value
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Verify the OTP code
|
|
76
|
+
const verifyCode = async () => {
|
|
77
|
+
if (otpCode.value.length !== 6) {
|
|
78
|
+
error.value = t('verify.invalidCodeLength')
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
isLoading.value = true
|
|
83
|
+
error.value = ''
|
|
84
|
+
success.value = ''
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
// For users who are verifying during login, we need to sign them in
|
|
88
|
+
// Check if this is a login verification (has redirect param) or signup verification
|
|
89
|
+
const isLoginVerification = route.query.redirect || route.query.from === 'login'
|
|
90
|
+
console.log('Verification context:', { isLoginVerification, email: email.value, redirect: route.query.redirect })
|
|
91
|
+
|
|
92
|
+
let result
|
|
93
|
+
if (isLoginVerification) {
|
|
94
|
+
// For login verification, use signIn.emailOtp (sign-in type OTP)
|
|
95
|
+
console.log('Using signIn.emailOtp for login verification')
|
|
96
|
+
result = await authClient.signIn.emailOtp({
|
|
97
|
+
email: email.value,
|
|
98
|
+
otp: otpCode.value
|
|
99
|
+
})
|
|
100
|
+
console.log('signIn.emailOtp result:', result)
|
|
101
|
+
} else {
|
|
102
|
+
// For signup verification, use verifyEmail (email-verification type OTP)
|
|
103
|
+
console.log('Using verifyEmail for signup verification')
|
|
104
|
+
result = await authClient.emailOtp.verifyEmail({
|
|
105
|
+
email: email.value,
|
|
106
|
+
otp: otpCode.value
|
|
107
|
+
})
|
|
108
|
+
console.log('verifyEmail result:', result)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check for success - signIn.emailOtp returns user data, verifyEmail fallback returns status
|
|
112
|
+
const isSuccess = result.data?.user || (result.data as unknown as { status: boolean })?.status === true
|
|
113
|
+
|
|
114
|
+
console.log('Verification success check:', {
|
|
115
|
+
isSuccess,
|
|
116
|
+
hasUser: !!result.data?.user,
|
|
117
|
+
hasStatus: !!(result.data as unknown as { status: boolean })?.status
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
if (isSuccess) {
|
|
121
|
+
success.value = t('verify.success')
|
|
122
|
+
console.log('Verification successful, checking session...')
|
|
123
|
+
|
|
124
|
+
// For signup verification, the user might not be signed in yet
|
|
125
|
+
// Don't try to accept invitation here - wait until after login
|
|
126
|
+
if (!isLoginVerification) {
|
|
127
|
+
console.log('Signup verification successful, user may need to sign in')
|
|
128
|
+
// Keep invitation ID in localStorage for acceptance after login
|
|
129
|
+
if (invitationId.value && import.meta.client) {
|
|
130
|
+
localStorage.setItem('pendingInvitationId', invitationId.value)
|
|
131
|
+
}
|
|
132
|
+
// Show a message and redirect to login
|
|
133
|
+
const invitationMessage = invitationId.value
|
|
134
|
+
? ' After signing in, your invitation will be accepted automatically.'
|
|
135
|
+
: ''
|
|
136
|
+
success.value = t('verify.success') + invitationMessage + ' Please sign in to continue.'
|
|
137
|
+
await new Promise((resolve) => setTimeout(resolve, 2000))
|
|
138
|
+
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
|
139
|
+
const loginPath = '/login'
|
|
140
|
+
const fullPath = `${loginPath}?email=${encodeURIComponent(email.value)}&redirect=${encodeURIComponent(redirectTo)}`
|
|
141
|
+
console.log('Redirecting to login:', fullPath)
|
|
142
|
+
window.location.href = fullPath
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// For login verification, user should be signed in
|
|
147
|
+
// Wait a moment for session to be established
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
149
|
+
|
|
150
|
+
// Check if session is established
|
|
151
|
+
try {
|
|
152
|
+
const sessionCheck = await authClient.getSession()
|
|
153
|
+
console.log('Session after verification:', sessionCheck)
|
|
154
|
+
} catch (sessionError) {
|
|
155
|
+
console.log('Session check error:', sessionError)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Accept invitation if present (user is now signed in)
|
|
159
|
+
if (invitationId.value) {
|
|
160
|
+
await acceptPendingInvitation()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log('Redirecting...')
|
|
164
|
+
// The user should now be automatically signed in after email verification
|
|
165
|
+
// Use window.location for a full page refresh to ensure session state is updated
|
|
166
|
+
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
|
167
|
+
console.log('Redirecting to:', redirectTo)
|
|
168
|
+
window.location.href = redirectTo
|
|
169
|
+
} else {
|
|
170
|
+
console.log('Verification failed:', result.error)
|
|
171
|
+
error.value = (result.error?.message as string) || t('verify.invalidCode')
|
|
172
|
+
}
|
|
173
|
+
} catch (err: unknown) {
|
|
174
|
+
const errorMessage = err instanceof Error ? err.message : String(err)
|
|
175
|
+
error.value = errorMessage || t('verify.error')
|
|
176
|
+
} finally {
|
|
177
|
+
isLoading.value = false
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Resend OTP code
|
|
182
|
+
const resendCode = async () => {
|
|
183
|
+
console.log('resendCode called, cooldown:', resendCooldown.value)
|
|
184
|
+
if (resendCooldown.value > 0) {
|
|
185
|
+
console.log('Cooldown active, skipping resend')
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
console.log('Starting resend process...')
|
|
190
|
+
isLoading.value = true
|
|
191
|
+
error.value = ''
|
|
192
|
+
success.value = ''
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
// Determine OTP type based on context
|
|
196
|
+
const isLoginVerification = route.query.redirect || route.query.from === 'login'
|
|
197
|
+
const otpType = isLoginVerification ? 'sign-in' : 'email-verification'
|
|
198
|
+
|
|
199
|
+
await authClient.emailOtp
|
|
200
|
+
.sendVerificationOtp({
|
|
201
|
+
email: email.value,
|
|
202
|
+
type: otpType
|
|
203
|
+
})
|
|
204
|
+
.then((result) => {
|
|
205
|
+
if (result.data?.success) {
|
|
206
|
+
success.value = t('verify.codeSent')
|
|
207
|
+
// Start the cooldown timer after successful resend
|
|
208
|
+
startCooldownTimer()
|
|
209
|
+
} else {
|
|
210
|
+
error.value = result.error?.message || t('verify.resendError')
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
} catch (err: unknown) {
|
|
214
|
+
const errorMessage = err instanceof Error ? err.message : String(err)
|
|
215
|
+
error.value = errorMessage || t('verify.resendError')
|
|
216
|
+
} finally {
|
|
217
|
+
isLoading.value = false
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Accept pending invitation
|
|
222
|
+
const acceptPendingInvitation = async () => {
|
|
223
|
+
if (!invitationId.value) {
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
acceptingInvitation.value = true
|
|
228
|
+
try {
|
|
229
|
+
console.log('Accepting invitation:', invitationId.value)
|
|
230
|
+
const result = await authClient.organization.acceptInvitation({
|
|
231
|
+
invitationId: invitationId.value
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
if (result.error) {
|
|
235
|
+
console.error('Failed to accept invitation:', result.error)
|
|
236
|
+
// Don't show error to user, just log it - they can accept manually later
|
|
237
|
+
} else {
|
|
238
|
+
console.log('Invitation accepted successfully')
|
|
239
|
+
// Clear stored invitation ID
|
|
240
|
+
if (import.meta.client) {
|
|
241
|
+
localStorage.removeItem('pendingInvitationId')
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error('Error accepting invitation:', err)
|
|
246
|
+
} finally {
|
|
247
|
+
acceptingInvitation.value = false
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Auto-submit when 6 digits are entered
|
|
252
|
+
watch(otpCode, (newValue) => {
|
|
253
|
+
if (newValue.length === 6) {
|
|
254
|
+
verifyCode()
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
definePageMeta({
|
|
259
|
+
layout: 'centerform',
|
|
260
|
+
public: true
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
useSeoMeta({
|
|
264
|
+
title: () => t('verify.title')
|
|
265
|
+
})
|
|
266
|
+
</script>
|
|
267
|
+
|
|
268
|
+
<template>
|
|
269
|
+
<CustomPageCard
|
|
270
|
+
:title="$t('verify.title')"
|
|
271
|
+
:description="$t('verify.subtitle', { email })"
|
|
272
|
+
:success="success"
|
|
273
|
+
:error="error"
|
|
274
|
+
>
|
|
275
|
+
<!-- OTP Input -->
|
|
276
|
+
<div class="flex flex-col items-center w-full">
|
|
277
|
+
<UFormField name="otp" required class="w-full flex flex-col items-center" :label="t('verify.enterCode')">
|
|
278
|
+
<UInput
|
|
279
|
+
id="otp"
|
|
280
|
+
v-model="otpCode"
|
|
281
|
+
size="xl"
|
|
282
|
+
type="text"
|
|
283
|
+
maxlength="6"
|
|
284
|
+
inputmode="numeric"
|
|
285
|
+
pattern="[0-9]*"
|
|
286
|
+
class="w-32 text-center"
|
|
287
|
+
:placeholder="$t('verify.codePlaceholder')"
|
|
288
|
+
:disabled="isLoading"
|
|
289
|
+
@input="handleOtpInput"
|
|
290
|
+
/>
|
|
291
|
+
</UFormField>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<!-- Resend Code -->
|
|
295
|
+
<div class="text-center">
|
|
296
|
+
<UButton :disabled="resendCooldown > 0 || isLoading" variant="ghost" size="sm" @click="resendCode">
|
|
297
|
+
{{ resendCooldown > 0 ? t('verify.resendIn', { seconds: resendCooldown }) : t('verify.resendCode') }}
|
|
298
|
+
</UButton>
|
|
299
|
+
</div>
|
|
300
|
+
</CustomPageCard>
|
|
301
|
+
</template>
|