feat(magic): identify current device via hardwareId, migrate existing devices

This commit is contained in:
chahinebrini 2026-06-17 17:18:40 +02:00
parent e4b28be5be
commit 5b0a4d03d2
16 changed files with 703 additions and 36 deletions

View File

@ -36,7 +36,7 @@ function normalizeHostname(value: string): string {
function mapToComputedDevice(d: MagicDeviceInfo, isCurrent: boolean): ComputedDevice {
return {
deviceId: d.deviceId,
name: d.model ?? d.hostname,
name: d.hostname ?? d.model ?? "Unbenanntes Gerät",
platform: normalizePlatform(d.model ?? d.hostname),
model: d.model,
osVersion: d.osVersion,
@ -53,14 +53,16 @@ export function useDeviceStatus(
localHostname: Ref<string | null>,
iphone: Ref<IphoneDeviceState | null>,
currentDeviceId?: Ref<string | null>,
currentHardwareId?: Ref<string | null>,
) {
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<ComputedDevice | null>(() => {

View File

@ -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<MagicSessionState | null>("magic-session", () => null);
}
export function useMagicDevices() {
return useState<MagicDeviceInfo[]>("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<IphoneDeviceState | null>("iphone-device", () => null);
}

View File

@ -43,9 +43,15 @@ export function useProtectionStatus() {
return iphone.value.isSupervised && hasEnrollment && hasLock;
});
const currentBackendDevice = computed<MagicDeviceInfo | null>(() => {
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);

View File

@ -41,6 +41,7 @@ export interface RegisterDeviceResponse {
export interface MagicDeviceInfo {
source: string;
deviceId: string;
hardwareId: string | null;
hostname: string;
model: string | null;
osVersion: string | null;
@ -131,11 +132,18 @@ export function useTauri() {
}
async function registerDevice(
deviceId: string,
model: string | undefined,
osVersion: string | undefined,
model?: string,
osVersion?: string,
): Promise<RegisterDeviceResponse> {
return await invokeLogged("register_device", { deviceId, model, osVersion });
return await invokeLogged("register_device", { model, osVersion });
}
async function getHardwareId(): Promise<string> {
return await invokeLogged("get_hardware_id");
}
async function getDeviceId(): Promise<string | null> {
return await invokeLogged("get_device_id");
}
async function getStoredSession(): Promise<MagicSession | null> {
@ -287,5 +295,7 @@ export function useTauri() {
getDesktopProtectionStatus,
setDesktopProtectionStatus,
getHostname,
getHardwareId,
getDeviceId,
};
}

View File

@ -149,6 +149,9 @@ const {
fetchMe,
setDesktopProtectionStatus,
getPlatform,
getHardwareId,
getDeviceId,
registerDevice,
} = useTauri();
const session = useMagicSession();
@ -156,7 +159,8 @@ const devices = useMagicDevices();
const iphone = useIphoneDevice();
const protection = useProtectionStatus();
const currentDeviceId = computed(() => session.value?.deviceId ?? null);
const currentDeviceId = ref<string | null>(null);
const currentHardwareId = ref<string | null>(null);
const profile = ref<UserProfile | null>(null);
const loading = ref(false);
@ -172,7 +176,7 @@ 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);
useDeviceStatus(devices, localHostname, iphone, currentDeviceId, currentHardwareId);
const selectedDeviceStars = computed(() => {
if (!selectedDevice.value || selectedDevice.value.platform !== "ios") return null;
@ -193,8 +197,33 @@ onMounted(async () => {
} 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();

View File

@ -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<String>,
#[serde(rename = "hardwareId", skip_serializing_if = "Option::is_none")]
pub hardware_id: Option<String>,
pub hostname: String,
pub model: Option<String>,
#[serde(rename = "osVersion")]
@ -45,6 +47,8 @@ pub struct MagicDeviceInfo {
pub source: String,
#[serde(rename = "deviceId")]
pub device_id: String,
#[serde(rename = "hardwareId")]
pub hardware_id: Option<String>,
pub hostname: String,
pub model: Option<String>,
#[serde(rename = "osVersion")]
@ -159,14 +163,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<RegisterDeviceResponse> {
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()),

View File

@ -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<String>,
/// Hardware-bound identity (UserDevice.hardwareId).
#[serde(skip_serializing_if = "Option::is_none")]
pub hardware_id: Option<String>,
}
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<chrono::Utc>,
pub label: Option<String>,
}
impl MagicSession {
pub fn new(access_token: String, session_id: String, label: Option<String>) -> 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<chrono::Utc>,
}
impl AppConfig {
pub fn config_dir() -> AppResult<PathBuf> {
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<PathBuf> {
Ok(Self::config_dir()?.join("config.json"))
}
pub fn binder_config_path() -> AppResult<PathBuf> {
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<BinderConfig> {
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<PathBuf> {
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<Option<MagicSession>> {
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<PathBuf> {
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<Option<DesktopProtectionState>> {
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()
}
}

View File

@ -54,6 +54,8 @@ pub fn run() {
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 +73,26 @@ fn hostname() -> AppResult<String> {
Ok(hostname)
}
fn get_or_init_hardware_id(config: &mut AppConfig) -> AppResult<String> {
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<String> {
let mut config = AppConfig::load();
get_or_init_hardware_id(&mut config)
}
#[tauri::command]
fn get_device_id() -> AppResult<Option<String>> {
Ok(AppConfig::load().device_id)
}
fn require_session() -> AppResult<MagicSession> {
AppConfig::load_magic_session()?.ok_or_else(|| {
error::AppError::new("Keine Magic-Session gefunden. Bitte neu paaren.")
@ -99,17 +121,28 @@ async fn redeem_pairing_code(code: String, label: Option<String>) -> AppResult<R
#[tauri::command]
async fn register_device(
device_id: String,
model: Option<String>,
os_version: Option<String>,
) -> AppResult<RegisterDeviceResponse> {
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)
}

View File

@ -0,0 +1,74 @@
#![allow(dead_code)]
use super::{Platform, PlatformInfo};
use crate::error::AppResult;
pub fn get_platform_info() -> AppResult<PlatformInfo> {
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<Option<String>> {
// 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<String> {
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(),
))
}

View File

@ -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<PlatformInfo> {
#[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<Option<String>>;
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<String> {
#[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(),
))
}

View File

@ -0,0 +1,94 @@
#![allow(dead_code)]
use super::{Platform, PlatformInfo};
use crate::error::AppResult;
pub fn get_platform_info() -> AppResult<PlatformInfo> {
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<Option<String>> {
// 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<String> {
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(),
))
}

View File

@ -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");

View File

@ -1102,6 +1102,9 @@ 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")
@ -1109,8 +1112,10 @@ model UserDevice {
magicCooldownUntil DateTime? @map("magic_cooldown_until")
@@unique([userId, deviceId])
@@unique([userId, hardwareId])
@@index([userId])
@@index([deviceId])
@@index([hardwareId])
@@map("user_devices")
@@schema("rebreak")
}

View File

@ -32,6 +32,7 @@ export default defineEventHandler(async (event) => {
select: {
id: true,
deviceId: true,
hardwareId: true,
platform: true,
model: true,
name: true,
@ -64,6 +65,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,
@ -86,6 +88,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,

View File

@ -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 } },
// 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

View File

@ -418,6 +418,7 @@ export async function deleteUserDevice(
export interface MagicDeviceRecord {
deviceId: string;
hardwareId: string | null;
hostname: string | null;
model: string | null;
osVersion: string | null;
@ -445,6 +446,7 @@ export async function listMagicDevices(
orderBy: { magicEnrolledAt: "desc" },
select: {
deviceId: true,
hardwareId: true,
magicHostname: true,
model: true,
osVersion: true,
@ -458,6 +460,7 @@ export async function listMagicDevices(
return devices.map((d) => ({
deviceId: d.deviceId,
hardwareId: d.hardwareId,
hostname: d.magicHostname,
model: d.model,
osVersion: d.osVersion,