feat: add onboarding tutorial for new users
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 3m4s
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 3m4s
- Introduced a modular tutorial layer to guide new parents through the app setup process. - Implemented a 3-step forced intro after first sign-in (PIN setup → child creation → chore creation). - Added just-in-time contextual hints for various features as users encounter them. - Persisted user progress on the backend with new fields in the User model. - Created a new tutorial controller and step registry in the frontend for managing tutorial states. - Added Help button for easy access to tutorial tips and a restart option in the user profile. - Ensured accessibility and mobile responsiveness for the tutorial overlay. - Included tests for backend and frontend functionalities related to the tutorial.
This commit is contained in:
@@ -34,6 +34,42 @@ vi.mock('../services/pushSubscription', () => ({
|
||||
getPushPermissionState: vi.fn().mockReturnValue('default'),
|
||||
}))
|
||||
|
||||
function stubUserProfile() {
|
||||
return {
|
||||
template: '<div class="user-profile"><slot /></div>',
|
||||
}
|
||||
}
|
||||
|
||||
function stubModalDialog() {
|
||||
return {
|
||||
template: '<div class="mock-modal" v-if="show"><slot /></div>',
|
||||
props: ['show'],
|
||||
}
|
||||
}
|
||||
|
||||
function stubImagePicker() {
|
||||
return {
|
||||
template: '<div class="mock-image-picker" />',
|
||||
props: ['modelValue', 'imageType'],
|
||||
emits: ['update:modelValue', 'add-image'],
|
||||
}
|
||||
}
|
||||
|
||||
function stubToggleField() {
|
||||
return {
|
||||
template: '<div class="mock-toggle-field" />',
|
||||
props: ['label', 'modelValue', 'disabled', 'description', 'error'],
|
||||
emits: ['update:modelValue'],
|
||||
}
|
||||
}
|
||||
|
||||
function stubProfileSection() {
|
||||
return {
|
||||
template: '<div class="mock-profile-section"><slot /></div>',
|
||||
props: ['title', 'defaultOpen'],
|
||||
}
|
||||
}
|
||||
|
||||
describe('UserProfile - Delete Account', () => {
|
||||
let wrapper: VueWrapper<any>
|
||||
|
||||
@@ -57,31 +93,24 @@ describe('UserProfile - Delete Account', () => {
|
||||
global: {
|
||||
plugins: [mockRouter],
|
||||
stubs: {
|
||||
EntityEditForm: {
|
||||
template:
|
||||
'<div><slot name="custom-field-email" :modelValue="\'test@example.com\'" /></div>',
|
||||
},
|
||||
ModalDialog: {
|
||||
template: '<div class="mock-modal" v-if="show"><slot /></div>',
|
||||
props: ['show'],
|
||||
},
|
||||
ProfileSection: stubProfileSection(),
|
||||
ImagePicker: stubImagePicker(),
|
||||
ToggleField: stubToggleField(),
|
||||
ModalDialog: stubModalDialog(),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders Delete My Account button', async () => {
|
||||
// Wait for component to mount and render
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// Test the functionality exists by calling the method directly
|
||||
expect(wrapper.vm.openDeleteWarning).toBeDefined()
|
||||
expect(wrapper.vm.confirmDeleteAccount).toBeDefined()
|
||||
})
|
||||
|
||||
it('opens warning modal when Delete My Account button is clicked', async () => {
|
||||
// Test by calling the method directly
|
||||
wrapper.vm.openDeleteWarning()
|
||||
await nextTick()
|
||||
|
||||
@@ -91,21 +120,19 @@ describe('UserProfile - Delete Account', () => {
|
||||
|
||||
it('Delete button in warning modal is disabled until email matches', async () => {
|
||||
// Set initial email
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
|
||||
// Open warning modal
|
||||
await wrapper.vm.openDeleteWarning()
|
||||
await nextTick()
|
||||
|
||||
// Find modal delete button (we need to check :disabled binding)
|
||||
// Since we're using a stub, we'll test the logic directly
|
||||
wrapper.vm.confirmEmail = 'wrong@example.com'
|
||||
await nextTick()
|
||||
expect(wrapper.vm.confirmEmail).not.toBe(wrapper.vm.initialData.email)
|
||||
expect(wrapper.vm.confirmEmail).not.toBe(wrapper.vm.email)
|
||||
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
await nextTick()
|
||||
expect(wrapper.vm.confirmEmail).toBe(wrapper.vm.initialData.email)
|
||||
expect(wrapper.vm.confirmEmail).toBe(wrapper.vm.email)
|
||||
})
|
||||
|
||||
it('calls API when confirmed with correct email', async () => {
|
||||
@@ -115,7 +142,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -132,7 +159,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
})
|
||||
|
||||
it('does not call API if email is invalid format', async () => {
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'invalid-email'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -149,7 +176,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -166,7 +193,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
return { ok: true, json: async () => ({ success: true }) }
|
||||
})
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -180,7 +207,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
json: async () => ({ success: true }),
|
||||
})
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -196,7 +223,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
json: async () => ({ error: 'fail', code: 'ERROR' }),
|
||||
})
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -208,7 +235,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
it('clears suppressForceLogout on network error', async () => {
|
||||
;(global.fetch as any).mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -228,7 +255,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -242,7 +269,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
it('shows error modal on network error', async () => {
|
||||
;(global.fetch as any).mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -307,7 +334,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
const deletePromise = wrapper.vm.confirmDeleteAccount()
|
||||
@@ -344,7 +371,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValueOnce(mockResponse)
|
||||
|
||||
wrapper.vm.initialData.email = 'test@example.com'
|
||||
wrapper.vm.email = 'test@example.com'
|
||||
wrapper.vm.confirmEmail = 'test@example.com'
|
||||
|
||||
await wrapper.vm.confirmDeleteAccount()
|
||||
@@ -354,7 +381,7 @@ describe('UserProfile - Delete Account', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserProfile - Profile Update', () => {
|
||||
describe('UserProfile - Auto-save', () => {
|
||||
let wrapper: VueWrapper<any>
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -369,90 +396,57 @@ describe('UserProfile - Profile Update', () => {
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
email_digest_enabled: true,
|
||||
}),
|
||||
})
|
||||
|
||||
// Mount component with router
|
||||
wrapper = mount(UserProfile, {
|
||||
global: {
|
||||
plugins: [mockRouter],
|
||||
stubs: {
|
||||
EntityEditForm: {
|
||||
template: '<div class="mock-form"><slot /></div>',
|
||||
props: ['initialData', 'fields', 'loading', 'error', 'isEdit', 'entityLabel', 'title'],
|
||||
emits: ['submit', 'cancel', 'add-image'],
|
||||
},
|
||||
ModalDialog: {
|
||||
template: '<div class="mock-modal"><slot /></div>',
|
||||
},
|
||||
ProfileSection: stubProfileSection(),
|
||||
ImagePicker: stubImagePicker(),
|
||||
ToggleField: stubToggleField(),
|
||||
ModalDialog: stubModalDialog(),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('updates initialData after successful profile save', async () => {
|
||||
it('saveNames sends PUT with first and last name', async () => {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// Initial image_id should be set from mount
|
||||
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
|
||||
// Mock successful save response
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
// Simulate form submission with new image_id
|
||||
const newFormData = {
|
||||
image_id: 'new-image-id',
|
||||
first_name: 'Updated',
|
||||
last_name: 'Name',
|
||||
email: 'test@example.com',
|
||||
}
|
||||
|
||||
await wrapper.vm.handleSubmit(newFormData)
|
||||
wrapper.vm.firstName = 'Updated'
|
||||
wrapper.vm.lastName = 'Name'
|
||||
await wrapper.vm.saveNames()
|
||||
await flushPromises()
|
||||
|
||||
// initialData should now be updated to match the saved form
|
||||
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
|
||||
expect(wrapper.vm.initialData.first_name).toBe('Updated')
|
||||
expect(wrapper.vm.initialData.last_name).toBe('Name')
|
||||
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.first_name).toBe('Updated')
|
||||
expect(body.last_name).toBe('Name')
|
||||
})
|
||||
|
||||
it('allows dirty detection after save when reverting to original value', async () => {
|
||||
it('saveImage sends PUT with image_id', async () => {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
// Start with initial-image-id
|
||||
expect(wrapper.vm.initialData.image_id).toBe('initial-image-id')
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
|
||||
// Mock successful save
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
// Change and save to new-image-id
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: 'new-image-id',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
await wrapper.vm.saveImage('new-image-id')
|
||||
await flushPromises()
|
||||
|
||||
// initialData should now be new-image-id
|
||||
expect(wrapper.vm.initialData.image_id).toBe('new-image-id')
|
||||
|
||||
// Now if user changes back to initial-image-id, it should be detected as different
|
||||
// (because initialData is now new-image-id)
|
||||
const currentInitial = wrapper.vm.initialData.image_id
|
||||
expect(currentInitial).toBe('new-image-id')
|
||||
expect(currentInitial).not.toBe('initial-image-id')
|
||||
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.image_id).toBe('new-image-id')
|
||||
})
|
||||
|
||||
it('handles image upload during profile save', async () => {
|
||||
it('uploadLocalImage uploads file then saves image_id', async () => {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
@@ -471,24 +465,18 @@ describe('UserProfile - Profile Update', () => {
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: 'local-upload',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
await wrapper.vm.uploadLocalImage()
|
||||
await flushPromises()
|
||||
|
||||
// Should have called image upload
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/image/upload',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
const uploadCall = (global.fetch as any).mock.calls.find(
|
||||
(c: any[]) => c[0] === '/api/image/upload',
|
||||
)
|
||||
expect(uploadCall).toBeDefined()
|
||||
expect(uploadCall[1].method).toBe('POST')
|
||||
|
||||
// initialData should be updated with uploaded image ID
|
||||
expect(wrapper.vm.initialData.image_id).toBe('uploaded-image-id')
|
||||
// imageId should be updated
|
||||
expect(wrapper.vm.imageId).toBe('uploaded-image-id')
|
||||
})
|
||||
|
||||
it('shows error message on failed image upload', async () => {
|
||||
@@ -504,57 +492,25 @@ describe('UserProfile - Profile Update', () => {
|
||||
status: 500,
|
||||
})
|
||||
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: 'local-upload',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
await wrapper.vm.uploadLocalImage()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.errorMsg).toBe('Failed to upload image.')
|
||||
expect(wrapper.vm.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('shows success modal after profile update', async () => {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: 'some-image-id',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.showModal).toBe(true)
|
||||
expect(wrapper.vm.modalTitle).toBe('Profile Updated')
|
||||
expect(wrapper.vm.modalMessage).toBe('Your profile was updated successfully.')
|
||||
})
|
||||
|
||||
it('shows error message on failed profile update', async () => {
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
;(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
})
|
||||
|
||||
await wrapper.vm.handleSubmit({
|
||||
image_id: 'some-image-id',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
})
|
||||
await wrapper.vm.saveNames()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.vm.errorMsg).toBe('Failed to update profile.')
|
||||
expect(wrapper.vm.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -576,14 +532,10 @@ describe('UserProfile - Notification Toggles', () => {
|
||||
global: {
|
||||
plugins: [mockRouter],
|
||||
stubs: {
|
||||
EntityEditForm: {
|
||||
template:
|
||||
'<div><slot name="custom-field-email" :modelValue="\'test@example.com\'" /></div>',
|
||||
},
|
||||
ModalDialog: {
|
||||
template: '<div class="mock-modal" v-if="show"><slot /></div>',
|
||||
props: ['show'],
|
||||
},
|
||||
ProfileSection: stubProfileSection(),
|
||||
ImagePicker: stubImagePicker(),
|
||||
ToggleField: stubToggleField(),
|
||||
ModalDialog: stubModalDialog(),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -598,64 +550,38 @@ describe('UserProfile - Notification Toggles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('initializes email_digest_enabled to true in initialData when profile returns true', async () => {
|
||||
it('initializes emailDigestEnabled to true when profile returns true', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.initialData.email_digest_enabled).toBe(true)
|
||||
expect(wrapper.vm.emailDigestEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('initializes email_digest_enabled to false in initialData when profile returns false', async () => {
|
||||
it('initializes emailDigestEnabled to false when profile returns false', async () => {
|
||||
wrapper = mountWithDigest(false)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.initialData.email_digest_enabled).toBe(false)
|
||||
expect(wrapper.vm.emailDigestEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('initializes push_enabled to false in initialData when not subscribed', async () => {
|
||||
it('initializes pushEnabled to false when not subscribed', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.initialData.push_enabled).toBe(false)
|
||||
expect(wrapper.vm.pushEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('fields array includes email_digest_enabled as toggle type', async () => {
|
||||
it('onToggleDigest sends PUT with new value', 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.handleSubmit({
|
||||
image_id: null,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
email_digest_enabled: false,
|
||||
push_enabled: false,
|
||||
})
|
||||
await wrapper.vm.onToggleDigest(false)
|
||||
await flushPromises()
|
||||
|
||||
const putCall = (global.fetch as any).mock.calls.find((c: any[]) => c[1]?.method === 'PUT')
|
||||
@@ -664,25 +590,20 @@ describe('UserProfile - Notification Toggles', () => {
|
||||
expect(body.email_digest_enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('profile PUT omits email_digest_enabled when unchanged on submit', async () => {
|
||||
it('onTogglePush sends PUT with new value and applies push change', async () => {
|
||||
wrapper = mountWithDigest(true)
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
|
||||
;(global.fetch as any).mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
||||
|
||||
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,
|
||||
})
|
||||
expect(wrapper.vm.pushEnabled).toBe(false)
|
||||
await wrapper.vm.onTogglePush(true)
|
||||
await flushPromises()
|
||||
|
||||
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()
|
||||
expect(body.push_notifications_enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user