chahinebrini 587b0c273b feat(admin): Phase 3 — requireAdmin middleware + verify-admin endpoint
Backend-side admin-auth. Admin-App (apps/admin/) braucht das damit
useAdminAuth.verifyAdminRole() nach Login server-side prüfen kann ob User
in admin_users-tabelle steht.

New schema:
- model AdminUser → table rebreak.admin_users (user_id UUID PK FK Profile.id,
  created_at, added_by). Migration 20260508_admin_users/migration.sql.
- ⚠️  SCHEMA-MIGRATION — NICHT autopushen. User entscheidet wann pipeline
  triggert.

New backend code:
- backend/server/db/admin.ts: isAdminUser(userId) → boolean
- backend/server/utils/auth.ts: requireAdmin(event) wraps requireUser +
  isAdminUser-check. Throws 403 wenn nicht admin.
- backend/server/api/admin/verify-admin.get.ts: GET endpoint. Returns
  { isAdmin: true, userId, email } bei success, 403 sonst, 401 if not auth'd.

Tests (5 cases in tests/admin/verify-admin.test.ts):
- isAdminUser DB-layer: row exists/null
- requireAdmin: admin → user, non-admin → 403, no token → 401
- Endpoint: admin → success, non-admin → 403

Pending User-Actions nach Push+Deploy:
1. Migration deploy auf staging:
   ssh rebreak-server && cd /srv/rebreak && pnpm exec prisma migrate deploy
2. Seed-Admin eintragen:
   INSERT INTO "rebreak"."admin_users" ("user_id", "created_at")
   VALUES ('128df360-2008-4d6f-8aa1-bdb41ec1362f', NOW())
   ON CONFLICT DO NOTHING;
3. Admin-App composables/useAdminAuth.ts kann dann verifyAdminRole()
   gegen GET /api/admin/verify-admin aufrufen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:30:03 +02:00

114 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createClient } from '@supabase/supabase-js';
import type { H3Event } from 'h3';
import { isAdminUser } from '../db/admin';
import { findUserDevice, registerDevice, touchDevice } from '../db/devices';
import { getProfile } from '../db/profile';
import { getPlanLimits } from './plan-features';
const TOUCH_THROTTLE_MS = 60_000; // touch lastSeenAt höchstens 1×/min pro Device
export interface RequireUserOptions {
/** Bootstrap-Endpoints (z.B. /api/devices/register) brauchen kein Device-Binding */
skipDeviceCheck?: boolean;
}
export async function requireUser(
event: H3Event,
opts: RequireUserOptions = {},
) {
const authHeader = getHeader(event, 'authorization');
let token = authHeader?.replace('Bearer ', '');
if (!token) {
const query = getQuery(event);
token = query.token as string;
}
if (!token) {
throw createError({ statusCode: 401, message: 'Nicht eingeloggt' });
}
const config = useRuntimeConfig(event);
const supabaseCfg =
(config as any).public?.supabase ?? (config as any).supabase;
const client = createClient(
supabaseCfg.url as string,
supabaseCfg.key as string,
{ global: { headers: { Authorization: `Bearer ${token}` } } },
);
const {
data: { user },
error,
} = await client.auth.getUser();
if (error || !user) {
throw createError({ statusCode: 401, message: 'Nicht eingeloggt' });
}
if (opts.skipDeviceCheck) return user;
// Device-Binding: nur enforced wenn Client einen x-device-id Header schickt.
// Web-Clients ohne Header laufen weiter wie bisher.
const deviceId = getHeader(event, 'x-device-id');
if (!deviceId) return user;
const existing = await findUserDevice(user.id, deviceId);
if (existing) {
// Touch lastSeenAt, throttled auf 1×/min — fire-and-forget
if (Date.now() - existing.lastSeenAt.getTime() > TOUCH_THROTTLE_MS) {
touchDevice(user.id, deviceId).catch(() => {});
}
return user;
}
// Device unbekannt — Auto-Register (ohne Frontend-explicit-call)
// Plan-Limit holen
const profile = await getProfile(user.id);
const limits = getPlanLimits(profile?.plan ?? 'free');
const platform = getHeader(event, 'x-platform') ?? 'unknown';
try {
await registerDevice({
userId: user.id,
deviceId,
platform,
maxDevices: limits.maxDevices,
});
return user;
} catch (err: any) {
if (err.code === 'DEVICE_LIMIT_REACHED') {
// Devices-Liste mitschicken damit das Frontend-Modal die Geräte
// anzeigen + Freigeben-Buttons rendern kann (auch wenn der 403
// nicht vom register-Endpoint sondern vom auth-Middleware kommt).
const { listUserDevices } = await import('../db/devices');
const devices = await listUserDevices(user.id);
throw createError({
statusCode: 403,
statusMessage: 'device_limit_reached',
data: {
error: 'device_limit_reached',
max: limits.maxDevices,
plan: profile?.plan ?? 'free',
devices,
},
});
}
throw err;
}
}
/**
* requireAdmin — wirft 401 wenn nicht eingeloggt, 403 wenn nicht in admin_users.
* Wraps requireUser mit skipDeviceCheck=true (Admin-App hat kein Device-Binding).
*/
export async function requireAdmin(event: H3Event) {
const user = await requireUser(event, { skipDeviceCheck: true });
const admin = await isAdminUser(user.id);
if (!admin) {
throw createError({ statusCode: 403, message: 'Kein Admin-Zugang' });
}
return user;
}