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/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/schema.prisma b/backend/prisma/schema.prisma index 6b66612..ef292c9 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1102,20 +1102,20 @@ model UserDevice { /// Wird in com.apple.profileRemovalPassword injiziert — User sieht es NIE, /// nur nach Cooldown-Release (Offboarding). NULL → noch keins generiert. magicRemovalPassword String? @map("magic_removal_password") - /// Hardware-gebundene ID (z. B. System-UUID, ANDROID_ID, IDFV). - /// Wird vom Client geliefert, nicht vom Backend generiert. - hardwareId String? @map("hardware_id") /// Wann der User die Entfernung des Magic-Profils beantragt hat. /// Removal-Passwort wird erst nach +MAGIC_RELEASE_COOLDOWN_H sichtbar. magicReleaseRequestedAt DateTime? @map("magic_release_requested_at") /// 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]) - @@unique([userId, hardwareId]) @@index([userId]) @@index([deviceId]) - @@index([hardwareId]) @@map("user_devices") @@schema("rebreak") } @@ -1150,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/[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..3ced5de --- /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: true, + lockProfileInstalled: lockState?.active ?? false, + lastAppPushAt: status.lastAppPushAt?.toISOString() ?? null, + }, + }; +}); \ No newline at end of file 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/mdm.ts b/backend/server/db/mdm.ts new file mode 100644 index 0000000..daee3c4 --- /dev/null +++ b/backend/server/db/mdm.ts @@ -0,0 +1,142 @@ +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; + 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<{ + enrolled: string; + token_update_at: Date | null; + last_ack: Date | null; + last_app_push_at: Date | null; + }>( + `SELECT + (SELECT count(*)::text FROM devices WHERE id = $1) AS enrolled, + (SELECT token_update_at FROM devices WHERE id = $1) AS token_update_at, + (SELECT max(updated_at) FROM command_results WHERE id = $1) 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 = $1 + AND c.request_type = 'InstallApplication' + AND r.status = 'Acknowledged') AS last_app_push_at`, + [udid], + ); + + const row = result.rows[0]; + const enrolled = row ? row.enrolled !== "0" : false; + + return { + enrolled, + company: enrolled ? "ReBreak" : 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/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.