Files
HealthLog/src/app/api/auth/me/security-activity/route.ts
T
Marc-André Bombeck 9269043e91 feat(security): list active sessions, sign out everywhere, and show security activity
Add the user-facing account-security surface in Settings:

- Active sessions (closes #64): GET /api/auth/me/sessions lists each browser
  login with a coarse device label, masked IP, resolved location, the sliding
  last-active stamp, and the current-session marker. DELETE the collection
  signs out everywhere else (keeps the current session, revokes other sessions
  and native refresh tokens, leaves API tokens alone); DELETE one revokes a
  single session, scoped to the owner so a foreign id 404s.
- A sliding last-active stamp on the session, written throttled and
  fire-and-forget off the request-resolution path.
- Security activity: GET /api/auth/me/security-activity, the shared read over
  the audit log filtered to sign-ins, second-factor changes, password changes,
  session revocations, exports, and deletions, with masked IPs.

OpenAPI regenerated for the new endpoints.
2026-06-27 20:34:43 +02:00

71 lines
2.1 KiB
TypeScript

/**
* GET /api/auth/me/security-activity
*
* v1.23 — the SHARED user-facing security-activity feed. Returns the caller's
* recent account-security audit events (logins, MFA, password change, token /
* session revocations, exports, deletions) with timestamp + resolved location
* + MASKED IP. The IP is never returned in full and no event detail body is
* surfaced — only the action name, time, and coarse geo. Consumed by the
* account-security surface and the privacy dashboard.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { securityActivityWhere } from "@/lib/auth/security-activity";
import { maskIp } from "@/lib/auth/device-fingerprint";
export const dynamic = "force-dynamic";
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
export interface SecurityActivityDTO {
action: string;
createdAt: string;
location: string | null;
ipMasked: string | null;
carrier: string | null;
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const limitParam = Number(
request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT,
);
const limit =
Number.isFinite(limitParam) && limitParam > 0
? Math.min(Math.floor(limitParam), MAX_LIMIT)
: DEFAULT_LIMIT;
const rows = await prisma.auditLog.findMany({
where: securityActivityWhere(user.id),
orderBy: { createdAt: "desc" },
take: limit,
select: {
action: true,
createdAt: true,
location: true,
ipAddress: true,
carrier: true,
},
});
const events: SecurityActivityDTO[] = rows.map((r) => ({
action: r.action,
createdAt: r.createdAt.toISOString(),
location: r.location ?? null,
ipMasked: maskIp(r.ipAddress),
carrier: r.carrier ?? null,
}));
annotate({
action: { name: "auth.security_activity.list" },
meta: { event_count: events.length },
});
return apiSuccess({ events });
});