Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 230 additions & 25 deletions web/app/components/FirebaseAuth.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import {
GithubAuthProvider,
signInWithEmailAndPassword,
createUserWithEmailAndPassword,
sendPasswordResetEmail,
} from 'firebase/auth'
import { mdiGoogle, mdiGithub, mdiEmail } from '@mdi/js'
import type { User as FirebaseUser } from 'firebase/auth'
import { ErrorMessages } from '~/utils/errors'

const props = withDefaults(
defineProps<{
Expand All @@ -25,9 +27,12 @@ const appStore = useAppStore()
const loading = ref(false)
const showEmailForm = ref(false)
const isSignUp = ref(false)
const showForgotPassword = ref(false)
const resetEmailSent = ref(false)
const email = ref('')
const password = ref('')
const emailError = ref('')
const generalError = ref('')
const errorMessages = ref(new ErrorMessages())

type LoginMethod = 'google' | 'github' | 'email'
const LAST_LOGIN_METHOD_KEY = 'httpsms_last_login_method'
Expand All @@ -44,6 +49,45 @@ onMounted(() => {
}
})

function clearErrors() {
errorMessages.value = new ErrorMessages()
generalError.value = ''
}

function validateEmail(): boolean {
clearErrors()
if (!email.value.trim()) {
errorMessages.value.add('email', 'Please provide an email address')
return false
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email.value.trim())) {
errorMessages.value.add('email', 'Please enter a valid email address')
return false
}
return true
}

function validateLoginForm(): boolean {
clearErrors()
let valid = true
if (!email.value.trim()) {
errorMessages.value.add('email', 'Please provide an email address')
valid = false
} else {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email.value.trim())) {
errorMessages.value.add('email', 'Please enter a valid email address')
valid = false
}
}
if (!password.value) {
errorMessages.value.add('password', 'Please enter your password')
valid = false
}
return valid
}

async function signInWithGoogle() {
loading.value = true
try {
Expand Down Expand Up @@ -71,21 +115,21 @@ async function signInWithGithub() {
}

async function submitEmail() {
emailError.value = ''
if (!validateLoginForm()) return
loading.value = true
try {
const auth = getAuth()
let result
if (isSignUp.value) {
result = await createUserWithEmailAndPassword(
auth,
email.value,
email.value.trim(),
password.value,
)
} else {
result = await signInWithEmailAndPassword(
auth,
email.value,
email.value.trim(),
password.value,
)
}
Expand All @@ -97,6 +141,32 @@ async function submitEmail() {
}
}

async function submitPasswordReset() {
if (!validateEmail()) return
loading.value = true
try {
const auth = getAuth()
await sendPasswordResetEmail(auth, email.value.trim())
resetEmailSent.value = true
} catch (error: unknown) {
handleError(error)
} finally {
loading.value = false
}
}

function showForgotPasswordForm() {
clearErrors()
resetEmailSent.value = false
showForgotPassword.value = true
}

function backToSignIn() {
clearErrors()
resetEmailSent.value = false
showForgotPassword.value = false
}

function onSuccess(user: FirebaseUser, method: LoginMethod) {
try {
localStorage.setItem(LAST_LOGIN_METHOD_KEY, method)
Expand All @@ -114,33 +184,94 @@ function onSuccess(user: FirebaseUser, method: LoginMethod) {
function handleError(error: unknown, isSocial = false) {
const firebaseError = error as { code?: string; message?: string }
const code = firebaseError.code || ''
let message = ''
if (code === 'auth/user-not-found' || code === 'auth/invalid-credential') {
message = 'Invalid email or password'
} else if (code === 'auth/email-already-in-use') {
message = 'An account with this email already exists'
} else if (code === 'auth/weak-password') {
message = 'Password must be at least 6 characters'
} else if (

if (
code === 'auth/popup-closed-by-user' ||
code === 'auth/cancelled-popup-request'
) {
// User closed the popup, no error to show
return
} else {
message = firebaseError.message || 'An error occurred'
}

if (isSocial) {
const message = getGeneralErrorMessage(code, firebaseError.message)
notificationsStore.addNotification({ message, type: 'error' })
} else {
emailError.value = message
return
}

clearErrors()

switch (code) {
case 'auth/wrong-password':
errorMessages.value.add('password', 'Incorrect password')
break
Comment on lines +204 to +206

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 auth/invalid-credential misleadingly shown as a password error. Modern Firebase Auth (with email enumeration protection enabled) returns auth/invalid-credential for both a non-existent email address and a wrong password. Placing that error exclusively on the password field ("Incorrect password") tells a user who simply mistyped their email address that their password is wrong — sending them in the wrong direction. The original wording "Invalid email or password" surfaced as a general message was intentionally ambiguous for this reason.

Suggested change
case 'auth/wrong-password':
case 'auth/invalid-credential':
errorMessages.value.add('password', 'Incorrect password')
break
case 'auth/wrong-password':
errorMessages.value.add('password', 'Incorrect password')
break
case 'auth/invalid-credential':
generalError.value = 'Invalid email or password'
break

case 'auth/invalid-credential':
errorMessages.value.add('email', 'Invalid email or password')
errorMessages.value.add('password', 'Invalid email or password')
break
case 'auth/user-not-found':
errorMessages.value.add(
'email',
'No account found with this email address',
)
break
case 'auth/invalid-email':
errorMessages.value.add('email', 'Please enter a valid email address')
break
case 'auth/email-already-in-use':
errorMessages.value.add(
'email',
'An account already exists with this email',
)
break
case 'auth/weak-password':
errorMessages.value.add(
'password',
'Password should be at least 6 characters',
)
break
case 'auth/user-disabled':
errorMessages.value.add('email', 'This account has been disabled')
break
case 'auth/too-many-requests':
generalError.value = 'Too many failed attempts. Please try again later'
break
case 'auth/network-request-failed':
generalError.value =
'Unable to connect to the server. Please check your internet connection'
break
case 'auth/missing-email':
errorMessages.value.add('email', 'Please provide an email address')
break
default:
generalError.value =
firebaseError.message || 'An unexpected error occurred'
}
}

function getGeneralErrorMessage(
code: string,
fallback: string | undefined,
): string {
switch (code) {
case 'auth/user-not-found':
return 'No account found with this email address'
case 'auth/wrong-password':
case 'auth/invalid-credential':
return 'The provided credentials are invalid.'
case 'auth/user-disabled':
return 'This account has been disabled'
case 'auth/too-many-requests':
return 'Too many failed attempts. Please try again later'
case 'auth/network-request-failed':
return 'Unable to connect to the server. Please check your internet connection'
default:
return fallback || 'An unexpected error occurred'
}
}
</script>

<template>
<div class="text-center">
<div>
<v-btn
block
color="white"
Expand Down Expand Up @@ -212,16 +343,78 @@ function handleError(error: unknown, isSocial = false) {
Continue with email
</v-btn>

<v-form v-if="showEmailForm" class="mt-4" @submit.prevent="submitEmail">
<!-- Forgot Password Form -->
<v-form
v-if="showEmailForm && showForgotPassword"
class="mt-4"
@submit.prevent="submitPasswordReset"
>
<template v-if="!resetEmailSent">
<p class="text-body-medium text-medium-emphasis mb-4">
Enter your email address to reset your password
</p>
<v-text-field
v-model="email"
label="Email Address"
color="primary"
type="email"
variant="outlined"
density="comfortable"
class="mb-2"
:error="errorMessages.has('email')"
:error-messages="errorMessages.get('email')"
/>
<v-alert
v-if="generalError"
type="error"
density="compact"
class="mb-3"
>
{{ generalError }}
</v-alert>
<v-btn
block
size="large"
color="primary"
type="submit"
:loading="loading"
>
Send Reset Link
</v-btn>
</template>
<template v-else>
<v-alert type="success" density="compact" class="mb-3">
Check your email for password reset instructions
</v-alert>
</template>
<v-btn
block
variant="text"
size="small"
color="warning"
class="mt-2"
@click="backToSignIn"
>
Back to Sign In
</v-btn>
</v-form>

<!-- Sign In / Sign Up Form -->
<v-form
v-if="showEmailForm && !showForgotPassword"
class="mt-4"
@submit.prevent="submitEmail"
>
<v-text-field
v-model="email"
label="Email"
label="Email Address"
color="primary"
type="email"
variant="outlined"
density="comfortable"
class="mb-2"
required
:error="errorMessages.has('email')"
:error-messages="errorMessages.get('email')"
/>
<v-text-field
v-model="password"
Expand All @@ -231,11 +424,22 @@ function handleError(error: unknown, isSocial = false) {
variant="outlined"
density="comfortable"
class="mb-2"
required
:error="errorMessages.has('password')"
:error-messages="errorMessages.get('password')"
/>
<v-alert v-if="emailError" type="error" density="compact" class="mb-3">
{{ emailError }}
<v-alert v-if="generalError" type="error" density="compact" class="mb-3">
{{ generalError }}
</v-alert>
<v-btn
v-if="!isSignUp"
variant="plain"
size="small"
color="primary"
class="mb-3 px-0 mt-n4"
@click="showForgotPasswordForm"
>
Forgot Password?
</v-btn>
<v-btn
block
size="large"
Expand All @@ -247,8 +451,9 @@ function handleError(error: unknown, isSocial = false) {
</v-btn>
<v-btn
block
variant="text"
variant="plain"
size="small"
color="primary"
class="mt-2"
@click="isSignUp = !isSignUp"
>
Expand Down
Loading