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>
|
||||
|
||||
<UBadge
|
||||
:color="statusColor"
|
||||
:variant="statusVariant"
|
||||
:color="topBadge.color"
|
||||
:variant="topBadge.variant"
|
||||
size="sm"
|
||||
class="font-bold shrink-0"
|
||||
>
|
||||
{{ statusLabel }}
|
||||
{{ topBadge.label }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<!-- Incomplete-protection hint -->
|
||||
<div
|
||||
v-if="isConnected && iosStars"
|
||||
class="mt-4 rounded-xl bg-gray-50 dark:bg-gray-800/50 p-4"
|
||||
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"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
iOS Schutz-Status
|
||||
</span>
|
||||
<span class="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
Verbunden
|
||||
</span>
|
||||
</div>
|
||||
<IosStarRating
|
||||
:enrollment="iosStars.enrollment"
|
||||
:sideload="iosStars.sideload"
|
||||
:app="iosStars.app"
|
||||
<UIcon
|
||||
name="i-heroicons-exclamation-triangle"
|
||||
class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5"
|
||||
/>
|
||||
<ul class="mt-3 space-y-1.5 text-sm text-gray-700 dark:text-gray-200">
|
||||
<li class="flex items-center justify-between">
|
||||
<span>Supervised</span>
|
||||
<span :class="iosStars.isSupervised ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'">
|
||||
{{ iosStars.isSupervised ? "Ja" : "Nein" }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span>Enrollment</span>
|
||||
<span :class="iosStars.enrollment ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'">
|
||||
{{ iosStars.enrollment ? "Ja" : "Nein" }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span>Sideload/Lock</span>
|
||||
<span :class="iosStars.sideload ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'">
|
||||
{{ iosStars.sideload ? "Ja" : "Nein" }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span>ReBreak App</span>
|
||||
<span :class="iosStars.app ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-gray-500'">
|
||||
{{ iosStars.app ? "Ja" : "Nein" }}
|
||||
<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="i-heroicons-information-circle" class="w-5 h-5 text-[var(--rebreak-primary)] shrink-0 mt-0.5" />
|
||||
<span>Verbinde dein iPhone mit USB, um den Schutz zu vervollständigen.</span>
|
||||
</div>
|
||||
|
||||
<!-- Split backend / local cards when USB-connected -->
|
||||
<div
|
||||
v-else
|
||||
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>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1.5"
|
||||
<!-- Local USB device 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">
|
||||
Lokales USB-Gerät
|
||||
</span>
|
||||
<UBadge
|
||||
color="success"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
<UIcon name="i-heroicons-information-circle" class="w-4 h-4" />
|
||||
<span>Zum Live-Status iPhone per USB verbinden und aktualisieren</span>
|
||||
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">
|
||||
@ -104,7 +188,8 @@
|
||||
:variant="action.variant"
|
||||
size="sm"
|
||||
:icon="action.icon"
|
||||
:loading="syncing"
|
||||
:loading="manualSyncing || autoSyncing"
|
||||
:disabled="autoSyncing"
|
||||
@click="onActionClick"
|
||||
>
|
||||
{{ action.label }}
|
||||
@ -126,8 +211,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
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<{
|
||||
@ -137,6 +223,9 @@ const props = defineProps<{
|
||||
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;
|
||||
@ -147,16 +236,215 @@ const ENROLLMENT_PROFILE_ID = "org.rebreak.mdm.enrollment";
|
||||
const LOCK_PROFILE_ID = "org.rebreak.protection.contentfilter.sideload";
|
||||
const APP_BUNDLE_ID = "org.rebreak.app";
|
||||
|
||||
const syncing = ref(false);
|
||||
const manualSyncing = ref(false);
|
||||
const autoSyncing = ref(false);
|
||||
const autoSyncComplete = ref(false);
|
||||
|
||||
const iosStars = computed(() => {
|
||||
if (!props.isConnected || !props.iphone) return null;
|
||||
return {
|
||||
enrollment: props.iphone.installedProfileIDs?.includes(ENROLLMENT_PROFILE_ID) ?? false,
|
||||
sideload: props.iphone.installedProfileIDs?.includes(LOCK_PROFILE_ID) ?? false,
|
||||
app: props.iphone.installedAppBundleIDs?.includes(APP_BUNDLE_ID) ?? false,
|
||||
isSupervised: props.iphone.isSupervised,
|
||||
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?.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 {
|
||||
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 {
|
||||
@ -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 {
|
||||
label: "Supervisen",
|
||||
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 {
|
||||
label: "Enrollen",
|
||||
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 {
|
||||
label: "Sideload installieren",
|
||||
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 {
|
||||
label: "App installieren",
|
||||
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) {
|
||||
return {
|
||||
label: "MDM-Update installieren",
|
||||
@ -257,31 +566,52 @@ const platformLabel = computed(() => {
|
||||
return "iOS";
|
||||
});
|
||||
|
||||
const statusConfig: Record<
|
||||
DeviceStatus,
|
||||
{ label: string; color: "success" | "warning" | "error" | "neutral"; variant: "subtle" | "outline" }
|
||||
> = {
|
||||
active: { label: "Aktiv", color: "success", variant: "subtle" },
|
||||
cooldown: { label: "Schlafmodus", color: "warning", variant: "subtle" },
|
||||
revoked: { label: "Widerrufen", color: "error", variant: "subtle" },
|
||||
pending: { label: "Ausstehend", color: "neutral", variant: "subtle" },
|
||||
unprotected: { label: "Ungeschützt", color: "neutral", variant: "outline" },
|
||||
};
|
||||
onMounted(() => {
|
||||
if (props.isConnected) {
|
||||
runAutoSync();
|
||||
}
|
||||
});
|
||||
|
||||
const statusLabel = computed(() => statusConfig[props.device.status].label);
|
||||
const statusColor = computed(() => statusConfig[props.device.status].color);
|
||||
const statusVariant = computed(() => statusConfig[props.device.status].variant);
|
||||
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;
|
||||
}
|
||||
syncing.value = true;
|
||||
|
||||
if (autoSyncing.value) return;
|
||||
|
||||
manualSyncing.value = true;
|
||||
emit("sync", props.device);
|
||||
// Parent is responsible for resetting syncing state via prop/loading if needed.
|
||||
setTimeout(() => {
|
||||
syncing.value = false;
|
||||
manualSyncing.value = false;
|
||||
}, 800);
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
export interface MdmStatusData {
|
||||
enrolled: boolean;
|
||||
company: string | null;
|
||||
supervised: boolean;
|
||||
lockProfileInstalled: boolean;
|
||||
lastAppPushAt: string | null;
|
||||
}
|
||||
|
||||
export interface SuperviseStatus {
|
||||
isSupervised: boolean;
|
||||
organizationName?: string;
|
||||
@ -262,6 +270,14 @@ export function useTauri() {
|
||||
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 {
|
||||
getPlatform,
|
||||
redeemPairingCode,
|
||||
@ -297,5 +313,7 @@ export function useTauri() {
|
||||
getHostname,
|
||||
getHardwareId,
|
||||
getDeviceId,
|
||||
getMdmStatus,
|
||||
linkMdmDevice,
|
||||
};
|
||||
}
|
||||
|
||||
@ -97,6 +97,23 @@ pub struct UserProfile {
|
||||
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)]
|
||||
pub struct ApiEnvelope<T> {
|
||||
pub success: bool,
|
||||
@ -345,6 +362,49 @@ impl MagicApiClient {
|
||||
.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>(
|
||||
response: reqwest::Response,
|
||||
) -> AppResult<T> {
|
||||
|
||||
@ -8,8 +8,8 @@ mod server;
|
||||
mod sidecar;
|
||||
|
||||
use backend::api::{
|
||||
MagicApiClient, MagicDeviceInfo, RedeemPairingResponse, RegisterDeviceResponse, ReleaseResponse,
|
||||
UserProfile,
|
||||
MagicApiClient, MagicDeviceInfo, MdmStatusData, RedeemPairingResponse, RegisterDeviceResponse,
|
||||
ReleaseResponse, UserProfile,
|
||||
};
|
||||
use config::{AppConfig, DesktopProtectionState, MagicSession};
|
||||
use error::AppResult;
|
||||
@ -51,6 +51,8 @@ pub fn run() {
|
||||
download_profile,
|
||||
activate_protection,
|
||||
fetch_me,
|
||||
get_mdm_status,
|
||||
link_mdm_device,
|
||||
get_desktop_protection_status,
|
||||
set_desktop_protection_status,
|
||||
get_hostname,
|
||||
@ -217,6 +219,22 @@ async fn fetch_me() -> AppResult<UserProfile> {
|
||||
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]
|
||||
async fn download_profile(profile_url: String) -> AppResult<String> {
|
||||
let session = require_session()?;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user