Merge feat/magic-ios-section into main

This commit is contained in:
chahinebrini 2026-06-17 23:51:29 +02:00
commit a60def33d9
34 changed files with 3356 additions and 99 deletions

View File

@ -0,0 +1,65 @@
name: Build ReBreak Magic Windows
# Baut den NSIS-Installer der neuen unified ReBreak-Magic-App (Tauri) auf einem
# echten Windows-Runner — vom Mac aus geht kein Cross-Compile (MSVC + WebView2).
# Artefakt: x64-Installer, herunterladbar unter Actions → Run → Artifacts.
on:
workflow_dispatch:
push:
branches: [main]
paths:
- "apps/rebreak-magic/**"
- ".github/workflows/build-rebreak-magic-win.yml"
- "ops/mdm/supervise-magic/**"
permissions:
contents: read
concurrency:
group: build-rebreak-magic-win
cancel-in-progress: true
jobs:
build:
name: NSIS Installer (x64)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- uses: dtolnay/rust-toolchain@stable
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Install deps (rebreak-magic)
run: pnpm install --filter @rebreak/magic --no-frozen-lockfile
- name: Build supervise-magic Sidecar (Windows)
working-directory: ops/mdm/supervise-magic
shell: pwsh
run: |
go build -o bin/rebreak-supervise-magic-x86_64-pc-windows-msvc.exe ./cmd/supervise
$triple = (rustc -vV | Select-String 'host: (.*)').Matches.Groups[1].Value
New-Item -ItemType Directory -Force -Path "../../apps/rebreak-magic/src-tauri/binaries" | Out-Null
Copy-Item "bin/rebreak-supervise-magic-x86_64-pc-windows-msvc.exe" "../../apps/rebreak-magic/src-tauri/binaries/supervise-magic-$triple.exe" -Force
- name: Build Tauri-App (Frontend + NSIS)
working-directory: apps/rebreak-magic
run: pnpm tauri build
- name: Upload Installer
uses: actions/upload-artifact@v4
with:
name: ReBreak-Magic-Windows-x64
path: apps/rebreak-magic/src-tauri/target/release/bundle/nsis/*.exe
if-no-files-found: error

View File

@ -249,7 +249,7 @@ const platformLabel = computed(() => {
return labels[props.device.platform];
});
const showIosStars = computed(() => props.device?.isCurrent && props.device?.platform === "ios");
const showIosStars = computed(() => props.device?.platform === "ios" && !!props.iosStars);
const showDesktopToggle = computed(() => props.device?.isCurrent && (props.device?.platform === "mac" || props.device?.platform === "windows"));
const statusConfig: Record<

View File

@ -0,0 +1,617 @@
<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="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>
<!-- 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 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"
:disabled="autoSyncing"
@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;
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;
}>();
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?.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 {
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.isConnected || !props.iphone) {
return {
label: "iPhone verbinden, um ReBreak Cloud zu synchronisieren",
icon: "i-heroicons-link",
color: "primary",
variant: "solid",
to: "/detect",
};
}
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) {
return {
label: "Enrollen",
icon: "i-heroicons-document-check",
color: "primary",
variant: "solid",
to: "/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;
manualSyncing.value = true;
emit("sync", props.device);
setTimeout(() => {
manualSyncing.value = false;
}, 800);
}
</script>

View File

@ -0,0 +1,78 @@
<template>
<section>
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-bold text-gray-500 uppercase tracking-wider">
Meine iOS-Geräte
</h2>
</div>
<div v-if="loading && devices.length === 0" class="py-12 text-center text-gray-500">
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin mx-auto mb-3 text-[var(--rebreak-primary)]" />
<p class="font-semibold">Lade iOS-Geräte</p>
</div>
<div v-else-if="devices.length === 0 && !hasUnknownUsbDevice" class="py-10 text-center rounded-2xl bg-white ring-1 ring-gray-100">
<p class="text-sm text-gray-500">
{{ hasRefreshed ? 'Keine iOS-Geräte registriert.' : 'Noch keine iOS-Geräte geladen.' }}
</p>
<p v-if="hasRefreshed" class="text-xs text-gray-400 mt-2">
Installiere die ReBreak-App, melde dich an und registriere das Gerät.
</p>
</div>
<div v-else class="space-y-3">
<UnknownIosDeviceCard
v-if="hasUnknownUsbDevice && iphone"
:iphone="iphone"
/>
<IosDeviceCard
v-for="device in devices"
:key="device.deviceId"
:device="device"
:iphone="iphone"
:is-connected="device.deviceId === connectedDeviceId"
:in-grace-period="inGracePeriod"
@sync="emit('sync', $event)"
@open="emit('open', $event)"
@remove="emit('remove', $event)"
/>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { ComputedDevice } from "~/composables/useDeviceStatus";
import type { IphoneDeviceState } from "~/composables/useTauri";
const props = defineProps<{
devices: ComputedDevice[];
iphone: IphoneDeviceState | null;
loading: boolean;
hasRefreshed: boolean;
inGracePeriod?: boolean;
}>();
const emit = defineEmits<{
(e: "sync", device: ComputedDevice): void;
(e: "open", device: ComputedDevice): void;
(e: "remove", device: ComputedDevice): void;
}>();
function matchesIphone(device: ComputedDevice, iphone: IphoneDeviceState): boolean {
if (device.mdmId && device.mdmId === iphone.udid) return true;
const modelMatch = (device.model ?? "").toLowerCase() === iphone.productType.toLowerCase();
const nameMatch = (device.name ?? "").toLowerCase() === iphone.name.toLowerCase();
return modelMatch || nameMatch;
}
const connectedDeviceId = computed(() => {
if (!props.iphone) return null;
return props.devices.find((d) => matchesIphone(d, props.iphone!))?.deviceId ?? null;
});
const hasUnknownUsbDevice = computed(() => {
return !!props.iphone && !connectedDeviceId.value;
});
</script>

View File

@ -0,0 +1,156 @@
<template>
<div
class="relative overflow-hidden rounded-2xl bg-amber-50 dark:bg-amber-900/20 border border-amber-100 dark:border-amber-800 p-5"
>
<div class="flex items-start gap-4">
<div
class="shrink-0 w-12 h-12 rounded-xl bg-amber-100 dark:bg-amber-800/50 flex items-center justify-center"
>
<UIcon
name="i-heroicons-exclamation-triangle"
class="w-6 h-6 text-amber-600 dark:text-amber-400"
/>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-bold text-gray-900 dark:text-white">
Dieses iPhone ist nicht erkennbar
</h3>
<p class="text-sm text-gray-600 dark:text-gray-300 mt-1">
Mit keinem ReBreak-Konto verbunden.
</p>
<div class="mt-3 text-sm text-gray-700 dark:text-gray-200 space-y-1">
<p><span class="font-medium">Modell:</span> {{ displayModel(iphone.productType) }}</p>
<p><span class="font-medium">iOS:</span> {{ iphone.productVersion }}</p>
<p class="truncate"><span class="font-medium">UDID:</span> {{ iphone.udid }}</p>
</div>
<!-- MDM status for this UDID -->
<div class="mt-4 rounded-xl bg-white/60 dark:bg-black/20 p-4">
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-bold text-gray-900 dark:text-white">
ReBreak Cloud-Status
</p>
<UBadge
v-if="mdmLoading"
color="neutral"
variant="subtle"
size="xs"
>
Lädt
</UBadge>
<UBadge
v-else-if="mdmStatus?.enrolled"
color="success"
variant="subtle"
size="xs"
>
Enrolled
</UBadge>
<UBadge
v-else
color="warning"
variant="subtle"
size="xs"
>
Nicht enrolled
</UBadge>
</div>
<p
v-if="mdmError"
class="text-xs text-red-600 dark:text-red-400"
>
{{ mdmError }}
</p>
<ul
v-else-if="mdmStatus?.enrolled"
class="space-y-1.5 text-sm text-gray-700 dark:text-gray-200"
>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Enrollment</span>
<span class="text-green-600 dark:text-green-400 font-medium">Ja</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Supervised</span>
<span :class="mdmStatus.supervised ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" class="font-medium">
{{ mdmStatus.supervised ? "Ja" : "Nein" }}
</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">Organisation</span>
<span class="font-medium">{{ mdmStatus.company ?? "—" }}</span>
</li>
<li class="flex items-center justify-between">
<span class="text-gray-500 dark:text-gray-400">ReBreak App</span>
<span :class="mdmStatus.lastAppPushAt ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'" class="font-medium">
{{ mdmStatus.lastAppPushAt ? "Gepusht" : "Nicht gepusht" }}
</span>
</li>
</ul>
<div
v-else-if="mdmStatus && !mdmStatus.enrolled && !mdmLoading"
class="text-sm text-amber-800 dark:text-amber-300"
>
Dieses iPhone ist noch nicht in der ReBreak Cloud. Folge den Schritten unten, um es zu verwalten.
</div>
</div>
<div class="mt-4 text-sm text-gray-600 dark:text-gray-300 bg-white/60 dark:bg-black/20 rounded-lg p-3">
<p class="font-medium mb-1">So kannst du es verwalten:</p>
<ol class="list-decimal list-inside space-y-0.5">
<li>ReBreak-App auf diesem Gerät installieren</li>
<li>Mit deinem Konto anmelden</li>
<li>Das Gerät in der App registrieren</li>
</ol>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useTauri, type IphoneDeviceState, type MdmStatusByUdidData } from "~/composables/useTauri";
const props = defineProps<{
iphone: IphoneDeviceState;
}>();
const { getMdmStatusByUdid } = useTauri();
const mdmLoading = ref(false);
const mdmStatus = ref<MdmStatusByUdidData | null>(null);
const mdmError = ref<string | null>(null);
onMounted(async () => {
mdmLoading.value = true;
mdmError.value = null;
try {
mdmStatus.value = await getMdmStatusByUdid(props.iphone.udid);
} catch (e: any) {
mdmError.value = e?.message ?? "Cloud-Status konnte nicht geladen werden";
} finally {
mdmLoading.value = false;
}
});
const productTypeMap: Record<string, string> = {
"iPhone18,4": "iPhone Air",
"iPhone17,1": "iPhone 16 Pro",
"iPhone17,2": "iPhone 16 Pro Max",
"iPhone17,3": "iPhone 16",
"iPhone17,4": "iPhone 16 Plus",
"iPhone16,1": "iPhone 15 Pro",
"iPhone16,2": "iPhone 15 Pro Max",
"iPhone15,4": "iPhone 15",
"iPhone15,5": "iPhone 15 Plus",
};
function displayModel(productType: string): string {
return productTypeMap[productType] ?? productType;
}
</script>

View File

@ -1,4 +1,4 @@
import { computed, watchEffect, type Ref } from "vue";
import { computed, type Ref } from "vue";
import type { MagicDeviceInfo, IphoneDeviceState } from "./useTauri";
export type DeviceStatus = "active" | "cooldown" | "revoked" | "pending" | "unprotected";
@ -9,6 +9,7 @@ export interface ComputedDevice {
platform: "mac" | "windows" | "ios" | "android" | "unknown";
model: string | null;
osVersion: string | null;
mdmId: string | null;
status: DeviceStatus;
isCurrent: boolean;
cooldownUntil: string | null;
@ -33,56 +34,60 @@ function normalizeHostname(value: string): string {
return (value.toLowerCase().split(".")[0] ?? "").replace(/[^a-z0-9]/g, "");
}
function mapToComputedDevice(d: MagicDeviceInfo, isCurrent: boolean): ComputedDevice {
return {
deviceId: d.deviceId,
name: d.hostname ?? d.model ?? "Unbenanntes Gerät",
platform: normalizePlatform(d.model ?? d.hostname),
model: d.model,
osVersion: d.osVersion,
mdmId: d.mdmId ?? null,
status: d.status as DeviceStatus,
isCurrent,
cooldownUntil: d.cooldownUntil,
lastSeenAt: d.lastSeenAt,
enrolledAt: d.magicEnrolledAt,
};
}
export function useDeviceStatus(
devices: Ref<MagicDeviceInfo[]>,
localHostname: Ref<string | null>,
iphone: Ref<IphoneDeviceState | null>,
currentDeviceId?: Ref<string | null>,
currentHardwareId?: Ref<string | null>,
) {
function isCurrentDevice(d: MagicDeviceInfo): boolean {
if (currentHardwareId?.value && d.hardwareId) {
return d.hardwareId === currentHardwareId.value;
}
if (currentDeviceId?.value) {
return d.deviceId === currentDeviceId.value;
}
if (!localHostname.value) return false;
const local = normalizeHostname(localHostname.value);
return normalizeHostname(d.hostname) === local;
return false;
}
const currentBackendDevice = computed<ComputedDevice | null>(() => {
const found = devices.value.find(isCurrentDevice);
if (!found) return null;
return {
deviceId: found.deviceId,
name: found.model ?? found.hostname,
platform: normalizePlatform(found.model ?? found.hostname),
model: found.model,
osVersion: found.osVersion,
status: found.status,
isCurrent: true,
cooldownUntil: found.cooldownUntil,
lastSeenAt: found.lastSeenAt,
enrolledAt: found.magicEnrolledAt,
};
return mapToComputedDevice(found, true);
});
const otherDevices = computed<ComputedDevice[]>(() => {
const currentId = currentBackendDevice.value?.deviceId;
return devices.value
.filter((d) => d.deviceId !== currentId)
.map((d) => ({
deviceId: d.deviceId,
name: d.model ?? d.hostname,
platform: normalizePlatform(d.model ?? d.hostname),
model: d.model,
osVersion: d.osVersion,
status: d.status,
isCurrent: false,
cooldownUntil: d.cooldownUntil,
lastSeenAt: d.lastSeenAt,
enrolledAt: d.magicEnrolledAt,
}));
.map((d) => mapToComputedDevice(d, false));
});
const iosDevices = computed<ComputedDevice[]>(() =>
otherDevices.value.filter((d) => d.platform === "ios"),
);
const desktopDevices = computed<ComputedDevice[]>(() =>
otherDevices.value.filter((d) => d.platform === "mac" || d.platform === "windows"),
);
const iosStars = computed(() => {
if (!iphone.value) return null;
return {
@ -93,15 +98,11 @@ export function useDeviceStatus(
};
});
// DEBUG: log current device matching details
watchEffect(() => {
const ids = devices.value.map((d) => ({ deviceId: d.deviceId, hostname: d.hostname, platform: d.model ?? d.hostname }));
console.log("[useDeviceStatus] local deviceId:", currentDeviceId?.value ?? "(none)", "hostname:", localHostname.value ?? "(none)", "backend ids:", ids, "matched:", currentBackendDevice.value?.deviceId ?? "(none)");
});
return {
currentBackendDevice,
otherDevices,
iosDevices,
desktopDevices,
iosStars,
};
}

View File

@ -0,0 +1,32 @@
import type { MagicDeviceInfo, RegisterDeviceResponse, IphoneDeviceState } from "./useTauri";
export interface MagicSessionState {
token?: string;
deviceId: string;
hardwareId?: string;
dnsToken: string;
profileUrl: string;
label?: string;
}
export function useMagicSession() {
return useState<MagicSessionState | null>("magic-session", () => null);
}
export function useMagicDevices() {
return useState<MagicDeviceInfo[]>("magic-devices", () => []);
}
export function useCurrentMagicDevice() {
const session = useMagicSession();
const devices = useMagicDevices();
return computed(() => {
if (!session.value) return null;
return devices.value.find((d) => d.deviceId === session.value!.deviceId) ?? null;
});
}
export function useIphoneDevice() {
return useState<IphoneDeviceState | null>("iphone-device", () => null);
}

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

@ -1,4 +1,4 @@
import { ref, computed, onMounted, onUnmounted } from "vue";
import { ref, computed } from "vue";
import { useTauri, type IphoneDeviceState, type DesktopProtectionState, type MagicDeviceInfo } from "~/composables/useTauri";
import { useIphoneDevice, useMagicDevices, useMagicSession } from "~/composables/useMagicState";
@ -12,9 +12,6 @@ export interface ProtectionStatus {
anyBackendDevice: boolean;
}
const IPHONE_POLL_INTERVAL = 5000;
const DEVICE_REFRESH_INTERVAL = 30000;
function normalizeHostname(value: string): string {
return (value.toLowerCase().split(".")[0] ?? "").replace(/[^a-z0-9]/g, "");
}
@ -38,9 +35,6 @@ export function useProtectionStatus() {
const lastError = ref<string | null>(null);
const lastUpdated = ref<Date | null>(null);
let iphoneTimer: ReturnType<typeof setInterval> | null = null;
let deviceTimer: ReturnType<typeof setInterval> | null = null;
const iosConnected = computed(() => !!iphone.value);
const iosProtected = computed(() => {
if (!iphone.value) return false;
@ -49,9 +43,15 @@ export function useProtectionStatus() {
return iphone.value.isSupervised && hasEnrollment && hasLock;
});
const currentBackendDevice = computed<MagicDeviceInfo | null>(() => {
if (!localHostname.value) return null;
const local = normalizeHostname(localHostname.value);
return devices.value.find((d) => normalizeHostname(d.hostname) === local) ?? null;
if (!session.value) return null;
const hardwareId = session.value.hardwareId;
const deviceId = session.value.deviceId;
return (
devices.value.find((d) => {
if (hardwareId && d.hardwareId) return d.hardwareId === hardwareId;
return d.deviceId === deviceId;
}) ?? null
);
});
const desktopProtected = computed(() => !!desktopProtection.value?.active || !!currentBackendDevice.value);
const anyBackendDevice = computed(() => devices.value.length > 0);
@ -175,17 +175,6 @@ export function useProtectionStatus() {
}
}
onMounted(() => {
refresh();
iphoneTimer = setInterval(refreshIphone, IPHONE_POLL_INTERVAL);
deviceTimer = setInterval(refreshBackendDevices, DEVICE_REFRESH_INTERVAL);
});
onUnmounted(() => {
if (iphoneTimer) clearInterval(iphoneTimer);
if (deviceTimer) clearInterval(deviceTimer);
});
return {
status,
iphone: computed(() => iphone.value),
@ -199,5 +188,7 @@ export function useProtectionStatus() {
desktopProtected,
anyBackendDevice,
refresh,
refreshIphone,
refreshBackendDevices,
};
}

View File

@ -41,9 +41,11 @@ export interface RegisterDeviceResponse {
export interface MagicDeviceInfo {
source: string;
deviceId: string;
hardwareId: string | null;
hostname: string;
model: string | null;
osVersion: string | null;
mdmId: string | null;
magicEnrolledAt: string | null;
releaseRequestedAt: string | null;
releaseAvailableAt: string | null;
@ -81,6 +83,21 @@ export interface DesktopProtectionState {
activatedAt: string;
}
export interface MdmStatusData {
enrolled: boolean;
company: string | null;
supervised: boolean;
lockProfileInstalled: boolean;
lastAppPushAt: string | null;
}
export interface MdmStatusByUdidData {
enrolled: boolean;
company: string | null;
supervised: boolean;
lastAppPushAt: string | null;
}
export interface SuperviseStatus {
isSupervised: boolean;
organizationName?: string;
@ -97,6 +114,13 @@ export interface MdmCommandResult {
response_body: string;
}
export const REBREAK_MDM_VERSION = "0.1";
export function getInstalledMdmVersion(installedProfileIDs: string[]): string | null {
const versionId = installedProfileIDs.find((id) => id.startsWith("org.rebreak.mdm.version."));
return versionId?.replace("org.rebreak.mdm.version.", "") ?? null;
}
async function invokeLogged<T>(command: string, args?: Record<string, unknown>): Promise<T> {
const { useLogger } = await import("~/composables/useLogger");
const logger = useLogger();
@ -124,11 +148,18 @@ export function useTauri() {
}
async function registerDevice(
deviceId: string,
model: string | undefined,
osVersion: string | undefined,
model?: string,
osVersion?: string,
): Promise<RegisterDeviceResponse> {
return await invokeLogged("register_device", { deviceId, model, osVersion });
return await invokeLogged("register_device", { model, osVersion });
}
async function getHardwareId(): Promise<string> {
return await invokeLogged("get_hardware_id");
}
async function getDeviceId(): Promise<string | null> {
return await invokeLogged("get_device_id");
}
async function getStoredSession(): Promise<MagicSession | null> {
@ -247,6 +278,18 @@ export function useTauri() {
return await invokeLogged("get_hostname");
}
async function getMdmStatus(deviceId: string): Promise<MdmStatusData> {
return await invokeLogged("get_mdm_status", { deviceId });
}
async function getMdmStatusByUdid(udid: string): Promise<MdmStatusByUdidData> {
return await invokeLogged("get_mdm_status_by_udid", { udid });
}
async function linkMdmDevice(deviceId: string, mdmId: string): Promise<void> {
await invokeLogged("link_mdm_device", { deviceId, mdmId });
}
return {
getPlatform,
redeemPairingCode,
@ -280,5 +323,10 @@ export function useTauri() {
getDesktopProtectionStatus,
setDesktopProtectionStatus,
getHostname,
getHardwareId,
getDeviceId,
getMdmStatus,
getMdmStatusByUdid,
linkMdmDevice,
};
}

View File

@ -41,7 +41,6 @@
v-if="currentBackendDevice"
:device="currentBackendDevice"
:is-current="true"
:ios-stars="currentBackendDevice.platform === 'ios' ? iosStars : null"
@open="openDevice"
@toggle-protection="toggleProtection"
/>
@ -51,14 +50,35 @@
<div class="w-16 h-16 mx-auto rounded-2xl bg-blue-50 flex items-center justify-center mb-4">
<UIcon name="i-heroicons-shield-exclamation" class="w-8 h-8 text-[var(--rebreak-primary)]" />
</div>
<h3 class="text-lg font-bold text-gray-900">Dieses Gerät ist nicht geschützt</h3>
<p class="text-sm text-gray-500 mt-1 mb-4">Aktiviere den Schutz für das Gerät, auf dem Magic läuft.</p>
<UButton color="primary" icon="i-heroicons-shield-check" to="/desktop-enroll">
Dieses Gerät schützen
</UButton>
<template v-if="!hasRefreshed">
<h3 class="text-lg font-bold text-gray-900">Geräte-Status noch nicht geladen</h3>
<p class="text-sm text-gray-500 mt-1 mb-4">Klicke auf Aktualisieren, um deine Geräte abzurufen.</p>
<UButton color="primary" icon="i-heroicons-arrow-path" :loading="loading" @click="refresh">
Aktualisieren
</UButton>
</template>
<template v-else>
<h3 class="text-lg font-bold text-gray-900">Dieses Gerät ist nicht geschützt</h3>
<p class="text-sm text-gray-500 mt-1 mb-4">Aktiviere den Schutz für das Gerät, auf dem Magic läuft.</p>
<UButton color="primary" icon="i-heroicons-shield-check" to="/desktop-enroll">
Dieses Gerät schützen
</UButton>
</template>
</div>
</section>
<!-- iOS devices section -->
<IosDeviceSection
:devices="iosDevices"
:iphone="iphone"
:loading="loading"
:has-refreshed="hasRefreshed"
:in-grace-period="subscriptionInGracePeriod"
@sync="onIosSync"
@open="openDevice"
@remove="onIosRemove"
/>
<!-- Other devices list -->
<section>
<div class="flex items-center justify-between mb-3">
@ -77,18 +97,20 @@
<div v-if="error" class="text-sm text-red-600 mb-4 p-4 bg-red-50 rounded-xl">{{ error }}</div>
<div v-if="loading && otherDevices.length === 0" class="py-12 text-center text-gray-500">
<div v-if="loading && desktopDevices.length === 0" class="py-12 text-center text-gray-500">
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin mx-auto mb-3 text-[var(--rebreak-primary)]" />
<p class="font-semibold">Lade Geräte</p>
</div>
<div v-else-if="otherDevices.length === 0" class="py-10 text-center rounded-2xl bg-white ring-1 ring-gray-100">
<p class="text-sm text-gray-500">Keine weiteren Geräte registriert.</p>
<div v-else-if="desktopDevices.length === 0" class="py-10 text-center rounded-2xl bg-white ring-1 ring-gray-100">
<p class="text-sm text-gray-500">
{{ hasRefreshed ? 'Keine weiteren Computer registriert.' : 'Noch keine Computer geladen.' }}
</p>
</div>
<div v-else class="space-y-3">
<DeviceListItem
v-for="device in otherDevices"
v-for="device in desktopDevices"
:key="device.deviceId"
:device="device"
:is-current="false"
@ -102,7 +124,7 @@
<DeviceDetailSheet
v-model:open="sheetOpen"
:device="selectedDevice"
:ios-stars="selectedDevice?.platform === 'ios' && selectedDevice?.isCurrent ? iosStars : null"
:ios-stars="selectedDeviceStars"
@close="sheetOpen = false"
@toggle-protection="toggleProtection"
@start-cooldown="startCooldown"
@ -127,6 +149,9 @@ const {
fetchMe,
setDesktopProtectionStatus,
getPlatform,
getHardwareId,
getDeviceId,
registerDevice,
} = useTauri();
const session = useMagicSession();
@ -134,30 +159,71 @@ const devices = useMagicDevices();
const iphone = useIphoneDevice();
const protection = useProtectionStatus();
const currentDeviceId = computed(() => session.value?.deviceId ?? null);
const currentDeviceId = ref<string | null>(null);
const currentHardwareId = ref<string | null>(null);
const profile = ref<UserProfile | null>(null);
const loading = ref(false);
const hasRefreshed = ref(false);
const error = ref<string | null>(null);
const sheetOpen = ref(false);
const selectedDevice = ref<ComputedDevice | null>(null);
const platformInfo = ref<{ platform: string } | null>(null);
// TODO: populate from backend once subscription/grace-period endpoint exists.
const subscriptionInGracePeriod = ref(false);
// Share localHostname from protection composable with device status logic.
const localHostname = protection.localHostname;
const { currentBackendDevice, otherDevices, iosStars } = useDeviceStatus(devices, localHostname, iphone, currentDeviceId);
const { currentBackendDevice, iosDevices, desktopDevices, iosStars } =
useDeviceStatus(devices, localHostname, iphone, currentDeviceId, currentHardwareId);
const selectedDeviceStars = computed(() => {
if (!selectedDevice.value || selectedDevice.value.platform !== "ios") return null;
if (!iphone.value) return null;
const modelMatch =
(selectedDevice.value.model ?? "").toLowerCase() === iphone.value.productType.toLowerCase();
const nameMatch =
(selectedDevice.value.name ?? "").toLowerCase() === iphone.value.name.toLowerCase();
if (!modelMatch && !nameMatch) return null;
return iosStars.value;
});
onMounted(async () => {
await loadProfile();
await refresh();
try {
const info = await getPlatform();
platformInfo.value = { platform: info.platform };
} catch (e) {
platformInfo.value = null;
}
await initCurrentDevice();
});
async function initCurrentDevice() {
try {
const hardwareId = await getHardwareId();
currentHardwareId.value = hardwareId;
// Ensure the backend knows this hardware ID.
// For existing devices this performs the one-time hardwareId migration.
const response = await registerDevice(
platformInfo.value?.platform,
undefined,
);
currentDeviceId.value = response.deviceId;
session.value = {
deviceId: response.deviceId,
hardwareId,
dnsToken: response.dnsToken,
profileUrl: response.profileUrl,
};
} catch (e: any) {
console.error("Failed to initialize current device:", e);
}
}
async function loadProfile() {
try {
profile.value = await fetchMe();
@ -176,9 +242,31 @@ async function refresh() {
error.value = e?.message ?? "Geräte konnten nicht geladen werden";
} finally {
loading.value = false;
hasRefreshed.value = true;
}
}
async function onIosSync(device: ComputedDevice) {
loading.value = true;
error.value = null;
try {
await protection.refreshIphone();
await protection.refreshBackendDevices();
// TODO: push missing MDM components and compare MDM version once backend exposes it.
} catch (e: any) {
error.value = e?.message ?? "Synchronisierung fehlgeschlagen";
} finally {
loading.value = false;
hasRefreshed.value = true;
}
}
async function onIosRemove(device: ComputedDevice) {
// TODO: call offboarding endpoint once backend provides it.
// For now this is a no-op placeholder to keep the UI safe.
console.log("[offboarding placeholder] remove ReBreak from", device.deviceId);
}
function openDevice(device: ComputedDevice) {
selectedDevice.value = device;
sheetOpen.value = true;

View File

@ -21,8 +21,10 @@ pub struct RedeemPairingResponse {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterDeviceRequest {
#[serde(rename = "deviceId")]
pub device_id: String,
#[serde(rename = "deviceId", skip_serializing_if = "Option::is_none")]
pub device_id: Option<String>,
#[serde(rename = "hardwareId", skip_serializing_if = "Option::is_none")]
pub hardware_id: Option<String>,
pub hostname: String,
pub model: Option<String>,
#[serde(rename = "osVersion")]
@ -45,10 +47,14 @@ pub struct MagicDeviceInfo {
pub source: String,
#[serde(rename = "deviceId")]
pub device_id: String,
#[serde(rename = "hardwareId")]
pub hardware_id: Option<String>,
pub hostname: String,
pub model: Option<String>,
#[serde(rename = "osVersion")]
pub os_version: Option<String>,
#[serde(default, rename = "mdmId")]
pub mdm_id: Option<String>,
#[serde(rename = "magicEnrolledAt")]
pub magic_enrolled_at: Option<String>,
#[serde(rename = "releaseRequestedAt")]
@ -93,6 +99,32 @@ 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 MdmStatusByUdidData {
pub enrolled: bool,
pub company: Option<String>,
pub supervised: 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,
@ -159,14 +191,16 @@ impl MagicApiClient {
pub async fn register_device(
&self,
token: &str,
device_id: &str,
device_id: Option<&str>,
hardware_id: Option<&str>,
hostname: &str,
model: Option<&str>,
os_version: Option<&str>,
) -> AppResult<RegisterDeviceResponse> {
let url = format!("{}/api/magic/register", self.base_url);
let body = RegisterDeviceRequest {
device_id: device_id.to_string(),
device_id: device_id.map(|s| s.to_string()),
hardware_id: hardware_id.map(|s| s.to_string()),
hostname: hostname.to_string(),
model: model.map(|s| s.to_string()),
os_version: os_version.map(|s| s.to_string()),
@ -339,6 +373,70 @@ 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 get_mdm_status_by_udid(
&self,
token: &str,
udid: &str,
) -> AppResult<MdmStatusByUdidData> {
let url = format!("{}/api/magic/mdm/by-udid", self.base_url);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.query(&[("udid", udid)])
.send()
.await
.map_err(|e| AppError::new(format!("Network error: {}", e)))?;
Self::handle_response::<ApiEnvelope<MdmStatusByUdidData>>(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> {

View File

@ -0,0 +1,223 @@
#![allow(dead_code)]
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub backend_base_url: String,
pub mdm_server_url: String,
pub supabase_url: String,
pub supabase_anon_key: String,
/// Stable device identity known to the backend (UserDevice.deviceId).
/// Filled after first successful device registration.
#[serde(skip_serializing_if = "Option::is_none")]
pub device_id: Option<String>,
/// Hardware-bound identity (UserDevice.hardwareId).
#[serde(skip_serializing_if = "Option::is_none")]
pub hardware_id: Option<String>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
backend_base_url: "https://staging.rebreak.org".to_string(),
mdm_server_url: "https://mdm.rebreak.org".to_string(),
supabase_url: String::new(),
supabase_anon_key: String::new(),
device_id: None,
hardware_id: None,
}
}
}
const KEYRING_SERVICE: &str = "org.rebreak.magic";
const KEYRING_ACCOUNT: &str = "magic-session";
const SESSION_FILE: &str = "session.json";
const DESKTOP_PROTECTION_FILE: &str = "desktop-protection.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MagicSession {
pub access_token: String,
pub session_id: String,
pub user_id: String,
#[serde(with = "chrono::serde::ts_seconds")]
pub created_at: chrono::DateTime<chrono::Utc>,
pub label: Option<String>,
}
impl MagicSession {
pub fn new(access_token: String, session_id: String, label: Option<String>) -> Self {
Self {
access_token,
session_id,
user_id: String::new(),
created_at: chrono::Utc::now(),
label,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BinderConfig {
#[serde(rename = "mdmServer")]
pub mdm_server: String,
#[serde(rename = "mdmUser")]
pub mdm_user: String,
#[serde(rename = "mdmApiKey")]
pub mdm_api_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopProtectionState {
pub active: bool,
pub platform: String,
#[serde(with = "chrono::serde::ts_seconds")]
pub activated_at: chrono::DateTime<chrono::Utc>,
}
impl AppConfig {
pub fn config_dir() -> AppResult<PathBuf> {
let dir = dirs::config_dir()
.ok_or_else(|| AppError::new("Could not find config directory"))?
.join("org.rebreak.magic");
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
pub fn config_path() -> AppResult<PathBuf> {
Ok(Self::config_dir()?.join("config.json"))
}
pub fn binder_config_path() -> AppResult<PathBuf> {
Ok(dirs::config_dir()
.ok_or_else(|| AppError::new("Could not find config directory"))?
.join("rebreak-binder")
.join("config.json"))
}
pub fn load_binder_config() -> AppResult<BinderConfig> {
let path = Self::binder_config_path()?;
if !path.exists() {
return Err(AppError::new(
"rebreak-binder config nicht gefunden. Bitte README → 'Config (lokal)'.".to_string(),
));
}
let json = std::fs::read_to_string(&path)?;
serde_json::from_str(&json)
.map_err(|e| AppError::new(format!("rebreak-binder config kaputt: {}", e)))
}
pub fn load() -> Self {
match Self::config_path() {
Ok(path) if path.exists() => {
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
_ => Self::default(),
}
}
pub fn save(&self) -> AppResult<()> {
let path = Self::config_path()?;
let contents = serde_json::to_string_pretty(self)?;
std::fs::write(path, contents)?;
Ok(())
}
fn session_path() -> AppResult<PathBuf> {
Ok(Self::config_dir()?.join(SESSION_FILE))
}
pub fn save_magic_session(session: &MagicSession) -> AppResult<()> {
let path = Self::session_path()?;
let json = serde_json::to_string_pretty(session)
.map_err(|e| AppError::new(format!("Failed to serialize session: {}", e)))?;
std::fs::write(&path, json)?;
Ok(())
}
pub fn load_magic_session() -> AppResult<Option<MagicSession>> {
let path = match Self::session_path() {
Ok(p) => p,
Err(_) => return Ok(None),
};
if !path.exists() {
return Ok(None);
}
let json = std::fs::read_to_string(&path)?;
if json.trim().is_empty() {
return Ok(None);
}
let session = serde_json::from_str(&json)
.map_err(|e| AppError::new(format!("Failed to parse session: {}", e)))?;
Ok(Some(session))
}
pub fn clear_magic_session() -> AppResult<()> {
let path = match Self::session_path() {
Ok(p) => p,
Err(_) => return Ok(()),
};
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
fn desktop_protection_path() -> AppResult<PathBuf> {
Ok(Self::config_dir()?.join(DESKTOP_PROTECTION_FILE))
}
pub fn save_desktop_protection(state: &DesktopProtectionState) -> AppResult<()> {
let path = Self::desktop_protection_path()?;
let json = serde_json::to_string_pretty(state)
.map_err(|e| AppError::new(format!("Failed to serialize desktop protection state: {}", e)))?;
std::fs::write(&path, json)?;
Ok(())
}
pub fn load_desktop_protection() -> AppResult<Option<DesktopProtectionState>> {
let path = match Self::desktop_protection_path() {
Ok(p) => p,
Err(_) => return Ok(None),
};
if !path.exists() {
return Ok(None);
}
let json = std::fs::read_to_string(&path)?;
if json.trim().is_empty() {
return Ok(None);
}
let state = serde_json::from_str(&json)
.map_err(|e| AppError::new(format!("Failed to parse desktop protection state: {}", e)))?;
Ok(Some(state))
}
pub fn clear_desktop_protection() -> AppResult<()> {
let path = match Self::desktop_protection_path() {
Ok(p) => p,
Err(_) => return Ok(()),
};
if path.exists() {
std::fs::remove_file(&path)?;
}
Ok(())
}
pub fn set_device_id(&mut self, id: String) -> AppResult<()> {
self.device_id = Some(id);
self.save()
}
pub fn set_hardware_id(&mut self, id: String) -> AppResult<()> {
self.hardware_id = Some(id);
self.save()
}
}

View File

@ -8,8 +8,8 @@ mod server;
mod sidecar;
use backend::api::{
MagicApiClient, MagicDeviceInfo, RedeemPairingResponse, RegisterDeviceResponse, ReleaseResponse,
UserProfile,
MagicApiClient, MagicDeviceInfo, MdmStatusByUdidData, MdmStatusData, RedeemPairingResponse,
RegisterDeviceResponse, ReleaseResponse, UserProfile,
};
use config::{AppConfig, DesktopProtectionState, MagicSession};
use error::AppResult;
@ -51,9 +51,14 @@ pub fn run() {
download_profile,
activate_protection,
fetch_me,
get_mdm_status,
get_mdm_status_by_udid,
link_mdm_device,
get_desktop_protection_status,
set_desktop_protection_status,
get_hostname,
get_hardware_id,
get_device_id,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@ -71,6 +76,26 @@ fn hostname() -> AppResult<String> {
Ok(hostname)
}
fn get_or_init_hardware_id(config: &mut AppConfig) -> AppResult<String> {
if let Some(id) = config.hardware_id.clone() {
return Ok(id);
}
let id = platform::get_hardware_id()?;
config.set_hardware_id(id.clone())?;
Ok(id)
}
#[tauri::command]
fn get_hardware_id() -> AppResult<String> {
let mut config = AppConfig::load();
get_or_init_hardware_id(&mut config)
}
#[tauri::command]
fn get_device_id() -> AppResult<Option<String>> {
Ok(AppConfig::load().device_id)
}
fn require_session() -> AppResult<MagicSession> {
AppConfig::load_magic_session()?.ok_or_else(|| {
error::AppError::new("Keine Magic-Session gefunden. Bitte neu paaren.")
@ -99,17 +124,28 @@ async fn redeem_pairing_code(code: String, label: Option<String>) -> AppResult<R
#[tauri::command]
async fn register_device(
device_id: String,
model: Option<String>,
os_version: Option<String>,
) -> AppResult<RegisterDeviceResponse> {
let session = require_session()?;
let config = AppConfig::load();
let mut config = AppConfig::load();
let client = MagicApiClient::new(&config);
let hostname = hostname()?;
// Hardware-ID ist der stabile Identifikator; deviceId wird vom Backend zurückgeliefert.
let hardware_id = get_or_init_hardware_id(&mut config)?;
let response = client
.register_device(&session.access_token, &device_id, &hostname, model.as_deref(), os_version.as_deref())
.register_device(
&session.access_token,
config.device_id.as_deref(),
Some(&hardware_id),
&hostname,
model.as_deref(),
os_version.as_deref(),
)
.await?;
config.set_device_id(response.device_id.clone())?;
Ok(response)
}
@ -184,6 +220,30 @@ 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 get_mdm_status_by_udid(udid: String) -> AppResult<MdmStatusByUdidData> {
let session = require_session()?;
let config = AppConfig::load();
let client = MagicApiClient::new(&config);
client.get_mdm_status_by_udid(&session.access_token, &udid).await
}
#[tauri::command]
async fn download_profile(profile_url: String) -> AppResult<String> {
let session = require_session()?;

View File

@ -0,0 +1,74 @@
#![allow(dead_code)]
use super::{Platform, PlatformInfo};
use crate::error::AppResult;
pub fn get_platform_info() -> AppResult<PlatformInfo> {
Ok(PlatformInfo {
platform: Platform::MacOS,
version: "14.0".to_string(), // TODO: Read actual macOS version
supports_ios_supervision: true,
})
}
pub fn install_dns_profile(profile_path: &str) -> AppResult<()> {
use std::process::Command;
let status = Command::new("open")
.arg(profile_path)
.status()
.map_err(|e| crate::error::AppError::new(format!("Failed to open profile: {}", e)))?;
if !status.success() {
return Err(crate::error::AppError::new(
"System Settings konnte das Profil nicht öffnen".to_string(),
));
}
Ok(())
}
pub fn store_token(_token: &str) -> AppResult<()> {
// TODO: Implement using macOS Keychain via keyring crate
Ok(())
}
pub fn read_token() -> AppResult<Option<String>> {
// TODO: Implement using macOS Keychain via keyring crate
Ok(None)
}
pub fn activate_protection(profile_path: &str) -> AppResult<()> {
install_dns_profile(profile_path)
}
pub fn deactivate_protection(_token: &str) -> AppResult<()> {
Ok(())
}
/// Returns the macOS system UUID (IOPlatformUUID).
pub fn get_hardware_id() -> AppResult<String> {
use std::process::Command;
let output = Command::new("ioreg")
.args([
"-rd1",
"-c",
"IOPlatformExpertDevice",
"-k",
"IOPlatformUUID",
])
.output()
.map_err(|e| crate::error::AppError::new(format!("Failed to read hardware ID: {}", e)))?;
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if line.contains("IOPlatformUUID") {
let parts: Vec<&str> = line.split('"').collect();
if let Some(uuid) = parts.iter().rev().find(|s| s.contains('-')) {
return Ok(uuid.trim().to_string());
}
}
}
Err(crate::error::AppError::new(
"Could not parse macOS hardware UUID".to_string(),
))
}

View File

@ -0,0 +1,75 @@
#![allow(dead_code)]
use crate::error::AppResult;
use serde::{Deserialize, Serialize};
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Platform {
MacOS,
Windows,
Linux,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlatformInfo {
pub platform: Platform,
pub version: String,
pub supports_ios_supervision: bool,
}
#[tauri::command]
pub fn get_platform() -> AppResult<PlatformInfo> {
#[cfg(target_os = "macos")]
return macos::get_platform_info();
#[cfg(target_os = "windows")]
return windows::get_platform_info();
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
Ok(PlatformInfo {
platform: Platform::Unknown,
version: String::new(),
supports_ios_supervision: false,
})
}
pub trait PlatformInterface {
fn install_dns_profile(&self, profile_path: &str) -> AppResult<()>;
fn store_token(&self, token: &str) -> AppResult<()>;
fn read_token(&self) -> AppResult<Option<String>>;
fn activate_protection(&self, token: &str) -> AppResult<()>;
fn deactivate_protection(&self, token: &str) -> AppResult<()>;
}
pub fn activate_protection(profile_or_token: &str) -> AppResult<()> {
#[cfg(target_os = "macos")]
return macos::activate_protection(profile_or_token);
#[cfg(target_os = "windows")]
return windows::activate_protection(profile_or_token);
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
Err(crate::error::AppError::new(
"Plattform wird nicht unterstützt".to_string(),
))
}
pub fn get_hardware_id() -> AppResult<String> {
#[cfg(target_os = "macos")]
return macos::get_hardware_id();
#[cfg(target_os = "windows")]
return windows::get_hardware_id();
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
Err(crate::error::AppError::new(
"Hardware-ID wird auf dieser Plattform nicht unterstützt".to_string(),
))
}

View File

@ -0,0 +1,94 @@
#![allow(dead_code)]
use super::{Platform, PlatformInfo};
use crate::error::AppResult;
pub fn get_platform_info() -> AppResult<PlatformInfo> {
Ok(PlatformInfo {
platform: Platform::Windows,
version: "11".to_string(), // TODO: Read actual Windows version
supports_ios_supervision: true, // Once supervise-magic Windows build is verified
})
}
pub fn install_doh(dns_token: &str) -> AppResult<()> {
use std::process::Command;
let doh_url = format!("https://dns.rebreak.org/dns-query/{}", dns_token);
let script = format!(
r#"
$iface = Get-NetAdapter | Where-Object {{ $_.Status -eq 'Up' }} | Select-Object -First 1
if (-not $iface) {{ exit 1 }}
Set-DnsClientDohServerAddress -ServerAddress '142.132.245.42' -DohTemplate '{}' -AutoUpgrade $true
Set-DnsClientServerAddress -InterfaceAlias $iface.Name -ServerAddresses ('142.132.245.42')
"#,
doh_url
);
let status = Command::new("powershell.exe")
.args([
"-NonInteractive",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
&script,
])
.status()
.map_err(|e| crate::error::AppError::new(format!("Failed to run PowerShell: {}", e)))?;
if !status.success() {
return Err(crate::error::AppError::new(
"DoH-Konfiguration fehlgeschlagen. Bitte ReBreak Magic mit Administratorrechten starten.".to_string(),
));
}
Ok(())
}
pub fn store_token(token: &str) -> AppResult<()> {
// TODO: Implement using Windows Credential Manager via keyring crate
Ok(())
}
pub fn read_token() -> AppResult<Option<String>> {
// TODO: Implement using Windows Credential Manager via keyring crate
Ok(None)
}
pub fn activate_protection(token: &str) -> AppResult<()> {
install_doh(token)
}
pub fn deactivate_protection(_token: &str) -> AppResult<()> {
Ok(())
}
/// Returns the Windows machine GUID (Registry: HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid).
pub fn get_hardware_id() -> AppResult<String> {
use std::process::Command;
let output = Command::new("reg")
.args([
"query",
"HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography",
"/v",
"MachineGuid",
])
.output()
.map_err(|e| crate::error::AppError::new(format!("Failed to read hardware ID: {}", e)))?;
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if line.contains("MachineGuid") {
let parts: Vec<&str> = line.split_whitespace().collect();
if let Some(uuid) = parts.last() {
if uuid.contains('-') {
return Ok(uuid.to_string());
}
}
}
}
Err(crate::error::AppError::new(
"Could not parse Windows machine GUID".to_string(),
))
}

1
backend/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
scripts/tts-bench-out/

View File

@ -64,6 +64,8 @@ export default defineNitroConfig({
// ─── Database / Core ─────────────────────────────────────────────────
databaseUrl:
process.env.DATABASE_URL ?? process.env.NUXT_DATABASE_URL ?? "",
// NanoMDM Postgres connection (e.g. postgres://nanomdm:PASS@178.105.101.137:5432/nanomdm).
mdmDatabaseUrl: process.env.MDM_DATABASE_URL ?? "",
encryptionKey: process.env.ENCRYPTION_KEY ?? "",
// ─── Admin / Cron ────────────────────────────────────────────────────

View File

@ -0,0 +1,12 @@
-- Hardware-ID für UserDevices
-- Client liefert hardwaregebundene ID; Backend speichert sie separat zur bestehenden deviceId.
ALTER TABLE "rebreak"."user_devices"
ADD COLUMN IF NOT EXISTS "hardware_id" TEXT;
-- Eindeutigkeit pro User + Hardware-ID (NULL-Werte sind bei UNIQUE erlaubt)
CREATE UNIQUE INDEX IF NOT EXISTS "user_devices_user_id_hardware_id_key"
ON "rebreak"."user_devices"("user_id", "hardware_id");
CREATE INDEX IF NOT EXISTS "user_devices_hardware_id_idx"
ON "rebreak"."user_devices"("hardware_id");

View File

@ -1108,6 +1108,11 @@ model UserDevice {
/// Temporärer Sleep-Mode für Magic-Desktop-Geräte. NULL = kein Cooldown aktiv.
magicCooldownUntil DateTime? @map("magic_cooldown_until")
// ─── NanoMDM iOS Enrollment ─────────────────────────────────────────────
/// Apple-Geräte-UDID wie von NanoMDM verwendet (z.B. 00008101-000544261E87001E).
/// NULL → Gerät ist nicht mit einem MDM-UDID verknüpft.
mdmId String? @map("mdm_id")
@@unique([userId, deviceId])
@@index([userId])
@@index([deviceId])
@ -1145,6 +1150,40 @@ model ProtectionStateLog {
@@schema("rebreak")
}
model DeviceProtectionState {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
deviceId String @map("device_id")
platform String // ios | android | mac | windows
protectionType String @map("protection_type")
active Boolean
lastSeenAt DateTime? @map("last_seen_at")
changedAt DateTime @default(now()) @map("changed_at")
reason String?
@@unique([userId, deviceId, protectionType])
@@index([userId])
@@index([deviceId])
@@index([protectionType])
@@map("device_protection_states")
@@schema("rebreak")
}
model DeviceProtectionStateLog {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
deviceId String @map("device_id")
protectionType String @map("protection_type")
active Boolean
occurredAt DateTime @map("occurred_at")
reason String?
source String // app | mdm | system | heartbeat
@@index([userId, deviceId])
@@map("device_protection_state_logs")
@@schema("rebreak")
}
model MagicPairingCode {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid

View File

@ -0,0 +1,92 @@
import { requireUser } from "../../utils/auth";
import {
PROTECTION_TYPES,
upsertDeviceProtectionState,
type ProtectionType,
} from "../../db/device-protection";
/**
* POST /api/devices/protection-state
*
* Body: {
* deviceId: string,
* platform: string,
* protectionType: 'nefilter' | 'vpn' | 'dns',
* active: boolean,
* reason?: string,
* source?: string
* }
*
* Reports the per-device protection state from a client.
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const body = await readBody(event);
const {
deviceId,
platform,
protectionType,
active,
reason,
source,
} = body as {
deviceId?: string;
platform?: string;
protectionType?: string;
active?: boolean;
reason?: string;
source?: string;
};
if (!deviceId) {
throw createError({
statusCode: 400,
data: { error: "device_id_required" },
});
}
if (!platform) {
throw createError({
statusCode: 400,
data: { error: "platform_required" },
});
}
if (!protectionType) {
throw createError({
statusCode: 400,
data: { error: "protection_type_required" },
});
}
if (!PROTECTION_TYPES.includes(protectionType as ProtectionType)) {
throw createError({
statusCode: 400,
data: {
error: "invalid_protection_type",
validTypes: PROTECTION_TYPES,
},
});
}
if (typeof active !== "boolean") {
throw createError({
statusCode: 400,
data: { error: "active_boolean_required" },
});
}
await upsertDeviceProtectionState(
user.id,
deviceId,
platform,
protectionType as ProtectionType,
active,
undefined,
reason ?? null,
source ?? "app",
);
return { success: true };
});

View File

@ -32,10 +32,12 @@ export default defineEventHandler(async (event) => {
select: {
id: true,
deviceId: true,
hardwareId: true,
platform: true,
model: true,
name: true,
osVersion: true,
mdmId: true,
lastSeenAt: true,
releaseRequestedAt: true,
},
@ -64,6 +66,7 @@ export default defineEventHandler(async (event) => {
return {
source: "magic" as const,
deviceId: d.deviceId,
hardwareId: d.hardwareId,
hostname: d.hostname ?? "Unbenanntes Ger\u00e4t",
model: d.model,
osVersion: d.osVersion,
@ -73,6 +76,7 @@ export default defineEventHandler(async (event) => {
cooldownUntil: d.magicCooldownUntil?.toISOString() ?? null,
status,
lastSeenAt: d.lastSeenAt?.toISOString() ?? null,
mdmId: d.mdmId,
};
});
@ -86,6 +90,7 @@ export default defineEventHandler(async (event) => {
return {
source: "locked" as const,
deviceId: d.deviceId,
hardwareId: d.hardwareId,
hostname: d.name ?? d.model ?? prettyPlatform(d.platform),
model: d.model,
osVersion: d.osVersion,
@ -95,6 +100,7 @@ export default defineEventHandler(async (event) => {
status: "active" as const,
lastSeenAt: d.lastSeenAt?.toISOString() ?? null,
cooldownUntil: null,
mdmId: d.mdmId,
};
});

View File

@ -0,0 +1,49 @@
import {
getUserDeviceByDeviceId,
setUserDeviceMdmId,
} from "../../../../db/mdm";
import { requireUser } from "../../../../utils/auth";
/**
* Apple UDID: hex/dash, 2050 chars.
*/
const UDID_RE = /^[A-Fa-f0-9-]{20,50}$/;
/**
* POST /api/magic/devices/:deviceId/mdm-link
*
* Body: { mdmId: string }
*
* Links a user's iOS device to a NanoMDM UDID.
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const deviceId = getRouterParam(event, "deviceId");
const body = await readBody(event);
const mdmId = (body?.mdmId as string | undefined)?.trim();
if (!deviceId) {
throw createError({ statusCode: 400, message: "deviceId required" });
}
if (!mdmId) {
throw createError({ statusCode: 400, message: "mdmId required" });
}
if (!UDID_RE.test(mdmId)) {
throw createError({ statusCode: 400, message: "invalid_udid_format" });
}
const device = await getUserDeviceByDeviceId(user.id, deviceId, "ios");
if (!device) {
throw createError({ statusCode: 404, message: "device_not_found" });
}
await setUserDeviceMdmId(user.id, deviceId, mdmId);
return {
success: true,
data: { mdmId },
};
});

View File

@ -0,0 +1,82 @@
import {
clearUserDeviceMdmId,
getMdmStatusByUdid,
getUserDeviceByDeviceId,
} from "../../../../db/mdm";
import { getDeviceProtectionState } from "../../../../db/device-protection";
import { requireUser } from "../../../../utils/auth";
/**
* GET /api/magic/devices/:deviceId/mdm
*
* Returns the NanoMDM enrollment status for the user's iOS device and the
* locally tracked nefilter (lock profile) protection state.
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const deviceId = getRouterParam(event, "deviceId");
if (!deviceId) {
throw createError({
statusCode: 400,
data: { error: "device_id_required" },
});
}
const device = await getUserDeviceByDeviceId(user.id, deviceId, "ios");
if (!device) {
throw createError({
statusCode: 404,
data: { error: "device_not_found" },
});
}
// Not linked to a NanoMDM UDID → enrolled false.
if (!device.mdmId) {
return {
success: true,
data: { enrolled: false },
};
}
let status: Awaited<ReturnType<typeof getMdmStatusByUdid>>;
try {
status = await getMdmStatusByUdid(device.mdmId);
} catch (err: any) {
console.error("[MDM] NanoMDM DB query failed:", err);
throw createError({
statusCode: 503,
message: "mdm_db_unreachable",
data: { code: "mdm_db_unreachable" },
});
}
// UDID stored but no longer present in NanoMDM → clear stale link.
if (!status.enrolled) {
await clearUserDeviceMdmId(user.id, deviceId);
return {
success: true,
data: { enrolled: false },
};
}
// Lock-profile state is derived from the locally tracked nefilter state,
// not from MDM enrollment alone.
const lockState = await getDeviceProtectionState(
user.id,
deviceId,
"nefilter",
);
return {
success: true,
data: {
enrolled: true,
company: "ReBreak",
supervised: status.supervised,
lockProfileInstalled: lockState?.active ?? false,
lastAppPushAt: status.lastAppPushAt?.toISOString() ?? null,
},
};
});

View File

@ -0,0 +1,44 @@
import { getMdmStatusByUdid } from "../../../db/mdm";
import { requireUser } from "../../../utils/auth";
/**
* GET /api/magic/mdm/by-udid?udid=...
*
* Looks up the NanoMDM enrollment status for an arbitrary UDID. Useful when a
* USB-connected iPhone has not yet been linked to a ReBreak user device, e.g.
* to show whether it is already enrolled in ReBreak Cloud.
*/
export default defineEventHandler(async (event) => {
await requireUser(event);
const query = getQuery(event);
const udid = query.udid;
if (!udid || typeof udid !== "string") {
throw createError({
statusCode: 400,
data: { error: "udid_required" },
});
}
let status: Awaited<ReturnType<typeof getMdmStatusByUdid>>;
try {
status = await getMdmStatusByUdid(udid);
} catch (err: any) {
console.error("[MDM] NanoMDM DB query failed:", err);
throw createError({
statusCode: 503,
message: "mdm_db_unreachable",
data: { code: "mdm_db_unreachable" },
});
}
return {
success: true,
data: {
enrolled: status.enrolled,
company: status.company,
supervised: status.supervised,
lastAppPushAt: status.lastAppPushAt?.toISOString() ?? null,
},
};
});

View File

@ -22,21 +22,33 @@ import { generateRemovalPassword } from "../../utils/magic-lock";
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const body = await readBody(event);
const { deviceId, hostname, model, osVersion, platform } = body as {
const { deviceId, hardwareId, hostname, model, osVersion, platform } = body as {
deviceId?: string;
hardwareId?: string;
hostname?: string;
model?: string;
osVersion?: string;
platform?: string;
};
if (!deviceId || !hostname) {
if (!hostname) {
throw createError({
statusCode: 400,
message: "deviceId und hostname required",
message: "hostname required",
});
}
if (!deviceId && !hardwareId) {
throw createError({
statusCode: 400,
message: "deviceId oder hardwareId required",
});
}
// Für neue Magic-Registrierungen: hardwareId wird gleichzeitig deviceId,
// damit das Backend keine eigene ID generieren muss.
const effectiveDeviceId = deviceId?.trim() || hardwareId!.trim();
// Plattform: Mac-App sendet nichts (legacy default), Windows-App sendet "windows"
const devicePlatform =
platform === "windows" ? "windows" : "macos";
@ -44,17 +56,60 @@ export default defineEventHandler(async (event) => {
const db = usePrisma();
// 1. Prüfe ob Device bereits als Magic-Client gebunden ist (idempotent)
const existing = await db.userDevice.findUnique({
where: { userId_deviceId: { userId: user.id, deviceId } },
select: {
id: true,
userId: true,
magicDnsToken: true,
magicEnrolledAt: true,
magicRevokedAt: true,
magicRemovalPassword: true,
},
});
// Priorität: hardwareId → deviceId → Migration über Modell/Plattform/OS.
let existing = null;
if (hardwareId) {
existing = await db.userDevice.findFirst({
where: { userId: user.id, hardwareId },
select: {
id: true,
userId: true,
deviceId: true,
magicDnsToken: true,
magicEnrolledAt: true,
magicRevokedAt: true,
magicRemovalPassword: true,
},
});
}
if (!existing && deviceId) {
existing = await db.userDevice.findUnique({
where: { userId_deviceId: { userId: user.id, deviceId } },
select: {
id: true,
userId: true,
deviceId: true,
magicDnsToken: true,
magicEnrolledAt: true,
magicRevokedAt: true,
magicRemovalPassword: true,
},
});
}
// Migration: bestehendes Gerät ohne hardwareId anhand von Modell/Plattform/OS finden.
if (!existing && hardwareId) {
existing = await db.userDevice.findFirst({
where: {
userId: user.id,
hardwareId: null,
platform: devicePlatform,
model: model ?? null,
osVersion: osVersion ?? null,
},
select: {
id: true,
userId: true,
deviceId: true,
magicDnsToken: true,
magicEnrolledAt: true,
magicRevokedAt: true,
magicRemovalPassword: true,
},
});
}
// Wenn Token existiert und nicht revoked → return existing
if (
@ -132,21 +187,26 @@ export default defineEventHandler(async (event) => {
}
// 5. Upsert UserDevice (platform="macos" | "windows")
// Bei Migration behalten wir die bestehende deviceId bei.
const upsertDeviceId = existing?.deviceId || effectiveDeviceId;
const device = await db.userDevice.upsert({
where: { userId_deviceId: { userId: user.id, deviceId } },
where: { userId_deviceId: { userId: user.id, deviceId: upsertDeviceId } },
create: {
userId: user.id,
deviceId,
deviceId: upsertDeviceId,
platform: devicePlatform,
model: model ?? null,
name: hostname,
osVersion: osVersion ?? null,
hardwareId: hardwareId ?? null,
magicDnsToken: dnsToken,
magicEnrolledAt: new Date(),
magicHostname: hostname,
magicRemovalPassword: removalPassword,
},
update: {
hardwareId: hardwareId ?? undefined,
magicDnsToken: dnsToken,
magicEnrolledAt: new Date(),
magicRevokedAt: null, // Clear falls vorher revoked

View File

@ -0,0 +1,123 @@
import { usePrisma } from "../utils/prisma";
// ─── Types ────────────────────────────────────────────────────────────────────
export type ProtectionType = "nefilter" | "vpn" | "dns";
export const PROTECTION_TYPES: ProtectionType[] = [
"nefilter",
"vpn",
"dns",
];
export interface DeviceProtectionStateRecord {
id: string;
userId: string;
deviceId: string;
platform: string;
protectionType: string;
active: boolean;
lastSeenAt: Date | null;
changedAt: Date;
reason: string | null;
}
// ─── Write ─────────────────────────────────────────────────────────────────────
/**
* Upserts the per-device/per-protection-type state.
*
* If `active` changed compared to the existing row (or there was no row), an
* entry is appended to `DeviceProtectionStateLog` with `occurredAt = now()`.
*/
export async function upsertDeviceProtectionState(
userId: string,
deviceId: string,
platform: string,
protectionType: ProtectionType,
active: boolean,
lastSeenAt?: Date | null,
reason?: string | null,
source?: string | null,
): Promise<DeviceProtectionStateRecord> {
const db = usePrisma();
const now = new Date();
const existing = await db.deviceProtectionState.findUnique({
where: {
userId_deviceId_protectionType: { userId, deviceId, protectionType },
},
});
const changed = !existing || existing.active !== active;
const row = await db.deviceProtectionState.upsert({
where: {
userId_deviceId_protectionType: { userId, deviceId, protectionType },
},
create: {
userId,
deviceId,
platform,
protectionType,
active,
lastSeenAt: lastSeenAt ?? null,
changedAt: now,
reason: reason ?? null,
},
update: {
platform,
active,
lastSeenAt: lastSeenAt === undefined ? undefined : lastSeenAt,
changedAt: now,
reason: reason === undefined ? undefined : reason,
},
});
if (changed) {
await db.deviceProtectionStateLog.create({
data: {
userId,
deviceId,
protectionType,
active,
occurredAt: now,
reason: reason ?? null,
source: source ?? "app",
},
});
}
return row;
}
// ─── Read ──────────────────────────────────────────────────────────────────────
/** Returns the current state for one protection type on a device. */
export async function getDeviceProtectionState(
userId: string,
deviceId: string,
protectionType: ProtectionType,
): Promise<DeviceProtectionStateRecord | null> {
const db = usePrisma();
return db.deviceProtectionState.findUnique({
where: {
userId_deviceId_protectionType: { userId, deviceId, protectionType },
},
});
}
/** Lists all protection states for a user, optionally filtered to one device. */
export async function listDeviceProtectionStates(
userId: string,
deviceId?: string,
): Promise<DeviceProtectionStateRecord[]> {
const db = usePrisma();
return db.deviceProtectionState.findMany({
where: {
userId,
...(deviceId ? { deviceId } : {}),
},
orderBy: [{ deviceId: "asc" }, { protectionType: "asc" }],
});
}

View File

@ -418,9 +418,11 @@ export async function deleteUserDevice(
export interface MagicDeviceRecord {
deviceId: string;
hardwareId: string | null;
hostname: string | null;
model: string | null;
osVersion: string | null;
mdmId: string | null;
magicEnrolledAt: Date;
releaseRequestedAt: Date | null;
magicRevokedAt: Date | null;
@ -445,9 +447,11 @@ export async function listMagicDevices(
orderBy: { magicEnrolledAt: "desc" },
select: {
deviceId: true,
hardwareId: true,
magicHostname: true,
model: true,
osVersion: true,
mdmId: true,
magicEnrolledAt: true,
releaseRequestedAt: true,
magicRevokedAt: true,
@ -458,9 +462,11 @@ export async function listMagicDevices(
return devices.map((d) => ({
deviceId: d.deviceId,
hardwareId: d.hardwareId,
hostname: d.magicHostname,
model: d.model,
osVersion: d.osVersion,
mdmId: d.mdmId,
magicEnrolledAt: d.magicEnrolledAt!,
releaseRequestedAt: d.releaseRequestedAt,
magicRevokedAt: d.magicRevokedAt,

146
backend/server/db/mdm.ts Normal file
View File

@ -0,0 +1,146 @@
import { usePrisma } from "../utils/prisma";
import pg from "pg";
const { Pool } = pg;
let _mdmPool: pg.Pool | null = null;
/**
* Lazily initialised pg.Pool against the NanoMDM Postgres.
* Connection string comes from runtimeConfig.mdmDatabaseUrl.
*/
function useMdmPool(): pg.Pool {
if (_mdmPool) return _mdmPool;
const config = useRuntimeConfig();
const connectionString = (config as any).mdmDatabaseUrl;
if (!connectionString) {
throw new Error("MDM_DATABASE_URL not configured");
}
_mdmPool = new Pool({
connectionString,
// NanoMDM queries are point lookups — keep pool small.
max: 5,
connectionTimeoutMillis: 5000,
queryTimeout: 5000,
});
return _mdmPool;
}
export interface UserDeviceMdmRecord {
id: string;
userId: string;
deviceId: string;
platform: string;
mdmId: string | null;
}
const USER_DEVICE_MDM_SELECT = {
id: true,
userId: true,
deviceId: true,
platform: true,
mdmId: true,
} as const;
/**
* Find a user's iOS device by Capacitor deviceId.
*/
export async function getUserDeviceByDeviceId(
userId: string,
deviceId: string,
platform: string = "ios",
): Promise<UserDeviceMdmRecord | null> {
const db = usePrisma();
return db.userDevice.findFirst({
where: { userId, deviceId, platform },
select: USER_DEVICE_MDM_SELECT,
});
}
/**
* Persist the NanoMDM UDID for a user's device.
*/
export async function setUserDeviceMdmId(
userId: string,
deviceId: string,
mdmId: string,
): Promise<void> {
const db = usePrisma();
await db.userDevice.updateMany({
where: { userId, deviceId, platform: "ios" },
data: { mdmId },
});
}
/**
* Clear the stored NanoMDM UDID (e.g. device no longer enrolled).
*/
export async function clearUserDeviceMdmId(
userId: string,
deviceId: string,
): Promise<void> {
const db = usePrisma();
await db.userDevice.updateMany({
where: { userId, deviceId, platform: "ios" },
data: { mdmId: null },
});
}
export interface MdmDeviceStatus {
enrolled: boolean;
company: string | null;
supervised: boolean;
tokenUpdateAt: Date | null;
lastAckAt: Date | null;
lastAppPushAt: Date | null;
}
/**
* Query NanoMDM Postgres for a device by UDID.
*
* Throws if the MDM DB is unreachable callers should treat this as an
* infra/runtime error and not cache a negative result.
*/
export async function getMdmStatusByUdid(
udid: string,
): Promise<MdmDeviceStatus> {
const pool = useMdmPool();
// Defensive: only raw parameters reach the query layer below.
const result = await pool.query<{
unlock_token: Buffer | null;
token_update_at: Date | null;
last_ack: Date | null;
last_app_push_at: Date | null;
}>(
`SELECT
d.unlock_token,
d.token_update_at,
(SELECT max(updated_at) FROM command_results WHERE id = d.id) AS last_ack,
(SELECT max(r.updated_at)
FROM command_results r
JOIN commands c ON c.command_uuid = r.command_uuid
WHERE r.id = d.id
AND c.request_type = 'InstallApplication'
AND r.status = 'Acknowledged') AS last_app_push_at
FROM devices d
WHERE d.id = $1`,
[udid],
);
const row = result.rows[0];
const enrolled = !!row;
return {
enrolled,
company: enrolled ? "ReBreak" : null,
supervised: enrolled && row?.unlock_token != null,
tokenUpdateAt: row?.token_update_at ?? null,
lastAckAt: row?.last_ack ?? null,
lastAppPushAt: row?.last_app_push_at ?? null,
};
}

View File

@ -55,6 +55,7 @@ exec infisical run \
[[ -n "${SUPABASE_ANON_KEY:-}" ]] && export NITRO_SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY" && export NITRO_PUBLIC_SUPABASE_KEY="$SUPABASE_ANON_KEY"
[[ -n "${SUPABASE_SERVICE_ROLE_KEY:-}" ]] && export NITRO_SUPABASE_SERVICE_KEY="$SUPABASE_SERVICE_ROLE_KEY"
[[ -n "${DATABASE_URL:-}" ]] && export NITRO_DATABASE_URL="$DATABASE_URL"
[[ -n "${MDM_DATABASE_URL:-}" ]] && export NITRO_MDM_DATABASE_URL="$MDM_DATABASE_URL"
[[ -n "${OPENROUTER_API_KEY:-}" ]] && export NITRO_OPENROUTER_API_KEY="$OPENROUTER_API_KEY"
[[ -n "${OPENAI_API_KEY:-}" ]] && export NITRO_OPENAI_API_KEY="$OPENAI_API_KEY"
[[ -n "${GROQ_API_KEY:-}" ]] && export NITRO_GROQ_API_KEY="$GROQ_API_KEY"

View File

@ -0,0 +1,500 @@
# Magic Dashboard iOS Section Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Redesign the Magic dashboard so iOS devices are shown in a dedicated section under the desktop hero, with live USB status, action buttons, and sync/offboarding flows while keeping detection on-demand only.
**Architecture:** A new `IosDeviceSection` component owns the iOS list. `useDeviceStatus` derives `iosDevices` and `desktopDevices` from the backend list. `IosDeviceCard` renders each device and its action button. `UnknownIosDeviceCard` handles USB-connected devices that are not registered in the backend. Existing `DeviceDetailSheet` is extended to show iOS stars for any connected iOS device.
**Tech Stack:** Nuxt 3, Vue 3, Nuxt UI v4, Tauri 2, TypeScript, pnpm.
---
## Task 1: Extend `useDeviceStatus.ts` to split devices by platform
**Files:**
- Modify: `apps/rebreak-magic/app/composables/useDeviceStatus.ts`
**Why:** The dashboard needs separate `iosDevices` and `desktopDevices` lists instead of one mixed `otherDevices` list.
- [ ] **Step 1: Add platform-based derived lists**
Replace the single `otherDevices` derived with `iosDevices` and `desktopDevices`. Keep `otherDevices` as the union for backward compatibility or remove it if unused after status.vue update.
```ts
const iosDevices = computed<ComputedDevice[]>(() =>
devices.value
.filter((d) => normalizePlatform(d.model ?? d.hostname) === "ios")
.map((d) => mapToComputedDevice(d, false)),
);
const desktopDevices = computed<ComputedDevice[]>(() =>
devices.value
.filter((d) => {
const p = normalizePlatform(d.model ?? d.hostname);
return p === "mac" || p === "windows";
})
.filter((d) => d.deviceId !== currentBackendDevice.value?.deviceId)
.map((d) => mapToComputedDevice(d, false)),
);
```
Introduce a small helper `mapToComputedDevice(d, isCurrent)` to avoid duplicating the mapping object.
- [ ] **Step 2: Update return object**
```ts
return {
currentBackendDevice,
iosDevices,
desktopDevices,
otherDevices: desktopDevices, // temporary alias until status.vue is updated
iosStars,
};
```
- [ ] **Step 3: Typecheck**
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
Expected: same pre-existing errors as before, no new ones.
---
## Task 2: Create `IosDeviceCard.vue`
**Files:**
- Create: `apps/rebreak-magic/app/components/IosDeviceCard.vue`
**Why:** Each backend iOS device needs its own card with status, stars (if USB-connected), and a context-aware action button.
- [ ] **Step 1: Write the component**
Props:
```ts
const props = defineProps<{
device: ComputedDevice;
iphone: IphoneDeviceState | null;
isConnected: boolean;
}>();
```
Compute `iosStars` from `iphone` when connected. Derive the action label/target from `device` and `iosStars`:
```ts
const action = computed(() => {
if (!props.isConnected || !props.iphone) {
return { label: "Verbinden", to: "/detect", icon: "i-heroicons-link" };
}
if (!props.iphone.isSupervised) {
return { label: "Supervisen", to: "/supervise", icon: "i-heroicons-shield-check" };
}
if (!props.iphone.installedProfileIDs?.includes("org.rebreak.mdm.enrollment")) {
return { label: "Enrollen", to: "/enroll", icon: "i-heroicons-document-check" };
}
if (!props.iphone.installedProfileIDs?.includes("org.rebreak.protection.contentfilter.sideload")) {
return { label: "Sideload installieren", to: "/sideload", icon: "i-heroicons-lock-closed" };
}
if (!props.iphone.installedAppBundleIDs?.includes("org.rebreak.app")) {
return { label: "App installieren", to: "/sideload", icon: "i-heroicons-arrow-down-tray" };
}
return { label: "Synchronisieren", icon: "i-heroicons-arrow-path" };
});
```
Template: device icon, name/model, status badge, optional `IosStarRating`, last-seen text, and the action button. If the label is "Synchronisieren" use `@click` to emit `sync`; otherwise use `to` navigation.
- [ ] **Step 2: Add emits**
```ts
const emit = defineEmits<{
(e: "sync", device: ComputedDevice): void;
(e: "open", device: ComputedDevice): void;
}>();
```
- [ ] **Step 3: Typecheck**
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
Expected: no new errors.
---
## Task 3: Create `UnknownIosDeviceCard.vue`
**Files:**
- Create: `apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue`
**Why:** A USB-connected iOS device that is not registered to the user's ReBreak account must be shown as unrecognizable with a clear next-step message.
- [ ] **Step 1: Write the component**
Props:
```ts
const props = defineProps<{
iphone: IphoneDeviceState;
}>();
```
Template: warning icon, title "Dieses iPhone ist nicht erkennbar", model/iOS version/UDID as read-only info, and helper text:
> "Mit keinem ReBreak-Konto verbunden. Um es zu verwalten: ReBreak-App installieren, anmelden und Gerät registrieren."
No action buttons.
- [ ] **Step 2: Typecheck**
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
---
## Task 4: Create `IosDeviceSection.vue`
**Files:**
- Create: `apps/rebreak-magic/app/components/IosDeviceSection.vue`
**Why:** This component owns the iOS section header, list, matching logic, and empty/unknown states.
- [ ] **Step 1: Write the component**
Props:
```ts
const props = defineProps<{
devices: ComputedDevice[];
iphone: IphoneDeviceState | null;
loading: boolean;
hasRefreshed: boolean;
}>();
const emit = defineEmits<{
(e: "sync", device: ComputedDevice): void;
(e: "open", device: ComputedDevice): void;
}>();
```
Implement matching helper:
```ts
function matchesIphone(device: ComputedDevice, iphone: IphoneDeviceState): boolean {
const modelMatch = (device.model ?? "").toLowerCase() === iphone.productType.toLowerCase();
const nameMatch = (device.name ?? "").toLowerCase() === iphone.name.toLowerCase();
return modelMatch || nameMatch;
}
```
Compute:
```ts
const connectedDeviceId = computed(() => {
if (!props.iphone) return null;
return props.devices.find((d) => matchesIphone(d, props.iphone!))?.deviceId ?? null;
});
const hasUnknownUsbDevice = computed(() => {
return !!props.iphone && !connectedDeviceId.value;
});
```
Template:
- Section title "Meine iOS-Geräte"
- If `!hasRefreshed && devices.length === 0`: "Noch keine iOS-Geräte geladen."
- If `hasRefreshed && devices.length === 0`: "Keine iOS-Geräte registriert. ReBreak-App installieren und Gerät hinzufügen."
- If `hasUnknownUsbDevice`: render `UnknownIosDeviceCard` first.
- Render `IosDeviceCard` for each device with `isConnected = device.deviceId === connectedDeviceId`.
- [ ] **Step 2: Typecheck**
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
---
## Task 5: Update `status.vue`
**Files:**
- Modify: `apps/rebreak-magic/app/pages/status.vue`
**Why:** The page must render the new iOS section and use `desktopDevices` instead of `otherDevices` for the remaining list.
- [ ] **Step 1: Replace `otherDevices` usage with `iosDevices` and `desktopDevices`**
Update import from `useDeviceStatus`:
```ts
const { currentBackendDevice, iosDevices, desktopDevices, iosStars } =
useDeviceStatus(devices, localHostname, iphone, currentDeviceId);
```
- [ ] **Step 2: Insert iOS section under hero**
After the hero section and before "Weitere Geräte", add:
```vue
<IosDeviceSection
:devices="iosDevices"
:iphone="iphone"
:loading="loading"
:has-refreshed="hasRefreshed"
@sync="onIosSync"
@open="openDevice"
/>
```
- [ ] **Step 3: Change "Weitere Geräte" list to `desktopDevices`**
Replace `otherDevices` references in the list with `desktopDevices`. Update empty copy to "Keine weiteren Computer geladen." / "Keine weiteren Computer registriert." depending on `hasRefreshed`.
- [ ] **Step 4: Add `onIosSync` handler**
```ts
async function onIosSync(device: ComputedDevice) {
loading.value = true;
error.value = null;
try {
await protection.refreshIphone();
// TODO: push missing MDM components and compare MDM version once the backend exposes it.
await protection.refreshBackendDevices();
} catch (e: any) {
error.value = e?.message ?? "Synchronisierung fehlgeschlagen";
} finally {
loading.value = false;
hasRefreshed.value = true;
}
}
```
Expose `refreshIphone` and `refreshBackendDevices` from `useProtectionStatus` if not already exported.
- [ ] **Step 5: Typecheck**
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
Expected: no new errors.
---
## Task 6: Extend `useProtectionStatus.ts` exports
**Files:**
- Modify: `apps/rebreak-magic/app/composables/useProtectionStatus.ts`
**Why:** `status.vue` needs to call `refreshIphone` and `refreshBackendDevices` independently for the sync action.
- [ ] **Step 1: Export the two refresh functions**
Add to the return object:
```ts
return {
// ... existing returns
refreshIphone,
refreshBackendDevices,
};
```
- [ ] **Step 2: Typecheck**
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
---
## Task 7: Adjust `DeviceDetailSheet.vue` for iOS stars
**Files:**
- Modify: `apps/rebreak-magic/app/components/DeviceDetailSheet.vue`
**Why:** iOS devices are never `isCurrent`, but the sheet should still show stars when the opened device is connected via USB.
- [ ] **Step 1: Change `showIosStars` condition**
Accept a new prop or use the existing `iosStars` prop directly. The current condition is:
```ts
const showIosStars = computed(() => props.device?.isCurrent && props.device?.platform === "ios");
```
Change to:
```ts
const showIosStars = computed(() => props.device?.platform === "ios" && !!props.iosStars);
```
Ensure the parent passes `iosStars` for the opened iOS device when it is connected.
- [ ] **Step 2: Hide desktop-only sections for iOS devices**
`showDesktopToggle` should remain as is (only mac/windows + isCurrent).
Cooldown controls should only show for `device.isCurrent` (desktop), which is already the case.
- [ ] **Step 3: Typecheck**
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
---
## Task 8: Update `DeviceHeroCard.vue` and `DeviceListItem.vue`
**Files:**
- Modify: `apps/rebreak-magic/app/components/DeviceHeroCard.vue`
- Modify: `apps/rebreak-magic/app/components/DeviceListItem.vue`
**Why:** These components are now desktop-only. Remove iOS-specific rendering if it is no longer needed or keep it defensive.
- [ ] **Step 1: In `DeviceHeroCard.vue`, keep `showIosStars` defensive**
No functional change needed because the hero only receives desktop devices, but confirm `showIosStars` still computes correctly.
- [ ] **Step 2: In `DeviceListItem.vue`, no change required**
It will only be rendered with desktop devices.
---
## Task 9: Add MDM version awareness (frontend foundation)
**Files:**
- Modify: `apps/rebreak-magic/app/composables/useTauri.ts`
- Modify: `apps/rebreak-magic/app/components/IosDeviceCard.vue`
**Why:** The sync action must later compare installed MDM version with the latest ReBreak MDM version.
- [ ] **Step 1: Add a constant and helper in `useTauri.ts`**
```ts
export const REBREAK_MDM_VERSION = "0.1";
export function getInstalledMdmVersion(installedProfileIDs: string[]): string | null {
const versionId = installedProfileIDs.find((id) => id.startsWith("org.rebreak.mdm.version."));
return versionId?.replace("org.rebreak.mdm.version.", "") ?? null;
}
```
- [ ] **Step 2: Use it in `IosDeviceCard.vue` action logic**
When connected and all core checks pass, compare `getInstalledMdmVersion(...)` with `REBREAK_MDM_VERSION`. If outdated or missing, return `{ label: "MDM-Update installieren", icon: "i-heroicons-arrow-up-tray" }` and emit `sync`.
- [ ] **Step 3: Typecheck**
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
---
## Task 10: Grace-period / Offboarding placeholder
**Files:**
- Modify: `apps/rebreak-magic/app/components/IosDeviceCard.vue`
- Modify: `apps/rebreak-magic/app/pages/status.vue`
**Why:** The spec requires a "ReBreak entfernen" action during the 3-day grace period after cancellation. The backend endpoint does not exist yet, so we add a safe placeholder.
- [ ] **Step 1: Add `subscriptionInGracePeriod` prop**
```ts
const props = defineProps<{
device: ComputedDevice;
iphone: IphoneDeviceState | null;
isConnected: boolean;
inGracePeriod?: boolean;
}>();
```
- [ ] **Step 2: Show offboarding button when in grace period**
At the top of the action derivation:
```ts
if (props.inGracePeriod) {
return { label: "ReBreak entfernen", icon: "i-heroicons-trash", variant: "danger" };
}
```
Emit a new `remove` event. The parent shows a placeholder toast or logs until the backend endpoint is ready.
- [ ] **Step 3: Stub grace-period state in `status.vue`**
```ts
const subscriptionInGracePeriod = ref(false);
// TODO: populate from backend once subscription status endpoint exists.
```
Pass it to `IosDeviceSection` and down to each card.
- [ ] **Step 4: Typecheck**
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
---
## Task 11: Verification and build
**Files:** n/a
**Why:** Ensure the frontend compiles and the Tauri bundle can be built.
- [ ] **Step 1: Typecheck**
Run: `cd apps/rebreak-magic && pnpm nuxi typecheck`
Expected: only pre-existing errors.
- [ ] **Step 2: Build Tauri bundle**
Run: `cd apps/rebreak-magic && pnpm tauri:build`
Expected: completes without new frontend errors. This may take several minutes on first run.
- [ ] **Step 3: Manual smoke test**
Launch the built app with the debug pairing code `000000`, open the dashboard, click **Aktualisieren**, and confirm:
- Desktop hero still renders.
- iOS section appears.
- If no iOS devices: correct empty message.
- If a USB iPhone is connected and registered: stars and action button render.
---
## Task 12: Commit changes
**Files:** n/a
- [ ] **Step 1: Stage and commit**
```bash
git add apps/rebreak-magic/app/composables/useDeviceStatus.ts \
apps/rebreak-magic/app/composables/useProtectionStatus.ts \
apps/rebreak-magic/app/composables/useTauri.ts \
apps/rebreak-magic/app/components/IosDeviceSection.vue \
apps/rebreak-magic/app/components/IosDeviceCard.vue \
apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue \
apps/rebreak-magic/app/components/DeviceDetailSheet.vue \
apps/rebreak-magic/app/components/DeviceHeroCard.vue \
apps/rebreak-magic/app/components/DeviceListItem.vue \
apps/rebreak-magic/app/pages/status.vue \
docs/superpowers/specs/2026-06-16-magic-dashboard-ios-section-design.md \
docs/superpowers/plans/2026-06-16-magic-dashboard-ios-section.md
git commit -m "feat(magic): dedicated iOS section in dashboard with on-demand sync"
```
---
## Spec Coverage Check
| Spec requirement | Task |
|---|---|
| Desktop hero remains | Task 5 |
| Dedicated iOS section under hero | Tasks 4, 5 |
| Backend iOS devices listed | Tasks 1, 4 |
| USB live status synced to matching device | Tasks 2, 4 |
| Unknown USB device shown as unrecognizable | Task 3 |
| Action buttons for supervise/enroll/sideload/app/sync | Task 2 |
| On-demand detection preserved | Task 5, existing code |
| Grace-period offboarding placeholder | Task 10 |
| MDM version foundation | Task 9 |
| DeviceDetailSheet iOS stars | Task 7 |
## Known Backend Dependencies (out of scope for this frontend plan)
- Subscription cancellation / grace-period endpoint.
- Offboarding endpoint: remove MDM profiles, unsupervise, clean DB entry.
- Central `REBREAK_MDM_VERSION` value injected into MDM enrollment profiles.

View File

@ -0,0 +1,167 @@
# Magic Dashboard iOS-Section Redesign
## Ziel
Das Magic-Dashboard soll klar zwischen zwei eigenen Bausteinen trennen:
1. **Desktop-Schutz** (Mac/Windows) das Gerät, auf dem Magic läuft.
2. **iOS-Verwaltung** (eigene iPhone/iPad-Geräte) registrierte iOS-Geräte des Users mit Status, Sternen und passenden Aktionen.
„Andere Geräte“ bleiben als sekundäre Information erhalten, sollen aber nicht den Fokus stehlen. Magic ist kein offenes Verwaltungstool für fremde Geräte.
---
## Annahmen (aus dem Abstimmungsgespräch)
- Backend-iOS-Geräte werden immer gelistet.
- Wenn ein iPhone/iPad per USB verbunden ist, werden Live-Daten (Supervision, Enrollment, Sideload, App) mit dem passenden Backend-Eintrag synchronisiert.
- Ein per USB verbundenes, aber im Backend unbekanntes iOS-Gerät wird als **„Nicht erkennbar“** markiert. Der Hinweis verweist auf: ReBreak-App installieren → anmelden → Gerät registrieren. Erst danach ist es verwaltbar.
- Supervise / Enroll / Sideload / App-Install bleiben im bestehenden Wizard (`/detect`, `/supervise`, `/enroll`, `/sideload`). Das Dashboard bietet nur den passenden Einstieg.
---
## Seitenstruktur
```
status.vue
├── Header (Profil, Logout)
├── Section: Aktives Gerät (Desktop-Hero)
│ └── DeviceHeroCard für currentBackendDevice (mac/windows)
├── Section: Meine iOS-Geräte
│ ├── UnknownIosDeviceCard (falls USB-Device nicht im Backend)
│ └── IosDeviceCard[] für jedes Backend-iOS-Gerät
│ (Sterne + Status + Action-Button)
├── Section: Weitere Geräte
│ └── DeviceListItem[] für sonstige Backend-Geräte
└── DeviceDetailSheet (weiterhin für Details/Cooldown)
```
---
## Neue Komponenten
### `IosDeviceSection.vue`
- Props:
- `devices: ComputedDevice[]` alle Backend-iOS-Geräte des Users
- `iphone: IphoneDeviceState | null` aktuell per USB erkanntes Gerät
- `loading: boolean`
- Zeigt den Section-Header und rendert die Liste von `IosDeviceCard`s.
- Wenn `iphone` verbunden, aber kein passendes Backend-Gerät gefunden wird, wird `UnknownIosDeviceCard` angezeigt.
- Wenn noch keine Backend-iOS-Geräte geladen wurden: Hinweis „Keine iOS-Geräte geladen“ + Aktualisieren-Button.
- Wenn geladen und leer: Hinweis „Keine iOS-Geräte registriert. ReBreak-App installieren und Gerät hinzufügen.“
### `IosDeviceCard.vue`
- Props:
- `device: ComputedDevice`
- `iphone: IphoneDeviceState | null` falls dieses Gerät per USB verbunden ist
- `isConnected: boolean`
- Zeigt:
- Name/Modell
- Status-Badge (`active`, `pending`, `unprotected` etc.)
- Letzte Sichtung
- `IosStarRating` + detaillierte Sternen-Liste, wenn `isConnected`
- Sonst Hinweis: „Zum Live-Status iPhone per USB verbinden“
- Action-Button (ableitet sich aus dem gemergten Zustand):
- Nicht supervised → „Supervisen" → `/supervise`
- Supervised, aber Enrollment fehlt → „Enrollen" → `/enroll`
- Enrollment vorhanden, aber Sideload-Profil fehlt → „Sideload installieren" → `/sideload`
- Sideload vorhanden, aber App fehlt → „App installieren" (MDM-Befehl oder Link)
- Alles okay → „Synchronisieren" (prüft Enrollment-, Sideload- und Supervision-Status; bei Abweichungen werden fehlende Profile/MDM-Kommandos gepusht; falls die lokale MDM-Version hinter der aktuellen ReBreak-MDM-Version zurückfällt, wird ein Update gepusht und der User informiert)
- Während der 3-Tage-Kündigungs-Grace-Period → „ReBreak entfernen" (löst Offboarding aus: MDM-Profile entfernen, Gerät unsupervised setzen, Eintrag bereinigen)
### `UnknownIosDeviceCard.vue`
- Props:
- `iphone: IphoneDeviceState`
- Zeigt:
- Warn-Icon + „Dieses iPhone ist nicht erkennbar"
- Modell, iOS-Version, UDID (nur zur Info)
- Hinweis: „Mit keinem ReBreak-Konto verbunden. Um es zu verwalten: ReBreak-App installieren, anmelden und Gerät registrieren."
- Keine Supervise-/Enroll-Aktionen.
---
## Datenfluss
1. User klickt in `status.vue` auf **Aktualisieren**.
2. `protection.refresh()` wird aufgerufen:
- `detectIphoneState()` lädt das per USB verbundene iOS-Gerät.
- `getMagicDevices()` lädt alle Backend-Geräte in den shared `useMagicDevices`-State.
3. `useDeviceStatus` liefert weiterhin:
- `currentBackendDevice` (Desktop)
- `otherDevices` (alles außer current)
4. `IosDeviceSection` erhält die Liste aller `otherDevices`, filtert intern auf `platform === 'ios'` und versucht, das verbundene `iphone` per Modell + Name zuzuordnen.
5. Action-Buttons leiten den User basierend auf dem gemergten Live-Status in den passenden Wizard-Schritt weiter.
### iOS-Matching
Da Backend-`deviceId` (Capacitor-UUID) nicht mit USB-UDID übereinstimmt, erfolgt das Matching über:
- `device.model` (Backend) ↔ `iphone.productType` (USB)
- `device.name` (Backend) ↔ `iphone.name` (USB) als Fallback / Verfeinerung
Sind mehrere Geräte mit identischem Modell vorhanden, wird das erste passende (`name` match) als verbunden markiert; bei Unklarheit wird das `iphone` nicht zugeordnet und erscheint als `UnknownIosDeviceCard`.
---
## Bestehende Komponenten Anpassungen
### `DeviceHeroCard.vue`
- Keine iOS-Sterne mehr anzeigen (`showIosStars` bleibt aber für zukünftige Flexibilität).
- Aktionen bleiben auf Desktop-Schutz beschränkt.
### `DeviceListItem.vue`
- Wird für „Weitere Geräte“ (andere Desktops) weiterverwendet.
- iOS-Geräte verschwinden aus dieser Liste und werden in der neuen iOS-Section angezeigt.
### `DeviceDetailSheet.vue`
- iOS-Sterne-Anzeige gilt für jedes iOS-Gerät, das gerade per USB verbunden ist (nicht nur `isCurrent`, da iOS-Geräte nie „current" sind).
- Cooldown-Steuerung bleibt nur für `isCurrent`-Desktop-Geräte.
### `useDeviceStatus.ts`
- Entfernt den Debug-`watchEffect` (bereits erledigt).
- Fügt optional `iosDevices` und `desktopDevices` als getrennte Derived Lists hinzu, damit `status.vue` weniger Filter-Logik enthält.
---
## On-Demand-Verhalten bleibt erhalten
- Kein automatisches Polling mehr.
- Sterne/Status werden nur beim manuellen Refresh aktualisiert.
- Das verhindert erneut Log-Spam durch wiederholte `detect_iphone_state`-Aufrufe.
---
## Fehlerbehandlung
- Wenn `detectIphoneState` fehlschlägt: Fehler nur in `protection.lastError`; iOS-Section zeigt Backend-Liste weiterhin an.
- Wenn `getMagicDevices` fehlschägt: `error`-Banner in `status.vue`.
- Wenn Matching mehrdeutig: `UnknownIosDeviceCard` statt falscher Zuordnung.
---
## Kündigungs-Grace-Period & Offboarding
- Solange das Gerät enrolled ist **und** das Abo aktiv ist, wird **nichts** deinstalliert.
- Nach einer Kündigung bleibt das iOS-Gerät für **3 Tage** in der Liste sichtbar.
- Der Button **„ReBreak entfernen"** ist **unsichtbar oder disabled**, solange die Grace-Period noch nicht begonnen hat.
- Sobald die Grace-Period läuft, erscheint der Button ohne zusätzliche Sicherheitsabfrage.
- Ein Klick darauf startet das Offboarding:
1. MDM-Enrollment-Profil und Sideload-Profil vom Gerät entfernen.
2. Gerät aus dem Supervised-Modus zurücksetzen.
3. Backend-Eintrag für das iOS-Gerät bereinigen.
- **Backend-Abhängigkeit:** Es braucht ein Feld/Endpoint, der die Kündigung + verbleibende Grace-Period erkennbar macht (z. B. `subscriptionCancelledAt` im Profil oder dedizierter `/api/magic/subscription-status`). Das Offboarding selbst braucht einen neuen API-Endpoint oder Tauri-Command, der MDM-Remove + Unsupervise orchestriert.
---
## Offene Punkte / Nächste Schritte
1. Existiert bereits ein Backend-Feld/Endpoint für Kündigung + Grace-Period, oder muss der gebaut werden?
2. Wie wird die „aktuelle ReBreak-MDM-Version" bestimmt ist sie im Profil hinterlegt, im Backend konfiguriert oder über eine Tauri-Funktion verfügbar?
3. Soll die App-Installation via MDM direkt aus dem Dashboard auslösbar sein, oder reicht ein Verweis auf `/sideload`?

View File

@ -0,0 +1,162 @@
# MDM Device Link für Magic App — Design
## Ziel
In der ReBreak Magic App unter „Meine iOS-Geräte“ automatisch prüfen, ob ein iOS-Gerät im NanoMDM enrolled ist, und den passenden Status in der Device-Card anzeigen.
## Annahmen / Einschränkungen
- Native-App-DB (`rebreak` auf `rebreak-server`) und NanoMDM-DB (`nanomdm` auf `rebreak-mdm`) liegen auf **unterschiedlichen Hetzner-VPS**.
- Apple gibt die Hardware-UDID **nicht** an React Native / Expo Apps weiter. Die Native-App-`deviceId` ist `identifierForVendor`, nicht die NanoMDM-UDID.
- Daher braucht `UserDevice` eine zusätzliche Spalte `mdmId`, die die NanoMDM-UDID speichert.
## Architektur
```
Magic App (Tauri/Nuxt)
├─ GET /api/magic/devices/:deviceId/mdm → Backend
│ │
│ ├─ Prisma: UserDevice.mdmId lesen
│ │
│ └─ pg.Pool → NanoMDM DB auf rebreak-mdm
│ SELECT devices / command_results
└─ POST /api/magic/devices/:deviceId/mdm-link
(setzt UserDevice.mdmId, z.B. nach USB-Enrollment)
```
## Datenmodell
```prisma
model UserDevice {
// ... bestehende Felder ...
mdmId String? @map("mdm_id") // NanoMDM-UDID, nullable
}
model DeviceProtectionState {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
deviceId String @map("device_id")
platform String
protectionType String @map("protection_type")
active Boolean
lastSeenAt DateTime? @map("last_seen_at")
changedAt DateTime @default(now()) @map("changed_at")
reason String?
@@unique([userId, deviceId, protectionType])
@@map("device_protection_states")
@@schema("rebreak")
}
model DeviceProtectionStateLog {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
deviceId String @map("device_id")
protectionType String @map("protection_type")
active Boolean
occurredAt DateTime @map("occurred_at")
reason String?
source String
@@index([userId, deviceId])
@@map("device_protection_state_logs")
@@schema("rebreak")
}
```
## Endpunkte
### `GET /api/magic/devices/:deviceId/mdm`
Auth: Magic-Session (`requireUser`)
Response enrolled:
```json
{
"success": true,
"data": {
"enrolled": true,
"company": "ReBreak",
"supervised": true,
"lockProfileInstalled": true,
"lastAppPushAt": "2026-06-11T19:09:04.363Z"
}
}
```
Response not enrolled:
```json
{ "success": true, "data": { "enrolled": false } }
```
Wenn `mdmId` gesetzt ist, aber NanoMDM das Gerät nicht mehr kennt, wird `mdmId` automatisch auf `null` gesetzt.
`lockProfileInstalled` wird nicht aus der bloßen MDM-Enrollment abgeleitet, sondern aus dem lokal gespeicherten `DeviceProtectionState` mit `protectionType = "nefilter"`. Solange die Native-App diesen Zustand noch nicht meldet, ist der Wert `false`.
### `POST /api/magic/devices/:deviceId/mdm-link`
Body: `{ "mdmId": "00008150-001C686601F0401C" }`
Setzt `UserDevice.mdmId` für das iOS-Gerät des aktuellen Users.
### `POST /api/devices/protection-state`
Body:
```json
{
"deviceId": "string",
"platform": "ios",
"protectionType": "nefilter | vpn | dns",
"active": true,
"reason": "optional",
"source": "optional"
}
```
Upsertet den per-Gerät-Schutz-Status und schreibt bei Änderung einen Log-Eintrag.
## Magic-App UI
`IosDeviceCard.vue` ruft über `useMdmStatus(deviceId)` den Backend-Status ab:
- **Enrolled**: Grüne Box mit Company / Supervised / Lock-Profil / Letzter App-Push.
- **Not enrolled**: Gelbe Box mit Hinweis: „Nicht MDM-enrolled. Verbinde das iPhone per USB. Das Enrollment dauert ca. 2 Minuten und geht ohne Datenverlust.“
- Wenn ein iPhone per USB verbunden ist, erscheint ein Button „Mit MDM verknüpfen“, der `linkMdmDevice(deviceId, udid)` aufruft.
## Infra / Env
- `MDM_DATABASE_URL` muss im Backend-Env gesetzt sein.
- `rebreak-mdm` PostgreSQL muss auf der öffentlichen IP lauschen und den Backend-Server in `pg_hba.conf` + UFW erlauben.
## Dateien
Backend:
- `backend/prisma/schema.prisma`
- `backend/nitro.config.ts`
- `backend/server/db/mdm.ts`
- `backend/server/db/device-protection.ts`
- `backend/server/api/magic/devices/[deviceId]/mdm.get.ts`
- `backend/server/api/magic/devices/[deviceId]/mdm-link.post.ts`
- `backend/server/api/devices/protection-state.post.ts`
- `backend/start-staging.sh`
Magic App:
- `apps/rebreak-magic/src-tauri/src/backend/api.rs`
- `apps/rebreak-magic/src-tauri/src/lib.rs`
- `apps/rebreak-magic/app/composables/useTauri.ts`
- `apps/rebreak-magic/app/composables/useMdmStatus.ts`
- `apps/rebreak-magic/app/components/IosDeviceCard.vue`
- `apps/rebreak-magic/app/components/DevLogDrawer.vue`
## Test-Ergebnis (Staging)
- Backend-Build: ✅
- Magic Rust: `cargo check`
- Magic Nuxt-Build: ✅
- API-Test für User `charioanouar@gmail.com`, iPhone `MHFLX23QM0`:
- `GET .../mdm``{ enrolled: true, company: "ReBreak", supervised: true, lockProfileInstalled: false, lastAppPushAt: "2026-06-11T19:09:04.363Z" }`
- `mdmId` gelöscht → `{ enrolled: false }`
- `POST .../mdm-link``{ mdmId: "00008150-001C686601F0401C" }`
## Offene TODOs
- `nefilter`, `vpn`, `dns` werden noch nicht von den Clients gemeldet. Dafür ist `POST /api/devices/protection-state` vorbereitet.
- Die Tabellen `device_protection_states` / `device_protection_state_logs` wurden für den schnellen Test manuell angelegt. Für Produktion muss eine Prisma-Migration erstellt und deployed werden.
- Die Spalte `mdm_id` wurde für den schnellen Test manuell mit `ALTER TABLE` angelegt.