feat(setup): post-link redirects to SPA so first-setup matches live UI
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Twig configure page replaced with a redirect: SetupController's index,
register, login, and the legacy /configure route all post-link redirect
to /?setup=<deviceId> for unconfigured devices. The SPA's HomeView
auto-opens its existing settings sheet for that id, with the same
controls everyone uses for live edits — themed to the user's choice,
pre-populated from the device record.
Fixes Matt's report:
- "every 6 hours" lost on save: the configure form posted
rotation_interval_hours but the controller read
rotation_interval_minutes, so the value silently defaulted to
1440 every time. Now the SPA's PATCH flow handles it correctly.
- "old settings still there in live settings": SPA settings sheet
pre-populates from the device's current state via onEdit.
- "uniqueness window in setup but not live settings": removed
from the (now-deleted) Twig form; both surfaces are consistent.
- "color scheme didn't match account": SPA respects the user's
theme natively (data-theme on <html>), so the first-setup screen
looks like the rest of the app.
Also adds a "Sign out of pictureFrame" link at the bottom of the
per-frame settings sheet (the existing /settings tab still has the
primary one). Easy escape hatch from a deeply-nested settings flow.
Tests:
- SetupControllerTest: S-03/04/05/06/08 updated for new redirect
targets, S-CLAIM-03 updated.
- HomeView.test.ts: useRoute now mockable per-test, two new cases
pinning the ?setup=<id> auto-open and its absence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,9 +49,13 @@ vi.mock('@/components/OrientationPicker.vue', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Stub vue-router so HomeView can call useRouter() without a real router
|
// Stub vue-router so HomeView can call useRouter() without a real router.
|
||||||
|
// `mockRoute` is mutated per-test where needed (e.g. to surface ?setup=<id>).
|
||||||
|
const mockRoute: { query: Record<string, string | undefined> } = { query: {} }
|
||||||
|
const routerReplace = vi.fn()
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRouter: () => ({ push: routerPush }),
|
useRouter: () => ({ push: routerPush, replace: routerReplace }),
|
||||||
|
useRoute: () => mockRoute,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Stub URL.createObjectURL used by upload store
|
// Stub URL.createObjectURL used by upload store
|
||||||
@@ -1128,6 +1132,41 @@ describe('HomeView', () => {
|
|||||||
expect(document.querySelector('.home-view__remove-modal')).toBeNull()
|
expect(document.querySelector('.home-view__remove-modal')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// First-time setup: the SetupController redirects new claim/register
|
||||||
|
// flows to /?setup=<id>. The SPA must auto-open the per-frame settings
|
||||||
|
// sheet for that device, with the standard pre-population, so the user
|
||||||
|
// never has to learn a separate Twig configure form.
|
||||||
|
it('auto-opens the per-frame settings sheet when ?setup=<id> is in the URL', async () => {
|
||||||
|
const devicesStore = useDevicesStore()
|
||||||
|
devicesStore.devices = [makeDevice({ id: 7, name: '' })]
|
||||||
|
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||||
|
|
||||||
|
// Simulate landing on /?setup=7 (SetupController's post-link redirect).
|
||||||
|
mockRoute.query = { setup: '7' }
|
||||||
|
|
||||||
|
const wrapper = mountView()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
|
||||||
|
expect(sheet.props('modelValue')).toBe(true)
|
||||||
|
expect(routerReplace).toHaveBeenCalled() // query cleaned off the URL
|
||||||
|
|
||||||
|
mockRoute.query = {}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT open the sheet when no ?setup= param is present', async () => {
|
||||||
|
const devicesStore = useDevicesStore()
|
||||||
|
devicesStore.devices = [makeDevice({ id: 7 })]
|
||||||
|
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||||
|
mockRoute.query = {}
|
||||||
|
|
||||||
|
const wrapper = mountView()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' })
|
||||||
|
expect(sheet.props('modelValue')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
// Update the existing propagation-note text test to assert the new
|
// Update the existing propagation-note text test to assert the new
|
||||||
// "disconnect power" hint that lets users force an immediate refresh.
|
// "disconnect power" hint that lets users force an immediate refresh.
|
||||||
it('propagation note mentions disconnecting power as an immediate-refresh gesture', async () => {
|
it('propagation note mentions disconnecting power as an immediate-refresh gesture', async () => {
|
||||||
|
|||||||
@@ -231,6 +231,8 @@
|
|||||||
class="home-view__remove"
|
class="home-view__remove"
|
||||||
@click="removeConfirmOpen = true"
|
@click="removeConfirmOpen = true"
|
||||||
>Remove this frame</button>
|
>Remove this frame</button>
|
||||||
|
|
||||||
|
<a href="/logout" class="home-view__logout">Sign out of pictureFrame</a>
|
||||||
</BaseBottomSheet>
|
</BaseBottomSheet>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
@@ -279,7 +281,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useDevicesStore } from '@/stores/devices'
|
import { useDevicesStore } from '@/stores/devices'
|
||||||
import { useUploadStore } from '@/stores/upload'
|
import { useUploadStore } from '@/stores/upload'
|
||||||
import { useDeviceMercure } from '@/composables/useDeviceMercure'
|
import { useDeviceMercure } from '@/composables/useDeviceMercure'
|
||||||
@@ -402,6 +404,7 @@ import OrientationPicker from '@/components/OrientationPicker.vue'
|
|||||||
import PullToRefresh from '@/components/PullToRefresh.vue'
|
import PullToRefresh from '@/components/PullToRefresh.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const devicesStore = useDevicesStore()
|
const devicesStore = useDevicesStore()
|
||||||
const uploadStore = useUploadStore()
|
const uploadStore = useUploadStore()
|
||||||
|
|
||||||
@@ -409,9 +412,22 @@ const uploadStore = useUploadStore()
|
|||||||
// (and on PATCH/lock/unlock); the composable splats it into the store.
|
// (and on PATCH/lock/unlock); the composable splats it into the store.
|
||||||
useDeviceMercure()
|
useDeviceMercure()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
devicesStore.fetchDevices()
|
await devicesStore.fetchDevices()
|
||||||
document.addEventListener('visibilitychange', onVisibility)
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
|
|
||||||
|
// First-time setup landing: SetupController redirects new users to
|
||||||
|
// /?setup=<deviceId> after register/login, instead of a separate
|
||||||
|
// Twig configure page. Auto-open the settings sheet for that device
|
||||||
|
// so the user sees the same rich UI everyone else uses for live edits
|
||||||
|
// — pre-populated, themed, with no duplicated form to maintain.
|
||||||
|
const setupId = Number(route.query.setup)
|
||||||
|
if (setupId) {
|
||||||
|
onEdit(setupId)
|
||||||
|
// Clean the query param off the URL so a refresh doesn't keep
|
||||||
|
// re-opening the sheet, but keep the user on the home view.
|
||||||
|
router.replace({ query: { ...route.query, setup: undefined } })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -1150,5 +1166,21 @@ async function saveSettings() {
|
|||||||
border-color: var(--color-danger, #c0392b);
|
border-color: var(--color-danger, #c0392b);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__logout {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: var(--space-2);
|
||||||
|
|
||||||
|
&:hover, &:focus-visible {
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: underline;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{_ as e,d as t,f as n,g as r,j as i,k as a,m as o,pt as s,s as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,t as m}from"./BaseBottomSheet-BJ-4S2HL.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=u(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(u,d)=>(a(),n(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>u.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=t(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=t(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),t(`div`,h,[(a(!0),o(c,null,i(l.devices,e=>(a(),o(`label`,{key:e.id,class:`device-picker__row`},[t(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),t(`span`,_,s(e.name),1),t(`span`,v,s(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>u.$emit(`confirm`)},{default:f(()=>[r(s(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
|
import{_ as e,d as t,f as n,g as r,j as i,k as a,m as o,pt as s,s as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,t as m}from"./BaseBottomSheet-DKEsd9DM.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=u(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(u,d)=>(a(),n(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>u.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=t(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=t(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),t(`div`,h,[(a(!0),o(c,null,i(l.devices,e=>(a(),o(`label`,{key:e.id,class:`device-picker__row`},[t(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),t(`span`,_,s(e.name),1),t(`span`,v,s(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>u.$emit(`confirm`)},{default:f(()=>[r(s(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{K as e,d as t,dt as n,ft as r,j as i,k as a,m as o,p as s,pt as c,s as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,r as m,t as h}from"./index-CmcHGdN5.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=d(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(u,d)=>(a(),o(`main`,g,[d[5]||=t(`h1`,{class:`settings__title`},`Settings`,-1),t(`section`,_,[d[1]||=t(`h2`,{class:`settings__section-title`},`Theme`,-1),t(`div`,v,[(a(!0),o(l,null,i(e(h),e=>(a(),o(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:n([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[d[0]||=t(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[t(`span`,{class:`theme-swatch__bar`}),t(`span`,{class:`theme-swatch__dot`})],-1),t(`span`,b,c(e.label),1),E.value===e.id?(a(),o(`span`,x,`✓`)):s(``,!0)],14,y))),128))])]),t(`section`,S,[d[3]||=t(`h2`,{class:`settings__section-title`},`Account`,-1),t(`div`,C,[d[2]||=t(`span`,{class:`settings__row-label`},`Signed in as`,-1),t(`span`,w,c(e(f).user?.email),1)]),d[4]||=t(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
|
import{K as e,d as t,dt as n,ft as r,j as i,k as a,m as o,p as s,pt as c,s as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,r as m,t as h}from"./index-DX-aWmo9.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=d(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(u,d)=>(a(),o(`main`,g,[d[5]||=t(`h1`,{class:`settings__title`},`Settings`,-1),t(`section`,_,[d[1]||=t(`h2`,{class:`settings__section-title`},`Theme`,-1),t(`div`,v,[(a(!0),o(l,null,i(e(h),e=>(a(),o(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:n([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[d[0]||=t(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[t(`span`,{class:`theme-swatch__bar`}),t(`span`,{class:`theme-swatch__dot`})],-1),t(`span`,b,c(e.label),1),E.value===e.id?(a(),o(`span`,x,`✓`)):s(``,!0)],14,y))),128))])]),t(`section`,S,[d[3]||=t(`h2`,{class:`settings__section-title`},`Account`,-1),t(`div`,C,[d[2]||=t(`span`,{class:`settings__row-label`},`Signed in as`,-1),t(`span`,w,c(e(f).user?.email),1)]),d[4]||=t(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@
|
|||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
||||||
<script type="module" crossorigin src="/build/assets/index-CmcHGdN5.js"></script>
|
<script type="module" crossorigin src="/build/assets/index-DX-aWmo9.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DRLwVS0w.js">
|
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DRLwVS0w.js">
|
||||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -39,10 +39,7 @@ class SetupController extends AbstractController
|
|||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
if (!$deviceService->isClaimedByAnotherUser($mac, $user)) {
|
if (!$deviceService->isClaimedByAnotherUser($mac, $user)) {
|
||||||
$device = $deviceService->linkToUser($mac, $user);
|
$device = $deviceService->linkToUser($mac, $user);
|
||||||
if (empty($device->getName())) {
|
return $this->postLinkRedirect($device);
|
||||||
return $this->redirectToRoute('setup_configure', ['mac' => $mac]);
|
|
||||||
}
|
|
||||||
return $this->redirectToRoute('spa');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +91,7 @@ class SetupController extends AbstractController
|
|||||||
|
|
||||||
$allowClaim = $request->request->getBoolean('claim_device');
|
$allowClaim = $request->request->getBoolean('claim_device');
|
||||||
try {
|
try {
|
||||||
$deviceService->linkToUser($mac, $user, $allowClaim);
|
$device = $deviceService->linkToUser($mac, $user, $allowClaim);
|
||||||
} catch (DeviceClaimRequiredException) {
|
} catch (DeviceClaimRequiredException) {
|
||||||
// New account just created and claim wasn't acknowledged.
|
// New account just created and claim wasn't acknowledged.
|
||||||
// Bounce back through the setup page; the checkbox will be
|
// Bounce back through the setup page; the checkbox will be
|
||||||
@@ -108,7 +105,7 @@ class SetupController extends AbstractController
|
|||||||
return $this->redirectToRoute('setup_index', ['mac' => $mac]);
|
return $this->redirectToRoute('setup_index', ['mac' => $mac]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('setup_configure', ['mac' => $mac]);
|
return $this->postLinkRedirect($device);
|
||||||
}
|
}
|
||||||
|
|
||||||
$existing = $em->getRepository(Device::class)->findOneBy(['mac' => strtoupper($mac)]);
|
$existing = $em->getRepository(Device::class)->findOneBy(['mac' => strtoupper($mac)]);
|
||||||
@@ -138,7 +135,7 @@ class SetupController extends AbstractController
|
|||||||
$security->login($user, 'form_login', 'main');
|
$security->login($user, 'form_login', 'main');
|
||||||
$allowClaim = $request->request->getBoolean('claim_device');
|
$allowClaim = $request->request->getBoolean('claim_device');
|
||||||
try {
|
try {
|
||||||
$deviceService->linkToUser($mac, $user, $allowClaim);
|
$device = $deviceService->linkToUser($mac, $user, $allowClaim);
|
||||||
} catch (DeviceClaimRequiredException) {
|
} catch (DeviceClaimRequiredException) {
|
||||||
$request->getSession()->set(
|
$request->getSession()->set(
|
||||||
'_setup_claim_error',
|
'_setup_claim_error',
|
||||||
@@ -146,50 +143,43 @@ class SetupController extends AbstractController
|
|||||||
);
|
);
|
||||||
return $this->redirectToRoute('setup_index', ['mac' => $mac]);
|
return $this->redirectToRoute('setup_index', ['mac' => $mac]);
|
||||||
}
|
}
|
||||||
return $this->redirectToRoute('setup_configure', ['mac' => $mac]);
|
return $this->postLinkRedirect($device);
|
||||||
}
|
}
|
||||||
|
|
||||||
$request->getSession()->set('_setup_login_error', 'Incorrect email or password');
|
$request->getSession()->set('_setup_login_error', 'Incorrect email or password');
|
||||||
return $this->redirectToRoute('setup_index', ['mac' => $mac]);
|
return $this->redirectToRoute('setup_index', ['mac' => $mac]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy route — first-time configuration now happens in the SPA's
|
||||||
|
* settings sheet (auto-opens via the ?setup=<id> query param) so users
|
||||||
|
* see the same controls as live editing, with their theme applied,
|
||||||
|
* pre-populated from the device's current state. Kept as a redirect so
|
||||||
|
* any in-flight bookmarks land in the right place.
|
||||||
|
*/
|
||||||
#[Route('/configure', name: 'setup_configure', methods: ['GET', 'POST'])]
|
#[Route('/configure', name: 'setup_configure', methods: ['GET', 'POST'])]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
public function configure(
|
public function configure(
|
||||||
string $mac,
|
string $mac,
|
||||||
Request $request,
|
|
||||||
EntityManagerInterface $em,
|
|
||||||
DeviceService $deviceService,
|
DeviceService $deviceService,
|
||||||
): Response {
|
): Response {
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
$device = $deviceService->linkToUser($mac, $user);
|
$device = $deviceService->linkToUser($mac, $user);
|
||||||
|
return $this->postLinkRedirect($device);
|
||||||
|
}
|
||||||
|
|
||||||
if ($request->isMethod('POST')) {
|
/**
|
||||||
$name = trim((string) $request->request->get('name', ''));
|
* After linkToUser, hand off to the SPA. If the device hasn't been
|
||||||
$orient = $request->request->get('orientation', Orientation::Landscape->value);
|
* named yet (= first-time setup), append ?setup=<id> so the SPA
|
||||||
$interval = (int) $request->request->get('rotation_interval_minutes', 1440);
|
* auto-opens its settings sheet for that device. Avoids duplicating
|
||||||
$window = (int) $request->request->get('uniqueness_window', 10);
|
* the rich settings UI in a plain-Twig setup page.
|
||||||
|
*/
|
||||||
if (empty($name)) {
|
private function postLinkRedirect(Device $device): Response
|
||||||
return $this->render('setup/configure.html.twig', [
|
{
|
||||||
'device' => $device,
|
if (empty($device->getName())) {
|
||||||
'error' => 'Please enter a name for your frame.',
|
return $this->redirect('/?setup=' . $device->getId());
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$device->setName($name);
|
|
||||||
$device->setOrientation(Orientation::from($orient));
|
|
||||||
$device->setRotationIntervalMinutes(max(1, $interval));
|
|
||||||
$device->setUniquenessWindow(max(1, $window));
|
|
||||||
$em->flush();
|
|
||||||
|
|
||||||
return $this->redirectToRoute('spa');
|
|
||||||
}
|
}
|
||||||
|
return $this->redirectToRoute('spa');
|
||||||
return $this->render('setup/configure.html.twig', [
|
|
||||||
'device' => $device,
|
|
||||||
'error' => null,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,19 +40,22 @@ class SetupControllerTest extends AppWebTestCase
|
|||||||
$this->assertResponseRedirects('/');
|
$this->assertResponseRedirects('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
// S-03: authenticated with no existing device (new link) → redirects to configure
|
// S-03: authenticated with no existing device → links and redirects to
|
||||||
public function test_setup_index_authenticated_new_device_redirects_to_configure(): void
|
// /?setup=<id> so the SPA opens its settings sheet for first-time setup.
|
||||||
|
public function test_setup_index_authenticated_new_device_redirects_to_spa_setup(): void
|
||||||
{
|
{
|
||||||
$user = $this->createUser('setup03@example.com');
|
$user = $this->createUser('setup03@example.com');
|
||||||
$this->loginAs($user);
|
$this->loginAs($user);
|
||||||
|
|
||||||
$this->client->request('GET', '/setup/' . self::MAC);
|
$this->client->request('GET', '/setup/' . self::MAC);
|
||||||
|
|
||||||
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
|
$this->assertResponseRedirects();
|
||||||
|
$location = $this->client->getResponse()->headers->get('Location');
|
||||||
|
$this->assertStringContainsString('/?setup=', $location, 'redirects to SPA with setup query');
|
||||||
}
|
}
|
||||||
|
|
||||||
// S-04: POST /setup/{mac}/register with valid data → creates user, links device, redirects to configure
|
// S-04: POST /register links + redirects to SPA with ?setup=<id>.
|
||||||
public function test_setup_register_creates_user_and_redirects_to_configure(): void
|
public function test_setup_register_creates_user_and_redirects_to_spa_setup(): void
|
||||||
{
|
{
|
||||||
$this->client->request('POST', '/setup/' . self::MAC . '/register', [
|
$this->client->request('POST', '/setup/' . self::MAC . '/register', [
|
||||||
'registration_form' => [
|
'registration_form' => [
|
||||||
@@ -64,11 +67,13 @@ class SetupControllerTest extends AppWebTestCase
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
|
$this->assertResponseRedirects();
|
||||||
|
$this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// S-05: POST /setup/{mac}/configure with valid name → saves device, redirects to spa
|
// S-05: legacy /configure POST is now a redirect to the SPA — the
|
||||||
public function test_setup_configure_saves_name_and_redirects_to_spa(): void
|
// first-time settings UI lives in the live PWA, not Twig.
|
||||||
|
public function test_legacy_configure_post_redirects_to_spa_setup(): void
|
||||||
{
|
{
|
||||||
$user = $this->createUser('setup05@example.com');
|
$user = $this->createUser('setup05@example.com');
|
||||||
|
|
||||||
@@ -78,25 +83,18 @@ class SetupControllerTest extends AppWebTestCase
|
|||||||
$this->em()->persist($device);
|
$this->em()->persist($device);
|
||||||
$this->em()->flush();
|
$this->em()->flush();
|
||||||
|
|
||||||
$deviceId = $device->getId();
|
|
||||||
|
|
||||||
$this->loginAs($user);
|
$this->loginAs($user);
|
||||||
|
// Old form POST — controller doesn't process the body, just redirects.
|
||||||
$this->client->request('POST', '/setup/' . self::MAC . '/configure', [
|
$this->client->request('POST', '/setup/' . self::MAC . '/configure', [
|
||||||
'name' => 'Kitchen Frame',
|
'name' => 'Kitchen Frame',
|
||||||
'orientation' => 'landscape',
|
|
||||||
'rotation_interval_minutes' => '1440',
|
|
||||||
'uniqueness_window' => '10',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertResponseRedirects('/');
|
$this->assertResponseRedirects();
|
||||||
|
$this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location'));
|
||||||
$this->em()->clear();
|
|
||||||
$saved = $this->em()->find(Device::class, $deviceId);
|
|
||||||
$this->assertSame('Kitchen Frame', $saved->getName());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// S-06: POST /setup/{mac}/login with valid credentials → redirects to configure
|
// S-06: /login → links + redirects to SPA setup.
|
||||||
public function test_login_valid_credentials_redirects_to_configure(): void
|
public function test_login_valid_credentials_redirects_to_spa_setup(): void
|
||||||
{
|
{
|
||||||
$this->createUser('setuplogin@example.com', 'testpass');
|
$this->createUser('setuplogin@example.com', 'testpass');
|
||||||
|
|
||||||
@@ -105,7 +103,8 @@ class SetupControllerTest extends AppWebTestCase
|
|||||||
'_password' => 'testpass',
|
'_password' => 'testpass',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
|
$this->assertResponseRedirects();
|
||||||
|
$this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// S-07: POST /setup/{mac}/login with wrong password → redirects to index
|
// S-07: POST /setup/{mac}/login with wrong password → redirects to index
|
||||||
@@ -121,8 +120,9 @@ class SetupControllerTest extends AppWebTestCase
|
|||||||
$this->assertResponseRedirects('/setup/' . self::MAC);
|
$this->assertResponseRedirects('/setup/' . self::MAC);
|
||||||
}
|
}
|
||||||
|
|
||||||
// S-08: authenticated GET /setup/{mac}/configure → 200
|
// S-08: GET /setup/{mac}/configure also redirects (legacy, kept for
|
||||||
public function test_configure_get_renders_form(): void
|
// any in-flight bookmarks).
|
||||||
|
public function test_legacy_configure_get_redirects_to_spa(): void
|
||||||
{
|
{
|
||||||
$user = $this->createUser('setupconfigget@example.com');
|
$user = $this->createUser('setupconfigget@example.com');
|
||||||
|
|
||||||
@@ -135,28 +135,10 @@ class SetupControllerTest extends AppWebTestCase
|
|||||||
$this->loginAs($user);
|
$this->loginAs($user);
|
||||||
$this->client->request('GET', '/setup/' . self::MAC . '/configure');
|
$this->client->request('GET', '/setup/' . self::MAC . '/configure');
|
||||||
|
|
||||||
$this->assertResponseIsSuccessful();
|
$this->assertResponseRedirects();
|
||||||
|
$this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// S-09: POST /setup/{mac}/configure with empty name → 200 (re-renders form with error)
|
|
||||||
public function test_configure_post_empty_name_renders_error(): void
|
|
||||||
{
|
|
||||||
$user = $this->createUser('setupconfigerr@example.com');
|
|
||||||
|
|
||||||
$device = new Device();
|
|
||||||
$device->setMac(self::MAC);
|
|
||||||
$device->setUser($user);
|
|
||||||
$this->em()->persist($device);
|
|
||||||
$this->em()->flush();
|
|
||||||
|
|
||||||
$this->loginAs($user);
|
|
||||||
$this->client->request('POST', '/setup/' . self::MAC . '/configure', [
|
|
||||||
'name' => '',
|
|
||||||
'orientation' => 'landscape',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertResponseIsSuccessful();
|
|
||||||
}
|
|
||||||
|
|
||||||
// S-10: POST /setup/{mac}/register with invalid form data → re-renders registration page
|
// S-10: POST /setup/{mac}/register with invalid form data → re-renders registration page
|
||||||
public function test_setup_register_invalid_form_renders_page(): void
|
public function test_setup_register_invalid_form_renders_page(): void
|
||||||
@@ -260,7 +242,8 @@ class SetupControllerTest extends AppWebTestCase
|
|||||||
],
|
],
|
||||||
'claim_device' => '1',
|
'claim_device' => '1',
|
||||||
]);
|
]);
|
||||||
$this->assertResponseRedirects('/setup/' . self::MAC . '/configure');
|
$this->assertResponseRedirects();
|
||||||
|
$this->assertStringContainsString('/?setup=', $this->client->getResponse()->headers->get('Location'));
|
||||||
|
|
||||||
$this->em()->clear();
|
$this->em()->clear();
|
||||||
$reloaded = $this->em()->find(Device::class, $deviceId);
|
$reloaded = $this->em()->find(Device::class, $deviceId);
|
||||||
|
|||||||
Reference in New Issue
Block a user