feat: add push notification settings to user profile and update related functionality
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m50s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m50s
This commit is contained in:
@@ -10,7 +10,7 @@ from api.pending_confirmation import PendingConfirmationResponse
|
||||
from api.reward_status import RewardStatus
|
||||
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||
import api.child_action_helpers as chore_actions
|
||||
from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db
|
||||
from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db, users_db
|
||||
from db.tracking import insert_tracking_event
|
||||
from db.child_overrides import get_override, delete_override, delete_overrides_for_child
|
||||
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
||||
@@ -929,25 +929,27 @@ def request_reward(id):
|
||||
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
||||
|
||||
# Fire web push notification to all parent subscriptions
|
||||
try:
|
||||
approve_token = create_action_token(user_id, child.id, reward.id, 'reward', 'approve')
|
||||
deny_token = create_action_token(user_id, child.id, reward.id, 'reward', 'deny')
|
||||
push_payload = {
|
||||
'type': 'reward_requested',
|
||||
'title': f'{child.name} wants a reward',
|
||||
'body': f'{reward.name} costs {reward.cost} points.',
|
||||
'user_id': user_id,
|
||||
'child_id': child.id,
|
||||
'child_name': child.name,
|
||||
'entity_id': reward.id,
|
||||
'entity_type': 'reward',
|
||||
'entity_name': reward.name,
|
||||
'approve_token': approve_token.id,
|
||||
'deny_token': deny_token.id,
|
||||
}
|
||||
send_push_to_user(user_id, push_payload)
|
||||
except Exception as _push_err:
|
||||
logger.warning(f'Push notification failed for reward request: {_push_err}')
|
||||
_push_user = users_db.get(Query().id == user_id)
|
||||
if _push_user and _push_user.get('push_notifications_enabled', True):
|
||||
try:
|
||||
approve_token = create_action_token(user_id, child.id, reward.id, 'reward', 'approve')
|
||||
deny_token = create_action_token(user_id, child.id, reward.id, 'reward', 'deny')
|
||||
push_payload = {
|
||||
'type': 'reward_requested',
|
||||
'title': f'{child.name} wants a reward',
|
||||
'body': f'{reward.name} costs {reward.cost} points.',
|
||||
'user_id': user_id,
|
||||
'child_id': child.id,
|
||||
'child_name': child.name,
|
||||
'entity_id': reward.id,
|
||||
'entity_type': 'reward',
|
||||
'entity_name': reward.name,
|
||||
'approve_token': approve_token.id,
|
||||
'deny_token': deny_token.id,
|
||||
}
|
||||
send_push_to_user(user_id, push_payload)
|
||||
except Exception as _push_err:
|
||||
logger.warning(f'Push notification failed for reward request: {_push_err}')
|
||||
|
||||
return jsonify({
|
||||
'message': f'Reward request for {reward.name} submitted for {child.name}.',
|
||||
@@ -1139,25 +1141,27 @@ def confirm_chore(id):
|
||||
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CONFIRMED)))
|
||||
|
||||
# Fire web push notification to all parent subscriptions
|
||||
try:
|
||||
approve_token = create_action_token(user_id, id, task_id, 'chore', 'approve')
|
||||
deny_token = create_action_token(user_id, id, task_id, 'chore', 'deny')
|
||||
push_payload = {
|
||||
'type': 'chore_confirmed',
|
||||
'title': f'{child.name} completed a chore',
|
||||
'body': f'{task.name} is waiting for your approval.',
|
||||
'user_id': user_id,
|
||||
'child_id': id,
|
||||
'child_name': child.name,
|
||||
'entity_id': task_id,
|
||||
'entity_type': 'chore',
|
||||
'entity_name': task.name,
|
||||
'approve_token': approve_token.id,
|
||||
'deny_token': deny_token.id,
|
||||
}
|
||||
send_push_to_user(user_id, push_payload)
|
||||
except Exception as _push_err:
|
||||
logger.warning(f'Push notification failed for chore confirmation: {_push_err}')
|
||||
_push_user = users_db.get(Query().id == user_id)
|
||||
if _push_user and _push_user.get('push_notifications_enabled', True):
|
||||
try:
|
||||
approve_token = create_action_token(user_id, id, task_id, 'chore', 'approve')
|
||||
deny_token = create_action_token(user_id, id, task_id, 'chore', 'deny')
|
||||
push_payload = {
|
||||
'type': 'chore_confirmed',
|
||||
'title': f'{child.name} completed a chore',
|
||||
'body': f'{task.name} is waiting for your approval.',
|
||||
'user_id': user_id,
|
||||
'child_id': id,
|
||||
'child_name': child.name,
|
||||
'entity_id': task_id,
|
||||
'entity_type': 'chore',
|
||||
'entity_name': task.name,
|
||||
'approve_token': approve_token.id,
|
||||
'deny_token': deny_token.id,
|
||||
}
|
||||
send_push_to_user(user_id, push_payload)
|
||||
except Exception as _push_err:
|
||||
logger.warning(f'Push notification failed for chore confirmation: {_push_err}')
|
||||
|
||||
return jsonify({'message': f'Chore {task.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ def get_profile():
|
||||
'email': user.email,
|
||||
'image_id': user.image_id,
|
||||
'email_digest_enabled': user.email_digest_enabled,
|
||||
'push_notifications_enabled': user.push_notifications_enabled,
|
||||
}), 200
|
||||
|
||||
@user_api.route('/user/profile', methods=['PUT'])
|
||||
@@ -59,11 +60,12 @@ def update_profile():
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
data = request.get_json()
|
||||
# Only allow first_name, last_name, image_id, email_digest_enabled to be updated
|
||||
# Only allow first_name, last_name, image_id, email_digest_enabled, push_notifications_enabled to be updated
|
||||
first_name = data.get('first_name')
|
||||
last_name = data.get('last_name')
|
||||
image_id = data.get('image_id')
|
||||
email_digest_enabled = data.get('email_digest_enabled')
|
||||
push_notifications_enabled = data.get('push_notifications_enabled')
|
||||
if first_name is not None:
|
||||
user.first_name = first_name
|
||||
if last_name is not None:
|
||||
@@ -72,6 +74,8 @@ def update_profile():
|
||||
user.image_id = image_id
|
||||
if email_digest_enabled is not None:
|
||||
user.email_digest_enabled = bool(email_digest_enabled)
|
||||
if push_notifications_enabled is not None:
|
||||
user.push_notifications_enabled = bool(push_notifications_enabled)
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
|
||||
# Create tracking event
|
||||
@@ -84,6 +88,8 @@ def update_profile():
|
||||
metadata['image_updated'] = True
|
||||
if email_digest_enabled is not None:
|
||||
metadata['email_digest_enabled_updated'] = True
|
||||
if push_notifications_enabled is not None:
|
||||
metadata['push_notifications_enabled_updated'] = True
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id,
|
||||
|
||||
@@ -24,6 +24,7 @@ class User(BaseModel):
|
||||
token_version: int = 0
|
||||
timezone: str | None = None
|
||||
email_digest_enabled: bool = True
|
||||
push_notifications_enabled: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
@@ -49,6 +50,7 @@ class User(BaseModel):
|
||||
token_version=d.get('token_version', 0),
|
||||
timezone=d.get('timezone'),
|
||||
email_digest_enabled=d.get('email_digest_enabled', True),
|
||||
push_notifications_enabled=d.get('push_notifications_enabled', True),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at')
|
||||
@@ -79,5 +81,6 @@ class User(BaseModel):
|
||||
'token_version': self.token_version,
|
||||
'timezone': self.timezone,
|
||||
'email_digest_enabled': self.email_digest_enabled,
|
||||
'push_notifications_enabled': self.push_notifications_enabled,
|
||||
})
|
||||
return base
|
||||
|
||||
@@ -558,7 +558,7 @@ describe('UserProfile - Profile Update', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserProfile - Email Digest Toggle', () => {
|
||||
describe('UserProfile - Notification Toggles', () => {
|
||||
let wrapper: VueWrapper<any>
|
||||
|
||||
function mountWithDigest(emailDigestEnabled: boolean) {
|
||||
@@ -592,85 +592,97 @@ describe('UserProfile - Email Digest Toggle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(global.fetch as any).mockClear()
|
||||
// Default response for any extra fetch calls (e.g. PUT)
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the email digest toggle button', async () => {
|
||||
it('initializes email_digest_enabled to true in initialData when profile returns true', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.toggle-btn').exists()).toBe(true)
|
||||
expect(wrapper.vm.initialData.email_digest_enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('initializes toggle as enabled when email_digest_enabled is true', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.emailDigestEnabled).toBe(true)
|
||||
expect(wrapper.find('.toggle-btn').attributes('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('initializes toggle as disabled when email_digest_enabled is false', async () => {
|
||||
it('initializes email_digest_enabled to false in initialData when profile returns false', async () => {
|
||||
wrapper = mountWithDigest(false)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.emailDigestEnabled).toBe(false)
|
||||
expect(wrapper.find('.toggle-btn').attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper.vm.initialData.email_digest_enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls PUT /api/user/profile with email_digest_enabled: false when toggled off', async () => {
|
||||
it('initializes push_enabled to false in initialData when not subscribed', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.initialData.push_enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('fields array includes email_digest_enabled as toggle type', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const digestField = wrapper.vm.fields.find((f: any) => f.name === 'email_digest_enabled')
|
||||
expect(digestField).toBeDefined()
|
||||
expect(digestField.type).toBe('toggle')
|
||||
})
|
||||
|
||||
it('fields array includes push_enabled as toggle type', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
const pushField = wrapper.vm.fields.find((f: any) => f.name === 'push_enabled')
|
||||
expect(pushField).toBeDefined()
|
||||
expect(pushField.type).toBe('toggle')
|
||||
})
|
||||
|
||||
it('profile PUT includes email_digest_enabled when changed on submit', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
|
||||
await wrapper.vm.toggleDigest()
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: null,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
email_digest_enabled: false,
|
||||
push_enabled: false,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/user/profile',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ email_digest_enabled: false }),
|
||||
}),
|
||||
)
|
||||
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
||||
expect(putCall).toBeDefined()
|
||||
const body = JSON.parse(putCall[1].body)
|
||||
expect(body.email_digest_enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls PUT /api/user/profile with email_digest_enabled: true when toggled on', async () => {
|
||||
wrapper = mountWithDigest(false)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
|
||||
await wrapper.vm.toggleDigest()
|
||||
await flushPromises()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/user/profile',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ email_digest_enabled: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('flips emailDigestEnabled state after toggle', async () => {
|
||||
it('profile PUT omits email_digest_enabled when unchanged on submit', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
|
||||
expect(wrapper.vm.emailDigestEnabled).toBe(true)
|
||||
await wrapper.vm.toggleDigest()
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: null,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
email_digest_enabled: true, // same as initial
|
||||
push_enabled: false,
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.emailDigestEnabled).toBe(false)
|
||||
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
||||
expect(putCall).toBeDefined()
|
||||
const body = JSON.parse(putCall[1].body)
|
||||
expect(body.email_digest_enabled).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
:loading="loading"
|
||||
:error="errorMsg"
|
||||
:title="'User Profile'"
|
||||
:fieldErrors="{ push_enabled: pushError }"
|
||||
@submit="handleSubmit"
|
||||
@cancel="router.back"
|
||||
@add-image="onAddImage"
|
||||
@@ -15,22 +16,6 @@
|
||||
<template #custom-field-email="{ modelValue }">
|
||||
<div class="email-actions">
|
||||
<input id="email" type="email" :value="modelValue" disabled class="readonly-input" />
|
||||
<ToggleField
|
||||
label="Daily Digest"
|
||||
:modelValue="emailDigestEnabled"
|
||||
:disabled="savingDigest"
|
||||
description="Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links."
|
||||
:error="digestError"
|
||||
@update:modelValue="toggleDigest"
|
||||
/>
|
||||
<ToggleField
|
||||
label="Push Notifications"
|
||||
:modelValue="pushEnabled"
|
||||
:disabled="savingPush || getPushPermissionState() === 'denied'"
|
||||
description="Receive instant push notifications when a chore or reward needs your approval."
|
||||
:error="pushError"
|
||||
@update:modelValue="togglePush"
|
||||
/>
|
||||
<button type="button" class="btn-link btn-link-space" @click="goToChangeParentPin">
|
||||
Change Parent PIN
|
||||
</button>
|
||||
@@ -114,16 +99,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import ToggleField from '../shared/ToggleField.vue'
|
||||
import {
|
||||
isSubscribedToPush,
|
||||
subscribeToPushWithResult,
|
||||
unsubscribeFromPush,
|
||||
getPushPermissionState,
|
||||
setPushOptOut,
|
||||
} from '@/services/pushSubscription'
|
||||
import { parseErrorResponse, isEmailValid } from '@/common/api'
|
||||
import { ALREADY_MARKED } from '@/common/errorCodes'
|
||||
@@ -148,39 +133,45 @@ const showDeleteSuccess = ref(false)
|
||||
const showDeleteError = ref(false)
|
||||
const deleteErrorMessage = ref('')
|
||||
|
||||
const emailDigestEnabled = ref(true)
|
||||
const savingDigest = ref(false)
|
||||
const digestError = ref('')
|
||||
|
||||
const pushEnabled = ref(false)
|
||||
const savingPush = ref(false)
|
||||
const pushError = ref('')
|
||||
const pushPermissionDenied = ref(false)
|
||||
|
||||
const initialData = ref<{
|
||||
image_id: string | null
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
email_digest_enabled: boolean
|
||||
push_enabled: boolean
|
||||
}>({
|
||||
image_id: null,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
email_digest_enabled: true,
|
||||
push_enabled: false,
|
||||
})
|
||||
|
||||
const fields: Array<{
|
||||
name: string
|
||||
label: string
|
||||
type: 'image' | 'text' | 'custom'
|
||||
imageType?: number
|
||||
required?: boolean
|
||||
maxlength?: number
|
||||
}> = [
|
||||
{ name: 'image_id', label: 'Image', type: 'image', imageType: 1 },
|
||||
{ name: 'first_name', label: 'First Name', type: 'text', required: true, maxlength: 64 },
|
||||
{ name: 'last_name', label: 'Last Name', type: 'text', required: true, maxlength: 64 },
|
||||
{ name: 'email', label: 'Email Address', type: 'custom' },
|
||||
]
|
||||
const fields = computed(() => [
|
||||
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 1 },
|
||||
{ name: 'first_name', label: 'First Name', type: 'text' as const, required: true, maxlength: 64 },
|
||||
{ name: 'last_name', label: 'Last Name', type: 'text' as const, required: true, maxlength: 64 },
|
||||
{ name: 'email', label: 'Email Address', type: 'custom' as const },
|
||||
{
|
||||
name: 'email_digest_enabled',
|
||||
label: 'Daily Digest',
|
||||
type: 'toggle' as const,
|
||||
description:
|
||||
'Receive a 9pm summary of pending chore and reward requests with one-click approve/deny links.',
|
||||
},
|
||||
{
|
||||
name: 'push_enabled',
|
||||
label: 'Push Notifications',
|
||||
type: 'toggle' as const,
|
||||
description: 'Receive instant push notifications when a chore or reward needs your approval.',
|
||||
disabled: pushPermissionDenied.value,
|
||||
},
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
@@ -188,14 +179,16 @@ onMounted(async () => {
|
||||
const res = await fetch('/api/user/profile')
|
||||
if (!res.ok) throw new Error('Failed to load profile')
|
||||
const data = await res.json()
|
||||
pushPermissionDenied.value = getPushPermissionState() === 'denied'
|
||||
const pushSubscribed = await isSubscribedToPush()
|
||||
initialData.value = {
|
||||
image_id: data.image_id || null,
|
||||
first_name: data.first_name || '',
|
||||
last_name: data.last_name || '',
|
||||
email: data.email || '',
|
||||
email_digest_enabled: data.email_digest_enabled !== false,
|
||||
push_enabled: data.push_notifications_enabled !== false && pushSubscribed,
|
||||
}
|
||||
emailDigestEnabled.value = data.email_digest_enabled !== false
|
||||
pushEnabled.value = await isSubscribedToPush()
|
||||
} catch {
|
||||
errorMsg.value = 'Could not load user profile.'
|
||||
} finally {
|
||||
@@ -217,6 +210,8 @@ function handleSubmit(form: {
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
email_digest_enabled?: boolean
|
||||
push_enabled?: boolean
|
||||
}) {
|
||||
errorMsg.value = ''
|
||||
loading.value = true
|
||||
@@ -256,31 +251,73 @@ async function updateProfile(form: {
|
||||
first_name: string
|
||||
last_name: string
|
||||
email: string
|
||||
email_digest_enabled?: boolean
|
||||
push_enabled?: boolean
|
||||
}) {
|
||||
fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
const prevDigest = initialData.value.email_digest_enabled
|
||||
const prevPush = initialData.value.push_enabled
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
first_name: form.first_name,
|
||||
last_name: form.last_name,
|
||||
image_id: form.image_id,
|
||||
}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('Failed to update profile')
|
||||
// Update initialData to reflect the saved state
|
||||
initialData.value = { ...form }
|
||||
modalTitle.value = 'Profile Updated'
|
||||
modalSubtitle.value = ''
|
||||
modalMessage.value = 'Your profile was updated successfully.'
|
||||
showModal.value = true
|
||||
})
|
||||
.catch(() => {
|
||||
errorMsg.value = 'Failed to update profile.'
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
}
|
||||
if (form.email_digest_enabled !== undefined && form.email_digest_enabled !== prevDigest) {
|
||||
body.email_digest_enabled = form.email_digest_enabled
|
||||
}
|
||||
if (form.push_enabled !== undefined && form.push_enabled !== prevPush) {
|
||||
body.push_notifications_enabled = form.push_enabled
|
||||
}
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update profile')
|
||||
let actualPushEnabled = prevPush
|
||||
if (form.push_enabled !== undefined && form.push_enabled !== prevPush) {
|
||||
const pushOk = await applyPushChange(form.push_enabled)
|
||||
actualPushEnabled = pushOk ? form.push_enabled : prevPush
|
||||
}
|
||||
initialData.value = {
|
||||
...initialData.value,
|
||||
...form,
|
||||
push_enabled: actualPushEnabled,
|
||||
}
|
||||
modalTitle.value = 'Profile Updated'
|
||||
modalSubtitle.value = ''
|
||||
modalMessage.value = 'Your profile was updated successfully.'
|
||||
showModal.value = true
|
||||
} catch {
|
||||
errorMsg.value = 'Failed to update profile.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPushChange(newValue: boolean): Promise<boolean> {
|
||||
pushError.value = ''
|
||||
try {
|
||||
if (newValue) {
|
||||
const result = await subscribeToPushWithResult()
|
||||
if (result.ok) {
|
||||
setPushOptOut(false)
|
||||
return true
|
||||
}
|
||||
pushError.value =
|
||||
result.reason === 'permission_denied'
|
||||
? 'Notifications are blocked. Enable them in your browser settings.'
|
||||
: 'Failed to enable push notifications.'
|
||||
return false
|
||||
} else {
|
||||
await unsubscribeFromPush()
|
||||
setPushOptOut(true)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
pushError.value = 'Failed to update push notification settings.'
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordModalClose() {
|
||||
@@ -317,47 +354,6 @@ async function resetPassword() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDigest(newValue?: boolean) {
|
||||
const value = newValue ?? !emailDigestEnabled.value
|
||||
digestError.value = ''
|
||||
savingDigest.value = true
|
||||
try {
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email_digest_enabled: value }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update preference')
|
||||
emailDigestEnabled.value = value
|
||||
} catch {
|
||||
digestError.value = 'Failed to save notification preference.'
|
||||
} finally {
|
||||
savingDigest.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePush(newValue: boolean) {
|
||||
pushError.value = ''
|
||||
savingPush.value = true
|
||||
try {
|
||||
if (newValue) {
|
||||
const result = await subscribeToPushWithResult()
|
||||
if (result.ok) {
|
||||
pushEnabled.value = true
|
||||
} else if (result.reason === 'permission_denied') {
|
||||
pushError.value = 'Notifications are blocked. Enable them in your browser settings.'
|
||||
} else {
|
||||
pushError.value = 'Failed to enable push notifications.'
|
||||
}
|
||||
} else {
|
||||
await unsubscribeFromPush()
|
||||
pushEnabled.value = false
|
||||
}
|
||||
} finally {
|
||||
savingPush.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToChangeParentPin() {
|
||||
router.push({ name: 'ParentPinSetup' })
|
||||
}
|
||||
|
||||
@@ -4,7 +4,16 @@
|
||||
<form v-else @submit.prevent="submit" class="entity-form" ref="formRef">
|
||||
<template v-for="field in fields" :key="field.name">
|
||||
<div class="group">
|
||||
<label :for="field.name">
|
||||
<ToggleField
|
||||
v-if="field.type === 'toggle'"
|
||||
:label="field.label"
|
||||
:modelValue="formData[field.name]"
|
||||
@update:modelValue="(val: boolean) => (formData[field.name] = val)"
|
||||
:disabled="field.disabled"
|
||||
:description="field.description"
|
||||
:error="props.fieldErrors?.[field.name]"
|
||||
/>
|
||||
<label v-else :for="field.name">
|
||||
{{ field.label }}
|
||||
<!-- Custom field slot -->
|
||||
<slot
|
||||
@@ -70,18 +79,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import ImagePicker from '@/components/utils/ImagePicker.vue'
|
||||
import ToggleField from './ToggleField.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import '@/assets/styles.css'
|
||||
|
||||
type Field = {
|
||||
name: string
|
||||
label: string
|
||||
type: 'text' | 'number' | 'image' | 'custom'
|
||||
type: 'text' | 'number' | 'image' | 'custom' | 'toggle'
|
||||
required?: boolean
|
||||
maxlength?: number
|
||||
min?: number
|
||||
max?: number
|
||||
imageType?: number
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -94,6 +106,7 @@ const props = withDefaults(
|
||||
error?: string | null
|
||||
title?: string
|
||||
requireDirty?: boolean
|
||||
fieldErrors?: Record<string, string>
|
||||
}>(),
|
||||
{
|
||||
requireDirty: true,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { getCachedImageUrl, getCachedImageBlob } from '@/common/imageCache'
|
||||
import '@/assets/styles.css'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import { subscribeToPushWithResult } from '@/services/pushSubscription'
|
||||
import { subscribeToPushWithResult, isPushOptedOut } from '@/services/pushSubscription'
|
||||
|
||||
const router = useRouter()
|
||||
const show = ref(false)
|
||||
@@ -140,7 +140,9 @@ const submit = async () => {
|
||||
}
|
||||
// Authenticate parent and navigate
|
||||
authenticateParent(stayInParentMode.value)
|
||||
subscribeToPushWithResult() // fire-and-forget — browser gesture is satisfied by the PIN button click
|
||||
if (!isPushOptedOut()) {
|
||||
subscribeToPushWithResult() // fire-and-forget — browser gesture is satisfied by the PIN button click
|
||||
}
|
||||
close()
|
||||
const returnUrl = consumePendingReturnUrl()
|
||||
router.push(returnUrl || '/parent')
|
||||
|
||||
@@ -186,3 +186,25 @@ export async function isSubscribedToPush(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** localStorage key used to persist the user's explicit push opt-out across logins. */
|
||||
const PUSH_OPT_OUT_KEY = 'push_opt_out'
|
||||
|
||||
/**
|
||||
* Persist or clear the user's push opt-out preference in localStorage.
|
||||
* Call with `true` when the user disables push, `false` when they enable it.
|
||||
*/
|
||||
export function setPushOptOut(optOut: boolean): void {
|
||||
if (optOut) {
|
||||
localStorage.setItem(PUSH_OPT_OUT_KEY, '1')
|
||||
} else {
|
||||
localStorage.removeItem(PUSH_OPT_OUT_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the user has explicitly opted out of push notifications.
|
||||
*/
|
||||
export function isPushOptedOut(): boolean {
|
||||
return localStorage.getItem(PUSH_OPT_OUT_KEY) === '1'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user