feat(devices): owner can mark a frame as sold and unlink it pre-emptively
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Pairs with the new claim-on-takeover checkbox: now the seller can purge
their data BEFORE handing the device over, so even if they forget to
hold the BOOT button to wipe NVS, the next owner can't accidentally pull
their photos.
Backend:
- DELETE /api/devices/{id}: owner-only (404 for cross-tenant). Revokes
image-device approvals, drops history rows, removes the Device row
entirely so the MAC is unclaimed. The next poll from that physical
frame returns 404 → setup QR for the next owner.
- DeviceService::deleteDeviceForOwner extracts the cleanup so the
controller stays thin.
- Mercure publish on delete sends {id, deleted: true} so any other
open PWA tabs splice the row out instantly.
Frontend:
- Settings sheet (BaseBottomSheet): "Remove this frame" link below
Save, in danger red with an explanatory hint about when to use it.
- Native window.confirm gate — destructive + irreversible, the
weight of native-confirm is honest. (A bespoke modal would be
polish.)
- useDeviceMercure: handles the {id, deleted: true} sentinel — splices
the device out + closes its own EventSource for that topic.
- useDevicesStore.removeDevice: DELETE + local store filter.
Tests added:
- DeviceApiControllerTest: 4 cases — happy-path delete purges
everything, 404 cross-tenant, anon redirects to login, and
post-delete the device-poll endpoint 404s (fresh-MAC guarantee).
- HomeView.test.ts: confirm-yes calls store + closes sheet,
confirm-cancel does NOT call removeDevice.
- useDeviceMercure.test.ts: deletion sentinel splices the device
out and closes the EventSource.
Coverage: 99.71% lines / 98.21% methods backend, 98.31% lines frontend.
558 tests total via ddev tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,15 @@ export function useDeviceMercure() {
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const updated = JSON.parse(event.data) as Device
|
||||
const payload = JSON.parse(event.data) as Device | { id: number; deleted: true }
|
||||
// Deletion sentinel: server sends {id, deleted: true} when the
|
||||
// owner removed the frame. Splice out + close our subscription.
|
||||
if ('deleted' in payload && payload.deleted === true) {
|
||||
devices.value = devices.value.filter(d => d.id !== payload.id)
|
||||
close(payload.id)
|
||||
return
|
||||
}
|
||||
const updated = payload as Device
|
||||
const idx = devices.value.findIndex(d => d.id === updated.id)
|
||||
if (idx !== -1) {
|
||||
// Splice replacement so Vue's reactivity tracks the swap.
|
||||
|
||||
@@ -52,6 +52,18 @@ export const useDevicesStore = defineStore('devices', () => {
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner-initiated remove. Hits the API DELETE; on success splices the
|
||||
* device out of the local list so the UI updates immediately. The
|
||||
* Mercure subscription will also receive a `{deleted: true}` push for
|
||||
* any other tabs the user has open.
|
||||
*/
|
||||
async function removeDevice(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/devices/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('Failed to remove device')
|
||||
devices.value = devices.value.filter(d => d.id !== id)
|
||||
}
|
||||
|
||||
async function unlockImage(deviceId: number): Promise<Device> {
|
||||
const res = await fetch(`/api/devices/${deviceId}/lock`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('Failed to unlock')
|
||||
@@ -61,5 +73,5 @@ export const useDevicesStore = defineStore('devices', () => {
|
||||
return updated
|
||||
}
|
||||
|
||||
return { devices, loading, error, fetchDevices, updateDevice, lockImage, unlockImage }
|
||||
return { devices, loading, error, fetchDevices, updateDevice, removeDevice, lockImage, unlockImage }
|
||||
})
|
||||
|
||||
@@ -163,6 +163,27 @@ describe('useDeviceMercure', () => {
|
||||
expect(instances).toHaveLength(2) // a fresh connection was opened
|
||||
})
|
||||
|
||||
it('removes the device from the store on a {deleted: true} sentinel', async () => {
|
||||
const store = useDevicesStore()
|
||||
store.devices = [makeDevice({ id: 7, name: 'Den' }), makeDevice({ id: 9, name: 'Cabin' })]
|
||||
|
||||
mountWithComposable()
|
||||
await flushPromises()
|
||||
expect(instances).toHaveLength(2)
|
||||
|
||||
// Find the EventSource instance subscribed to device 7.
|
||||
const dev7 = instances.find(i => i.url.includes('%2F7'))!
|
||||
dev7.onmessage?.(new MessageEvent('message', {
|
||||
data: JSON.stringify({ id: 7, deleted: true }),
|
||||
}))
|
||||
await flushPromises()
|
||||
|
||||
// Device 7 spliced out, 9 still there. The deleted device's EventSource
|
||||
// is closed too — no point keeping a connection for a vanished device.
|
||||
expect(store.devices.map(d => d.id)).toEqual([9])
|
||||
expect(dev7.close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes EventSources for devices that disappear from the store', async () => {
|
||||
const store = useDevicesStore()
|
||||
store.devices = [makeDevice({ id: 7 }), makeDevice({ id: 9 })]
|
||||
|
||||
@@ -1028,6 +1028,45 @@ describe('HomeView', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('Remove this frame confirms, calls store.removeDevice, and closes the sheet', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 5, name: 'Den' })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
const removeSpy = vi.spyOn(devicesStore, 'removeDevice').mockResolvedValue()
|
||||
const confirmSpy = vi.fn().mockReturnValue(true)
|
||||
vi.stubGlobal('confirm', confirmSpy)
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.home-view__remove').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled()
|
||||
expect(removeSpy).toHaveBeenCalledWith(5)
|
||||
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
|
||||
expect(sheet.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('Remove this frame cancel does NOT call removeDevice', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 5 })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
const removeSpy = vi.spyOn(devicesStore, 'removeDevice').mockResolvedValue()
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false))
|
||||
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
|
||||
await flushPromises()
|
||||
await wrapper.find('.home-view__remove').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(removeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the propagation note in the settings sheet', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 5 })]
|
||||
|
||||
@@ -223,6 +223,20 @@
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</BaseButton>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="home-view__remove"
|
||||
:disabled="removing"
|
||||
@click="confirmAndRemove"
|
||||
>
|
||||
{{ removing ? 'Removing…' : 'Remove this frame' }}
|
||||
</button>
|
||||
<p class="home-view__remove-hint">
|
||||
Use this if you’re selling or giving away the frame. It deletes
|
||||
this frame from your account and unlinks it from your photos so the
|
||||
next owner can claim it fresh.
|
||||
</p>
|
||||
</BaseBottomSheet>
|
||||
</template>
|
||||
|
||||
@@ -481,6 +495,7 @@ const TIMEZONE_GROUPS = [
|
||||
|
||||
const sheetOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const removing = ref(false)
|
||||
const editingDevice = ref<Device | null>(null)
|
||||
const editName = ref('')
|
||||
const editOrientation = ref<Device['orientation']>('landscape')
|
||||
@@ -637,6 +652,27 @@ function onEdit(deviceId: number) {
|
||||
sheetOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmAndRemove() {
|
||||
if (!editingDevice.value) return
|
||||
const name = editingDevice.value.name || 'this frame'
|
||||
// Native confirm — destructive, irreversible, single-user action. A
|
||||
// bespoke modal would be polish; native is honest about the weight.
|
||||
const ok = window.confirm(
|
||||
`Remove "${name}" from your account?\n\n` +
|
||||
`This deletes the frame's history and unlinks all photos you'd ` +
|
||||
`approved for it. Use this when selling or giving the frame away.`,
|
||||
)
|
||||
if (!ok) return
|
||||
|
||||
removing.value = true
|
||||
try {
|
||||
await devicesStore.removeDevice(editingDevice.value.id)
|
||||
sheetOpen.value = false
|
||||
} finally {
|
||||
removing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
if (!editingDevice.value) return
|
||||
saving.value = true
|
||||
@@ -972,5 +1008,38 @@ async function saveSettings() {
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
&__remove {
|
||||
width: 100%;
|
||||
margin-top: var(--space-5);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
min-height: var(--touch-min);
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-danger, #c0392b);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast);
|
||||
|
||||
&:hover, &:focus-visible {
|
||||
border-color: var(--color-danger, #c0392b);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__remove-hint {
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user