This commit is contained in:
2026-02-14 17:00:43 -05:00
parent d183e0a4b6
commit c17838241a
23 changed files with 403 additions and 99 deletions

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import LoginButton from '../shared/LoginButton.vue'
// Mock dependencies
vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}))
vi.mock('../../stores/auth', () => ({
authenticateParent: vi.fn(),
isParentAuthenticated: { value: false },
logoutParent: vi.fn(),
logoutUser: vi.fn(),
}))
vi.mock('@/common/imageCache', () => ({
getCachedImageUrl: vi.fn(),
getCachedImageBlob: vi.fn(),
}))
vi.mock('@/common/eventBus', () => ({
eventBus: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
},
}))
import { eventBus } from '@/common/eventBus'
describe('LoginButton', () => {
let wrapper: VueWrapper<any>
let mockFetch: any
beforeEach(() => {
vi.clearAllMocks()
mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
vi.unstubAllGlobals()
})
describe('Event Listeners', () => {
it('registers event listeners on mount', () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ first_name: '', last_name: '', email: '', image_id: null }),
})
wrapper = mount(LoginButton)
expect(eventBus.on).toHaveBeenCalledWith('open-login', expect.any(Function))
expect(eventBus.on).toHaveBeenCalledWith('profile_updated', expect.any(Function))
})
it('unregisters event listeners on unmount', () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ first_name: '', last_name: '', email: '', image_id: null }),
})
wrapper = mount(LoginButton)
wrapper.unmount()
expect(eventBus.off).toHaveBeenCalledWith('open-login', expect.any(Function))
expect(eventBus.off).toHaveBeenCalledWith('profile_updated', expect.any(Function))
})
it('refetches profile when profile_updated event is received', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ first_name: '', last_name: '', email: '', image_id: null }),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
first_name: 'Updated',
last_name: 'User',
email: 'updated@example.com',
image_id: 'new-image-id',
}),
})
wrapper = mount(LoginButton)
// Get the profile_updated callback
const profileUpdatedCall = eventBus.on.mock.calls.find(
(call) => call[0] === 'profile_updated',
)
const profileUpdatedCallback = profileUpdatedCall[1]
// Call the callback
await profileUpdatedCallback()
// Check that fetch was called for profile
expect(mockFetch).toHaveBeenCalledWith('/api/user/profile', { credentials: 'include' })
})
})
})

View File

@@ -4,7 +4,7 @@
<h1>Welcome</h1>
<p>Please sign in or create an account to continue.</p>
<div class="auth-actions">
<button class="btn btn-primary" @click="goToLogin">Log In</button>
<button class="btn btn-primary" @click="goToLogin">Sign In</button>
<button class="btn btn-secondary" @click="goToSignup">Sign Up</button>
</div>
</div>

View File

@@ -12,14 +12,14 @@
autocomplete="username"
autofocus
v-model="email"
:class="{ 'input-error': submitAttempted && !isEmailValid }"
:class="{ 'input-error': submitAttempted && !isFormValid }"
required
/>
<small v-if="submitAttempted && !email" class="error-message" aria-live="polite">
Email is required.
</small>
<small
v-else-if="submitAttempted && !isEmailValid"
v-else-if="submitAttempted && !isFormValid"
class="error-message"
aria-live="polite"
>
@@ -40,7 +40,7 @@
</div>
<div class="form-group actions" style="margin-top: 0.4rem">
<button type="submit" class="btn btn-primary" :disabled="loading || !isEmailValid">
<button type="submit" class="btn btn-primary" :disabled="loading || !isFormValid">
{{ loading ? 'Sending…' : 'Send Reset Link' }}
</button>
</div>
@@ -92,12 +92,15 @@ const successMsg = ref('')
const isEmailValidRef = computed(() => isEmailValid(email.value))
// Add computed for form validity: email must be non-empty and valid
const isFormValid = computed(() => email.value.trim() !== '' && isEmailValidRef.value)
async function submitForm() {
submitAttempted.value = true
errorMsg.value = ''
successMsg.value = ''
if (!isEmailValidRef.value) return
if (!isFormValid.value) return
loading.value = true
try {
const res = await fetch('/api/request-password-reset', {

View File

@@ -20,7 +20,14 @@
</p>
<input v-model="code" maxlength="6" class="code-input" placeholder="6-digit code" />
<div class="button-group">
<button v-if="!loading" class="btn btn-primary" @click="verifyCode">Verify Code</button>
<button
v-if="!loading"
class="btn btn-primary"
@click="verifyCode"
:disabled="!isCodeValid"
>
Verify Code
</button>
<button class="btn btn-link" @click="resendCode" v-if="showResend" :disabled="loading">
Resend Code
</button>
@@ -46,7 +53,7 @@
class="pin-input"
placeholder="Confirm PIN"
/>
<button class="btn btn-primary" @click="setPin" :disabled="loading">
<button class="btn btn-primary" @click="setPin" :disabled="loading || !isPinValid">
{{ loading ? 'Saving...' : 'Set PIN' }}
</button>
<div v-if="error" class="error-message">{{ error }}</div>
@@ -60,7 +67,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ref, watch, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { logoutParent } from '@/stores/auth'
import '@/assets/styles.css'
@@ -77,6 +84,14 @@ const showResend = ref(false)
let resendTimeout: ReturnType<typeof setTimeout> | null = null
const router = useRouter()
const isCodeValid = computed(() => code.value.length === 6)
const isPinValid = computed(() => {
const p1 = pin.value
const p2 = pin2.value
return /^\d{4,6}$/.test(p1) && /^\d{4,6}$/.test(p2) && p1 === p2
})
async function requestCode() {
error.value = ''
info.value = ''

View File

@@ -112,6 +112,16 @@
</div>
<div v-else style="margin-top: 2rem; text-align: center">Checking reset link...</div>
</div>
<!-- Success Modal -->
<ModalDialog v-if="showModal" title="Password Reset Successful" @backdrop-click="closeModal">
<p class="modal-message">
Your password has been reset successfully. You can now sign in with your new password.
</p>
<div class="modal-actions">
<button @click="goToLogin" class="btn btn-primary">Sign In</button>
</div>
</ModalDialog>
</div>
</template>
@@ -119,6 +129,7 @@
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { isPasswordStrong } from '@/common/api'
import ModalDialog from '@/components/shared/ModalDialog.vue'
import '@/assets/styles.css'
const router = useRouter()
@@ -133,6 +144,7 @@ const successMsg = ref('')
const token = ref('')
const tokenValid = ref(false)
const tokenChecked = ref(false)
const showModal = ref(false)
const isPasswordStrongRef = computed(() => isPasswordStrong(password.value))
const passwordsMatch = computed(() => password.value === confirmPassword.value)
@@ -202,10 +214,11 @@ async function submitForm() {
errorMsg.value = msg
return
}
successMsg.value = 'Your password has been reset. You may now sign in.'
// Success: Show modal instead of successMsg
showModal.value = true
password.value = ''
confirmPassword.value = ''
submitAttempted.value = false // <-- add this line
submitAttempted.value = false
} catch {
errorMsg.value = 'Network error. Please try again.'
} finally {
@@ -213,6 +226,10 @@ async function submitForm() {
}
}
function closeModal() {
showModal.value = false
}
async function goToLogin() {
await router.push({ name: 'Login' }).catch(() => (window.location.href = '/auth/login'))
}

View File

@@ -105,7 +105,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue'
import ModalDialog from '../shared/ModalDialog.vue'
@@ -172,52 +172,9 @@ function onAddImage({ id, file }: { id: string; file: File }) {
} else {
localImageFile.value = null
initialData.value.image_id = id
updateAvatar(id)
}
}
async function updateAvatar(imageId: string) {
errorMsg.value = ''
successMsg.value = ''
//todo update avatar loading state
try {
const res = await fetch('/api/user/avatar', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_id: imageId }),
})
if (!res.ok) throw new Error('Failed to update avatar')
initialData.value.image_id = imageId
successMsg.value = 'Avatar updated!'
} catch {
//errorMsg.value = 'Failed to update avatar.'
//todo update avatar error handling
errorMsg.value = ''
}
}
watch(localImageFile, async (file) => {
if (!file) return
errorMsg.value = ''
successMsg.value = ''
const formData = new FormData()
formData.append('file', file)
formData.append('type', '2')
formData.append('permanent', 'true')
try {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
initialData.value.image_id = data.id
await updateAvatar(data.id)
} catch {
errorMsg.value = 'Failed to upload avatar image.'
}
})
function handleSubmit(form: {
image_id: string | null
first_name: string
@@ -226,6 +183,43 @@ function handleSubmit(form: {
}) {
errorMsg.value = ''
loading.value = true
// Handle image upload if local file
let imageId = form.image_id
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '1')
formData.append('permanent', 'true')
fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
.then(async (resp) => {
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
// Now update profile
return updateProfile({
...form,
image_id: imageId,
})
})
.catch(() => {
errorMsg.value = 'Failed to upload image.'
loading.value = false
})
} else {
updateProfile(form)
}
}
async function updateProfile(form: {
image_id: string | null
first_name: string
last_name: string
email: string
}) {
fetch('/api/user/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },

View File

@@ -239,12 +239,14 @@ function handleClickOutside(event: MouseEvent) {
onMounted(() => {
eventBus.on('open-login', open)
eventBus.on('profile_updated', fetchUserProfile)
document.addEventListener('mousedown', handleClickOutside)
fetchUserProfile()
})
onUnmounted(() => {
eventBus.off('open-login', open)
eventBus.off('profile_updated', fetchUserProfile)
document.removeEventListener('mousedown', handleClickOutside)
// Revoke object URL to free memory
@@ -372,6 +374,10 @@ onUnmounted(() => {
</template>
<style scoped>
.error {
color: var(--error);
}
.avatar-btn {
width: 44px;
min-width: 44px;

View File

@@ -1,7 +1,7 @@
<template>
<div class="layout-root">
<header class="topbar">
<div class="back-btn-container">
<div class="end-button-container">
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0"> Back</button>
</div>
<div class="spacer"></div>
@@ -55,7 +55,7 @@ const showBack = computed(
box-sizing: border-box;
}
.back-btn-container {
.end-button-container {
display: flex;
align-items: center;
justify-content: center;

View File

@@ -45,7 +45,7 @@ onMounted(async () => {
<template>
<div class="layout-root">
<header class="topbar">
<div class="back-btn-container edge-btn-container">
<div class="end-button-container">
<button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0"> Back</button>
</div>
<nav v-if="!hideViewSelector" class="view-selector">
@@ -153,7 +153,8 @@ onMounted(async () => {
</svg>
</button>
</nav>
<div class="login-btn-container edge-btn-container">
<div v-else class="spacer"></div>
<div class="end-button-container">
<LoginButton />
</div>
</header>
@@ -186,7 +187,7 @@ onMounted(async () => {
box-sizing: border-box;
}
.edge-btn-container {
.end-button-container {
display: flex;
align-items: center;
justify-content: center;
@@ -227,6 +228,13 @@ onMounted(async () => {
color 0.18s;
}
.spacer {
flex: 1 1 auto;
height: 100%;
display: flex;
align-items: center;
}
@media (max-width: 480px) {
.back-btn {
font-size: 0.7rem;