feat(magic): iOS device card warning badge, USB hint, split backend/local cards and auto-sync

This commit is contained in:
chahinebrini 2026-06-17 23:32:41 +02:00
parent b87ec08431
commit 75d1b06105
5 changed files with 571 additions and 80 deletions

View File

@ -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>
<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" }}
</span>
</li>
</ul>
</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 <div
v-else v-else
class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1.5" class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4 relative"
> >
<UIcon name="i-heroicons-information-circle" class="w-4 h-4" /> <!-- Animated sync overlay -->
<span>Zum Live-Status iPhone per USB verbinden und aktualisieren</span> <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 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>
<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,
return { );
enrollment: props.iphone.installedProfileIDs?.includes(ENROLLMENT_PROFILE_ID) ?? false, const localLock = computed(() =>
sideload: props.iphone.installedProfileIDs?.includes(LOCK_PROFILE_ID) ?? false, props.iphone?.installedProfileIDs?.includes(LOCK_PROFILE_ID) ?? false,
app: props.iphone.installedAppBundleIDs?.includes(APP_BUNDLE_ID) ?? false, );
isSupervised: props.iphone.isSupervised, 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 { 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>

View 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,
};
}

View File

@ -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,
}; };
} }

View File

@ -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> {

View File

@ -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()?;