feat(mdm): remove mdm_lock type, derive lockProfileInstalled from nefilter state

This commit is contained in:
chahinebrini 2026-06-17 22:32:40 +02:00
parent 5b0a4d03d2
commit b87ec08431
11 changed files with 758 additions and 5 deletions

View File

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

1
backend/.gitignore vendored Normal file
View File

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

View File

@ -64,6 +64,8 @@ export default defineNitroConfig({
// ─── Database / Core ───────────────────────────────────────────────── // ─── Database / Core ─────────────────────────────────────────────────
databaseUrl: databaseUrl:
process.env.DATABASE_URL ?? process.env.NUXT_DATABASE_URL ?? "", 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 ?? "", encryptionKey: process.env.ENCRYPTION_KEY ?? "",
// ─── Admin / Cron ──────────────────────────────────────────────────── // ─── Admin / Cron ────────────────────────────────────────────────────

View File

@ -1102,20 +1102,20 @@ model UserDevice {
/// Wird in com.apple.profileRemovalPassword injiziert — User sieht es NIE, /// Wird in com.apple.profileRemovalPassword injiziert — User sieht es NIE,
/// nur nach Cooldown-Release (Offboarding). NULL → noch keins generiert. /// nur nach Cooldown-Release (Offboarding). NULL → noch keins generiert.
magicRemovalPassword String? @map("magic_removal_password") 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. /// Wann der User die Entfernung des Magic-Profils beantragt hat.
/// Removal-Passwort wird erst nach +MAGIC_RELEASE_COOLDOWN_H sichtbar. /// Removal-Passwort wird erst nach +MAGIC_RELEASE_COOLDOWN_H sichtbar.
magicReleaseRequestedAt DateTime? @map("magic_release_requested_at") magicReleaseRequestedAt DateTime? @map("magic_release_requested_at")
/// Temporärer Sleep-Mode für Magic-Desktop-Geräte. NULL = kein Cooldown aktiv. /// Temporärer Sleep-Mode für Magic-Desktop-Geräte. NULL = kein Cooldown aktiv.
magicCooldownUntil DateTime? @map("magic_cooldown_until") 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, deviceId])
@@unique([userId, hardwareId])
@@index([userId]) @@index([userId])
@@index([deviceId]) @@index([deviceId])
@@index([hardwareId])
@@map("user_devices") @@map("user_devices")
@@schema("rebreak") @@schema("rebreak")
} }
@ -1150,6 +1150,40 @@ model ProtectionStateLog {
@@schema("rebreak") @@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 { model MagicPairingCode {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid

View File

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

View File

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

View File

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

View File

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

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

@ -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<UserDeviceMdmRecord | null> {
const db = usePrisma();
return db.userDevice.findFirst({
where: { userId, deviceId, platform },
select: USER_DEVICE_MDM_SELECT,
});
}
/**
* Persist the NanoMDM UDID for a user's device.
*/
export async function setUserDeviceMdmId(
userId: string,
deviceId: string,
mdmId: string,
): Promise<void> {
const db = usePrisma();
await db.userDevice.updateMany({
where: { userId, deviceId, platform: "ios" },
data: { mdmId },
});
}
/**
* Clear the stored NanoMDM UDID (e.g. device no longer enrolled).
*/
export async function clearUserDeviceMdmId(
userId: string,
deviceId: string,
): Promise<void> {
const db = usePrisma();
await db.userDevice.updateMany({
where: { userId, deviceId, platform: "ios" },
data: { mdmId: null },
});
}
export interface MdmDeviceStatus {
enrolled: boolean;
company: string | null;
tokenUpdateAt: Date | null;
lastAckAt: Date | null;
lastAppPushAt: Date | null;
}
/**
* Query NanoMDM Postgres for a device by UDID.
*
* Throws if the MDM DB is unreachable callers should treat this as an
* infra/runtime error and not cache a negative result.
*/
export async function getMdmStatusByUdid(
udid: string,
): Promise<MdmDeviceStatus> {
const pool = useMdmPool();
// Defensive: only raw parameters reach the query layer below.
const result = await pool.query<{
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,
};
}

View File

@ -55,6 +55,7 @@ exec infisical run \
[[ -n "${SUPABASE_ANON_KEY:-}" ]] && export NITRO_SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY" && export NITRO_PUBLIC_SUPABASE_KEY="$SUPABASE_ANON_KEY" [[ -n "${SUPABASE_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 "${SUPABASE_SERVICE_ROLE_KEY:-}" ]] && export NITRO_SUPABASE_SERVICE_KEY="$SUPABASE_SERVICE_ROLE_KEY"
[[ -n "${DATABASE_URL:-}" ]] && export NITRO_DATABASE_URL="$DATABASE_URL" [[ -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 "${OPENROUTER_API_KEY:-}" ]] && export NITRO_OPENROUTER_API_KEY="$OPENROUTER_API_KEY"
[[ -n "${OPENAI_API_KEY:-}" ]] && export NITRO_OPENAI_API_KEY="$OPENAI_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" [[ -n "${GROQ_API_KEY:-}" ]] && export NITRO_GROQ_API_KEY="$GROQ_API_KEY"

View File

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