Some checks failed
- Status kommt jetzt über /api/protection/event vom iOS Plugin.
- /api/devices/protection-state gibt jetzt { success: true, data: null }
zurück, damit der Rust-Client nicht failed.
921 lines
32 KiB
Vue
921 lines
32 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>("");
|
|
|
|
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`);
|
|
}
|
|
if (backend.lockProfileInstalled !== localLock.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;
|
|
}
|
|
},
|
|
);
|
|
|
|
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 {
|
|
const ids = await getInstalledProfiles();
|
|
if (props.iphone) {
|
|
props.iphone.installedProfileIDs = ids;
|
|
}
|
|
|
|
if (!ids.includes(ENROLLMENT_PROFILE_ID)) {
|
|
enrollmentError.value = "Enrollment-Profil noch nicht installiert. Bitte QR-Code scannen und Profil installieren.";
|
|
enrollmentPhase.value = "error";
|
|
return;
|
|
}
|
|
|
|
enrollmentLogs.value.push("✓ Enrollment-Profil 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;
|
|
|
|
try {
|
|
const ids = await getInstalledProfiles();
|
|
props.iphone.installedProfileIDs = ids;
|
|
|
|
if (!ids.includes(LOCK_PROFILE_ID)) {
|
|
lockError.value = "Lock-Profil noch nicht installiert. Bitte QR-Code scannen und Profil installieren.";
|
|
lockPhase.value = "error";
|
|
return;
|
|
}
|
|
|
|
lockLogs.value.push("✓ Lock-Profil 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>
|