chahinebrini e20b21e0ef
Some checks failed
Build ReBreak Magic Windows / NSIS Installer (x64) (push) Waiting to run
ci/woodpecker/push/woodpecker Pipeline failed
Deploy Staging / Build backend (Nitro) (push) Has been cancelled
Deploy Staging / Deploy zu Hetzner (push) Has been cancelled
feat(mdm): healthcheck sends ProfileList, disables nefilter when lock profile missing; cfgutil fallback in Magic App
2026-06-18 14:21:45 +02:00

978 lines
34 KiB
Vue

<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="topBadge.color" :variant="topBadge.variant" size="sm" class="font-bold shrink-0">
{{ topBadge.label }}
</UBadge>
</div>
<!-- Incomplete-protection hint -->
<div v-if="showIncompleteHint"
class="mt-3 rounded-xl bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800 p-3 flex items-start gap-2.5">
<UIcon name="i-heroicons-exclamation-triangle"
class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
<div>
<p class="text-sm font-bold text-amber-800 dark:text-amber-300">
Schutz unvollständig
</p>
<p class="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
{{ incompleteMessage }}
</p>
</div>
</div>
<!-- USB connection hint -->
<div v-if="!isConnected" class="mt-3 text-sm text-gray-600 dark:text-gray-300 flex items-start gap-2">
<UIcon :name="isSearching ? 'i-heroicons-arrow-path' : 'i-heroicons-information-circle'"
:class="isSearching ? 'animate-spin' : ''" class="w-5 h-5 text-[var(--rebreak-primary)] shrink-0 mt-0.5" />
<span>{{ isSearching ? "Suche nach verbundenem iPhone…" : "Verbinde dein iPhone mit USB, um den Schutz zu vervollständigen." }}</span>
</div>
<!-- Backend-MDM always visible; local USB only when connected -->
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4 relative">
<!-- Animated sync overlay -->
<div v-if="autoSyncing"
class="absolute inset-0 z-10 rounded-2xl bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm flex flex-col items-center justify-center">
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin text-[var(--rebreak-primary)]" />
<p class="mt-2 text-sm font-bold text-gray-900 dark:text-white">
Schutz wird geprüft…
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Backend- und USB-Status werden abgeglichen
</p>
</div>
<!-- Backend-MDM card -->
<div class="rounded-xl bg-gray-50 dark:bg-gray-800/50 p-4">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-bold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Backend-MDM
</span>
<UBadge v-if="mdmState.loading" color="neutral" variant="subtle" size="xs">
Lädt…
</UBadge>
<UBadge v-else-if="mdmState.data?.enrolled" color="success" variant="subtle" size="xs">
Enrolled
</UBadge>
<UBadge v-else color="warning" variant="subtle" size="xs">
Nicht enrolled
</UBadge>
</div>
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-200">
<li v-for="row in backendRows" :key="row.label" class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ row.label }}</span>
<span :class="row.valueClass">{{ row.value }}</span>
</li>
</ul>
</div>
<!-- Local USB device card -->
<div v-if="isConnected" class="rounded-xl bg-gray-50 dark:bg-gray-800/50 p-4">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-bold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Lokales USB-Gerät
</span>
<UBadge color="success" variant="subtle" size="xs">
Verbunden
</UBadge>
</div>
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-200">
<li v-for="row in localRows" :key="row.label" class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ row.label }}</span>
<span :class="row.valueClass">{{ row.value }}</span>
</li>
</ul>
</div>
</div>
<!-- Mismatch summary after sync -->
<div v-if="isConnected && autoSyncComplete && mismatches.length > 0"
class="mt-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-800 p-3">
<p class="text-sm font-bold text-red-800 dark:text-red-300">
{{ mismatches.length }} Unterschied(e) erkannt
</p>
<ul class="mt-1.5 space-y-0.5 text-xs text-red-700 dark:text-red-400 list-disc list-inside">
<li v-for="(mismatch, idx) in mismatches" :key="idx">
{{ mismatch }}
</li>
</ul>
</div>
<!-- Inline enrollment panel -->
<div v-if="enrollmentPhase !== 'idle'"
class="mt-4 rounded-xl bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-100 dark:border-indigo-800 p-4">
<div class="flex items-center gap-2 mb-3">
<UIcon name="i-heroicons-arrow-path" class="w-5 h-5 text-indigo-600 dark:text-indigo-400"
:class="{ 'animate-spin': enrollmentPhase === 'loading' || enrollmentPhase === 'checking' }" />
<p class="text-sm font-bold text-indigo-900 dark:text-indigo-200">
MDM-Enrollment
</p>
</div>
<!-- Progress steps -->
<div class="flex items-center gap-2 text-xs mb-4">
<span class="px-2 py-1 rounded-full"
:class="enrollmentPhase === 'loading' ? 'bg-indigo-200 text-indigo-800 dark:bg-indigo-700 dark:text-indigo-100' : 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'">
1. Profil laden
</span>
<span class="text-gray-400"></span>
<span class="px-2 py-1 rounded-full"
:class="enrollmentPhase === 'waiting' ? 'bg-indigo-200 text-indigo-800 dark:bg-indigo-700 dark:text-indigo-100' : (enrollmentPhase === 'checking' || enrollmentPhase === 'success' || enrollmentPhase === 'error') ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'">
2. QR-Code scannen
</span>
<span class="text-gray-400"></span>
<span class="px-2 py-1 rounded-full"
:class="enrollmentPhase === 'checking' ? 'bg-indigo-200 text-indigo-800 dark:bg-indigo-700 dark:text-indigo-100' : enrollmentPhase === 'success' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'">
3. Prüfen
</span>
</div>
<!-- QR code -->
<div v-if="enrollmentPhase === 'waiting' && enrollmentQrUrl" class="text-center space-y-3">
<div class="bg-white p-3 rounded-xl inline-block">
<img :src="enrollmentQrUrl" alt="Enrollment QR-Code" class="w-40 h-40">
</div>
<p class="text-xs text-indigo-700 dark:text-indigo-300">
Scanne den Code mit der iPhone-Kamera und installiere das Profil.
</p>
<UButton size="sm" color="primary" :loading="enrollmentPhase === 'checking'" @click="checkInlineEnrollment">
Installation prüfen
</UButton>
</div>
<!-- Success / error -->
<div v-if="enrollmentPhase === 'success'" class="space-y-3">
<div class="text-sm text-green-700 dark:text-green-300">
Enrollment abgeschlossen. Das Gerät synchronisiert sich jetzt mit dem Backend.
</div>
<UButton size="sm" color="primary" icon="i-heroicons-lock-closed" @click="startInlineLockProfile">
Lock-Profil installieren
</UButton>
</div>
<div v-if="enrollmentPhase === 'error'" class="text-sm text-red-700 dark:text-red-300">
{{ enrollmentError || "Enrollment fehlgeschlagen" }}
</div>
<!-- Logs -->
<div v-if="enrollmentLogs.length > 0"
class="mt-3 text-xs bg-white/60 dark:bg-black/20 p-2 rounded overflow-auto max-h-32">
<pre class="whitespace-pre-wrap">{{ enrollmentLogs.join('\n') }}</pre>
</div>
<div class="mt-3 flex justify-end">
<UButton size="xs" color="neutral" variant="ghost" @click="closeInlineEnrollment">
Schließen
</UButton>
</div>
</div>
<!-- Inline lock profile panel -->
<div
v-if="lockPhase !== 'idle'"
class="mt-4 rounded-xl bg-purple-50 dark:bg-purple-900/20 border border-purple-100 dark:border-purple-800 p-4"
>
<div class="flex items-center gap-2 mb-3">
<UIcon
name="i-heroicons-lock-closed"
class="w-5 h-5 text-purple-600 dark:text-purple-400"
:class="{ 'animate-spin': lockPhase === 'loading' || lockPhase === 'checking' }"
/>
<p class="text-sm font-bold text-purple-900 dark:text-purple-200">
Lock-Profil
</p>
</div>
<!-- Progress steps -->
<div class="flex items-center gap-2 text-xs mb-4">
<span
class="px-2 py-1 rounded-full"
:class="lockPhase === 'loading' ? 'bg-purple-200 text-purple-800 dark:bg-purple-700 dark:text-purple-100' : 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'"
>
1. Server starten
</span>
<span class="text-gray-400"></span>
<span
class="px-2 py-1 rounded-full"
:class="lockPhase === 'waiting' ? 'bg-purple-200 text-purple-800 dark:bg-purple-700 dark:text-purple-100' : (lockPhase === 'checking' || lockPhase === 'success' || lockPhase === 'error') ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'"
>
2. QR-Code scannen
</span>
<span class="text-gray-400"></span>
<span
class="px-2 py-1 rounded-full"
:class="lockPhase === 'checking' ? 'bg-purple-200 text-purple-800 dark:bg-purple-700 dark:text-purple-100' : lockPhase === 'success' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'"
>
3. Prüfen
</span>
</div>
<!-- QR code -->
<div v-if="lockPhase === 'waiting' && lockQrUrl" class="text-center space-y-3">
<div class="bg-white p-3 rounded-xl inline-block">
<img :src="lockQrUrl" alt="Lock-Profil QR-Code" class="w-40 h-40">
</div>
<p class="text-xs text-purple-700 dark:text-purple-300">
Scanne den Code mit der iPhone-Kamera und installiere das Lock-Profil.
</p>
<UButton
size="sm"
color="primary"
:loading="lockPhase === 'checking'"
@click="checkInlineLockProfile"
>
Installation prüfen
</UButton>
</div>
<div v-if="lockPhase === 'loading'" class="text-sm text-purple-700 dark:text-purple-300">
Lock-Profil-Server wird gestartet
</div>
<div v-if="lockPhase === 'success'" class="text-sm text-green-700 dark:text-green-300">
Lock-Profil installiert. Das Gerät aktualisiert den Schutz in Kürze.
</div>
<div v-if="lockPhase === 'error'" class="text-sm text-red-700 dark:text-red-300">
{{ lockError || "Lock-Profil-Installation fehlgeschlagen" }}
</div>
<div v-if="lockLogs.length > 0" class="mt-3 text-xs bg-white/60 dark:bg-black/20 p-2 rounded overflow-auto max-h-32">
<pre class="whitespace-pre-wrap">{{ lockLogs.join('\n') }}</pre>
</div>
<div class="mt-3 flex justify-end">
<UButton size="xs" color="neutral" variant="ghost" @click="closeInlineLockProfile">
Schließen
</UButton>
</div>
</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="manualSyncing || autoSyncing || isSearching" :disabled="autoSyncing || isSearching || action.disabled"
@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, onMounted, ref, watch } from "vue";
import QRCode from "qrcode";
import type { ComputedDevice, DeviceStatus } from "~/composables/useDeviceStatus";
import { useMdmStatus } from "~/composables/useMdmStatus";
import { useTauri, REBREAK_MDM_VERSION, getInstalledMdmVersion, type IphoneDeviceState, type LocalServerInfo } from "~/composables/useTauri";
const props = defineProps<{
device: ComputedDevice;
iphone: IphoneDeviceState | null;
isConnected: boolean;
isSearching?: boolean;
inGracePeriod?: boolean;
}>();
const deviceIdRef = computed(() => props.device.deviceId);
const { state: mdmState, refresh: refreshMdmStatus } = useMdmStatus(deviceIdRef);
const emit = defineEmits<{
(e: "sync", device: ComputedDevice): void;
(e: "open", device: ComputedDevice): void;
(e: "remove", device: ComputedDevice): void;
(e: "connect", 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 manualSyncing = ref(false);
const autoSyncing = ref(false);
const autoSyncComplete = ref(false);
const {
downloadAndPatchEnrollmentProfile,
startLocalProfileServer,
stopLocalProfileServer,
getInstalledProfiles,
mdmPush,
} = useTauri();
const enrollmentPhase = ref<"idle" | "loading" | "waiting" | "checking" | "success" | "error">("idle");
const enrollmentServerInfo = ref<LocalServerInfo | null>(null);
const enrollmentQrUrl = ref<string>("");
const enrollmentError = ref<string | null>(null);
const enrollmentLogs = ref<string[]>([]);
const lockPhase = ref<"idle" | "loading" | "waiting" | "checking" | "success" | "error">("idle");
const lockError = ref<string | null>(null);
const lockLogs = ref<string[]>([]);
const lockQrUrl = ref<string>("");
// Wird true wenn die USB-Profilabfrage (cfgutil) fehlschlägt, z.B. weil das Gerät
// während des QR-Code-Flows kurz nicht erreichbar ist. In dem Fall vertrauen wir
// dem Backend-Status und flaggen keinen Lock-Profil-Mismatch.
const lockProfileUsbCheckFailed = ref(false);
const LOCK_PROFILE_PATH = "/Users/chahinebrini/mono/rebreak-monorepo/ops/mdm/profiles/rebreak-content-filter-sideload.mobileconfig";
const localEnrollment = computed(() =>
props.iphone?.installedProfileIDs?.includes(ENROLLMENT_PROFILE_ID) ?? false,
);
const localLock = computed(() =>
props.iphone?.installedProfileIDs?.includes(LOCK_PROFILE_ID) ?? false,
);
const localApp = computed(() =>
props.iphone?.installedAppBundleIDs?.includes(APP_BUNDLE_ID) ?? false,
);
const backendRows = computed(() => {
const data = mdmState.value.data;
return [
{
label: "Enrollment",
value: data?.enrolled ? "Ja" : "Nein",
valueClass: data?.enrolled
? "text-green-600 dark:text-green-400 font-medium"
: "text-red-600 dark:text-red-400 font-medium",
},
{
label: "Supervised",
value: data?.supervised ? "Ja" : "Nein",
valueClass: data?.supervised
? "text-green-600 dark:text-green-400 font-medium"
: "text-red-600 dark:text-red-400 font-medium",
},
{
label: "Organisation",
value: data?.company ?? "—",
valueClass: data?.company
? "text-gray-900 dark:text-white font-medium"
: "text-gray-400 dark:text-gray-500",
},
{
label: "Lock-Profil",
value: data?.lockProfileInstalled ? "Installiert" : "Fehlt",
valueClass: data?.lockProfileInstalled
? "text-green-600 dark:text-green-400 font-medium"
: "text-red-600 dark:text-red-400 font-medium",
},
{
label: "ReBreak App",
value: data?.lastAppPushAt
? new Date(data.lastAppPushAt).toLocaleString("de-DE")
: "Nicht gepusht",
valueClass: data?.lastAppPushAt
? "text-green-600 dark:text-green-400 font-medium"
: "text-red-600 dark:text-red-400 font-medium",
},
];
});
const localRows = computed(() => {
if (!props.isConnected || !props.iphone) {
return [
{ label: "Supervised", value: "—", valueClass: "text-gray-400 dark:text-gray-500" },
{ label: "Enrollment", value: "—", valueClass: "text-gray-400 dark:text-gray-500" },
{ label: "Organisation", value: "—", valueClass: "text-gray-400 dark:text-gray-500" },
{ label: "Lock-Profil", value: "—", valueClass: "text-gray-400 dark:text-gray-500" },
{ label: "ReBreak App", value: "—", valueClass: "text-gray-400 dark:text-gray-500" },
];
}
const iphone = props.iphone;
return [
{
label: "Enrollment",
value: localEnrollment.value ? "Ja" : "Nein",
valueClass: localEnrollment.value
? "text-green-600 dark:text-green-400 font-medium"
: "text-red-600 dark:text-red-400 font-medium",
},
{
label: "Supervised",
value: iphone.isSupervised ? "Ja" : "Nein",
valueClass: iphone.isSupervised
? "text-green-600 dark:text-green-400 font-medium"
: "text-red-600 dark:text-red-400 font-medium",
},
{
label: "Organisation",
value: iphone.organizationName ?? "—",
valueClass: iphone.organizationName
? "text-gray-900 dark:text-white font-medium"
: "text-gray-400 dark:text-gray-500",
},
{
label: "Lock-Profil",
value: localLock.value ? "Installiert" : "Fehlt",
valueClass: localLock.value
? "text-green-600 dark:text-green-400 font-medium"
: "text-red-600 dark:text-red-400 font-medium",
},
{
label: "ReBreak App",
value: localApp.value ? "Installiert" : "Fehlt",
valueClass: localApp.value
? "text-green-600 dark:text-green-400 font-medium"
: "text-red-600 dark:text-red-400 font-medium",
},
];
});
const mismatches = computed(() => {
const list: string[] = [];
if (!props.isConnected || !props.iphone || !mdmState.value.data) return list;
const backend = mdmState.value.data;
const local = props.iphone;
if (backend.enrolled !== localEnrollment.value) {
list.push("Enrollment-Status stimmt nicht überein");
}
if (backend.supervised !== local.isSupervised) {
list.push("Supervision-Status stimmt nicht überein");
}
if (backend.company && local.organizationName !== backend.company) {
list.push(`Organisation "${backend.company}" im Backend passt nicht zum lokalen Gerät`);
}
// Wenn die lokale USB-Abfrage bewusst fehlgeschlagen ist (z.B. weil das Gerät
// während des QR-Code-Installations-Flows kurz nicht erreichbar war), vertrauen
// wir dem Backend-Status und zeigen keinen false-positive-Mismatch an.
if (backend.lockProfileInstalled !== localLock.value && !lockProfileUsbCheckFailed.value) {
list.push("Lock-Profil-Status stimmt nicht überein");
}
if (!localApp.value && !backend.lastAppPushAt) {
list.push("ReBreak App wurde noch nicht installiert");
} else if (localApp.value && !backend.lastAppPushAt) {
list.push("App ist lokal installiert, aber das Backend hat keinen Push verzeichnet");
} else if (!localApp.value && backend.lastAppPushAt) {
list.push("App wurde vom Backend gepusht, ist aber lokal nicht installiert");
}
return list;
});
const isProtectionIncomplete = computed(() => {
if (mdmState.value.loading || !mdmState.value.data) return false;
const backend = mdmState.value.data;
if (!backend.enrolled) return true;
if (!backend.supervised) return true;
if (!backend.lockProfileInstalled) return true;
// Local checks only matter when an iPhone is actually connected via USB.
if (props.isConnected && props.iphone) {
if (!props.iphone.isSupervised) return true;
if (!localEnrollment.value) return true;
if (!localLock.value) return true;
if (!localApp.value) return true;
}
return false;
});
const showIncompleteHint = computed(() => isProtectionIncomplete.value);
const incompleteMessage = computed(() => {
if (!mdmState.value.data?.enrolled) {
return "Das Gerät ist im Backend noch nicht MDM-enrolled.";
}
if (!mdmState.value.data?.supervised) {
return "Das Gerät ist im Backend nicht supervised.";
}
if (!mdmState.value.data?.lockProfileInstalled) {
return "Das Lock-Profil ist im Backend noch nicht als aktiv markiert.";
}
if (props.isConnected && props.iphone) {
if (!props.iphone.isSupervised) {
return "Das iPhone ist nicht supervised.";
}
if (!localEnrollment.value) {
return "Das Enrollment-Profil fehlt auf dem iPhone.";
}
if (!localLock.value) {
return "Das Lock-Profil fehlt auf dem iPhone.";
}
if (!localApp.value) {
return "Die ReBreak App fehlt auf dem iPhone.";
}
}
return "Schutz ist noch unvollständig.";
});
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 topBadge = computed(() => {
if (isProtectionIncomplete.value) {
return {
label: "Schutz unvollständig",
color: "warning" as const,
variant: "subtle" as const,
};
}
if (props.isConnected && props.iphone) {
return {
label: "Voller Schutz aktiv",
color: "success" as const,
variant: "subtle" as const,
};
}
const cfg = statusConfig[props.device.status];
return { label: cfg.label, color: cfg.color, variant: cfg.variant };
});
interface IosAction {
label: string;
icon: string;
color: "primary" | "warning" | "success" | "error" | "neutral";
variant: "solid" | "soft" | "outline" | "ghost";
to?: string;
disabled?: boolean;
}
const action = computed<IosAction>(() => {
if (props.inGracePeriod) {
return {
label: "ReBreak entfernen",
icon: "i-heroicons-trash",
color: "error",
variant: "soft",
};
}
if (props.isSearching) {
return {
label: "iPhone suchen…",
icon: "i-heroicons-arrow-path",
color: "neutral",
variant: "soft",
};
}
if (enrollmentPhase.value !== "idle") {
return {
label: "Enrollment läuft…",
icon: "i-heroicons-arrow-path",
color: "neutral",
variant: "soft",
};
}
if (lockPhase.value !== "idle") {
return {
label: "Lock-Profil wird installiert…",
icon: "i-heroicons-arrow-path",
color: "neutral",
variant: "soft",
};
}
if (!props.isConnected || !props.iphone) {
return {
label: "iPhone verbinden, um ReBreak Cloud zu synchronisieren",
icon: "i-heroicons-link",
color: "primary",
variant: "solid",
};
}
if (autoSyncing.value) {
return {
label: "Prüfe Schutz…",
icon: "i-heroicons-arrow-path",
color: "neutral",
variant: "soft",
};
}
const backend = mdmState.value.data;
const local = props.iphone;
if (!local.isSupervised) {
return {
label: "Supervisen",
icon: "i-heroicons-shield-check",
color: "primary",
variant: "solid",
to: "/supervise",
};
}
if (!localEnrollment.value) {
const isKnownDevice = !!props.device.mdmId;
if (isKnownDevice) {
return {
label: "Enrollment starten",
icon: "i-heroicons-document-check",
color: "primary",
variant: "solid",
};
}
return {
label: "Enrollen",
icon: "i-heroicons-document-check",
color: "primary",
variant: "solid",
to: "/enroll",
};
}
if (!localLock.value) {
return {
label: "Lock-Profil installieren",
icon: "i-heroicons-lock-closed",
color: "warning",
variant: "solid",
};
}
if (!localApp.value) {
return {
label: "App installieren",
icon: "i-heroicons-arrow-down-tray",
color: "primary",
variant: "solid",
to: "/sideload",
};
}
if (!backend?.lockProfileInstalled) {
return {
label: "Schutz synchronisieren",
icon: "i-heroicons-arrow-path",
color: "warning",
variant: "soft",
};
}
const installedMdmVersion = getInstalledMdmVersion(local.installedProfileIDs ?? []);
if (installedMdmVersion && installedMdmVersion !== REBREAK_MDM_VERSION) {
return {
label: "MDM-Update installieren",
icon: "i-heroicons-arrow-up-tray",
color: "warning",
variant: "soft",
};
}
return {
label: "Voller Schutz aktiv",
icon: "i-heroicons-shield-check",
color: "success",
variant: "soft",
disabled: true,
};
});
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";
});
onMounted(() => {
if (props.isConnected) {
runAutoSync();
}
});
watch(
() => props.isConnected,
(connected) => {
if (connected) {
autoSyncComplete.value = false;
runAutoSync();
} else {
autoSyncComplete.value = false;
autoSyncing.value = false;
}
},
);
// Wenn das iPhone neu erkannt wird und die Profil-Liste erfolgreich ausgelesen
// wurde, heben wir den USB-Failure-Flag wieder auf.
watch(
() => props.iphone?.installedProfileIDs,
(ids) => {
if (ids && ids.length > 0) {
lockProfileUsbCheckFailed.value = false;
}
},
);
async function runAutoSync() {
if (autoSyncing.value) return;
autoSyncing.value = true;
try {
await refreshMdmStatus();
emit("sync", props.device);
// Keep the animation visible briefly so the user perceives the comparison.
await new Promise((resolve) => setTimeout(resolve, 1500));
} finally {
autoSyncing.value = false;
autoSyncComplete.value = true;
}
}
function onActionClick() {
if (props.inGracePeriod) {
emit("remove", props.device);
return;
}
if (autoSyncing.value) return;
if (!props.isConnected || !props.iphone) {
emit("connect", props.device);
return;
}
const backend = mdmState.value.data;
if (props.device.mdmId && (!backend?.enrolled || !localEnrollment.value)) {
startInlineEnrollment();
return;
}
if (localEnrollment.value && !localLock.value) {
startInlineLockProfile();
return;
}
manualSyncing.value = true;
emit("sync", props.device);
setTimeout(() => {
manualSyncing.value = false;
}, 800);
}
async function startInlineEnrollment() {
if (!props.iphone?.udid) return;
enrollmentPhase.value = "loading";
enrollmentError.value = null;
enrollmentLogs.value = [];
enrollmentQrUrl.value = "";
enrollmentServerInfo.value = null;
try {
const url = "https://mdm.rebreak.org/enrollment/rebreak-enrollment.mobileconfig";
enrollmentLogs.value.push(`→ Lade Enrollment-Profil`);
const path = await downloadAndPatchEnrollmentProfile(url, props.iphone.udid);
enrollmentLogs.value.push(`✓ Profil gespeichert`);
enrollmentServerInfo.value = await startLocalProfileServer(path);
enrollmentLogs.value.push(`✓ Lokaler Server gestartet`);
enrollmentQrUrl.value = await QRCode.toDataURL(enrollmentServerInfo.value.qr_payload, {
width: 192,
margin: 2,
});
enrollmentPhase.value = "waiting";
} catch (e: any) {
enrollmentError.value = e?.message ?? "Enrollment konnte nicht gestartet werden";
enrollmentLogs.value.push(`${enrollmentError.value}`);
enrollmentPhase.value = "error";
}
}
async function checkInlineEnrollment() {
if (!props.iphone?.udid) return;
enrollmentPhase.value = "checking";
enrollmentError.value = null;
try {
let ids: string[] = [];
try {
ids = await getInstalledProfiles();
if (props.iphone) {
props.iphone.installedProfileIDs = ids;
}
enrollmentLogs.value.push("✓ Lokale Profile via USB gelesen");
} catch (usbErr: any) {
enrollmentLogs.value.push(
`⚠ USB-Profilabfrage nicht möglich: ${usbErr?.message ?? String(usbErr)}`,
);
enrollmentLogs.value.push("→ Versuche MDM-Push als Alternative …");
}
if (!ids.includes(ENROLLMENT_PROFILE_ID)) {
// Wenn cfgutil nicht greift, prüfen wir das Backend: sobald enrolled=true
// ist, wissen wir, dass das Profil installiert wurde.
await refreshMdmStatus();
if (mdmState.value.data?.enrolled) {
enrollmentLogs.value.push("✓ Enrollment im Backend als aktiv bestätigt");
} else {
enrollmentError.value =
"Enrollment-Profil noch nicht installiert. Bitte QR-Code scannen und Profil installieren.";
enrollmentPhase.value = "error";
return;
}
} else {
enrollmentLogs.value.push("✓ Enrollment-Profil lokal erkannt");
}
try {
const push = await mdmPush(props.iphone.udid);
enrollmentLogs.value.push(`✓ Push: ${push.push_result}`);
} catch (pushErr: any) {
enrollmentLogs.value.push(`⚠ Push nicht möglich: ${pushErr?.message ?? String(pushErr)}`);
enrollmentLogs.value.push("→ Enrollment ist lokal aktiv. Fahre mit Lock-Profil fort.");
}
await refreshMdmStatus();
// After successful enrollment, automatically continue to lock profile if needed.
if (!localLock.value) {
enrollmentLogs.value.push("→ Enrollment abgeschlossen. Starte Lock-Profil …");
closeInlineEnrollment();
await startInlineLockProfile();
return;
}
enrollmentPhase.value = "success";
} catch (e: any) {
enrollmentError.value = e?.message ?? "Prüfung fehlgeschlagen";
enrollmentLogs.value.push(`${enrollmentError.value}`);
enrollmentPhase.value = "error";
}
}
function closeInlineEnrollment() {
stopLocalProfileServer();
enrollmentPhase.value = "idle";
enrollmentServerInfo.value = null;
enrollmentQrUrl.value = "";
enrollmentError.value = null;
}
async function startInlineLockProfile() {
lockPhase.value = "loading";
lockError.value = null;
lockLogs.value = [];
lockQrUrl.value = "";
try {
lockLogs.value.push("→ Starte lokalen Server für Lock-Profil …");
const serverInfo = await startLocalProfileServer(LOCK_PROFILE_PATH);
lockLogs.value.push(`✓ Server gestartet: ${serverInfo.url}`);
lockQrUrl.value = await QRCode.toDataURL(serverInfo.qr_payload, {
width: 192,
margin: 2,
});
lockPhase.value = "waiting";
} catch (e: any) {
lockError.value = e?.message ?? "Lock-Profil-Server konnte nicht gestartet werden";
lockLogs.value.push(`${lockError.value}`);
lockPhase.value = "error";
}
}
async function checkInlineLockProfile() {
if (!props.iphone) return;
lockPhase.value = "checking";
lockError.value = null;
lockProfileUsbCheckFailed.value = false;
try {
let ids: string[] = [];
try {
ids = await getInstalledProfiles();
props.iphone.installedProfileIDs = ids;
lockLogs.value.push("✓ Lokale Profile via USB gelesen");
} catch (usbErr: any) {
lockProfileUsbCheckFailed.value = true;
lockLogs.value.push(
`⚠ USB-Profilabfrage nicht möglich: ${usbErr?.message ?? String(usbErr)}`,
);
lockLogs.value.push("→ Verwende Backend-Status als Quelle der Wahrheit.");
}
if (!ids.includes(LOCK_PROFILE_ID)) {
lockLogs.value.push("→ Prüfe Backend-Status …");
await refreshMdmStatus();
const backendInstalled = mdmState.value.data?.lockProfileInstalled ?? false;
if (backendInstalled) {
lockLogs.value.push("✓ Lock-Profil im Backend als installiert bestätigt");
lockPhase.value = "success";
return;
}
lockError.value =
"Lock-Profil noch nicht installiert. Bitte QR-Code scannen und Profil installieren.";
lockPhase.value = "error";
return;
}
lockLogs.value.push("✓ Lock-Profil lokal erkannt");
lockLogs.value.push("→ Hole aktuellen Backend-Status …");
await refreshMdmStatus();
lockLogs.value.push("✓ Status aktualisiert");
lockPhase.value = "success";
} catch (e: any) {
lockError.value = e?.message ?? "Prüfung fehlgeschlagen";
lockLogs.value.push(`${lockError.value}`);
lockPhase.value = "error";
}
}
function closeInlineLockProfile() {
stopLocalProfileServer();
lockPhase.value = "idle";
lockError.value = null;
lockLogs.value = [];
lockQrUrl.value = "";
}
</script>