WIP Sync
This commit is contained in:
108
frontend/vue-app/src/components/__tests__/LoginButton.spec.ts
Normal file
108
frontend/vue-app/src/components/__tests__/LoginButton.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user