@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.
@@ -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>