feat(magic): dedicated iOS section in dashboard with on-demand sync
This commit is contained in:
parent
48af756a86
commit
e4b28be5be
@ -249,7 +249,7 @@ const platformLabel = computed(() => {
|
|||||||
return labels[props.device.platform];
|
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 showDesktopToggle = computed(() => props.device?.isCurrent && (props.device?.platform === "mac" || props.device?.platform === "windows"));
|
||||||
|
|
||||||
const statusConfig: Record<
|
const statusConfig: Record<
|
||||||
|
|||||||
287
apps/rebreak-magic/app/components/IosDeviceCard.vue
Normal file
287
apps/rebreak-magic/app/components/IosDeviceCard.vue
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden rounded-2xl bg-white dark:bg-gray-900 shadow-sm ring-1 ring-gray-100 dark:ring-gray-800 p-5"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-700 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-heroicons-device-phone-mobile"
|
||||||
|
class="w-6 h-6 text-[var(--rebreak-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-bold text-gray-900 dark:text-white truncate">
|
||||||
|
{{ deviceName }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{{ platformLabel }}
|
||||||
|
<span v-if="device.osVersion">· iOS {{ device.osVersion }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UBadge
|
||||||
|
:color="statusColor"
|
||||||
|
:variant="statusVariant"
|
||||||
|
size="sm"
|
||||||
|
class="font-bold shrink-0"
|
||||||
|
>
|
||||||
|
{{ statusLabel }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isConnected && iosStars"
|
||||||
|
class="mt-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
iOS Schutz-Status
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||||
|
Verbunden
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<IosStarRating
|
||||||
|
:enrollment="iosStars.enrollment"
|
||||||
|
:sideload="iosStars.sideload"
|
||||||
|
:app="iosStars.app"
|
||||||
|
/>
|
||||||
|
<ul class="mt-3 space-y-1.5 text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span>Supervised</span>
|
||||||
|
<span :class="iosStars.isSupervised ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'">
|
||||||
|
{{ iosStars.isSupervised ? "Ja" : "Nein" }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span>Enrollment</span>
|
||||||
|
<span :class="iosStars.enrollment ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'">
|
||||||
|
{{ iosStars.enrollment ? "Ja" : "Nein" }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span>Sideload/Lock</span>
|
||||||
|
<span :class="iosStars.sideload ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'">
|
||||||
|
{{ iosStars.sideload ? "Ja" : "Nein" }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center justify-between">
|
||||||
|
<span>ReBreak App</span>
|
||||||
|
<span :class="iosStars.app ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'">
|
||||||
|
{{ iosStars.app ? "Ja" : "Nein" }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-information-circle" class="w-4 h-4" />
|
||||||
|
<span>Zum Live-Status iPhone per USB verbinden und aktualisieren</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center gap-3">
|
||||||
|
<UButton
|
||||||
|
v-if="action.to"
|
||||||
|
:color="action.color"
|
||||||
|
:variant="action.variant"
|
||||||
|
size="sm"
|
||||||
|
:icon="action.icon"
|
||||||
|
:to="action.to"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-else
|
||||||
|
:color="action.color"
|
||||||
|
:variant="action.variant"
|
||||||
|
size="sm"
|
||||||
|
:icon="action.icon"
|
||||||
|
:loading="syncing"
|
||||||
|
@click="onActionClick"
|
||||||
|
>
|
||||||
|
{{ action.label }}
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
trailing-icon="i-heroicons-chevron-right"
|
||||||
|
@click="emit('open', device)"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import type { ComputedDevice, DeviceStatus } from "~/composables/useDeviceStatus";
|
||||||
|
import { REBREAK_MDM_VERSION, getInstalledMdmVersion, type IphoneDeviceState } from "~/composables/useTauri";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
device: ComputedDevice;
|
||||||
|
iphone: IphoneDeviceState | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
inGracePeriod?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "sync", device: ComputedDevice): void;
|
||||||
|
(e: "open", device: ComputedDevice): void;
|
||||||
|
(e: "remove", device: ComputedDevice): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const ENROLLMENT_PROFILE_ID = "org.rebreak.mdm.enrollment";
|
||||||
|
const LOCK_PROFILE_ID = "org.rebreak.protection.contentfilter.sideload";
|
||||||
|
const APP_BUNDLE_ID = "org.rebreak.app";
|
||||||
|
|
||||||
|
const syncing = ref(false);
|
||||||
|
|
||||||
|
const iosStars = computed(() => {
|
||||||
|
if (!props.isConnected || !props.iphone) return null;
|
||||||
|
return {
|
||||||
|
enrollment: props.iphone.installedProfileIDs?.includes(ENROLLMENT_PROFILE_ID) ?? false,
|
||||||
|
sideload: props.iphone.installedProfileIDs?.includes(LOCK_PROFILE_ID) ?? false,
|
||||||
|
app: props.iphone.installedAppBundleIDs?.includes(APP_BUNDLE_ID) ?? false,
|
||||||
|
isSupervised: props.iphone.isSupervised,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IosAction {
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
color: "primary" | "warning" | "success" | "error" | "neutral";
|
||||||
|
variant: "solid" | "soft" | "outline" | "ghost";
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = computed<IosAction>(() => {
|
||||||
|
if (props.inGracePeriod) {
|
||||||
|
return {
|
||||||
|
label: "ReBreak entfernen",
|
||||||
|
icon: "i-heroicons-trash",
|
||||||
|
color: "error",
|
||||||
|
variant: "soft",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.isConnected || !props.iphone) {
|
||||||
|
return {
|
||||||
|
label: "iPhone verbinden",
|
||||||
|
icon: "i-heroicons-link",
|
||||||
|
color: "primary",
|
||||||
|
variant: "solid",
|
||||||
|
to: "/detect",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.iphone.isSupervised) {
|
||||||
|
return {
|
||||||
|
label: "Supervisen",
|
||||||
|
icon: "i-heroicons-shield-check",
|
||||||
|
color: "primary",
|
||||||
|
variant: "solid",
|
||||||
|
to: "/supervise",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.iphone.installedProfileIDs?.includes(ENROLLMENT_PROFILE_ID)) {
|
||||||
|
return {
|
||||||
|
label: "Enrollen",
|
||||||
|
icon: "i-heroicons-document-check",
|
||||||
|
color: "primary",
|
||||||
|
variant: "solid",
|
||||||
|
to: "/enroll",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.iphone.installedProfileIDs?.includes(LOCK_PROFILE_ID)) {
|
||||||
|
return {
|
||||||
|
label: "Sideload installieren",
|
||||||
|
icon: "i-heroicons-lock-closed",
|
||||||
|
color: "warning",
|
||||||
|
variant: "solid",
|
||||||
|
to: "/sideload",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.iphone.installedAppBundleIDs?.includes(APP_BUNDLE_ID)) {
|
||||||
|
return {
|
||||||
|
label: "App installieren",
|
||||||
|
icon: "i-heroicons-arrow-down-tray",
|
||||||
|
color: "primary",
|
||||||
|
variant: "solid",
|
||||||
|
to: "/sideload",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const installedMdmVersion = getInstalledMdmVersion(props.iphone.installedProfileIDs ?? []);
|
||||||
|
if (installedMdmVersion && installedMdmVersion !== REBREAK_MDM_VERSION) {
|
||||||
|
return {
|
||||||
|
label: "MDM-Update installieren",
|
||||||
|
icon: "i-heroicons-arrow-up-tray",
|
||||||
|
color: "warning",
|
||||||
|
variant: "soft",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: "Synchronisieren",
|
||||||
|
icon: "i-heroicons-arrow-path",
|
||||||
|
color: "success",
|
||||||
|
variant: "soft",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const deviceName = computed(() => {
|
||||||
|
if (props.device.name) return props.device.name;
|
||||||
|
return "iOS-Gerät";
|
||||||
|
});
|
||||||
|
|
||||||
|
const platformLabel = computed(() => {
|
||||||
|
const model = (props.device.model ?? "").toLowerCase();
|
||||||
|
if (model.startsWith("ipad")) return "iPad";
|
||||||
|
if (model.startsWith("iphone") || model.startsWith("ios")) return "iPhone";
|
||||||
|
return "iOS";
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusConfig: Record<
|
||||||
|
DeviceStatus,
|
||||||
|
{ label: string; color: "success" | "warning" | "error" | "neutral"; variant: "subtle" | "outline" }
|
||||||
|
> = {
|
||||||
|
active: { label: "Aktiv", color: "success", variant: "subtle" },
|
||||||
|
cooldown: { label: "Schlafmodus", color: "warning", variant: "subtle" },
|
||||||
|
revoked: { label: "Widerrufen", color: "error", variant: "subtle" },
|
||||||
|
pending: { label: "Ausstehend", color: "neutral", variant: "subtle" },
|
||||||
|
unprotected: { label: "Ungeschützt", color: "neutral", variant: "outline" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel = computed(() => statusConfig[props.device.status].label);
|
||||||
|
const statusColor = computed(() => statusConfig[props.device.status].color);
|
||||||
|
const statusVariant = computed(() => statusConfig[props.device.status].variant);
|
||||||
|
|
||||||
|
function onActionClick() {
|
||||||
|
if (props.inGracePeriod) {
|
||||||
|
emit("remove", props.device);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncing.value = true;
|
||||||
|
emit("sync", props.device);
|
||||||
|
// Parent is responsible for resetting syncing state via prop/loading if needed.
|
||||||
|
setTimeout(() => {
|
||||||
|
syncing.value = false;
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
77
apps/rebreak-magic/app/components/IosDeviceSection.vue
Normal file
77
apps/rebreak-magic/app/components/IosDeviceSection.vue
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-sm font-bold text-gray-500 uppercase tracking-wider">
|
||||||
|
Meine iOS-Geräte
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading && devices.length === 0" class="py-12 text-center text-gray-500">
|
||||||
|
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin mx-auto mb-3 text-[var(--rebreak-primary)]" />
|
||||||
|
<p class="font-semibold">Lade iOS-Geräte…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="devices.length === 0 && !hasUnknownUsbDevice" class="py-10 text-center rounded-2xl bg-white ring-1 ring-gray-100">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
{{ hasRefreshed ? 'Keine iOS-Geräte registriert.' : 'Noch keine iOS-Geräte geladen.' }}
|
||||||
|
</p>
|
||||||
|
<p v-if="hasRefreshed" class="text-xs text-gray-400 mt-2">
|
||||||
|
Installiere die ReBreak-App, melde dich an und registriere das Gerät.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<UnknownIosDeviceCard
|
||||||
|
v-if="hasUnknownUsbDevice && iphone"
|
||||||
|
:iphone="iphone"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IosDeviceCard
|
||||||
|
v-for="device in devices"
|
||||||
|
:key="device.deviceId"
|
||||||
|
:device="device"
|
||||||
|
:iphone="iphone"
|
||||||
|
:is-connected="device.deviceId === connectedDeviceId"
|
||||||
|
:in-grace-period="inGracePeriod"
|
||||||
|
@sync="emit('sync', $event)"
|
||||||
|
@open="emit('open', $event)"
|
||||||
|
@remove="emit('remove', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { ComputedDevice } from "~/composables/useDeviceStatus";
|
||||||
|
import type { IphoneDeviceState } from "~/composables/useTauri";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
devices: ComputedDevice[];
|
||||||
|
iphone: IphoneDeviceState | null;
|
||||||
|
loading: boolean;
|
||||||
|
hasRefreshed: boolean;
|
||||||
|
inGracePeriod?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "sync", device: ComputedDevice): void;
|
||||||
|
(e: "open", device: ComputedDevice): void;
|
||||||
|
(e: "remove", device: ComputedDevice): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
64
apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue
Normal file
64
apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden rounded-2xl bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800 p-5"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="shrink-0 w-12 h-12 rounded-xl bg-amber-100 dark:bg-amber-800/50 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-heroicons-exclamation-triangle"
|
||||||
|
class="w-6 h-6 text-amber-600 dark:text-amber-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-base font-bold text-gray-900 dark:text-white">
|
||||||
|
Dieses iPhone ist nicht erkennbar
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300 mt-1">
|
||||||
|
Mit keinem ReBreak-Konto verbunden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-3 text-sm text-gray-700 dark:text-gray-200 space-y-1">
|
||||||
|
<p><span class="font-medium">Modell:</span> {{ displayModel(iphone.productType) }}</p>
|
||||||
|
<p><span class="font-medium">iOS:</span> {{ iphone.productVersion }}</p>
|
||||||
|
<p class="truncate"><span class="font-medium">UDID:</span> {{ iphone.udid }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-sm text-gray-600 dark:text-gray-300 bg-white/60 dark:bg-black/20 rounded-lg p-3">
|
||||||
|
<p class="font-medium mb-1">So kannst du es verwalten:</p>
|
||||||
|
<ol class="list-decimal list-inside space-y-0.5">
|
||||||
|
<li>ReBreak-App auf diesem Gerät installieren</li>
|
||||||
|
<li>Mit deinem Konto anmelden</li>
|
||||||
|
<li>Das Gerät in der App registrieren</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { IphoneDeviceState } from "~/composables/useTauri";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
iphone: IphoneDeviceState;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const productTypeMap: Record<string, string> = {
|
||||||
|
"iPhone18,4": "iPhone Air",
|
||||||
|
"iPhone17,1": "iPhone 16 Pro",
|
||||||
|
"iPhone17,2": "iPhone 16 Pro Max",
|
||||||
|
"iPhone17,3": "iPhone 16",
|
||||||
|
"iPhone17,4": "iPhone 16 Plus",
|
||||||
|
"iPhone16,1": "iPhone 15 Pro",
|
||||||
|
"iPhone16,2": "iPhone 15 Pro Max",
|
||||||
|
"iPhone15,4": "iPhone 15",
|
||||||
|
"iPhone15,5": "iPhone 15 Plus",
|
||||||
|
};
|
||||||
|
|
||||||
|
function displayModel(productType: string): string {
|
||||||
|
return productTypeMap[productType] ?? productType;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { computed, watchEffect, type Ref } from "vue";
|
import { computed, type Ref } from "vue";
|
||||||
import type { MagicDeviceInfo, IphoneDeviceState } from "./useTauri";
|
import type { MagicDeviceInfo, IphoneDeviceState } from "./useTauri";
|
||||||
|
|
||||||
export type DeviceStatus = "active" | "cooldown" | "revoked" | "pending" | "unprotected";
|
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, "");
|
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(
|
export function useDeviceStatus(
|
||||||
devices: Ref<MagicDeviceInfo[]>,
|
devices: Ref<MagicDeviceInfo[]>,
|
||||||
localHostname: Ref<string | null>,
|
localHostname: Ref<string | null>,
|
||||||
@ -51,38 +66,24 @@ export function useDeviceStatus(
|
|||||||
const currentBackendDevice = computed<ComputedDevice | null>(() => {
|
const currentBackendDevice = computed<ComputedDevice | null>(() => {
|
||||||
const found = devices.value.find(isCurrentDevice);
|
const found = devices.value.find(isCurrentDevice);
|
||||||
if (!found) return null;
|
if (!found) return null;
|
||||||
return {
|
return mapToComputedDevice(found, true);
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const otherDevices = computed<ComputedDevice[]>(() => {
|
const otherDevices = computed<ComputedDevice[]>(() => {
|
||||||
const currentId = currentBackendDevice.value?.deviceId;
|
const currentId = currentBackendDevice.value?.deviceId;
|
||||||
return devices.value
|
return devices.value
|
||||||
.filter((d) => d.deviceId !== currentId)
|
.filter((d) => d.deviceId !== currentId)
|
||||||
.map((d) => ({
|
.map((d) => mapToComputedDevice(d, false));
|
||||||
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,
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const iosDevices = computed<ComputedDevice[]>(() =>
|
||||||
|
otherDevices.value.filter((d) => d.platform === "ios"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const desktopDevices = computed<ComputedDevice[]>(() =>
|
||||||
|
otherDevices.value.filter((d) => d.platform === "mac" || d.platform === "windows"),
|
||||||
|
);
|
||||||
|
|
||||||
const iosStars = computed(() => {
|
const iosStars = computed(() => {
|
||||||
if (!iphone.value) return null;
|
if (!iphone.value) return null;
|
||||||
return {
|
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 {
|
return {
|
||||||
currentBackendDevice,
|
currentBackendDevice,
|
||||||
otherDevices,
|
otherDevices,
|
||||||
|
iosDevices,
|
||||||
|
desktopDevices,
|
||||||
iosStars,
|
iosStars,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { useTauri, type IphoneDeviceState, type DesktopProtectionState, type MagicDeviceInfo } from "~/composables/useTauri";
|
||||||
import { useIphoneDevice, useMagicDevices, useMagicSession } from "~/composables/useMagicState";
|
import { useIphoneDevice, useMagicDevices, useMagicSession } from "~/composables/useMagicState";
|
||||||
|
|
||||||
@ -12,9 +12,6 @@ export interface ProtectionStatus {
|
|||||||
anyBackendDevice: boolean;
|
anyBackendDevice: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IPHONE_POLL_INTERVAL = 5000;
|
|
||||||
const DEVICE_REFRESH_INTERVAL = 30000;
|
|
||||||
|
|
||||||
function normalizeHostname(value: string): string {
|
function normalizeHostname(value: string): string {
|
||||||
return (value.toLowerCase().split(".")[0] ?? "").replace(/[^a-z0-9]/g, "");
|
return (value.toLowerCase().split(".")[0] ?? "").replace(/[^a-z0-9]/g, "");
|
||||||
}
|
}
|
||||||
@ -38,9 +35,6 @@ export function useProtectionStatus() {
|
|||||||
const lastError = ref<string | null>(null);
|
const lastError = ref<string | null>(null);
|
||||||
const lastUpdated = ref<Date | null>(null);
|
const lastUpdated = ref<Date | null>(null);
|
||||||
|
|
||||||
let iphoneTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
let deviceTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
const iosConnected = computed(() => !!iphone.value);
|
const iosConnected = computed(() => !!iphone.value);
|
||||||
const iosProtected = computed(() => {
|
const iosProtected = computed(() => {
|
||||||
if (!iphone.value) return false;
|
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 {
|
return {
|
||||||
status,
|
status,
|
||||||
iphone: computed(() => iphone.value),
|
iphone: computed(() => iphone.value),
|
||||||
@ -199,5 +182,7 @@ export function useProtectionStatus() {
|
|||||||
desktopProtected,
|
desktopProtected,
|
||||||
anyBackendDevice,
|
anyBackendDevice,
|
||||||
refresh,
|
refresh,
|
||||||
|
refreshIphone,
|
||||||
|
refreshBackendDevices,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,6 +97,13 @@ export interface MdmCommandResult {
|
|||||||
response_body: string;
|
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<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
async function invokeLogged<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
const { useLogger } = await import("~/composables/useLogger");
|
const { useLogger } = await import("~/composables/useLogger");
|
||||||
const logger = useLogger();
|
const logger = useLogger();
|
||||||
|
|||||||
@ -41,7 +41,6 @@
|
|||||||
v-if="currentBackendDevice"
|
v-if="currentBackendDevice"
|
||||||
:device="currentBackendDevice"
|
:device="currentBackendDevice"
|
||||||
:is-current="true"
|
:is-current="true"
|
||||||
:ios-stars="currentBackendDevice.platform === 'ios' ? iosStars : null"
|
|
||||||
@open="openDevice"
|
@open="openDevice"
|
||||||
@toggle-protection="toggleProtection"
|
@toggle-protection="toggleProtection"
|
||||||
/>
|
/>
|
||||||
@ -51,14 +50,35 @@
|
|||||||
<div class="w-16 h-16 mx-auto rounded-2xl bg-blue-50 flex items-center justify-center mb-4">
|
<div class="w-16 h-16 mx-auto rounded-2xl bg-blue-50 flex items-center justify-center mb-4">
|
||||||
<UIcon name="i-heroicons-shield-exclamation" class="w-8 h-8 text-[var(--rebreak-primary)]" />
|
<UIcon name="i-heroicons-shield-exclamation" class="w-8 h-8 text-[var(--rebreak-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="!hasRefreshed">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900">Geräte-Status noch nicht geladen</h3>
|
||||||
|
<p class="text-sm text-gray-500 mt-1 mb-4">Klicke auf „Aktualisieren“, um deine Geräte abzurufen.</p>
|
||||||
|
<UButton color="primary" icon="i-heroicons-arrow-path" :loading="loading" @click="refresh">
|
||||||
|
Aktualisieren
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<h3 class="text-lg font-bold text-gray-900">Dieses Gerät ist nicht geschützt</h3>
|
<h3 class="text-lg font-bold text-gray-900">Dieses Gerät ist nicht geschützt</h3>
|
||||||
<p class="text-sm text-gray-500 mt-1 mb-4">Aktiviere den Schutz für das Gerät, auf dem Magic läuft.</p>
|
<p class="text-sm text-gray-500 mt-1 mb-4">Aktiviere den Schutz für das Gerät, auf dem Magic läuft.</p>
|
||||||
<UButton color="primary" icon="i-heroicons-shield-check" to="/desktop-enroll">
|
<UButton color="primary" icon="i-heroicons-shield-check" to="/desktop-enroll">
|
||||||
Dieses Gerät schützen
|
Dieses Gerät schützen
|
||||||
</UButton>
|
</UButton>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- iOS devices section -->
|
||||||
|
<IosDeviceSection
|
||||||
|
:devices="iosDevices"
|
||||||
|
:iphone="iphone"
|
||||||
|
:loading="loading"
|
||||||
|
:has-refreshed="hasRefreshed"
|
||||||
|
:in-grace-period="subscriptionInGracePeriod"
|
||||||
|
@sync="onIosSync"
|
||||||
|
@open="openDevice"
|
||||||
|
@remove="onIosRemove"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Other devices list -->
|
<!-- Other devices list -->
|
||||||
<section>
|
<section>
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
@ -77,18 +97,20 @@
|
|||||||
|
|
||||||
<div v-if="error" class="text-sm text-red-600 mb-4 p-4 bg-red-50 rounded-xl">{{ error }}</div>
|
<div v-if="error" class="text-sm text-red-600 mb-4 p-4 bg-red-50 rounded-xl">{{ error }}</div>
|
||||||
|
|
||||||
<div v-if="loading && otherDevices.length === 0" class="py-12 text-center text-gray-500">
|
<div v-if="loading && desktopDevices.length === 0" class="py-12 text-center text-gray-500">
|
||||||
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin mx-auto mb-3 text-[var(--rebreak-primary)]" />
|
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin mx-auto mb-3 text-[var(--rebreak-primary)]" />
|
||||||
<p class="font-semibold">Lade Geräte…</p>
|
<p class="font-semibold">Lade Geräte…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="otherDevices.length === 0" class="py-10 text-center rounded-2xl bg-white ring-1 ring-gray-100">
|
<div v-else-if="desktopDevices.length === 0" class="py-10 text-center rounded-2xl bg-white ring-1 ring-gray-100">
|
||||||
<p class="text-sm text-gray-500">Keine weiteren Geräte registriert.</p>
|
<p class="text-sm text-gray-500">
|
||||||
|
{{ hasRefreshed ? 'Keine weiteren Computer registriert.' : 'Noch keine Computer geladen.' }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<DeviceListItem
|
<DeviceListItem
|
||||||
v-for="device in otherDevices"
|
v-for="device in desktopDevices"
|
||||||
:key="device.deviceId"
|
:key="device.deviceId"
|
||||||
:device="device"
|
:device="device"
|
||||||
:is-current="false"
|
:is-current="false"
|
||||||
@ -102,7 +124,7 @@
|
|||||||
<DeviceDetailSheet
|
<DeviceDetailSheet
|
||||||
v-model:open="sheetOpen"
|
v-model:open="sheetOpen"
|
||||||
:device="selectedDevice"
|
:device="selectedDevice"
|
||||||
:ios-stars="selectedDevice?.platform === 'ios' && selectedDevice?.isCurrent ? iosStars : null"
|
:ios-stars="selectedDeviceStars"
|
||||||
@close="sheetOpen = false"
|
@close="sheetOpen = false"
|
||||||
@toggle-protection="toggleProtection"
|
@toggle-protection="toggleProtection"
|
||||||
@start-cooldown="startCooldown"
|
@start-cooldown="startCooldown"
|
||||||
@ -138,18 +160,33 @@ const currentDeviceId = computed(() => session.value?.deviceId ?? null);
|
|||||||
|
|
||||||
const profile = ref<UserProfile | null>(null);
|
const profile = ref<UserProfile | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const hasRefreshed = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const sheetOpen = ref(false);
|
const sheetOpen = ref(false);
|
||||||
const selectedDevice = ref<ComputedDevice | null>(null);
|
const selectedDevice = ref<ComputedDevice | null>(null);
|
||||||
const platformInfo = ref<{ platform: string } | null>(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.
|
// Share localHostname from protection composable with device status logic.
|
||||||
const localHostname = protection.localHostname;
|
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 () => {
|
onMounted(async () => {
|
||||||
await loadProfile();
|
await loadProfile();
|
||||||
await refresh();
|
|
||||||
try {
|
try {
|
||||||
const info = await getPlatform();
|
const info = await getPlatform();
|
||||||
platformInfo.value = { platform: info.platform };
|
platformInfo.value = { platform: info.platform };
|
||||||
@ -176,9 +213,31 @@ async function refresh() {
|
|||||||
error.value = e?.message ?? "Geräte konnten nicht geladen werden";
|
error.value = e?.message ?? "Geräte konnten nicht geladen werden";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
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) {
|
function openDevice(device: ComputedDevice) {
|
||||||
selectedDevice.value = device;
|
selectedDevice.value = device;
|
||||||
sheetOpen.value = true;
|
sheetOpen.value = true;
|
||||||
|
|||||||
500
docs/superpowers/plans/2026-06-16-magic-dashboard-ios-section.md
Normal file
500
docs/superpowers/plans/2026-06-16-magic-dashboard-ios-section.md
Normal file
@ -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<ComputedDevice[]>(() =>
|
||||||
|
devices.value
|
||||||
|
.filter((d) => normalizePlatform(d.model ?? d.hostname) === "ios")
|
||||||
|
.map((d) => mapToComputedDevice(d, false)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const desktopDevices = computed<ComputedDevice[]>(() =>
|
||||||
|
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
|
||||||
|
<IosDeviceSection
|
||||||
|
:devices="iosDevices"
|
||||||
|
:iphone="iphone"
|
||||||
|
:loading="loading"
|
||||||
|
:has-refreshed="hasRefreshed"
|
||||||
|
@sync="onIosSync"
|
||||||
|
@open="openDevice"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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.
|
||||||
@ -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`?
|
||||||
Loading…
x
Reference in New Issue
Block a user