feat(magic): inline enrollment in device card, remove preflight, align flag order

This commit is contained in:
chahinebrini 2026-06-18 07:25:43 +02:00
parent abeb1462f4
commit c6035b97d9
9 changed files with 1014 additions and 949 deletions

@ -0,0 +1 @@
Subproject commit b7e3af80f6e331f6fb456667b82b12cade7c9d35

View File

@ -177,6 +177,89 @@
</ul>
</div>
<!-- Inline enrollment panel -->
<div
v-if="enrollmentPhase !== 'idle'"
class="mt-4 rounded-xl bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-100 dark:border-indigo-800 p-4"
>
<div class="flex items-center gap-2 mb-3">
<UIcon
name="i-heroicons-arrow-path"
class="w-5 h-5 text-indigo-600 dark:text-indigo-400"
:class="{ 'animate-spin': enrollmentPhase === 'loading' || enrollmentPhase === 'checking' }"
/>
<p class="text-sm font-bold text-indigo-900 dark:text-indigo-200">
MDM-Enrollment
</p>
</div>
<!-- Progress steps -->
<div class="flex items-center gap-2 text-xs mb-4">
<span
class="px-2 py-1 rounded-full"
:class="enrollmentPhase === 'loading' ? 'bg-indigo-200 text-indigo-800 dark:bg-indigo-700 dark:text-indigo-100' : 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'"
>
1. Profil laden
</span>
<span class="text-gray-400"></span>
<span
class="px-2 py-1 rounded-full"
:class="enrollmentPhase === 'waiting' ? 'bg-indigo-200 text-indigo-800 dark:bg-indigo-700 dark:text-indigo-100' : (enrollmentPhase === 'checking' || enrollmentPhase === 'success' || enrollmentPhase === 'error') ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'"
>
2. QR-Code scannen
</span>
<span class="text-gray-400"></span>
<span
class="px-2 py-1 rounded-full"
:class="enrollmentPhase === 'checking' ? 'bg-indigo-200 text-indigo-800 dark:bg-indigo-700 dark:text-indigo-100' : enrollmentPhase === 'success' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'"
>
3. Prüfen
</span>
</div>
<!-- QR code -->
<div v-if="enrollmentPhase === 'waiting' && enrollmentQrUrl" class="text-center space-y-3">
<div class="bg-white p-3 rounded-xl inline-block">
<img :src="enrollmentQrUrl" alt="Enrollment QR-Code" class="w-40 h-40">
</div>
<p class="text-xs text-indigo-700 dark:text-indigo-300">
Scanne den Code mit der iPhone-Kamera und installiere das Profil.
</p>
<UButton
size="sm"
color="primary"
:loading="enrollmentPhase === 'checking'"
@click="checkInlineEnrollment"
>
Installation prüfen
</UButton>
</div>
<!-- Success / error -->
<div v-if="enrollmentPhase === 'success'" class="text-sm text-green-700 dark:text-green-300">
Enrollment abgeschlossen. Das Gerät synchronisiert sich jetzt mit dem Backend.
</div>
<div v-if="enrollmentPhase === 'error'" class="text-sm text-red-700 dark:text-red-300">
{{ enrollmentError || "Enrollment fehlgeschlagen" }}
</div>
<!-- Logs -->
<div v-if="enrollmentLogs.length > 0" class="mt-3 text-xs bg-white/60 dark:bg-black/20 p-2 rounded overflow-auto max-h-32">
<pre class="whitespace-pre-wrap">{{ enrollmentLogs.join('\n') }}</pre>
</div>
<div class="mt-3 flex justify-end">
<UButton
size="xs"
color="neutral"
variant="ghost"
@click="closeInlineEnrollment"
>
Schließen
</UButton>
</div>
</div>
<div class="mt-4 flex items-center gap-3">
<UButton
v-if="action.to"
@ -218,9 +301,10 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import QRCode from "qrcode";
import type { ComputedDevice, DeviceStatus } from "~/composables/useDeviceStatus";
import { useMdmStatus } from "~/composables/useMdmStatus";
import { REBREAK_MDM_VERSION, getInstalledMdmVersion, type IphoneDeviceState } from "~/composables/useTauri";
import { useTauri, REBREAK_MDM_VERSION, getInstalledMdmVersion, type IphoneDeviceState, type LocalServerInfo } from "~/composables/useTauri";
const props = defineProps<{
device: ComputedDevice;
@ -248,6 +332,20 @@ const manualSyncing = ref(false);
const autoSyncing = ref(false);
const autoSyncComplete = ref(false);
const {
downloadAndPatchEnrollmentProfile,
startLocalProfileServer,
stopLocalProfileServer,
getInstalledProfiles,
mdmPush,
} = useTauri();
const enrollmentPhase = ref<"idle" | "loading" | "waiting" | "checking" | "success" | "error">("idle");
const enrollmentServerInfo = ref<LocalServerInfo | null>(null);
const enrollmentQrUrl = ref<string>("");
const enrollmentError = ref<string | null>(null);
const enrollmentLogs = ref<string[]>([]);
const localEnrollment = computed(() =>
props.iphone?.installedProfileIDs?.includes(ENROLLMENT_PROFILE_ID) ?? false,
);
@ -314,16 +412,16 @@ const localRows = computed(() => {
const iphone = props.iphone;
return [
{
label: "Supervised",
value: iphone.isSupervised ? "Ja" : "Nein",
valueClass: iphone.isSupervised
label: "Enrollment",
value: localEnrollment.value ? "Ja" : "Nein",
valueClass: localEnrollment.value
? "text-green-600 dark:text-green-400 font-medium"
: "text-red-600 dark:text-red-400 font-medium",
},
{
label: "Enrollment",
value: localEnrollment.value ? "Ja" : "Nein",
valueClass: localEnrollment.value
label: "Supervised",
value: iphone.isSupervised ? "Ja" : "Nein",
valueClass: iphone.isSupervised
? "text-green-600 dark:text-green-400 font-medium"
: "text-red-600 dark:text-red-400 font-medium",
},
@ -479,6 +577,15 @@ const action = computed<IosAction>(() => {
};
}
if (enrollmentPhase.value !== "idle") {
return {
label: "Enrollment läuft…",
icon: "i-heroicons-arrow-path",
color: "neutral",
variant: "soft",
};
}
if (!props.isConnected || !props.iphone) {
return {
label: "iPhone verbinden, um ReBreak Cloud zu synchronisieren",
@ -512,12 +619,20 @@ const action = computed<IosAction>(() => {
if (!backend?.enrolled || !localEnrollment.value) {
const isKnownDevice = !!props.device.mdmId;
if (isKnownDevice) {
return {
label: isKnownDevice ? "Schutz vervollständigen" : "Enrollen",
icon: isKnownDevice ? "i-heroicons-shield-check" : "i-heroicons-document-check",
label: "Enrollment starten",
icon: "i-heroicons-document-check",
color: "primary",
variant: "solid",
to: isKnownDevice ? "/preflight" : "/enroll",
};
}
return {
label: "Enrollen",
icon: "i-heroicons-document-check",
color: "primary",
variant: "solid",
to: "/enroll",
};
}
@ -627,10 +742,86 @@ function onActionClick() {
return;
}
const backend = mdmState.value.data;
if (props.device.mdmId && (!backend?.enrolled || !localEnrollment.value)) {
startInlineEnrollment();
return;
}
manualSyncing.value = true;
emit("sync", props.device);
setTimeout(() => {
manualSyncing.value = false;
}, 800);
}
async function startInlineEnrollment() {
if (!props.iphone?.udid) return;
enrollmentPhase.value = "loading";
enrollmentError.value = null;
enrollmentLogs.value = [];
enrollmentQrUrl.value = "";
enrollmentServerInfo.value = null;
try {
const url = "https://mdm.rebreak.org/enrollment/rebreak-enrollment.mobileconfig";
enrollmentLogs.value.push(`→ Lade Enrollment-Profil`);
const path = await downloadAndPatchEnrollmentProfile(url, props.iphone.udid);
enrollmentLogs.value.push(`✓ Profil gespeichert`);
enrollmentServerInfo.value = await startLocalProfileServer(path);
enrollmentLogs.value.push(`✓ Lokaler Server gestartet`);
enrollmentQrUrl.value = await QRCode.toDataURL(enrollmentServerInfo.value.qr_payload, {
width: 192,
margin: 2,
});
enrollmentPhase.value = "waiting";
} catch (e: any) {
enrollmentError.value = e?.message ?? "Enrollment konnte nicht gestartet werden";
enrollmentLogs.value.push(`${enrollmentError.value}`);
enrollmentPhase.value = "error";
}
}
async function checkInlineEnrollment() {
if (!props.iphone?.udid) return;
enrollmentPhase.value = "checking";
enrollmentError.value = null;
try {
const ids = await getInstalledProfiles();
if (props.iphone) {
props.iphone.installedProfileIDs = ids;
}
if (!ids.includes(ENROLLMENT_PROFILE_ID)) {
enrollmentError.value = "Enrollment-Profil noch nicht installiert. Bitte QR-Code scannen und Profil installieren.";
enrollmentPhase.value = "error";
return;
}
enrollmentLogs.value.push("✓ Enrollment-Profil erkannt");
const push = await mdmPush(props.iphone.udid);
enrollmentLogs.value.push(`✓ Push: ${push.push_result}`);
await refreshMdmStatus();
enrollmentPhase.value = "success";
} catch (e: any) {
enrollmentError.value = e?.message ?? "Prüfung fehlgeschlagen";
enrollmentLogs.value.push(`${enrollmentError.value}`);
enrollmentPhase.value = "error";
}
}
function closeInlineEnrollment() {
stopLocalProfileServer();
enrollmentPhase.value = "idle";
enrollmentServerInfo.value = null;
enrollmentQrUrl.value = "";
enrollmentError.value = null;
}
</script>

View File

@ -1,43 +0,0 @@
<template>
<button
class="w-full text-left"
@click="toggle"
>
<div
class="flex items-start gap-3 p-4 rounded-xl transition-colors"
:class="checked || auto ? 'bg-green-50/50 ring-1 ring-green-100' : 'bg-gray-50 hover:bg-gray-100'"
>
<UIcon
:name="checked || auto ? 'i-heroicons-check-circle-solid' : 'i-heroicons-circle'"
class="w-6 h-6 shrink-0"
:class="checked || auto ? 'text-green-600' : 'text-gray-400'"
/>
<div>
<div class="font-bold text-gray-900">{{ title }}</div>
<div class="text-sm text-gray-500 mt-0.5">{{ detail }}</div>
<div v-if="auto && !checked" class="text-xs text-green-600 font-semibold mt-1">
Automatisch erkannt
</div>
</div>
</div>
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: boolean;
title: string;
detail: string;
auto?: boolean;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
}>();
const checked = computed(() => props.modelValue || props.auto);
function toggle() {
emit("update:modelValue", !props.modelValue);
}
</script>

View File

@ -97,7 +97,7 @@
Zurück
</UButton>
<UButton
to="/preflight"
to="/supervise"
variant="solid"
color="primary"
:disabled="!iphone"

View File

@ -1,79 +0,0 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6">
<div class="max-w-md w-full space-y-6">
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-900">Pre-Flight Check</h1>
<p class="text-gray-600 mt-2">
Bevor wir dein iPhone supervisieren, müssen ein paar Apple-Sicherheitschecks erledigt sein.
</p>
</div>
<UCard>
<div class="space-y-3">
<PreflightItem
v-model="checks.fmi"
title="Find My iPhone deaktiviert"
detail="Settings → [Apple-ID] → Wo ist? → Mein iPhone suchen → AUS. Ohne das blockiert Apple das Supervisieren."
:auto="iphone?.findMyEnabled === false"
/>
<PreflightItem
v-model="checks.sdp"
title="Stolen Device Protection ausgeschaltet"
detail="Settings → Face ID & Code → Schutz für gestohlene Geräte → AUS. SDP zwingt FMI an."
/>
<PreflightItem
v-model="checks.appleId"
title="Apple-ID-Passwort griffbereit"
detail="Apple fragt evtl. dein Apple-ID-PW während des FMI-Toggles ab."
/>
<PreflightItem
v-model="checks.appInstalled"
title="ReBreak-App ist auf dem iPhone installiert"
detail="Über TestFlight. Erst danach kann der Wizard die App in den Managed-State versetzen."
:auto="hasReBreakApp"
/>
</div>
</UCard>
<div class="flex justify-between">
<UButton to="/detect" variant="ghost" color="neutral">
Zurück
</UButton>
<UButton
to="/supervise"
variant="solid"
color="primary"
:disabled="!allChecked"
>
Supervisieren starten
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useIphoneDevice } from "~/composables/useMagicState";
import PreflightItem from "~/components/PreflightItem.vue";
const iphone = useIphoneDevice();
const checks = ref({
fmi: false,
sdp: false,
appleId: false,
appInstalled: false,
});
const hasReBreakApp = computed(() =>
iphone.value?.installedAppBundleIDs?.includes("org.rebreak.app") ?? false,
);
const allChecked = computed(() =>
(checks.value.fmi || iphone.value?.findMyEnabled === false) &&
checks.value.sdp &&
checks.value.appleId &&
(checks.value.appInstalled || hasReBreakApp.value),
);
</script>

View File

@ -54,7 +54,7 @@
</UCard>
<div class="flex justify-between">
<UButton to="/preflight" variant="ghost" color="neutral">
<UButton to="/" variant="ghost" color="neutral">
Zurück
</UButton>
<UButton

View File

@ -1,7 +1,7 @@
# Graph Report - rebreak-monorepo (2026-06-18)
## Corpus Check
- 1526 files · ~1,868,068 words
- 1526 files · ~1,868,054 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
@ -10,7 +10,7 @@
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `0efdf2f8`
- Built from commit: `709f8cb3`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@ -5700,11 +5700,11 @@ Nodes (3): ShellScopeEntryAllowedArgs, anyOf, description
_Questions this graph is uniquely positioned to answer:_
- **Why does `Notification` connect `Community 93` to `Debug & Dev Tools`, `Community 39`, `i18n: Pricing Strings`, `i18n: Pricing Strings`, `Community 181`, `Community 53`?**
_High betweenness centrality (0.032) - this node is a cross-community bridge._
_High betweenness centrality (0.038) - this node is a cross-community bridge._
- **Why does `useColors()` connect `Community 53` to `Backend API Routes`, `Community 1030`, `Consent & Magic API Routes`, `i18n: Pricing Strings`, `App Root Layout & Shell`, `Community 18`, `Community 20`, `Community 21`, `Community 22`, `Community 27`, `Community 413`, `Community 34`, `Community 37`, `Community 426`, `Community 686`, `Community 437`, `Community 54`, `Community 80`, `Community 93`, `Community 95`, `Community 865`, `Community 872`, `Community 616`?**
_High betweenness centrality (0.032) - this node is a cross-community bridge._
- **Why does `usePrisma()` connect `Debug & Dev Tools` to `Community 384`, `Community 257`, `Community 385`, `Backend API Routes`, `Backend Tests & Auth Routes`, `Android DNS Filter (Kotlin)`, `Community 32`, `Community 33`, `Community 39`, `Community 937`, `Community 47`, `Community 181`, `Community 566`, `Community 62`, `Community 68`, `Community 69`, `Community 80`, `Community 93`, `Community 244`, `Community 127`?**
_High betweenness centrality (0.025) - this node is a cross-community bridge._
_High betweenness centrality (0.036) - this node is a cross-community bridge._
- **Why does `Error` connect `Community 396` to `Community 290`, `Community 355`, `Community 419`, `Community 67`, `Community 263`, `Community 74`, `Community 876`, `Community 109`, `Community 81`, `Community 883`, `Community 212`, `Community 213`, `Community 117`, `Community 438`, `Community 122`, `Community 254`, `Community 607`?**
_High betweenness centrality (0.031) - this node is a cross-community bridge._
- **Are the 2 inferred relationships involving `usePrisma()` (e.g. with `runRevoke()` and `requireUser()`) actually correct?**
_`usePrisma()` has 2 INFERRED edges - model-reasoned connections that need verification._
- **What connects `deviceIdRef`, `{ state: mdmState, refresh: refreshMdmStatus }`, `manualSyncing` to the rest of the system?**

File diff suppressed because it is too large Load Diff

View File

@ -8470,8 +8470,8 @@
"semantic_hash": ""
},
"/Users/chahinebrini/mono/rebreak-monorepo/apps/rebreak-magic/app/components/IosDeviceCard.vue": {
"mtime": 1781756877.0845668,
"ast_hash": "6d8059a60590b33fc238b2d3012cb3ee",
"mtime": 1781760166.407032,
"ast_hash": "594b640f925dd262380a953908ec2e53",
"semantic_hash": ""
},
"/Users/chahinebrini/mono/rebreak-monorepo/apps/rebreak-magic/app/components/IosDeviceSection.vue": {
@ -9070,8 +9070,8 @@
"semantic_hash": ""
},
"/Users/chahinebrini/mono/rebreak-monorepo/backend/server/api/magic/devices/[deviceId]/mdm.get.ts": {
"mtime": 1781758009.6469717,
"ast_hash": "b0a1b76bdc305c925326f6787aebbe11",
"mtime": 1781759183.6660445,
"ast_hash": "e1c779f82c164aaf5dfe1b55f6f0eaca",
"semantic_hash": ""
},
"/Users/chahinebrini/mono/rebreak-monorepo/backend/server/api/magic/devices/[deviceId]/mdm-link.post.ts": {
@ -9134,11 +9134,6 @@
"ast_hash": "793e67cb3bfa5b6eaac5b50c74cde28b",
"semantic_hash": ""
},
"/Users/chahinebrini/mono/rebreak-monorepo/apps/rebreak-magic/app/components/PreflightItem.vue": {
"mtime": 1781729471.357726,
"ast_hash": "654d7bdac2f3c7e4909a13ee00ac3c37",
"semantic_hash": ""
},
"/Users/chahinebrini/mono/rebreak-monorepo/apps/rebreak-magic/app/components/StepButton.vue": {
"mtime": 1781729471.3821392,
"ast_hash": "5ff0e5638aa149c647921ab6687b5ea8",
@ -9160,8 +9155,8 @@
"semantic_hash": ""
},
"/Users/chahinebrini/mono/rebreak-monorepo/apps/rebreak-magic/app/pages/detect.vue": {
"mtime": 1781729471.4505167,
"ast_hash": "70c0a0b1b507a92cfb06e0c980b23b8f",
"mtime": 1781759990.788815,
"ast_hash": "eee90e00189e22dd8c99eccf5b7598a5",
"semantic_hash": ""
},
"/Users/chahinebrini/mono/rebreak-monorepo/apps/rebreak-magic/app/pages/desktop-enroll.vue": {
@ -9189,14 +9184,9 @@
"ast_hash": "19f47d384164a9f9e4cceacd3161e5c0",
"semantic_hash": ""
},
"/Users/chahinebrini/mono/rebreak-monorepo/apps/rebreak-magic/app/pages/preflight.vue": {
"mtime": 1781729471.51261,
"ast_hash": "8cbd20d4fcc93d5228d6ac58bfca2c89",
"semantic_hash": ""
},
"/Users/chahinebrini/mono/rebreak-monorepo/apps/rebreak-magic/app/pages/supervise.vue": {
"mtime": 1781729471.5354013,
"ast_hash": "113ce0ab42e4e160b3276e008f99a913",
"mtime": 1781759990.7880056,
"ast_hash": "e00cf0b40d94df6eb80dfec34ff1b323",
"semantic_hash": ""
},
"/Users/chahinebrini/mono/rebreak-monorepo/apps/rebreak-magic/src-tauri/tauri.conf.json": {
@ -9338,5 +9328,10 @@
"mtime": 1781748641.7178783,
"ast_hash": "22842b3019c2bb3175d475e92b437109",
"semantic_hash": ""
},
"/Users/chahinebrini/mono/rebreak-monorepo/.woodpecker.yml": {
"mtime": 1781760270.0506988,
"ast_hash": "52e575610d792dfc647b067ecdb518b5",
"semantic_hash": ""
}
}