@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 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,3 @@
1
+ # Authentication layer
2
+
3
+ Owns login, signup, password recovery, and email-verification user interfaces. Shared identity infrastructure is provided by portal core.
@@ -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>