feat(magic): iOS device card warning badge, USB hint, split backend/local cards and auto-sync
This commit is contained in:
parent
b87ec08431
commit
75d1b06105
@ -25,66 +25,150 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UBadge
|
<UBadge
|
||||||
:color="statusColor"
|
:color="topBadge.color"
|
||||||
:variant="statusVariant"
|
:variant="topBadge.variant"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="font-bold shrink-0"
|
class="font-bold shrink-0"
|
||||||
>
|
>
|
||||||
{{ statusLabel }}
|
{{ topBadge.label }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Incomplete-protection hint -->
|
||||||
<div
|
<div
|
||||||
v-if="isConnected && iosStars"
|
v-if="showIncompleteHint"
|
||||||
class="mt-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 p-4"
|
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"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-2">
|
<UIcon
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
name="i-heroicons-exclamation-triangle"
|
||||||
iOS Schutz-Status
|
class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5"
|
||||||
</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">
|
<div>
|
||||||
<li class="flex items-center justify-between">
|
<p class="text-sm font-bold text-amber-800 dark:text-amber-300">
|
||||||
<span>Supervised</span>
|
Schutz unvollständig
|
||||||
<span :class="iosStars.isSupervised ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'">
|
</p>
|
||||||
{{ iosStars.isSupervised ? "Ja" : "Nein" }}
|
<p class="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
|
||||||
</span>
|
{{ incompleteMessage }}
|
||||||
</li>
|
</p>
|
||||||
<li class="flex items-center justify-between">
|
</div>
|
||||||
<span>Enrollment</span>
|
</div>
|
||||||
<span :class="iosStars.enrollment ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'">
|
|
||||||
{{ iosStars.enrollment ? "Ja" : "Nein" }}
|
<!-- USB connection hint -->
|
||||||
</span>
|
<div
|
||||||
</li>
|
v-if="!isConnected"
|
||||||
<li class="flex items-center justify-between">
|
class="mt-3 text-sm text-gray-600 dark:text-gray-300 flex items-start gap-2"
|
||||||
<span>Sideload/Lock</span>
|
>
|
||||||
<span :class="iosStars.sideload ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'">
|
<UIcon name="i-heroicons-information-circle" class="w-5 h-5 text-[var(--rebreak-primary)] shrink-0 mt-0.5" />
|
||||||
{{ iosStars.sideload ? "Ja" : "Nein" }}
|
<span>Verbinde dein iPhone mit USB, um den Schutz zu vervollständigen.</span>
|
||||||
</span>
|
</div>
|
||||||
</li>
|
|
||||||
<li class="flex items-center justify-between">
|
<!-- Split backend / local cards when USB-connected -->
|
||||||
<span>ReBreak App</span>
|
<div
|
||||||
<span :class="iosStars.app ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'">
|
v-else
|
||||||
{{ iosStars.app ? "Ja" : "Nein" }}
|
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>
|
</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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<!-- Local USB device card -->
|
||||||
v-else
|
<div class="rounded-xl bg-gray-50 dark:bg-gray-800/50 p-4">
|
||||||
class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1.5"
|
<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"
|
||||||
>
|
>
|
||||||
<UIcon name="i-heroicons-information-circle" class="w-4 h-4" />
|
Verbunden
|
||||||
<span>Zum Live-Status iPhone per USB verbinden und aktualisieren</span>
|
</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>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center gap-3">
|
<div class="mt-4 flex items-center gap-3">
|
||||||
@ -104,7 +188,8 @@
|
|||||||
:variant="action.variant"
|
:variant="action.variant"
|
||||||
size="sm"
|
size="sm"
|
||||||
:icon="action.icon"
|
:icon="action.icon"
|
||||||
:loading="syncing"
|
:loading="manualSyncing || autoSyncing"
|
||||||
|
:disabled="autoSyncing"
|
||||||
@click="onActionClick"
|
@click="onActionClick"
|
||||||
>
|
>
|
||||||
{{ action.label }}
|
{{ action.label }}
|
||||||
@ -126,8 +211,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import type { ComputedDevice, DeviceStatus } from "~/composables/useDeviceStatus";
|
import type { ComputedDevice, DeviceStatus } from "~/composables/useDeviceStatus";
|
||||||
|
import { useMdmStatus } from "~/composables/useMdmStatus";
|
||||||
import { REBREAK_MDM_VERSION, getInstalledMdmVersion, type IphoneDeviceState } from "~/composables/useTauri";
|
import { REBREAK_MDM_VERSION, getInstalledMdmVersion, type IphoneDeviceState } from "~/composables/useTauri";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -137,6 +223,9 @@ const props = defineProps<{
|
|||||||
inGracePeriod?: boolean;
|
inGracePeriod?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const deviceIdRef = computed(() => props.device.deviceId);
|
||||||
|
const { state: mdmState, refresh: refreshMdmStatus } = useMdmStatus(deviceIdRef);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "sync", device: ComputedDevice): void;
|
(e: "sync", device: ComputedDevice): void;
|
||||||
(e: "open", device: ComputedDevice): void;
|
(e: "open", device: ComputedDevice): void;
|
||||||
@ -147,16 +236,215 @@ const ENROLLMENT_PROFILE_ID = "org.rebreak.mdm.enrollment";
|
|||||||
const LOCK_PROFILE_ID = "org.rebreak.protection.contentfilter.sideload";
|
const LOCK_PROFILE_ID = "org.rebreak.protection.contentfilter.sideload";
|
||||||
const APP_BUNDLE_ID = "org.rebreak.app";
|
const APP_BUNDLE_ID = "org.rebreak.app";
|
||||||
|
|
||||||
const syncing = ref(false);
|
const manualSyncing = ref(false);
|
||||||
|
const autoSyncing = ref(false);
|
||||||
|
const autoSyncComplete = ref(false);
|
||||||
|
|
||||||
const iosStars = computed(() => {
|
const localEnrollment = computed(() =>
|
||||||
if (!props.isConnected || !props.iphone) return null;
|
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?.enrolled ? (data.supervised ? "Ja" : "Nein") : "—",
|
||||||
|
valueClass: data?.enrolled
|
||||||
|
? data.supervised
|
||||||
|
? "text-green-600 dark:text-green-400 font-medium"
|
||||||
|
: "text-red-600 dark:text-red-400 font-medium"
|
||||||
|
: "text-gray-400 dark:text-gray-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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?.enrolled
|
||||||
|
? data.lockProfileInstalled
|
||||||
|
? "Installiert"
|
||||||
|
: "Fehlt"
|
||||||
|
: "—",
|
||||||
|
valueClass: data?.enrolled
|
||||||
|
? data.lockProfileInstalled
|
||||||
|
? "text-green-600 dark:text-green-400 font-medium"
|
||||||
|
: "text-red-600 dark:text-red-400 font-medium"
|
||||||
|
: "text-gray-400 dark:text-gray-500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 (!props.isConnected || !props.iphone) return true;
|
||||||
|
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;
|
||||||
|
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 (!props.isConnected || !props.iphone) {
|
||||||
|
return "Verbinde dein iPhone per USB, damit wir den lokalen Schutz prüfen können.";
|
||||||
|
}
|
||||||
|
if (!mdmState.value.data?.enrolled) {
|
||||||
|
return "Das Gerät ist im Backend noch nicht MDM-enrolled.";
|
||||||
|
}
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
if (!mdmState.value.data?.lockProfileInstalled) {
|
||||||
|
return "Das Lock-Profil ist im Backend noch nicht als aktiv markiert.";
|
||||||
|
}
|
||||||
|
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 {
|
return {
|
||||||
enrollment: props.iphone.installedProfileIDs?.includes(ENROLLMENT_PROFILE_ID) ?? false,
|
label: "Schutz unvollständig",
|
||||||
sideload: props.iphone.installedProfileIDs?.includes(LOCK_PROFILE_ID) ?? false,
|
color: "warning" as const,
|
||||||
app: props.iphone.installedAppBundleIDs?.includes(APP_BUNDLE_ID) ?? false,
|
variant: "subtle" as const,
|
||||||
isSupervised: props.iphone.isSupervised,
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
const cfg = statusConfig[props.device.status];
|
||||||
|
return { label: cfg.label, color: cfg.color, variant: cfg.variant };
|
||||||
});
|
});
|
||||||
|
|
||||||
interface IosAction {
|
interface IosAction {
|
||||||
@ -187,7 +475,19 @@ const action = computed<IosAction>(() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.iphone.isSupervised) {
|
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 {
|
return {
|
||||||
label: "Supervisen",
|
label: "Supervisen",
|
||||||
icon: "i-heroicons-shield-check",
|
icon: "i-heroicons-shield-check",
|
||||||
@ -197,7 +497,7 @@ const action = computed<IosAction>(() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.iphone.installedProfileIDs?.includes(ENROLLMENT_PROFILE_ID)) {
|
if (!backend?.enrolled || !localEnrollment.value) {
|
||||||
return {
|
return {
|
||||||
label: "Enrollen",
|
label: "Enrollen",
|
||||||
icon: "i-heroicons-document-check",
|
icon: "i-heroicons-document-check",
|
||||||
@ -207,7 +507,7 @@ const action = computed<IosAction>(() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.iphone.installedProfileIDs?.includes(LOCK_PROFILE_ID)) {
|
if (!localLock.value) {
|
||||||
return {
|
return {
|
||||||
label: "Sideload installieren",
|
label: "Sideload installieren",
|
||||||
icon: "i-heroicons-lock-closed",
|
icon: "i-heroicons-lock-closed",
|
||||||
@ -217,7 +517,7 @@ const action = computed<IosAction>(() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.iphone.installedAppBundleIDs?.includes(APP_BUNDLE_ID)) {
|
if (!localApp.value) {
|
||||||
return {
|
return {
|
||||||
label: "App installieren",
|
label: "App installieren",
|
||||||
icon: "i-heroicons-arrow-down-tray",
|
icon: "i-heroicons-arrow-down-tray",
|
||||||
@ -227,7 +527,16 @@ const action = computed<IosAction>(() => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const installedMdmVersion = getInstalledMdmVersion(props.iphone.installedProfileIDs ?? []);
|
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) {
|
if (installedMdmVersion && installedMdmVersion !== REBREAK_MDM_VERSION) {
|
||||||
return {
|
return {
|
||||||
label: "MDM-Update installieren",
|
label: "MDM-Update installieren",
|
||||||
@ -257,31 +566,52 @@ const platformLabel = computed(() => {
|
|||||||
return "iOS";
|
return "iOS";
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusConfig: Record<
|
onMounted(() => {
|
||||||
DeviceStatus,
|
if (props.isConnected) {
|
||||||
{ label: string; color: "success" | "warning" | "error" | "neutral"; variant: "subtle" | "outline" }
|
runAutoSync();
|
||||||
> = {
|
}
|
||||||
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);
|
watch(
|
||||||
const statusColor = computed(() => statusConfig[props.device.status].color);
|
() => props.isConnected,
|
||||||
const statusVariant = computed(() => statusConfig[props.device.status].variant);
|
(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() {
|
function onActionClick() {
|
||||||
if (props.inGracePeriod) {
|
if (props.inGracePeriod) {
|
||||||
emit("remove", props.device);
|
emit("remove", props.device);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
syncing.value = true;
|
|
||||||
|
if (autoSyncing.value) return;
|
||||||
|
|
||||||
|
manualSyncing.value = true;
|
||||||
emit("sync", props.device);
|
emit("sync", props.device);
|
||||||
// Parent is responsible for resetting syncing state via prop/loading if needed.
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
syncing.value = false;
|
manualSyncing.value = false;
|
||||||
}, 800);
|
}, 800);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
65
apps/rebreak-magic/app/composables/useMdmStatus.ts
Normal file
65
apps/rebreak-magic/app/composables/useMdmStatus.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { ref, watch, type Ref } from "vue";
|
||||||
|
import { useTauri, type MdmStatusData } from "./useTauri";
|
||||||
|
|
||||||
|
export interface MdmStatusState {
|
||||||
|
data: MdmStatusData | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMdmStatus(deviceId: Ref<string | null | undefined>) {
|
||||||
|
const { getMdmStatus, linkMdmDevice } = useTauri();
|
||||||
|
|
||||||
|
const state = ref<MdmStatusState>({
|
||||||
|
data: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const id = deviceId.value;
|
||||||
|
if (!id) {
|
||||||
|
state.value.data = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.value.loading = true;
|
||||||
|
state.value.error = null;
|
||||||
|
try {
|
||||||
|
state.value.data = await getMdmStatus(id);
|
||||||
|
} catch (e: any) {
|
||||||
|
state.value.error = e?.message ?? "MDM-Status konnte nicht geladen werden";
|
||||||
|
state.value.data = null;
|
||||||
|
} finally {
|
||||||
|
state.value.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function link(mdmId: string) {
|
||||||
|
const id = deviceId.value;
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
state.value.loading = true;
|
||||||
|
state.value.error = null;
|
||||||
|
try {
|
||||||
|
await linkMdmDevice(id, mdmId);
|
||||||
|
await refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
state.value.error = e?.message ?? "MDM-Verknüpfung fehlgeschlagen";
|
||||||
|
} finally {
|
||||||
|
state.value.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => deviceId.value,
|
||||||
|
() => refresh(),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
refresh,
|
||||||
|
link,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -82,6 +82,14 @@ export interface DesktopProtectionState {
|
|||||||
activatedAt: string;
|
activatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MdmStatusData {
|
||||||
|
enrolled: boolean;
|
||||||
|
company: string | null;
|
||||||
|
supervised: boolean;
|
||||||
|
lockProfileInstalled: boolean;
|
||||||
|
lastAppPushAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SuperviseStatus {
|
export interface SuperviseStatus {
|
||||||
isSupervised: boolean;
|
isSupervised: boolean;
|
||||||
organizationName?: string;
|
organizationName?: string;
|
||||||
@ -262,6 +270,14 @@ export function useTauri() {
|
|||||||
return await invokeLogged("get_hostname");
|
return await invokeLogged("get_hostname");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getMdmStatus(deviceId: string): Promise<MdmStatusData> {
|
||||||
|
return await invokeLogged("get_mdm_status", { deviceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkMdmDevice(deviceId: string, mdmId: string): Promise<void> {
|
||||||
|
await invokeLogged("link_mdm_device", { deviceId, mdmId });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getPlatform,
|
getPlatform,
|
||||||
redeemPairingCode,
|
redeemPairingCode,
|
||||||
@ -297,5 +313,7 @@ export function useTauri() {
|
|||||||
getHostname,
|
getHostname,
|
||||||
getHardwareId,
|
getHardwareId,
|
||||||
getDeviceId,
|
getDeviceId,
|
||||||
|
getMdmStatus,
|
||||||
|
linkMdmDevice,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,6 +97,23 @@ pub struct UserProfile {
|
|||||||
pub plan: Option<String>,
|
pub plan: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MdmStatusData {
|
||||||
|
pub enrolled: bool,
|
||||||
|
pub company: Option<String>,
|
||||||
|
pub supervised: bool,
|
||||||
|
#[serde(rename = "lockProfileInstalled")]
|
||||||
|
pub lock_profile_installed: bool,
|
||||||
|
#[serde(rename = "lastAppPushAt")]
|
||||||
|
pub last_app_push_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MdmLinkRequest {
|
||||||
|
#[serde(rename = "mdmId")]
|
||||||
|
pub mdm_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ApiEnvelope<T> {
|
pub struct ApiEnvelope<T> {
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
@ -345,6 +362,49 @@ impl MagicApiClient {
|
|||||||
.map_err(|e| AppError::new(format!("Failed to read profile: {}", e)))
|
.map_err(|e| AppError::new(format!("Failed to read profile: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_mdm_status(&self, token: &str, device_id: &str) -> AppResult<MdmStatusData> {
|
||||||
|
let url = format!("{}/api/magic/devices/{}/mdm", self.base_url, device_id);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
|
||||||
|
|
||||||
|
Self::handle_response::<ApiEnvelope<MdmStatusData>>(response)
|
||||||
|
.await
|
||||||
|
.map(|envelope| envelope.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn link_mdm_device(
|
||||||
|
&self,
|
||||||
|
token: &str,
|
||||||
|
device_id: &str,
|
||||||
|
mdm_id: &str,
|
||||||
|
) -> AppResult<()> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/magic/devices/{}/mdm-link",
|
||||||
|
self.base_url, device_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.json(&MdmLinkRequest {
|
||||||
|
mdm_id: mdm_id.to_string(),
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
|
||||||
|
|
||||||
|
Self::handle_response::<ApiEnvelope<serde_json::Value>>(response)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_response<T: serde::de::DeserializeOwned>(
|
async fn handle_response<T: serde::de::DeserializeOwned>(
|
||||||
response: reqwest::Response,
|
response: reqwest::Response,
|
||||||
) -> AppResult<T> {
|
) -> AppResult<T> {
|
||||||
|
|||||||
@ -8,8 +8,8 @@ mod server;
|
|||||||
mod sidecar;
|
mod sidecar;
|
||||||
|
|
||||||
use backend::api::{
|
use backend::api::{
|
||||||
MagicApiClient, MagicDeviceInfo, RedeemPairingResponse, RegisterDeviceResponse, ReleaseResponse,
|
MagicApiClient, MagicDeviceInfo, MdmStatusData, RedeemPairingResponse, RegisterDeviceResponse,
|
||||||
UserProfile,
|
ReleaseResponse, UserProfile,
|
||||||
};
|
};
|
||||||
use config::{AppConfig, DesktopProtectionState, MagicSession};
|
use config::{AppConfig, DesktopProtectionState, MagicSession};
|
||||||
use error::AppResult;
|
use error::AppResult;
|
||||||
@ -51,6 +51,8 @@ pub fn run() {
|
|||||||
download_profile,
|
download_profile,
|
||||||
activate_protection,
|
activate_protection,
|
||||||
fetch_me,
|
fetch_me,
|
||||||
|
get_mdm_status,
|
||||||
|
link_mdm_device,
|
||||||
get_desktop_protection_status,
|
get_desktop_protection_status,
|
||||||
set_desktop_protection_status,
|
set_desktop_protection_status,
|
||||||
get_hostname,
|
get_hostname,
|
||||||
@ -217,6 +219,22 @@ async fn fetch_me() -> AppResult<UserProfile> {
|
|||||||
client.fetch_me(&session.access_token).await
|
client.fetch_me(&session.access_token).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_mdm_status(device_id: String) -> AppResult<MdmStatusData> {
|
||||||
|
let session = require_session()?;
|
||||||
|
let config = AppConfig::load();
|
||||||
|
let client = MagicApiClient::new(&config);
|
||||||
|
client.get_mdm_status(&session.access_token, &device_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn link_mdm_device(device_id: String, mdm_id: String) -> AppResult<()> {
|
||||||
|
let session = require_session()?;
|
||||||
|
let config = AppConfig::load();
|
||||||
|
let client = MagicApiClient::new(&config);
|
||||||
|
client.link_mdm_device(&session.access_token, &device_id, &mdm_id).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn download_profile(profile_url: String) -> AppResult<String> {
|
async fn download_profile(profile_url: String) -> AppResult<String> {
|
||||||
let session = require_session()?;
|
let session = require_session()?;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user