feat(protection): /api/protection/event aktualisiert device_protection_states
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
Deploy Staging / Build backend (Nitro) (push) Has been cancelled
Deploy Staging / Deploy zu Hetzner (push) Has been cancelled

- Backend: /api/protection/event setzt bei Vorhandensein von deviceId
  (Body oder x-device-id Header) auch device_protection_states.
  source=mdm -> protectionType=nefilter, sonst vpn.
- Native App: sendet deviceId im Body von /api/protection/event.
- Magic App: Lock-Profil-Status wird nach lokaler Installation ans Backend
  gemeldet und Backend-Status neu geladen.
This commit is contained in:
chahinebrini 2026-06-18 09:10:33 +02:00
parent 45d7981680
commit 97f8d593a5
2 changed files with 45 additions and 3 deletions

View File

@ -8,6 +8,7 @@ import {
formatCooldownRemaining,
} from '../lib/protection';
import { apiFetch } from '../lib/api';
import { getDeviceId } from '../lib/deviceId';
import type { WebContentFilterResult } from '../modules/rebreak-protection';
const POLL_MS_ACTIVE_COOLDOWN = 5_000;
@ -210,7 +211,7 @@ export function useProtectionState(): UseProtectionStateReturn {
return () => sub?.remove();
}, [fetchState]);
// Report protection-state transitions to the coverage log.
// Report protection-state transitions to the coverage log and per-device state.
// Fires only on genuine active↔inactive flips; deduped via ref.
useEffect(() => {
if (state === null) return;
@ -218,7 +219,14 @@ export function useProtectionState(): UseProtectionStateReturn {
if (lastReportedActiveRef.current === active) return;
lastReportedActiveRef.current = active;
const source = resolveEventSource(state);
apiFetch('/api/protection/event', { method: 'POST', body: { active, source } }).catch(() => {});
getDeviceId()
.then((deviceId) => {
apiFetch('/api/protection/event', {
method: 'POST',
body: { active, source, deviceId },
}).catch(() => {});
})
.catch(() => {});
}, [state]);
// ─── Public Actions ────────────────────────────────────────────────

View File

@ -1,21 +1,37 @@
import { getHeader } from "h3";
import { requireUser } from "../../utils/auth";
import {
appendProtectionEventDeduped,
type ProtectionSource,
} from "../../db/protectionStateLog";
import { upsertDeviceProtectionState } from "../../db/device-protection";
import type { ProtectionType } from "../../db/device-protection";
const VALID_SOURCES: ProtectionSource[] = ["vpn", "mdm", "client"];
function sourceToProtectionType(source: ProtectionSource): ProtectionType {
if (source === "mdm") return "nefilter";
return "vpn";
}
/**
* POST /api/protection/event
*
* Body: { active: boolean, source: 'vpn' | 'mdm' | 'client' }
* Body: {
* active: boolean,
* source: 'vpn' | 'mdm' | 'client',
* deviceId?: string // optional, falls bekannt (z.B. native App)
* }
*
* Called from the native app (useProtectionState / lib/protection) when the
* combined protection state transitions onoff. The client deduplicates
* locally (only fires on real transitions); the server deduplicates again
* against the last DB row for the user.
*
* Side-effect: if deviceId is provided (body or x-device-id header), the
* per-device protection state (device_protection_states) is also updated so
* that MDM / Magic-App views see the current nefilter/vpn status.
*
* Returns { success: true, written: true } if a new row was written,
* { success: true, written: false } if deduplicated (state unchanged).
*/
@ -33,5 +49,23 @@ export default defineEventHandler(async (event) => {
const row = await appendProtectionEventDeduped(user.id, body.active, source);
const deviceId = body?.deviceId ?? getHeader(event, "x-device-id") ?? null;
if (deviceId && typeof deviceId === "string") {
try {
await upsertDeviceProtectionState(
user.id,
deviceId,
"ios",
sourceToProtectionType(source),
body.active,
new Date(),
`protection event via ${source}`,
"native-app",
);
} catch (err: any) {
console.error("[protection/event] device_protection_states upsert failed:", err?.message ?? err);
}
}
return { success: true, written: row !== null };
});