637 lines
19 KiB
Vue
637 lines
19 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>
|
|
|
|
<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"
|
|
@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 type { ComputedDevice, DeviceStatus } from "~/composables/useDeviceStatus";
|
|
import { useMdmStatus } from "~/composables/useMdmStatus";
|
|
import { REBREAK_MDM_VERSION, getInstalledMdmVersion, type IphoneDeviceState } 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 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: "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: "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: "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,
|
|
};
|
|
}
|
|
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;
|
|
}
|
|
|
|
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 (!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 (!backend?.enrolled || !localEnrollment.value) {
|
|
const isKnownDevice = !!props.device.mdmId;
|
|
return {
|
|
label: isKnownDevice ? "Schutz vervollständigen" : "Enrollen",
|
|
icon: isKnownDevice ? "i-heroicons-shield-check" : "i-heroicons-document-check",
|
|
color: "primary",
|
|
variant: "solid",
|
|
to: isKnownDevice ? "/preflight" : "/enroll",
|
|
};
|
|
}
|
|
|
|
if (!localLock.value) {
|
|
return {
|
|
label: "Sideload installieren",
|
|
icon: "i-heroicons-lock-closed",
|
|
color: "warning",
|
|
variant: "solid",
|
|
to: "/sideload",
|
|
};
|
|
}
|
|
|
|
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: "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";
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
manualSyncing.value = true;
|
|
emit("sync", props.device);
|
|
setTimeout(() => {
|
|
manualSyncing.value = false;
|
|
}, 800);
|
|
}
|
|
</script>
|