Include recent Magic app work: Tauri native shell, iOS device detection via supervise-magic sidecar, MDM client, local HTTP server, new pages (detect, enroll, supervise, sideload, pair, preflight, configure, done), and updated device section/status UI.
369 lines
12 KiB
Vue
369 lines
12 KiB
Vue
<template>
|
|
<div class="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/40">
|
|
<!-- Header -->
|
|
<div class="bg-white/80 backdrop-blur-md border-b border-white/60 sticky top-0 z-10">
|
|
<div class="max-w-3xl mx-auto px-6 py-4">
|
|
<div class="flex items-center gap-4">
|
|
<div class="relative">
|
|
<img :src="rebreakIcon" alt="ReBreak" class="w-11 h-11 rounded-xl shadow-lg shadow-blue-500/20">
|
|
<div class="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 rounded-full border-2 border-white" />
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<h1 class="text-lg font-extrabold text-gray-900 truncate">
|
|
{{ profile?.nickname || 'ReBreak Magic' }}
|
|
</h1>
|
|
<div class="flex items-center gap-2 text-sm text-gray-500">
|
|
<UBadge v-if="profile?.plan" color="primary" variant="subtle" size="xs" class="font-bold">
|
|
{{ profile.plan }}
|
|
</UBadge>
|
|
<span v-if="session?.label" class="truncate">{{ session.label }}</span>
|
|
</div>
|
|
</div>
|
|
<UButton
|
|
icon="i-heroicons-arrow-right-start-on-rectangle"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="logout"
|
|
>
|
|
Abmelden
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="max-w-3xl mx-auto px-6 py-8 space-y-6">
|
|
<!-- Hero section -->
|
|
<section>
|
|
<h2 class="text-sm font-bold text-gray-500 uppercase tracking-wider mb-3">Aktives Gerät</h2>
|
|
|
|
<DeviceHeroCard
|
|
v-if="currentBackendDevice"
|
|
:device="currentBackendDevice"
|
|
:is-current="true"
|
|
@open="openDevice"
|
|
@toggle-protection="toggleProtection"
|
|
/>
|
|
|
|
<!-- Empty / not registered state -->
|
|
<div v-else class="rounded-2xl bg-white ring-1 ring-gray-100 shadow-sm p-6 text-center">
|
|
<div class="w-16 h-16 mx-auto rounded-2xl bg-blue-50 flex items-center justify-center mb-4">
|
|
<UIcon name="i-heroicons-shield-exclamation" class="w-8 h-8 text-[var(--rebreak-primary)]" />
|
|
</div>
|
|
<template v-if="!hasRefreshed">
|
|
<h3 class="text-lg font-bold text-gray-900">Geräte-Status noch nicht geladen</h3>
|
|
<p class="text-sm text-gray-500 mt-1 mb-4">Klicke auf „Aktualisieren“, um deine Geräte abzurufen.</p>
|
|
<UButton color="primary" icon="i-heroicons-arrow-path" :loading="loading" @click="refresh">
|
|
Aktualisieren
|
|
</UButton>
|
|
</template>
|
|
<template v-else>
|
|
<h3 class="text-lg font-bold text-gray-900">Dieses Gerät ist nicht geschützt</h3>
|
|
<p class="text-sm text-gray-500 mt-1 mb-4">Aktiviere den Schutz für das Gerät, auf dem Magic läuft.</p>
|
|
<UButton color="primary" icon="i-heroicons-shield-check" to="/desktop-enroll">
|
|
Dieses Gerät schützen
|
|
</UButton>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- iOS devices section -->
|
|
<IosDeviceSection
|
|
:devices="iosDevices"
|
|
:iphone="iphone"
|
|
:loading="loading"
|
|
:has-refreshed="hasRefreshed"
|
|
:in-grace-period="subscriptionInGracePeriod"
|
|
:searching-for-device-id="searchingForDeviceId"
|
|
@sync="onIosSync"
|
|
@open="openDevice"
|
|
@remove="onIosRemove"
|
|
@connect="startIphoneSearch"
|
|
/>
|
|
|
|
<!-- Other devices list -->
|
|
<section>
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h2 class="text-sm font-bold text-gray-500 uppercase tracking-wider">Weitere Geräte</h2>
|
|
<UButton
|
|
icon="i-heroicons-arrow-path"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="xs"
|
|
:loading="loading"
|
|
@click="refresh"
|
|
>
|
|
Aktualisieren
|
|
</UButton>
|
|
</div>
|
|
|
|
<div v-if="error" class="text-sm text-red-600 mb-4 p-4 bg-red-50 rounded-xl">{{ error }}</div>
|
|
|
|
<div v-if="loading && desktopDevices.length === 0" class="py-12 text-center text-gray-500">
|
|
<UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin mx-auto mb-3 text-[var(--rebreak-primary)]" />
|
|
<p class="font-semibold">Lade Geräte…</p>
|
|
</div>
|
|
|
|
<div v-else-if="desktopDevices.length === 0" class="py-10 text-center rounded-2xl bg-white ring-1 ring-gray-100">
|
|
<p class="text-sm text-gray-500">
|
|
{{ hasRefreshed ? 'Keine weiteren Computer registriert.' : 'Noch keine Computer geladen.' }}
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else class="space-y-3">
|
|
<DeviceListItem
|
|
v-for="device in desktopDevices"
|
|
:key="device.deviceId"
|
|
:device="device"
|
|
:is-current="false"
|
|
@open="openDevice"
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
<DeviceDetailSheet
|
|
v-model:open="sheetOpen"
|
|
:device="selectedDevice"
|
|
:ios-stars="selectedDeviceStars"
|
|
@close="sheetOpen = false"
|
|
@toggle-protection="toggleProtection"
|
|
@start-cooldown="startCooldown"
|
|
@cancel-cooldown="cancelCooldown"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
|
import { useTauri, type UserProfile, type IphoneDeviceState } from "~/composables/useTauri";
|
|
import { useMagicSession, useMagicDevices, useIphoneDevice } from "~/composables/useMagicState";
|
|
import { useProtectionStatus } from "~/composables/useProtectionStatus";
|
|
import { useDeviceStatus, type ComputedDevice } from "~/composables/useDeviceStatus";
|
|
import rebreakIcon from "~/assets/rebreak-icon.png";
|
|
|
|
const {
|
|
getMagicDevices,
|
|
startCooldown: apiStartCooldown,
|
|
cancelCooldown: apiCancelCooldown,
|
|
logoutMagic,
|
|
fetchMe,
|
|
setDesktopProtectionStatus,
|
|
getPlatform,
|
|
getHardwareId,
|
|
getDeviceId,
|
|
registerDevice,
|
|
} = useTauri();
|
|
|
|
const session = useMagicSession();
|
|
const devices = useMagicDevices();
|
|
const iphone = useIphoneDevice();
|
|
const protection = useProtectionStatus();
|
|
|
|
const currentDeviceId = ref<string | null>(null);
|
|
const currentHardwareId = ref<string | null>(null);
|
|
|
|
const profile = ref<UserProfile | null>(null);
|
|
const loading = ref(false);
|
|
const hasRefreshed = ref(false);
|
|
const error = ref<string | null>(null);
|
|
const sheetOpen = ref(false);
|
|
const selectedDevice = ref<ComputedDevice | null>(null);
|
|
const platformInfo = ref<{ platform: string } | null>(null);
|
|
const searchingForDeviceId = ref<string | null>(null);
|
|
let searchInterval: ReturnType<typeof setInterval> | 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, 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();
|
|
try {
|
|
const info = await getPlatform();
|
|
platformInfo.value = { platform: info.platform };
|
|
} catch (e) {
|
|
platformInfo.value = null;
|
|
}
|
|
await initCurrentDevice();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
stopIphoneSearch();
|
|
});
|
|
|
|
function matchesIphone(device: ComputedDevice, iphone: IphoneDeviceState): boolean {
|
|
if (device.mdmId && device.mdmId === iphone.udid) return true;
|
|
const modelMatch = (device.model ?? "").toLowerCase() === iphone.productType.toLowerCase();
|
|
const nameMatch = (device.name ?? "").toLowerCase() === iphone.name.toLowerCase();
|
|
return modelMatch || nameMatch;
|
|
}
|
|
|
|
async function startIphoneSearch(device: ComputedDevice) {
|
|
if (searchInterval) return;
|
|
searchingForDeviceId.value = device.deviceId;
|
|
error.value = null;
|
|
|
|
const check = async () => {
|
|
await protection.refreshIphone();
|
|
if (iphone.value && matchesIphone(device, iphone.value)) {
|
|
stopIphoneSearch();
|
|
}
|
|
};
|
|
|
|
await check();
|
|
if (!searchingForDeviceId.value) return;
|
|
|
|
searchInterval = setInterval(check, 1100);
|
|
}
|
|
|
|
function stopIphoneSearch() {
|
|
if (searchInterval) {
|
|
clearInterval(searchInterval);
|
|
searchInterval = null;
|
|
}
|
|
searchingForDeviceId.value = null;
|
|
}
|
|
|
|
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();
|
|
} catch (e: any) {
|
|
console.error("Failed to load profile:", e);
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
loading.value = true;
|
|
error.value = null;
|
|
try {
|
|
await protection.refresh();
|
|
devices.value = await getMagicDevices();
|
|
} catch (e: any) {
|
|
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;
|
|
}
|
|
|
|
async function toggleProtection(device: ComputedDevice) {
|
|
if (!device.isCurrent) return;
|
|
if (device.platform === "mac" || device.platform === "windows") {
|
|
if (protection.status.value.desktopProtected) {
|
|
try {
|
|
await setDesktopProtectionStatus(false, device.platform);
|
|
await refresh();
|
|
} catch (e: any) {
|
|
error.value = e?.message ?? "Schutz konnte nicht deaktiviert werden";
|
|
}
|
|
} else {
|
|
await navigateTo("/desktop-enroll");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function startCooldown(device: ComputedDevice, minutes: number) {
|
|
if (!device.isCurrent) return;
|
|
loading.value = true;
|
|
try {
|
|
await apiStartCooldown(device.deviceId, minutes);
|
|
await refresh();
|
|
} catch (e: any) {
|
|
error.value = e?.message ?? "Cooldown konnte nicht gestartet werden";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function cancelCooldown(device: ComputedDevice) {
|
|
if (!device.isCurrent) return;
|
|
loading.value = true;
|
|
try {
|
|
await apiCancelCooldown(device.deviceId);
|
|
await refresh();
|
|
} catch (e: any) {
|
|
error.value = e?.message ?? "Cooldown konnte nicht beendet werden";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await logoutMagic();
|
|
session.value = null;
|
|
devices.value = [];
|
|
await navigateTo("/");
|
|
} catch (e: any) {
|
|
error.value = e?.message ?? "Abmelden fehlgeschlagen";
|
|
}
|
|
}
|
|
</script>
|