Refactored frontend directory
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
This commit is contained in:
641
frontend/src/components/shared/LoginButton.vue
Normal file
641
frontend/src/components/shared/LoginButton.vue
Normal file
@@ -0,0 +1,641 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import {
|
||||
authenticateParent,
|
||||
isParentAuthenticated,
|
||||
isParentPersistent,
|
||||
logoutParent,
|
||||
logoutUser,
|
||||
consumePendingReturnUrl,
|
||||
hasPendingReturnUrl,
|
||||
clearPendingReturnUrl,
|
||||
} from '../../stores/auth'
|
||||
import { getCachedImageBlob } from '@/common/imageCache'
|
||||
import '@/assets/styles.css'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import {
|
||||
subscribeToPushWithResult,
|
||||
unsubscribeFromPush,
|
||||
isPushOptedOut,
|
||||
} from '@/services/pushSubscription'
|
||||
|
||||
const router = useRouter()
|
||||
const show = ref(false)
|
||||
const pin = ref('')
|
||||
const error = ref('')
|
||||
const stayInParentMode = ref(false)
|
||||
const pinInput = ref<HTMLInputElement | null>(null)
|
||||
const dropdownOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const avatarButtonRef = ref<HTMLButtonElement | null>(null)
|
||||
const focusedMenuIndex = ref(0)
|
||||
|
||||
// User profile data
|
||||
const userImageId = ref<string | null>(null)
|
||||
const userFirstName = ref<string>('')
|
||||
const userEmail = ref<string>('')
|
||||
const profileLoading = ref(true)
|
||||
const avatarImageUrl = ref<string | null>(null)
|
||||
const dropdownAvatarImageUrl = ref<string | null>(null)
|
||||
|
||||
// Compute avatar initial
|
||||
const avatarInitial = ref<string>('?')
|
||||
|
||||
// Fetch user profile
|
||||
async function fetchUserProfile() {
|
||||
try {
|
||||
const res = await fetch('/api/user/profile', { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
console.error('Failed to fetch user profile')
|
||||
profileLoading.value = false
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
userImageId.value = data.image_id || null
|
||||
userFirstName.value = data.first_name || ''
|
||||
userEmail.value = data.email || ''
|
||||
|
||||
// Update avatar initial
|
||||
avatarInitial.value = userFirstName.value ? userFirstName.value.charAt(0).toUpperCase() : '?'
|
||||
|
||||
profileLoading.value = false
|
||||
|
||||
// Load cached image if available
|
||||
if (userImageId.value) {
|
||||
await loadAvatarImages(userImageId.value)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching user profile:', e)
|
||||
profileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvatarImages(imageId: string) {
|
||||
try {
|
||||
const blob = await getCachedImageBlob(imageId)
|
||||
// Clean up previous URLs
|
||||
if (avatarImageUrl.value) URL.revokeObjectURL(avatarImageUrl.value)
|
||||
if (dropdownAvatarImageUrl.value) URL.revokeObjectURL(dropdownAvatarImageUrl.value)
|
||||
avatarImageUrl.value = URL.createObjectURL(blob)
|
||||
dropdownAvatarImageUrl.value = URL.createObjectURL(blob)
|
||||
} catch (e) {
|
||||
avatarImageUrl.value = null
|
||||
dropdownAvatarImageUrl.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const open = async () => {
|
||||
// Check if user has a pin
|
||||
try {
|
||||
const res = await fetch('/api/user/has-pin', { credentials: 'include' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Error checking PIN')
|
||||
if (!data.has_pin) {
|
||||
console.log('No PIN set, redirecting to setup')
|
||||
// Route to PIN setup view
|
||||
router.push('/parent/pin-setup')
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Network error'
|
||||
return
|
||||
}
|
||||
pin.value = ''
|
||||
error.value = ''
|
||||
show.value = true
|
||||
await nextTick()
|
||||
pinInput.value?.focus()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
show.value = false
|
||||
error.value = ''
|
||||
stayInParentMode.value = false
|
||||
clearPendingReturnUrl()
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const isDigits = /^\d{4,6}$/.test(pin.value)
|
||||
if (!isDigits) {
|
||||
error.value = 'Enter 4–6 digits'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/user/check-pin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pin: pin.value }),
|
||||
credentials: 'include',
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
error.value = data.error || 'Error validating PIN'
|
||||
return
|
||||
}
|
||||
if (!data.valid) {
|
||||
error.value = 'Incorrect PIN'
|
||||
pin.value = ''
|
||||
await nextTick()
|
||||
pinInput.value?.focus()
|
||||
return
|
||||
}
|
||||
// Authenticate parent and navigate
|
||||
authenticateParent(stayInParentMode.value)
|
||||
if (!isPushOptedOut()) {
|
||||
subscribeToPushWithResult() // fire-and-forget — browser gesture is satisfied by the PIN button click
|
||||
}
|
||||
const returnUrl = consumePendingReturnUrl()
|
||||
close()
|
||||
router.push(returnUrl || '/parent')
|
||||
} catch (e) {
|
||||
error.value = 'Network error'
|
||||
}
|
||||
}
|
||||
|
||||
function handlePinInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
pin.value = target.value.replace(/\D/g, '').slice(0, 6)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
unsubscribeFromPush()
|
||||
logoutParent()
|
||||
router.push('/child')
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
if (dropdownOpen.value) {
|
||||
focusedMenuIndex.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
dropdownOpen.value = false
|
||||
focusedMenuIndex.value = 0
|
||||
avatarButtonRef.value?.focus()
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!dropdownOpen.value) {
|
||||
// Handle avatar button keyboard
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
if (isParentAuthenticated.value) {
|
||||
toggleDropdown()
|
||||
} else {
|
||||
open()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle dropdown keyboard navigation
|
||||
const menuItems = 3 // Profile, Child Mode, Sign Out
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
focusedMenuIndex.value = (focusedMenuIndex.value + 1) % menuItems
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
focusedMenuIndex.value = (focusedMenuIndex.value - 1 + menuItems) % menuItems
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
executeMenuItem(focusedMenuIndex.value)
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
closeDropdown()
|
||||
break
|
||||
case 'Tab':
|
||||
closeDropdown()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function executeMenuItem(index: number) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
goToProfile()
|
||||
break
|
||||
case 1:
|
||||
handleLogout()
|
||||
closeDropdown()
|
||||
break
|
||||
case 2:
|
||||
signOut()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
logoutParent()
|
||||
logoutUser()
|
||||
router.push('/')
|
||||
} catch {
|
||||
// Optionally show error
|
||||
}
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
function goToProfile() {
|
||||
router.push('/parent/profile')
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownOpen.value &&
|
||||
dropdownRef.value &&
|
||||
!dropdownRef.value.contains(event.target as Node)
|
||||
) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('open-login', open)
|
||||
eventBus.on('profile_updated', fetchUserProfile)
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
fetchUserProfile()
|
||||
if (!isParentAuthenticated.value && hasPendingReturnUrl()) {
|
||||
open()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
eventBus.off('open-login', open)
|
||||
eventBus.off('profile_updated', fetchUserProfile)
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
|
||||
// Revoke object URL to free memory
|
||||
if (avatarImageUrl.value) URL.revokeObjectURL(avatarImageUrl.value)
|
||||
if (dropdownAvatarImageUrl.value) URL.revokeObjectURL(dropdownAvatarImageUrl.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="position: relative">
|
||||
<button
|
||||
ref="avatarButtonRef"
|
||||
@click="isParentAuthenticated ? toggleDropdown() : open()"
|
||||
@keydown="handleKeyDown"
|
||||
:aria-label="isParentAuthenticated ? 'Parent menu' : 'Parent login'"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="isParentAuthenticated && dropdownOpen ? 'true' : 'false'"
|
||||
class="avatar-btn"
|
||||
>
|
||||
<img
|
||||
v-if="avatarImageUrl && !profileLoading"
|
||||
:src="avatarImageUrl"
|
||||
:alt="userFirstName || 'User avatar'"
|
||||
class="avatar-image"
|
||||
/>
|
||||
<span v-else class="avatar-text">{{ profileLoading ? '...' : avatarInitial }}</span>
|
||||
</button>
|
||||
<span
|
||||
v-if="isParentAuthenticated && isParentPersistent"
|
||||
class="persistent-badge"
|
||||
aria-label="Persistent parent mode active"
|
||||
>🔒</span
|
||||
>
|
||||
|
||||
<Transition name="slide-fade">
|
||||
<div
|
||||
v-if="isParentAuthenticated && dropdownOpen"
|
||||
ref="dropdownRef"
|
||||
role="menu"
|
||||
class="dropdown-menu"
|
||||
>
|
||||
<div class="dropdown-header">
|
||||
<img
|
||||
v-if="avatarImageUrl"
|
||||
:src="avatarImageUrl"
|
||||
:alt="userFirstName || 'User avatar'"
|
||||
class="dropdown-avatar"
|
||||
/>
|
||||
<span v-else class="dropdown-avatar-initial">{{ avatarInitial }}</span>
|
||||
<div class="dropdown-user-info">
|
||||
<div class="dropdown-user-name">{{ userFirstName || 'User' }}</div>
|
||||
<div v-if="userEmail" class="dropdown-user-email">{{ userEmail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
role="menuitem"
|
||||
:aria-selected="focusedMenuIndex === 0"
|
||||
:class="['menu-item', { focused: focusedMenuIndex === 0 }]"
|
||||
@mouseenter="focusedMenuIndex = 0"
|
||||
@click="goToProfile"
|
||||
>
|
||||
<span class="menu-icon-stub">
|
||||
<img
|
||||
src="/profile.png"
|
||||
alt="Profile"
|
||||
style="width: 20px; height: 20px; object-fit: contain; vertical-align: middle"
|
||||
/>
|
||||
</span>
|
||||
<span>Profile</span>
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
:aria-selected="focusedMenuIndex === 1"
|
||||
:class="['menu-item', { focused: focusedMenuIndex === 1 }]"
|
||||
@mouseenter="focusedMenuIndex = 1"
|
||||
@click="
|
||||
() => {
|
||||
handleLogout()
|
||||
closeDropdown()
|
||||
}
|
||||
"
|
||||
>
|
||||
<span class="menu-icon-stub">
|
||||
<img
|
||||
src="/child-mode.png"
|
||||
alt="Child Mode"
|
||||
style="width: 20px; height: 20px; object-fit: contain; vertical-align: middle"
|
||||
/>
|
||||
</span>
|
||||
<span>Child Mode</span>
|
||||
</button>
|
||||
<button
|
||||
role="menuitem"
|
||||
:aria-selected="focusedMenuIndex === 2"
|
||||
:class="['menu-item', 'danger', { focused: focusedMenuIndex === 2 }]"
|
||||
@mouseenter="focusedMenuIndex = 2"
|
||||
@click="signOut"
|
||||
>
|
||||
<span class="menu-icon-stub">
|
||||
<img
|
||||
src="/sign-out.png"
|
||||
alt="Sign out"
|
||||
style="width: 20px; height: 20px; object-fit: contain; vertical-align: middle"
|
||||
/>
|
||||
</span>
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<ModalDialog v-if="show" title="Enter parent PIN" @click.self="close" @close="close">
|
||||
<form @submit.prevent="submit">
|
||||
<input
|
||||
ref="pinInput"
|
||||
v-model="pin"
|
||||
@input="handlePinInput"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
maxlength="6"
|
||||
placeholder="4–6 digits"
|
||||
class="pin-input"
|
||||
/>
|
||||
<label class="stay-label">
|
||||
<input type="checkbox" v-model="stayInParentMode" class="stay-checkbox" />
|
||||
Stay in parent mode on this device
|
||||
</label>
|
||||
<div class="actions modal-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="pin.length < 4">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="error" class="error modal-message">{{ error }}</div>
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.avatar-btn {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
max-width: 44px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
max-height: 44px;
|
||||
margin: 0;
|
||||
background: var(--button-bg, #fff);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
transition:
|
||||
transform 0.18s,
|
||||
box-shadow 0.18s;
|
||||
}
|
||||
|
||||
.avatar-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.avatar-btn:focus-visible {
|
||||
outline: 3px solid var(--primary, #667eea);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--button-text, #667eea);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.avatar-text {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pin-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e6e6e6;
|
||||
margin-bottom: 0.8rem;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stay-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--form-label, #444);
|
||||
cursor: pointer;
|
||||
margin-bottom: 1rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.stay-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--btn-primary, #667eea);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.persistent-badge {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: -2px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
background: var(--form-bg, #fff);
|
||||
border: 1px solid var(--form-input-border, #cbd5e1);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
min-width: 240px;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--header-bg, linear-gradient(135deg, #667eea 0%, #764ba2 100%));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dropdown-avatar,
|
||||
.dropdown-avatar-initial {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.dropdown-avatar-initial {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dropdown-user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dropdown-user-name {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dropdown-user-email {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--form-label, #444);
|
||||
font-size: 0.95rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.menu-item.focused {
|
||||
background: var(--btn-secondary-hover, #e2e8f0);
|
||||
}
|
||||
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid var(--primary, #667eea);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.menu-item.danger {
|
||||
color: var(--btn-danger, #ef4444);
|
||||
}
|
||||
|
||||
.menu-icon-stub {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: var(--form-input-bg, #f8fafc);
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Slide fade animation */
|
||||
.slide-fade-enter-active,
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.menu-item {
|
||||
padding: 12px 14px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user