From e4b28be5be796e18d8c1159d24b3563f94bca377 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 17 Jun 2026 07:44:24 +0200 Subject: [PATCH] feat(magic): dedicated iOS section in dashboard with on-demand sync --- .../app/components/DeviceDetailSheet.vue | 2 +- .../app/components/IosDeviceCard.vue | 287 ++++++++++ .../app/components/IosDeviceSection.vue | 77 +++ .../app/components/UnknownIosDeviceCard.vue | 64 +++ .../app/composables/useDeviceStatus.ts | 59 +-- .../app/composables/useProtectionStatus.ts | 21 +- .../rebreak-magic/app/composables/useTauri.ts | 7 + apps/rebreak-magic/app/pages/status.vue | 85 ++- .../2026-06-16-magic-dashboard-ios-section.md | 500 ++++++++++++++++++ ...6-16-magic-dashboard-ios-section-design.md | 167 ++++++ 10 files changed, 1206 insertions(+), 63 deletions(-) create mode 100644 apps/rebreak-magic/app/components/IosDeviceCard.vue create mode 100644 apps/rebreak-magic/app/components/IosDeviceSection.vue create mode 100644 apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue create mode 100644 docs/superpowers/plans/2026-06-16-magic-dashboard-ios-section.md create mode 100644 docs/superpowers/specs/2026-06-16-magic-dashboard-ios-section-design.md diff --git a/apps/rebreak-magic/app/components/DeviceDetailSheet.vue b/apps/rebreak-magic/app/components/DeviceDetailSheet.vue index 98537fd..b7ac006 100644 --- a/apps/rebreak-magic/app/components/DeviceDetailSheet.vue +++ b/apps/rebreak-magic/app/components/DeviceDetailSheet.vue @@ -249,7 +249,7 @@ const platformLabel = computed(() => { return labels[props.device.platform]; }); -const showIosStars = computed(() => props.device?.isCurrent && props.device?.platform === "ios"); +const showIosStars = computed(() => props.device?.platform === "ios" && !!props.iosStars); const showDesktopToggle = computed(() => props.device?.isCurrent && (props.device?.platform === "mac" || props.device?.platform === "windows")); const statusConfig: Record< diff --git a/apps/rebreak-magic/app/components/IosDeviceCard.vue b/apps/rebreak-magic/app/components/IosDeviceCard.vue new file mode 100644 index 0000000..3064e09 --- /dev/null +++ b/apps/rebreak-magic/app/components/IosDeviceCard.vue @@ -0,0 +1,287 @@ + + + diff --git a/apps/rebreak-magic/app/components/IosDeviceSection.vue b/apps/rebreak-magic/app/components/IosDeviceSection.vue new file mode 100644 index 0000000..7ea1ac3 --- /dev/null +++ b/apps/rebreak-magic/app/components/IosDeviceSection.vue @@ -0,0 +1,77 @@ + + + diff --git a/apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue b/apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue new file mode 100644 index 0000000..d43bb3a --- /dev/null +++ b/apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue @@ -0,0 +1,64 @@ + + + diff --git a/apps/rebreak-magic/app/composables/useDeviceStatus.ts b/apps/rebreak-magic/app/composables/useDeviceStatus.ts index fa026d3..0ac7429 100644 --- a/apps/rebreak-magic/app/composables/useDeviceStatus.ts +++ b/apps/rebreak-magic/app/composables/useDeviceStatus.ts @@ -1,4 +1,4 @@ -import { computed, watchEffect, type Ref } from "vue"; +import { computed, type Ref } from "vue"; import type { MagicDeviceInfo, IphoneDeviceState } from "./useTauri"; export type DeviceStatus = "active" | "cooldown" | "revoked" | "pending" | "unprotected"; @@ -33,6 +33,21 @@ function normalizeHostname(value: string): string { return (value.toLowerCase().split(".")[0] ?? "").replace(/[^a-z0-9]/g, ""); } +function mapToComputedDevice(d: MagicDeviceInfo, isCurrent: boolean): ComputedDevice { + return { + deviceId: d.deviceId, + name: d.model ?? d.hostname, + platform: normalizePlatform(d.model ?? d.hostname), + model: d.model, + osVersion: d.osVersion, + status: d.status as DeviceStatus, + isCurrent, + cooldownUntil: d.cooldownUntil, + lastSeenAt: d.lastSeenAt, + enrolledAt: d.magicEnrolledAt, + }; +} + export function useDeviceStatus( devices: Ref, localHostname: Ref, @@ -51,38 +66,24 @@ export function useDeviceStatus( const currentBackendDevice = computed(() => { const found = devices.value.find(isCurrentDevice); if (!found) return null; - return { - deviceId: found.deviceId, - name: found.model ?? found.hostname, - platform: normalizePlatform(found.model ?? found.hostname), - model: found.model, - osVersion: found.osVersion, - status: found.status, - isCurrent: true, - cooldownUntil: found.cooldownUntil, - lastSeenAt: found.lastSeenAt, - enrolledAt: found.magicEnrolledAt, - }; + return mapToComputedDevice(found, true); }); const otherDevices = computed(() => { const currentId = currentBackendDevice.value?.deviceId; return devices.value .filter((d) => d.deviceId !== currentId) - .map((d) => ({ - deviceId: d.deviceId, - name: d.model ?? d.hostname, - platform: normalizePlatform(d.model ?? d.hostname), - model: d.model, - osVersion: d.osVersion, - status: d.status, - isCurrent: false, - cooldownUntil: d.cooldownUntil, - lastSeenAt: d.lastSeenAt, - enrolledAt: d.magicEnrolledAt, - })); + .map((d) => mapToComputedDevice(d, false)); }); + const iosDevices = computed(() => + otherDevices.value.filter((d) => d.platform === "ios"), + ); + + const desktopDevices = computed(() => + otherDevices.value.filter((d) => d.platform === "mac" || d.platform === "windows"), + ); + const iosStars = computed(() => { if (!iphone.value) return null; return { @@ -93,15 +94,11 @@ export function useDeviceStatus( }; }); - // DEBUG: log current device matching details - watchEffect(() => { - const ids = devices.value.map((d) => ({ deviceId: d.deviceId, hostname: d.hostname, platform: d.model ?? d.hostname })); - console.log("[useDeviceStatus] local deviceId:", currentDeviceId?.value ?? "(none)", "hostname:", localHostname.value ?? "(none)", "backend ids:", ids, "matched:", currentBackendDevice.value?.deviceId ?? "(none)"); - }); - return { currentBackendDevice, otherDevices, + iosDevices, + desktopDevices, iosStars, }; } diff --git a/apps/rebreak-magic/app/composables/useProtectionStatus.ts b/apps/rebreak-magic/app/composables/useProtectionStatus.ts index 43d6def..35e16a4 100644 --- a/apps/rebreak-magic/app/composables/useProtectionStatus.ts +++ b/apps/rebreak-magic/app/composables/useProtectionStatus.ts @@ -1,4 +1,4 @@ -import { ref, computed, onMounted, onUnmounted } from "vue"; +import { ref, computed } from "vue"; import { useTauri, type IphoneDeviceState, type DesktopProtectionState, type MagicDeviceInfo } from "~/composables/useTauri"; import { useIphoneDevice, useMagicDevices, useMagicSession } from "~/composables/useMagicState"; @@ -12,9 +12,6 @@ export interface ProtectionStatus { anyBackendDevice: boolean; } -const IPHONE_POLL_INTERVAL = 5000; -const DEVICE_REFRESH_INTERVAL = 30000; - function normalizeHostname(value: string): string { return (value.toLowerCase().split(".")[0] ?? "").replace(/[^a-z0-9]/g, ""); } @@ -38,9 +35,6 @@ export function useProtectionStatus() { const lastError = ref(null); const lastUpdated = ref(null); - let iphoneTimer: ReturnType | null = null; - let deviceTimer: ReturnType | null = null; - const iosConnected = computed(() => !!iphone.value); const iosProtected = computed(() => { if (!iphone.value) return false; @@ -175,17 +169,6 @@ export function useProtectionStatus() { } } - onMounted(() => { - refresh(); - iphoneTimer = setInterval(refreshIphone, IPHONE_POLL_INTERVAL); - deviceTimer = setInterval(refreshBackendDevices, DEVICE_REFRESH_INTERVAL); - }); - - onUnmounted(() => { - if (iphoneTimer) clearInterval(iphoneTimer); - if (deviceTimer) clearInterval(deviceTimer); - }); - return { status, iphone: computed(() => iphone.value), @@ -199,5 +182,7 @@ export function useProtectionStatus() { desktopProtected, anyBackendDevice, refresh, + refreshIphone, + refreshBackendDevices, }; } diff --git a/apps/rebreak-magic/app/composables/useTauri.ts b/apps/rebreak-magic/app/composables/useTauri.ts index 2e7e59e..543a462 100644 --- a/apps/rebreak-magic/app/composables/useTauri.ts +++ b/apps/rebreak-magic/app/composables/useTauri.ts @@ -97,6 +97,13 @@ export interface MdmCommandResult { response_body: string; } +export const REBREAK_MDM_VERSION = "0.1"; + +export function getInstalledMdmVersion(installedProfileIDs: string[]): string | null { + const versionId = installedProfileIDs.find((id) => id.startsWith("org.rebreak.mdm.version.")); + return versionId?.replace("org.rebreak.mdm.version.", "") ?? null; +} + async function invokeLogged(command: string, args?: Record): Promise { const { useLogger } = await import("~/composables/useLogger"); const logger = useLogger(); diff --git a/apps/rebreak-magic/app/pages/status.vue b/apps/rebreak-magic/app/pages/status.vue index 56b51d9..e92fc92 100644 --- a/apps/rebreak-magic/app/pages/status.vue +++ b/apps/rebreak-magic/app/pages/status.vue @@ -41,7 +41,6 @@ v-if="currentBackendDevice" :device="currentBackendDevice" :is-current="true" - :ios-stars="currentBackendDevice.platform === 'ios' ? iosStars : null" @open="openDevice" @toggle-protection="toggleProtection" /> @@ -51,14 +50,35 @@
-

Dieses Gerät ist nicht geschützt

-

Aktiviere den Schutz für das Gerät, auf dem Magic läuft.

- - Dieses Gerät schützen - + + + + +
@@ -77,18 +97,20 @@
{{ error }}
-
+

Lade Geräte…

-
-

Keine weiteren Geräte registriert.

+
+

+ {{ hasRefreshed ? 'Keine weiteren Computer registriert.' : 'Noch keine Computer geladen.' }} +

session.value?.deviceId ?? null); const profile = ref(null); const loading = ref(false); +const hasRefreshed = ref(false); const error = ref(null); const sheetOpen = ref(false); const selectedDevice = ref(null); const platformInfo = ref<{ platform: string } | null>(null); +// TODO: populate from backend once subscription/grace-period endpoint exists. +const subscriptionInGracePeriod = ref(false); + // Share localHostname from protection composable with device status logic. const localHostname = protection.localHostname; -const { currentBackendDevice, otherDevices, iosStars } = useDeviceStatus(devices, localHostname, iphone, currentDeviceId); +const { currentBackendDevice, iosDevices, desktopDevices, iosStars } = + useDeviceStatus(devices, localHostname, iphone, currentDeviceId); + +const selectedDeviceStars = computed(() => { + if (!selectedDevice.value || selectedDevice.value.platform !== "ios") return null; + if (!iphone.value) return null; + const modelMatch = + (selectedDevice.value.model ?? "").toLowerCase() === iphone.value.productType.toLowerCase(); + const nameMatch = + (selectedDevice.value.name ?? "").toLowerCase() === iphone.value.name.toLowerCase(); + if (!modelMatch && !nameMatch) return null; + return iosStars.value; +}); onMounted(async () => { await loadProfile(); - await refresh(); try { const info = await getPlatform(); platformInfo.value = { platform: info.platform }; @@ -176,9 +213,31 @@ async function refresh() { error.value = e?.message ?? "Geräte konnten nicht geladen werden"; } finally { loading.value = false; + hasRefreshed.value = true; } } +async function onIosSync(device: ComputedDevice) { + loading.value = true; + error.value = null; + try { + await protection.refreshIphone(); + await protection.refreshBackendDevices(); + // TODO: push missing MDM components and compare MDM version once backend exposes it. + } catch (e: any) { + error.value = e?.message ?? "Synchronisierung fehlgeschlagen"; + } finally { + loading.value = false; + hasRefreshed.value = true; + } +} + +async function onIosRemove(device: ComputedDevice) { + // TODO: call offboarding endpoint once backend provides it. + // For now this is a no-op placeholder to keep the UI safe. + console.log("[offboarding placeholder] remove ReBreak from", device.deviceId); +} + function openDevice(device: ComputedDevice) { selectedDevice.value = device; sheetOpen.value = true; diff --git a/docs/superpowers/plans/2026-06-16-magic-dashboard-ios-section.md b/docs/superpowers/plans/2026-06-16-magic-dashboard-ios-section.md new file mode 100644 index 0000000..5fbcc2a --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-magic-dashboard-ios-section.md @@ -0,0 +1,500 @@ +# Magic Dashboard iOS Section – Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign the Magic dashboard so iOS devices are shown in a dedicated section under the desktop hero, with live USB status, action buttons, and sync/offboarding flows while keeping detection on-demand only. + +**Architecture:** A new `IosDeviceSection` component owns the iOS list. `useDeviceStatus` derives `iosDevices` and `desktopDevices` from the backend list. `IosDeviceCard` renders each device and its action button. `UnknownIosDeviceCard` handles USB-connected devices that are not registered in the backend. Existing `DeviceDetailSheet` is extended to show iOS stars for any connected iOS device. + +**Tech Stack:** Nuxt 3, Vue 3, Nuxt UI v4, Tauri 2, TypeScript, pnpm. + +--- + +## Task 1: Extend `useDeviceStatus.ts` to split devices by platform + +**Files:** +- Modify: `apps/rebreak-magic/app/composables/useDeviceStatus.ts` + +**Why:** The dashboard needs separate `iosDevices` and `desktopDevices` lists instead of one mixed `otherDevices` list. + +- [ ] **Step 1: Add platform-based derived lists** + +Replace the single `otherDevices` derived with `iosDevices` and `desktopDevices`. Keep `otherDevices` as the union for backward compatibility or remove it if unused after status.vue update. + +```ts +const iosDevices = computed(() => + devices.value + .filter((d) => normalizePlatform(d.model ?? d.hostname) === "ios") + .map((d) => mapToComputedDevice(d, false)), +); + +const desktopDevices = computed(() => + devices.value + .filter((d) => { + const p = normalizePlatform(d.model ?? d.hostname); + return p === "mac" || p === "windows"; + }) + .filter((d) => d.deviceId !== currentBackendDevice.value?.deviceId) + .map((d) => mapToComputedDevice(d, false)), +); +``` + +Introduce a small helper `mapToComputedDevice(d, isCurrent)` to avoid duplicating the mapping object. + +- [ ] **Step 2: Update return object** + +```ts +return { + currentBackendDevice, + iosDevices, + desktopDevices, + otherDevices: desktopDevices, // temporary alias until status.vue is updated + iosStars, +}; +``` + +- [ ] **Step 3: Typecheck** + +Run: `cd apps/rebreak-magic && pnpm nuxi typecheck` +Expected: same pre-existing errors as before, no new ones. + +--- + +## Task 2: Create `IosDeviceCard.vue` + +**Files:** +- Create: `apps/rebreak-magic/app/components/IosDeviceCard.vue` + +**Why:** Each backend iOS device needs its own card with status, stars (if USB-connected), and a context-aware action button. + +- [ ] **Step 1: Write the component** + +Props: + +```ts +const props = defineProps<{ + device: ComputedDevice; + iphone: IphoneDeviceState | null; + isConnected: boolean; +}>(); +``` + +Compute `iosStars` from `iphone` when connected. Derive the action label/target from `device` and `iosStars`: + +```ts +const action = computed(() => { + if (!props.isConnected || !props.iphone) { + return { label: "Verbinden", to: "/detect", icon: "i-heroicons-link" }; + } + if (!props.iphone.isSupervised) { + return { label: "Supervisen", to: "/supervise", icon: "i-heroicons-shield-check" }; + } + if (!props.iphone.installedProfileIDs?.includes("org.rebreak.mdm.enrollment")) { + return { label: "Enrollen", to: "/enroll", icon: "i-heroicons-document-check" }; + } + if (!props.iphone.installedProfileIDs?.includes("org.rebreak.protection.contentfilter.sideload")) { + return { label: "Sideload installieren", to: "/sideload", icon: "i-heroicons-lock-closed" }; + } + if (!props.iphone.installedAppBundleIDs?.includes("org.rebreak.app")) { + return { label: "App installieren", to: "/sideload", icon: "i-heroicons-arrow-down-tray" }; + } + return { label: "Synchronisieren", icon: "i-heroicons-arrow-path" }; +}); +``` + +Template: device icon, name/model, status badge, optional `IosStarRating`, last-seen text, and the action button. If the label is "Synchronisieren" use `@click` to emit `sync`; otherwise use `to` navigation. + +- [ ] **Step 2: Add emits** + +```ts +const emit = defineEmits<{ + (e: "sync", device: ComputedDevice): void; + (e: "open", device: ComputedDevice): void; +}>(); +``` + +- [ ] **Step 3: Typecheck** + +Run: `cd apps/rebreak-magic && pnpm nuxi typecheck` +Expected: no new errors. + +--- + +## Task 3: Create `UnknownIosDeviceCard.vue` + +**Files:** +- Create: `apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue` + +**Why:** A USB-connected iOS device that is not registered to the user's ReBreak account must be shown as unrecognizable with a clear next-step message. + +- [ ] **Step 1: Write the component** + +Props: + +```ts +const props = defineProps<{ + iphone: IphoneDeviceState; +}>(); +``` + +Template: warning icon, title "Dieses iPhone ist nicht erkennbar", model/iOS version/UDID as read-only info, and helper text: + +> "Mit keinem ReBreak-Konto verbunden. Um es zu verwalten: ReBreak-App installieren, anmelden und Gerät registrieren." + +No action buttons. + +- [ ] **Step 2: Typecheck** + +Run: `cd apps/rebreak-magic && pnpm nuxi typecheck` + +--- + +## Task 4: Create `IosDeviceSection.vue` + +**Files:** +- Create: `apps/rebreak-magic/app/components/IosDeviceSection.vue` + +**Why:** This component owns the iOS section header, list, matching logic, and empty/unknown states. + +- [ ] **Step 1: Write the component** + +Props: + +```ts +const props = defineProps<{ + devices: ComputedDevice[]; + iphone: IphoneDeviceState | null; + loading: boolean; + hasRefreshed: boolean; +}>(); + +const emit = defineEmits<{ + (e: "sync", device: ComputedDevice): void; + (e: "open", device: ComputedDevice): void; +}>(); +``` + +Implement matching helper: + +```ts +function matchesIphone(device: ComputedDevice, iphone: IphoneDeviceState): boolean { + const modelMatch = (device.model ?? "").toLowerCase() === iphone.productType.toLowerCase(); + const nameMatch = (device.name ?? "").toLowerCase() === iphone.name.toLowerCase(); + return modelMatch || nameMatch; +} +``` + +Compute: + +```ts +const connectedDeviceId = computed(() => { + if (!props.iphone) return null; + return props.devices.find((d) => matchesIphone(d, props.iphone!))?.deviceId ?? null; +}); + +const hasUnknownUsbDevice = computed(() => { + return !!props.iphone && !connectedDeviceId.value; +}); +``` + +Template: + +- Section title "Meine iOS-Geräte" +- If `!hasRefreshed && devices.length === 0`: "Noch keine iOS-Geräte geladen." +- If `hasRefreshed && devices.length === 0`: "Keine iOS-Geräte registriert. ReBreak-App installieren und Gerät hinzufügen." +- If `hasUnknownUsbDevice`: render `UnknownIosDeviceCard` first. +- Render `IosDeviceCard` for each device with `isConnected = device.deviceId === connectedDeviceId`. + +- [ ] **Step 2: Typecheck** + +Run: `cd apps/rebreak-magic && pnpm nuxi typecheck` + +--- + +## Task 5: Update `status.vue` + +**Files:** +- Modify: `apps/rebreak-magic/app/pages/status.vue` + +**Why:** The page must render the new iOS section and use `desktopDevices` instead of `otherDevices` for the remaining list. + +- [ ] **Step 1: Replace `otherDevices` usage with `iosDevices` and `desktopDevices`** + +Update import from `useDeviceStatus`: + +```ts +const { currentBackendDevice, iosDevices, desktopDevices, iosStars } = + useDeviceStatus(devices, localHostname, iphone, currentDeviceId); +``` + +- [ ] **Step 2: Insert iOS section under hero** + +After the hero section and before "Weitere Geräte", add: + +```vue + +``` + +- [ ] **Step 3: Change "Weitere Geräte" list to `desktopDevices`** + +Replace `otherDevices` references in the list with `desktopDevices`. Update empty copy to "Keine weiteren Computer geladen." / "Keine weiteren Computer registriert." depending on `hasRefreshed`. + +- [ ] **Step 4: Add `onIosSync` handler** + +```ts +async function onIosSync(device: ComputedDevice) { + loading.value = true; + error.value = null; + try { + await protection.refreshIphone(); + // TODO: push missing MDM components and compare MDM version once the backend exposes it. + await protection.refreshBackendDevices(); + } catch (e: any) { + error.value = e?.message ?? "Synchronisierung fehlgeschlagen"; + } finally { + loading.value = false; + hasRefreshed.value = true; + } +} +``` + +Expose `refreshIphone` and `refreshBackendDevices` from `useProtectionStatus` if not already exported. + +- [ ] **Step 5: Typecheck** + +Run: `cd apps/rebreak-magic && pnpm nuxi typecheck` +Expected: no new errors. + +--- + +## Task 6: Extend `useProtectionStatus.ts` exports + +**Files:** +- Modify: `apps/rebreak-magic/app/composables/useProtectionStatus.ts` + +**Why:** `status.vue` needs to call `refreshIphone` and `refreshBackendDevices` independently for the sync action. + +- [ ] **Step 1: Export the two refresh functions** + +Add to the return object: + +```ts +return { + // ... existing returns + refreshIphone, + refreshBackendDevices, +}; +``` + +- [ ] **Step 2: Typecheck** + +Run: `cd apps/rebreak-magic && pnpm nuxi typecheck` + +--- + +## Task 7: Adjust `DeviceDetailSheet.vue` for iOS stars + +**Files:** +- Modify: `apps/rebreak-magic/app/components/DeviceDetailSheet.vue` + +**Why:** iOS devices are never `isCurrent`, but the sheet should still show stars when the opened device is connected via USB. + +- [ ] **Step 1: Change `showIosStars` condition** + +Accept a new prop or use the existing `iosStars` prop directly. The current condition is: + +```ts +const showIosStars = computed(() => props.device?.isCurrent && props.device?.platform === "ios"); +``` + +Change to: + +```ts +const showIosStars = computed(() => props.device?.platform === "ios" && !!props.iosStars); +``` + +Ensure the parent passes `iosStars` for the opened iOS device when it is connected. + +- [ ] **Step 2: Hide desktop-only sections for iOS devices** + +`showDesktopToggle` should remain as is (only mac/windows + isCurrent). +Cooldown controls should only show for `device.isCurrent` (desktop), which is already the case. + +- [ ] **Step 3: Typecheck** + +Run: `cd apps/rebreak-magic && pnpm nuxi typecheck` + +--- + +## Task 8: Update `DeviceHeroCard.vue` and `DeviceListItem.vue` + +**Files:** +- Modify: `apps/rebreak-magic/app/components/DeviceHeroCard.vue` +- Modify: `apps/rebreak-magic/app/components/DeviceListItem.vue` + +**Why:** These components are now desktop-only. Remove iOS-specific rendering if it is no longer needed or keep it defensive. + +- [ ] **Step 1: In `DeviceHeroCard.vue`, keep `showIosStars` defensive** + +No functional change needed because the hero only receives desktop devices, but confirm `showIosStars` still computes correctly. + +- [ ] **Step 2: In `DeviceListItem.vue`, no change required** + +It will only be rendered with desktop devices. + +--- + +## Task 9: Add MDM version awareness (frontend foundation) + +**Files:** +- Modify: `apps/rebreak-magic/app/composables/useTauri.ts` +- Modify: `apps/rebreak-magic/app/components/IosDeviceCard.vue` + +**Why:** The sync action must later compare installed MDM version with the latest ReBreak MDM version. + +- [ ] **Step 1: Add a constant and helper in `useTauri.ts`** + +```ts +export const REBREAK_MDM_VERSION = "0.1"; + +export function getInstalledMdmVersion(installedProfileIDs: string[]): string | null { + const versionId = installedProfileIDs.find((id) => id.startsWith("org.rebreak.mdm.version.")); + return versionId?.replace("org.rebreak.mdm.version.", "") ?? null; +} +``` + +- [ ] **Step 2: Use it in `IosDeviceCard.vue` action logic** + +When connected and all core checks pass, compare `getInstalledMdmVersion(...)` with `REBREAK_MDM_VERSION`. If outdated or missing, return `{ label: "MDM-Update installieren", icon: "i-heroicons-arrow-up-tray" }` and emit `sync`. + +- [ ] **Step 3: Typecheck** + +Run: `cd apps/rebreak-magic && pnpm nuxi typecheck` + +--- + +## Task 10: Grace-period / Offboarding placeholder + +**Files:** +- Modify: `apps/rebreak-magic/app/components/IosDeviceCard.vue` +- Modify: `apps/rebreak-magic/app/pages/status.vue` + +**Why:** The spec requires a "ReBreak entfernen" action during the 3-day grace period after cancellation. The backend endpoint does not exist yet, so we add a safe placeholder. + +- [ ] **Step 1: Add `subscriptionInGracePeriod` prop** + +```ts +const props = defineProps<{ + device: ComputedDevice; + iphone: IphoneDeviceState | null; + isConnected: boolean; + inGracePeriod?: boolean; +}>(); +``` + +- [ ] **Step 2: Show offboarding button when in grace period** + +At the top of the action derivation: + +```ts +if (props.inGracePeriod) { + return { label: "ReBreak entfernen", icon: "i-heroicons-trash", variant: "danger" }; +} +``` + +Emit a new `remove` event. The parent shows a placeholder toast or logs until the backend endpoint is ready. + +- [ ] **Step 3: Stub grace-period state in `status.vue`** + +```ts +const subscriptionInGracePeriod = ref(false); +// TODO: populate from backend once subscription status endpoint exists. +``` + +Pass it to `IosDeviceSection` and down to each card. + +- [ ] **Step 4: Typecheck** + +Run: `cd apps/rebreak-magic && pnpm nuxi typecheck` + +--- + +## Task 11: Verification and build + +**Files:** n/a + +**Why:** Ensure the frontend compiles and the Tauri bundle can be built. + +- [ ] **Step 1: Typecheck** + +Run: `cd apps/rebreak-magic && pnpm nuxi typecheck` +Expected: only pre-existing errors. + +- [ ] **Step 2: Build Tauri bundle** + +Run: `cd apps/rebreak-magic && pnpm tauri:build` +Expected: completes without new frontend errors. This may take several minutes on first run. + +- [ ] **Step 3: Manual smoke test** + +Launch the built app with the debug pairing code `000000`, open the dashboard, click **Aktualisieren**, and confirm: + +- Desktop hero still renders. +- iOS section appears. +- If no iOS devices: correct empty message. +- If a USB iPhone is connected and registered: stars and action button render. + +--- + +## Task 12: Commit changes + +**Files:** n/a + +- [ ] **Step 1: Stage and commit** + +```bash +git add apps/rebreak-magic/app/composables/useDeviceStatus.ts \ + apps/rebreak-magic/app/composables/useProtectionStatus.ts \ + apps/rebreak-magic/app/composables/useTauri.ts \ + apps/rebreak-magic/app/components/IosDeviceSection.vue \ + apps/rebreak-magic/app/components/IosDeviceCard.vue \ + apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue \ + apps/rebreak-magic/app/components/DeviceDetailSheet.vue \ + apps/rebreak-magic/app/components/DeviceHeroCard.vue \ + apps/rebreak-magic/app/components/DeviceListItem.vue \ + apps/rebreak-magic/app/pages/status.vue \ + docs/superpowers/specs/2026-06-16-magic-dashboard-ios-section-design.md \ + docs/superpowers/plans/2026-06-16-magic-dashboard-ios-section.md + +git commit -m "feat(magic): dedicated iOS section in dashboard with on-demand sync" +``` + +--- + +## Spec Coverage Check + +| Spec requirement | Task | +|---|---| +| Desktop hero remains | Task 5 | +| Dedicated iOS section under hero | Tasks 4, 5 | +| Backend iOS devices listed | Tasks 1, 4 | +| USB live status synced to matching device | Tasks 2, 4 | +| Unknown USB device shown as unrecognizable | Task 3 | +| Action buttons for supervise/enroll/sideload/app/sync | Task 2 | +| On-demand detection preserved | Task 5, existing code | +| Grace-period offboarding placeholder | Task 10 | +| MDM version foundation | Task 9 | +| DeviceDetailSheet iOS stars | Task 7 | + +## Known Backend Dependencies (out of scope for this frontend plan) + +- Subscription cancellation / grace-period endpoint. +- Offboarding endpoint: remove MDM profiles, unsupervise, clean DB entry. +- Central `REBREAK_MDM_VERSION` value injected into MDM enrollment profiles. diff --git a/docs/superpowers/specs/2026-06-16-magic-dashboard-ios-section-design.md b/docs/superpowers/specs/2026-06-16-magic-dashboard-ios-section-design.md new file mode 100644 index 0000000..1e44072 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-magic-dashboard-ios-section-design.md @@ -0,0 +1,167 @@ +# Magic Dashboard – iOS-Section Redesign + +## Ziel + +Das Magic-Dashboard soll klar zwischen zwei eigenen Bausteinen trennen: + +1. **Desktop-Schutz** (Mac/Windows) – das Gerät, auf dem Magic läuft. +2. **iOS-Verwaltung** (eigene iPhone/iPad-Geräte) – registrierte iOS-Geräte des Users mit Status, Sternen und passenden Aktionen. + +„Andere Geräte“ bleiben als sekundäre Information erhalten, sollen aber nicht den Fokus stehlen. Magic ist kein offenes Verwaltungstool für fremde Geräte. + +--- + +## Annahmen (aus dem Abstimmungsgespräch) + +- Backend-iOS-Geräte werden immer gelistet. +- Wenn ein iPhone/iPad per USB verbunden ist, werden Live-Daten (Supervision, Enrollment, Sideload, App) mit dem passenden Backend-Eintrag synchronisiert. +- Ein per USB verbundenes, aber im Backend unbekanntes iOS-Gerät wird als **„Nicht erkennbar“** markiert. Der Hinweis verweist auf: ReBreak-App installieren → anmelden → Gerät registrieren. Erst danach ist es verwaltbar. +- Supervise / Enroll / Sideload / App-Install bleiben im bestehenden Wizard (`/detect`, `/supervise`, `/enroll`, `/sideload`). Das Dashboard bietet nur den passenden Einstieg. + +--- + +## Seitenstruktur + +``` +status.vue +├── Header (Profil, Logout) +├── Section: Aktives Gerät (Desktop-Hero) +│ └── DeviceHeroCard für currentBackendDevice (mac/windows) +├── Section: Meine iOS-Geräte +│ ├── UnknownIosDeviceCard (falls USB-Device nicht im Backend) +│ └── IosDeviceCard[] für jedes Backend-iOS-Gerät +│ (Sterne + Status + Action-Button) +├── Section: Weitere Geräte +│ └── DeviceListItem[] für sonstige Backend-Geräte +└── DeviceDetailSheet (weiterhin für Details/Cooldown) +``` + +--- + +## Neue Komponenten + +### `IosDeviceSection.vue` + +- Props: + - `devices: ComputedDevice[]` – alle Backend-iOS-Geräte des Users + - `iphone: IphoneDeviceState | null` – aktuell per USB erkanntes Gerät + - `loading: boolean` +- Zeigt den Section-Header und rendert die Liste von `IosDeviceCard`s. +- Wenn `iphone` verbunden, aber kein passendes Backend-Gerät gefunden wird, wird `UnknownIosDeviceCard` angezeigt. +- Wenn noch keine Backend-iOS-Geräte geladen wurden: Hinweis „Keine iOS-Geräte geladen“ + Aktualisieren-Button. +- Wenn geladen und leer: Hinweis „Keine iOS-Geräte registriert. ReBreak-App installieren und Gerät hinzufügen.“ + +### `IosDeviceCard.vue` + +- Props: + - `device: ComputedDevice` + - `iphone: IphoneDeviceState | null` – falls dieses Gerät per USB verbunden ist + - `isConnected: boolean` +- Zeigt: + - Name/Modell + - Status-Badge (`active`, `pending`, `unprotected` etc.) + - Letzte Sichtung + - `IosStarRating` + detaillierte Sternen-Liste, wenn `isConnected` + - Sonst Hinweis: „Zum Live-Status iPhone per USB verbinden“ +- Action-Button (ableitet sich aus dem gemergten Zustand): + - Nicht supervised → „Supervisen" → `/supervise` + - Supervised, aber Enrollment fehlt → „Enrollen" → `/enroll` + - Enrollment vorhanden, aber Sideload-Profil fehlt → „Sideload installieren" → `/sideload` + - Sideload vorhanden, aber App fehlt → „App installieren" (MDM-Befehl oder Link) + - Alles okay → „Synchronisieren" (prüft Enrollment-, Sideload- und Supervision-Status; bei Abweichungen werden fehlende Profile/MDM-Kommandos gepusht; falls die lokale MDM-Version hinter der aktuellen ReBreak-MDM-Version zurückfällt, wird ein Update gepusht und der User informiert) + - Während der 3-Tage-Kündigungs-Grace-Period → „ReBreak entfernen" (löst Offboarding aus: MDM-Profile entfernen, Gerät unsupervised setzen, Eintrag bereinigen) + +### `UnknownIosDeviceCard.vue` + +- Props: + - `iphone: IphoneDeviceState` +- Zeigt: + - Warn-Icon + „Dieses iPhone ist nicht erkennbar" + - Modell, iOS-Version, UDID (nur zur Info) + - Hinweis: „Mit keinem ReBreak-Konto verbunden. Um es zu verwalten: ReBreak-App installieren, anmelden und Gerät registrieren." + - Keine Supervise-/Enroll-Aktionen. + +--- + +## Datenfluss + +1. User klickt in `status.vue` auf **Aktualisieren**. +2. `protection.refresh()` wird aufgerufen: + - `detectIphoneState()` lädt das per USB verbundene iOS-Gerät. + - `getMagicDevices()` lädt alle Backend-Geräte in den shared `useMagicDevices`-State. +3. `useDeviceStatus` liefert weiterhin: + - `currentBackendDevice` (Desktop) + - `otherDevices` (alles außer current) +4. `IosDeviceSection` erhält die Liste aller `otherDevices`, filtert intern auf `platform === 'ios'` und versucht, das verbundene `iphone` per Modell + Name zuzuordnen. +5. Action-Buttons leiten den User basierend auf dem gemergten Live-Status in den passenden Wizard-Schritt weiter. + +### iOS-Matching + +Da Backend-`deviceId` (Capacitor-UUID) nicht mit USB-UDID übereinstimmt, erfolgt das Matching über: + +- `device.model` (Backend) ↔ `iphone.productType` (USB) +- `device.name` (Backend) ↔ `iphone.name` (USB) als Fallback / Verfeinerung + +Sind mehrere Geräte mit identischem Modell vorhanden, wird das erste passende (`name` match) als verbunden markiert; bei Unklarheit wird das `iphone` nicht zugeordnet und erscheint als `UnknownIosDeviceCard`. + +--- + +## Bestehende Komponenten – Anpassungen + +### `DeviceHeroCard.vue` + +- Keine iOS-Sterne mehr anzeigen (`showIosStars` bleibt aber für zukünftige Flexibilität). +- Aktionen bleiben auf Desktop-Schutz beschränkt. + +### `DeviceListItem.vue` + +- Wird für „Weitere Geräte“ (andere Desktops) weiterverwendet. +- iOS-Geräte verschwinden aus dieser Liste und werden in der neuen iOS-Section angezeigt. + +### `DeviceDetailSheet.vue` + +- iOS-Sterne-Anzeige gilt für jedes iOS-Gerät, das gerade per USB verbunden ist (nicht nur `isCurrent`, da iOS-Geräte nie „current" sind). +- Cooldown-Steuerung bleibt nur für `isCurrent`-Desktop-Geräte. + +### `useDeviceStatus.ts` + +- Entfernt den Debug-`watchEffect` (bereits erledigt). +- Fügt optional `iosDevices` und `desktopDevices` als getrennte Derived Lists hinzu, damit `status.vue` weniger Filter-Logik enthält. + +--- + +## On-Demand-Verhalten bleibt erhalten + +- Kein automatisches Polling mehr. +- Sterne/Status werden nur beim manuellen Refresh aktualisiert. +- Das verhindert erneut Log-Spam durch wiederholte `detect_iphone_state`-Aufrufe. + +--- + +## Fehlerbehandlung + +- Wenn `detectIphoneState` fehlschlägt: Fehler nur in `protection.lastError`; iOS-Section zeigt Backend-Liste weiterhin an. +- Wenn `getMagicDevices` fehlschägt: `error`-Banner in `status.vue`. +- Wenn Matching mehrdeutig: `UnknownIosDeviceCard` statt falscher Zuordnung. + +--- + +## Kündigungs-Grace-Period & Offboarding + +- Solange das Gerät enrolled ist **und** das Abo aktiv ist, wird **nichts** deinstalliert. +- Nach einer Kündigung bleibt das iOS-Gerät für **3 Tage** in der Liste sichtbar. +- Der Button **„ReBreak entfernen"** ist **unsichtbar oder disabled**, solange die Grace-Period noch nicht begonnen hat. +- Sobald die Grace-Period läuft, erscheint der Button ohne zusätzliche Sicherheitsabfrage. +- Ein Klick darauf startet das Offboarding: + 1. MDM-Enrollment-Profil und Sideload-Profil vom Gerät entfernen. + 2. Gerät aus dem Supervised-Modus zurücksetzen. + 3. Backend-Eintrag für das iOS-Gerät bereinigen. +- **Backend-Abhängigkeit:** Es braucht ein Feld/Endpoint, der die Kündigung + verbleibende Grace-Period erkennbar macht (z. B. `subscriptionCancelledAt` im Profil oder dedizierter `/api/magic/subscription-status`). Das Offboarding selbst braucht einen neuen API-Endpoint oder Tauri-Command, der MDM-Remove + Unsupervise orchestriert. + +--- + +## Offene Punkte / Nächste Schritte + +1. Existiert bereits ein Backend-Feld/Endpoint für Kündigung + Grace-Period, oder muss der gebaut werden? +2. Wie wird die „aktuelle ReBreak-MDM-Version" bestimmt – ist sie im Profil hinterlegt, im Backend konfiguriert oder über eine Tauri-Funktion verfügbar? +3. Soll die App-Installation via MDM direkt aus dem Dashboard auslösbar sein, oder reicht ein Verweis auf `/sideload`?