diff --git a/.github/workflows/build-rebreak-magic-win.yml b/.github/workflows/build-rebreak-magic-win.yml new file mode 100644 index 0000000..b93f3ff --- /dev/null +++ b/.github/workflows/build-rebreak-magic-win.yml @@ -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 diff --git a/apps/rebreak-magic/app/components/DeviceDetailSheet.vue b/apps/rebreak-magic/app/components/DeviceDetailSheet.vue index 98537fd..b7ac006 100644 --- a/apps/rebreak-magic/app/components/DeviceDetailSheet.vue +++ b/apps/rebreak-magic/app/components/DeviceDetailSheet.vue @@ -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< diff --git a/apps/rebreak-magic/app/components/IosDeviceCard.vue b/apps/rebreak-magic/app/components/IosDeviceCard.vue new file mode 100644 index 0000000..862c15d --- /dev/null +++ b/apps/rebreak-magic/app/components/IosDeviceCard.vue @@ -0,0 +1,617 @@ + + + diff --git a/apps/rebreak-magic/app/components/IosDeviceSection.vue b/apps/rebreak-magic/app/components/IosDeviceSection.vue new file mode 100644 index 0000000..282b069 --- /dev/null +++ b/apps/rebreak-magic/app/components/IosDeviceSection.vue @@ -0,0 +1,78 @@ + + + diff --git a/apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue b/apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue new file mode 100644 index 0000000..969a44f --- /dev/null +++ b/apps/rebreak-magic/app/components/UnknownIosDeviceCard.vue @@ -0,0 +1,156 @@ + + + diff --git a/apps/rebreak-magic/app/composables/useDeviceStatus.ts b/apps/rebreak-magic/app/composables/useDeviceStatus.ts index fa026d3..79c84b4 100644 --- a/apps/rebreak-magic/app/composables/useDeviceStatus.ts +++ b/apps/rebreak-magic/app/composables/useDeviceStatus.ts @@ -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, localHostname: Ref, iphone: Ref, currentDeviceId?: Ref, + currentHardwareId?: Ref, ) { 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(() => { 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(() => { 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(() => + otherDevices.value.filter((d) => d.platform === "ios"), + ); + + const desktopDevices = computed(() => + 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, }; } diff --git a/apps/rebreak-magic/app/composables/useMagicState.ts b/apps/rebreak-magic/app/composables/useMagicState.ts new file mode 100644 index 0000000..9801b93 --- /dev/null +++ b/apps/rebreak-magic/app/composables/useMagicState.ts @@ -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("magic-session", () => null); +} + +export function useMagicDevices() { + return useState("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("iphone-device", () => null); +} diff --git a/apps/rebreak-magic/app/composables/useMdmStatus.ts b/apps/rebreak-magic/app/composables/useMdmStatus.ts new file mode 100644 index 0000000..b588fc3 --- /dev/null +++ b/apps/rebreak-magic/app/composables/useMdmStatus.ts @@ -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) { + const { getMdmStatus, linkMdmDevice } = useTauri(); + + const state = ref({ + 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, + }; +} diff --git a/apps/rebreak-magic/app/composables/useProtectionStatus.ts b/apps/rebreak-magic/app/composables/useProtectionStatus.ts index 43d6def..1e2aa5d 100644 --- a/apps/rebreak-magic/app/composables/useProtectionStatus.ts +++ b/apps/rebreak-magic/app/composables/useProtectionStatus.ts @@ -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(null); const lastUpdated = ref(null); - let iphoneTimer: ReturnType | null = null; - let deviceTimer: ReturnType | 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(() => { - 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, }; } diff --git a/apps/rebreak-magic/app/composables/useTauri.ts b/apps/rebreak-magic/app/composables/useTauri.ts index 2e7e59e..85fee67 100644 --- a/apps/rebreak-magic/app/composables/useTauri.ts +++ b/apps/rebreak-magic/app/composables/useTauri.ts @@ -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(command: string, args?: Record): Promise { 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 { - return await invokeLogged("register_device", { deviceId, model, osVersion }); + return await invokeLogged("register_device", { model, osVersion }); + } + + async function getHardwareId(): Promise { + return await invokeLogged("get_hardware_id"); + } + + async function getDeviceId(): Promise { + return await invokeLogged("get_device_id"); } async function getStoredSession(): Promise { @@ -247,6 +278,18 @@ export function useTauri() { return await invokeLogged("get_hostname"); } + async function getMdmStatus(deviceId: string): Promise { + return await invokeLogged("get_mdm_status", { deviceId }); + } + + async function getMdmStatusByUdid(udid: string): Promise { + return await invokeLogged("get_mdm_status_by_udid", { udid }); + } + + async function linkMdmDevice(deviceId: string, mdmId: string): Promise { + 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, }; } diff --git a/apps/rebreak-magic/app/pages/status.vue b/apps/rebreak-magic/app/pages/status.vue index 56b51d9..597b73c 100644 --- a/apps/rebreak-magic/app/pages/status.vue +++ b/apps/rebreak-magic/app/pages/status.vue @@ -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 @@
-

Dieses Gerät ist nicht geschützt

-

Aktiviere den Schutz für das Gerät, auf dem Magic läuft.

- - Dieses Gerät schützen - + + + + +
@@ -77,18 +97,20 @@
{{ error }}
-
+

Lade Geräte…

-
-

Keine weiteren Geräte registriert.

+
+

+ {{ hasRefreshed ? 'Keine weiteren Computer registriert.' : 'Noch keine Computer geladen.' }} +

session.value?.deviceId ?? null); +const currentDeviceId = ref(null); +const currentHardwareId = ref(null); const profile = ref(null); const loading = ref(false); +const hasRefreshed = ref(false); const error = ref(null); const sheetOpen = ref(false); const selectedDevice = ref(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; diff --git a/apps/rebreak-magic/src-tauri/src/backend/api.rs b/apps/rebreak-magic/src-tauri/src/backend/api.rs index 375f979..71c8530 100644 --- a/apps/rebreak-magic/src-tauri/src/backend/api.rs +++ b/apps/rebreak-magic/src-tauri/src/backend/api.rs @@ -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, + #[serde(rename = "hardwareId", skip_serializing_if = "Option::is_none")] + pub hardware_id: Option, pub hostname: String, pub model: Option, #[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, pub hostname: String, pub model: Option, #[serde(rename = "osVersion")] pub os_version: Option, + #[serde(default, rename = "mdmId")] + pub mdm_id: Option, #[serde(rename = "magicEnrolledAt")] pub magic_enrolled_at: Option, #[serde(rename = "releaseRequestedAt")] @@ -93,6 +99,32 @@ pub struct UserProfile { pub plan: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MdmStatusData { + pub enrolled: bool, + pub company: Option, + pub supervised: bool, + #[serde(rename = "lockProfileInstalled")] + pub lock_profile_installed: bool, + #[serde(rename = "lastAppPushAt")] + pub last_app_push_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MdmStatusByUdidData { + pub enrolled: bool, + pub company: Option, + pub supervised: bool, + #[serde(rename = "lastAppPushAt")] + pub last_app_push_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MdmLinkRequest { + #[serde(rename = "mdmId")] + pub mdm_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiEnvelope { 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 { 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 { + 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::>(response) + .await + .map(|envelope| envelope.data) + } + + pub async fn get_mdm_status_by_udid( + &self, + token: &str, + udid: &str, + ) -> AppResult { + 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::>(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::>(response) + .await + .map(|_| ()) + } + async fn handle_response( response: reqwest::Response, ) -> AppResult { diff --git a/apps/rebreak-magic/src-tauri/src/config.rs b/apps/rebreak-magic/src-tauri/src/config.rs new file mode 100644 index 0000000..4cc9eac --- /dev/null +++ b/apps/rebreak-magic/src-tauri/src/config.rs @@ -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, + /// Hardware-bound identity (UserDevice.hardwareId). + #[serde(skip_serializing_if = "Option::is_none")] + pub hardware_id: Option, +} + +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, + pub label: Option, +} + +impl MagicSession { + pub fn new(access_token: String, session_id: String, label: Option) -> 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, +} + +impl AppConfig { + pub fn config_dir() -> AppResult { + 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 { + Ok(Self::config_dir()?.join("config.json")) + } + + pub fn binder_config_path() -> AppResult { + 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 { + 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 { + 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> { + 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 { + 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> { + 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() + } +} diff --git a/apps/rebreak-magic/src-tauri/src/lib.rs b/apps/rebreak-magic/src-tauri/src/lib.rs index 2da0291..dbb2a23 100644 --- a/apps/rebreak-magic/src-tauri/src/lib.rs +++ b/apps/rebreak-magic/src-tauri/src/lib.rs @@ -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 { Ok(hostname) } +fn get_or_init_hardware_id(config: &mut AppConfig) -> AppResult { + 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 { + let mut config = AppConfig::load(); + get_or_init_hardware_id(&mut config) +} + +#[tauri::command] +fn get_device_id() -> AppResult> { + Ok(AppConfig::load().device_id) +} + fn require_session() -> AppResult { 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) -> AppResult, os_version: Option, ) -> AppResult { 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 { client.fetch_me(&session.access_token).await } +#[tauri::command] +async fn get_mdm_status(device_id: String) -> AppResult { + 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 { + 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 { let session = require_session()?; diff --git a/apps/rebreak-magic/src-tauri/src/platform/macos.rs b/apps/rebreak-magic/src-tauri/src/platform/macos.rs new file mode 100644 index 0000000..6503755 --- /dev/null +++ b/apps/rebreak-magic/src-tauri/src/platform/macos.rs @@ -0,0 +1,74 @@ +#![allow(dead_code)] + +use super::{Platform, PlatformInfo}; +use crate::error::AppResult; + +pub fn get_platform_info() -> AppResult { + 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> { + // 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 { + 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(), + )) +} diff --git a/apps/rebreak-magic/src-tauri/src/platform/mod.rs b/apps/rebreak-magic/src-tauri/src/platform/mod.rs new file mode 100644 index 0000000..81d82f9 --- /dev/null +++ b/apps/rebreak-magic/src-tauri/src/platform/mod.rs @@ -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 { + #[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>; + 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 { + #[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(), + )) +} diff --git a/apps/rebreak-magic/src-tauri/src/platform/windows.rs b/apps/rebreak-magic/src-tauri/src/platform/windows.rs new file mode 100644 index 0000000..13f5207 --- /dev/null +++ b/apps/rebreak-magic/src-tauri/src/platform/windows.rs @@ -0,0 +1,94 @@ +#![allow(dead_code)] + +use super::{Platform, PlatformInfo}; +use crate::error::AppResult; + +pub fn get_platform_info() -> AppResult { + 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> { + // 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 { + 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(), + )) +} diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..b7df527 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +scripts/tts-bench-out/ diff --git a/backend/nitro.config.ts b/backend/nitro.config.ts index 1584870..6e29833 100644 --- a/backend/nitro.config.ts +++ b/backend/nitro.config.ts @@ -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 ──────────────────────────────────────────────────── diff --git a/backend/prisma/migrations/20250616_add_hardware_id_to_user_devices/migration.sql b/backend/prisma/migrations/20250616_add_hardware_id_to_user_devices/migration.sql new file mode 100644 index 0000000..fa9ef99 --- /dev/null +++ b/backend/prisma/migrations/20250616_add_hardware_id_to_user_devices/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 41ee24c..ef292c9 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/server/api/devices/protection-state.post.ts b/backend/server/api/devices/protection-state.post.ts new file mode 100644 index 0000000..4809eac --- /dev/null +++ b/backend/server/api/devices/protection-state.post.ts @@ -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 }; +}); diff --git a/backend/server/api/magic/devices.get.ts b/backend/server/api/magic/devices.get.ts index 7c78c91..6b4f232 100644 --- a/backend/server/api/magic/devices.get.ts +++ b/backend/server/api/magic/devices.get.ts @@ -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, }; }); diff --git a/backend/server/api/magic/devices/[deviceId]/mdm-link.post.ts b/backend/server/api/magic/devices/[deviceId]/mdm-link.post.ts new file mode 100644 index 0000000..4fb3940 --- /dev/null +++ b/backend/server/api/magic/devices/[deviceId]/mdm-link.post.ts @@ -0,0 +1,49 @@ +import { + getUserDeviceByDeviceId, + setUserDeviceMdmId, +} from "../../../../db/mdm"; +import { requireUser } from "../../../../utils/auth"; + +/** + * Apple UDID: hex/dash, 20–50 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 }, + }; +}); diff --git a/backend/server/api/magic/devices/[deviceId]/mdm.get.ts b/backend/server/api/magic/devices/[deviceId]/mdm.get.ts new file mode 100644 index 0000000..29a83ee --- /dev/null +++ b/backend/server/api/magic/devices/[deviceId]/mdm.get.ts @@ -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>; + 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, + }, + }; +}); \ No newline at end of file diff --git a/backend/server/api/magic/mdm/by-udid.get.ts b/backend/server/api/magic/mdm/by-udid.get.ts new file mode 100644 index 0000000..f480c4e --- /dev/null +++ b/backend/server/api/magic/mdm/by-udid.get.ts @@ -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>; + 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, + }, + }; +}); diff --git a/backend/server/api/magic/register.post.ts b/backend/server/api/magic/register.post.ts index afb2e0a..7444d4c 100644 --- a/backend/server/api/magic/register.post.ts +++ b/backend/server/api/magic/register.post.ts @@ -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 diff --git a/backend/server/db/device-protection.ts b/backend/server/db/device-protection.ts new file mode 100644 index 0000000..9c9611d --- /dev/null +++ b/backend/server/db/device-protection.ts @@ -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 { + 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 { + 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 { + const db = usePrisma(); + return db.deviceProtectionState.findMany({ + where: { + userId, + ...(deviceId ? { deviceId } : {}), + }, + orderBy: [{ deviceId: "asc" }, { protectionType: "asc" }], + }); +} diff --git a/backend/server/db/devices.ts b/backend/server/db/devices.ts index 78e5d19..bcf1bdb 100644 --- a/backend/server/db/devices.ts +++ b/backend/server/db/devices.ts @@ -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, diff --git a/backend/server/db/mdm.ts b/backend/server/db/mdm.ts new file mode 100644 index 0000000..34e7b24 --- /dev/null +++ b/backend/server/db/mdm.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/backend/start-staging.sh b/backend/start-staging.sh index 3421b13..7858f3a 100755 --- a/backend/start-staging.sh +++ b/backend/start-staging.sh @@ -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" diff --git a/docs/superpowers/plans/2026-06-16-magic-dashboard-ios-section.md b/docs/superpowers/plans/2026-06-16-magic-dashboard-ios-section.md new file mode 100644 index 0000000..5fbcc2a --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-magic-dashboard-ios-section.md @@ -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(() => + devices.value + .filter((d) => normalizePlatform(d.model ?? d.hostname) === "ios") + .map((d) => mapToComputedDevice(d, false)), +); + +const desktopDevices = computed(() => + 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 + +``` + +- [ ] **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. diff --git a/docs/superpowers/specs/2026-06-16-magic-dashboard-ios-section-design.md b/docs/superpowers/specs/2026-06-16-magic-dashboard-ios-section-design.md new file mode 100644 index 0000000..1e44072 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-magic-dashboard-ios-section-design.md @@ -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`? diff --git a/docs/superpowers/specs/2026-06-17-mdm-device-link-design.md b/docs/superpowers/specs/2026-06-17-mdm-device-link-design.md new file mode 100644 index 0000000..db5c96f --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-mdm-device-link-design.md @@ -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.