feat(ui): Benachrichtigungsziele unter "Mein Konto"
Beliebig viele Ziele je Konto, jedes mit eigenen Anlaessen. Jedes laesst sich einzeln testen und pausieren; Ergebnis und Zeitpunkt der letzten Zustellung stehen an der Karte. Der Weg laesst sich nach dem Anlegen nicht mehr aendern - ein Webhook-Ziel zu einer E-Mail-Adresse umzubiegen ergaebe keinen Sinn und muesste das Geheimnis mitwandern lassen. Ist kein SMTP hinterlegt, ist die Auswahl "E-Mail" gesperrt und erklaert, warum. targetSafeSelect liegt in lib/notify/types statt in der Route: Next laesst in route.ts nur bestimmte Exporte zu und bricht den Bau bei allem anderen ab. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AccountManager, type AccountData } from "@/components/account/AccountManager";
|
||||
import {
|
||||
NotificationSection,
|
||||
type NotificationTargetEntry,
|
||||
} from "@/components/account/NotificationSection";
|
||||
import { emailAvailable } from "@/lib/notify";
|
||||
import { requireUserPage } from "@/lib/auth/guard";
|
||||
import { WEBAUTHN_AVAILABLE } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -13,7 +18,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
export default async function AccountPage() {
|
||||
const user = await requireUserPage();
|
||||
|
||||
const [passkeys, sessions] = await Promise.all([
|
||||
const [passkeys, sessions, targets] = await Promise.all([
|
||||
prisma.credential.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
@@ -37,6 +42,22 @@ export default async function AccountPage() {
|
||||
},
|
||||
orderBy: { lastSeenAt: "desc" },
|
||||
}),
|
||||
prisma.notificationTarget.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
channel: true,
|
||||
target: true,
|
||||
secretHint: true,
|
||||
events: true,
|
||||
isActive: true,
|
||||
lastSentAt: true,
|
||||
lastOk: true,
|
||||
lastMessage: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const data: AccountData = {
|
||||
@@ -60,6 +81,11 @@ export default async function AccountPage() {
|
||||
</header>
|
||||
|
||||
<AccountManager data={data} />
|
||||
|
||||
<NotificationSection
|
||||
targets={targets as unknown as NotificationTargetEntry[]}
|
||||
emailAvailable={emailAvailable()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { encryptSecret, tokenHint } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
|
||||
import { targetSafeSelect } from "@/lib/notify/types";
|
||||
import { formatZodError, notificationTargetUpdateSchema } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function PATCH(request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.notificationTarget.findFirst({
|
||||
where: { id, userId: auth.user.id },
|
||||
});
|
||||
if (!existing) return jsonError("Ziel nicht gefunden", 404);
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = notificationTargetUpdateSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const update: Record<string, unknown> = {};
|
||||
|
||||
if (data.name !== undefined) update.name = data.name;
|
||||
if (data.events !== undefined) update.events = data.events;
|
||||
if (data.isActive !== undefined) update.isActive = data.isActive;
|
||||
|
||||
if (data.target !== undefined) {
|
||||
if (existing.channel !== "EMAIL") {
|
||||
try {
|
||||
await assertAllowedUrl(data.target);
|
||||
} catch (error) {
|
||||
if (error instanceof BlockedHostError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, fields: { target: error.message } },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
update.target = data.target;
|
||||
}
|
||||
|
||||
// Leeres Feld heisst "unveraendert", null heisst "entfernen".
|
||||
if (data.secret !== undefined) {
|
||||
if (data.secret) {
|
||||
update.secretCipher = encryptSecret(data.secret);
|
||||
update.secretHint = tokenHint(data.secret);
|
||||
} else {
|
||||
update.secretCipher = null;
|
||||
update.secretHint = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const target = await prisma.notificationTarget.update({
|
||||
where: { id },
|
||||
data: update,
|
||||
select: targetSafeSelect,
|
||||
});
|
||||
return NextResponse.json({ target });
|
||||
} catch {
|
||||
return jsonError("Name bereits vergeben.", 409);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
const result = await prisma.notificationTarget.deleteMany({
|
||||
where: { id, userId: auth.user.id },
|
||||
});
|
||||
if (result.count === 0) return jsonError("Ziel nicht gefunden", 404);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { deliver } from "@/lib/notify/channels";
|
||||
import { testMessage } from "@/lib/notify";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Schickt eine Testnachricht an genau dieses Ziel. */
|
||||
export async function POST(request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
// Loest einen ausgehenden Aufruf aus – entsprechend begrenzt.
|
||||
const limited = rateLimit(
|
||||
`notify-test:${auth.user.id}:${clientIp(request)}`,
|
||||
LIMITS.outbound.limit,
|
||||
LIMITS.outbound.windowMs,
|
||||
);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
const { id } = await params;
|
||||
const target = await prisma.notificationTarget.findFirst({
|
||||
where: { id, userId: auth.user.id },
|
||||
});
|
||||
if (!target) return jsonError("Ziel nicht gefunden", 404);
|
||||
|
||||
const result = await deliver(target, testMessage());
|
||||
|
||||
await prisma.notificationTarget.update({
|
||||
where: { id },
|
||||
data: { lastSentAt: new Date(), lastOk: result.ok, lastMessage: result.message },
|
||||
});
|
||||
|
||||
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { encryptSecret, tokenHint } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readJsonBody } from "@/lib/security/request";
|
||||
import { targetSafeSelect } from "@/lib/notify/types";
|
||||
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
|
||||
import { formatZodError, notificationTargetCreateSchema } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const targets = await prisma.notificationTarget.findMany({
|
||||
where: { userId: auth.user.id },
|
||||
select: targetSafeSelect,
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return NextResponse.json({ targets });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = notificationTargetCreateSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const { name, channel, target, secret, events, isActive } = parsed.data;
|
||||
|
||||
// Webhook und ntfy ruft der Server spaeter selbst auf – gleiche Pruefung
|
||||
// wie bei den Git-Verbindungen.
|
||||
if (channel !== "EMAIL") {
|
||||
try {
|
||||
await assertAllowedUrl(target);
|
||||
} catch (error) {
|
||||
if (error instanceof BlockedHostError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, fields: { target: error.message } },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const duplicate = await prisma.notificationTarget.findFirst({
|
||||
where: { userId: auth.user.id, name },
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate) {
|
||||
return NextResponse.json(
|
||||
{ error: "Name bereits vergeben.", fields: { name: "Name bereits vergeben" } },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const created = await prisma.notificationTarget.create({
|
||||
data: {
|
||||
userId: auth.user.id,
|
||||
name,
|
||||
channel,
|
||||
target,
|
||||
secretCipher: secret ? encryptSecret(secret) : null,
|
||||
secretHint: secret ? tokenHint(secret) : null,
|
||||
events,
|
||||
isActive,
|
||||
},
|
||||
select: targetSafeSelect,
|
||||
});
|
||||
|
||||
return NextResponse.json({ target: created }, { status: 201 });
|
||||
}
|
||||
Reference in New Issue
Block a user