Let users see and unsubscribe their own push devices

Fixes duplicate/triple OS notifications on machines where more than
one browser (e.g. both Chrome and Edge on Windows) independently got
notification permission for the site — each held its own valid
subscription, so every event pushed to both.

Adds a userAgent column (captured client-side at subscribe time,
display-only) and a Settings > Account section listing a user's own
subscribed browsers with a friendly label plus an unsubscribe button,
backed by new GET/DELETE /api/push/subscriptions endpoints scoped to
the caller's own rows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-09-07 06:49:34 +00:00
1 parent 4d393aec91
commit f5e49b5c53
11 files changed
+1374 -6

No files matched your search

@@ -1,6 +1,7 @@
import { getCurrentSession } from "@/lib/auth/session"; import { getCurrentSession } from "@/lib/auth/session";
import { ChangePasswordForm } from "./change-password-form"; import { ChangePasswordForm } from "./change-password-form";
import { TwoFactorSettings } from "./two-factor-settings"; import { TwoFactorSettings } from "./two-factor-settings";
import { PushSubscriptionsSettings } from "./push-subscriptions-settings";
export default async function AccountSettingsPage() { export default async function AccountSettingsPage() {
const session = await getCurrentSession(); const session = await getCurrentSession();
@@ -15,6 +16,9 @@ export default async function AccountSettingsPage() {
<TwoFactorSettings initialEnabled={session.user.totpEnabled} /> <TwoFactorSettings initialEnabled={session.user.totpEnabled} />
</div> </div>
)} )}
<div className="mt-4">
<PushSubscriptionsSettings />
</div>
</div> </div>
); );
} }
@@ -0,0 +1,100 @@
"use client";
import { useEffect, useState } from "react";
import { BellOff, Loader2 } from "lucide-react";
import { formatRelativeTime } from "@/lib/format";
type Subscription = { id: string; userAgent: string | null; createdAt: string };
/** Best-effort "Chrome on Windows"-style label from a stored navigator.userAgent — purely cosmetic, never parsed for anything security-relevant. */
function describeUserAgent(userAgent: string | null): string {
if (!userAgent) return "Неизвестное устройство";
let browser = "Браузер";
if (/Edg\//.test(userAgent)) browser = "Edge";
else if (/OPR\//.test(userAgent)) browser = "Opera";
else if (/Chrome\//.test(userAgent)) browser = "Chrome";
else if (/Firefox\//.test(userAgent)) browser = "Firefox";
else if (/Safari\//.test(userAgent)) browser = "Safari";
let os = "";
if (/Windows/.test(userAgent)) os = "Windows";
else if (/Android/.test(userAgent)) os = "Android";
else if (/iPhone|iPad/.test(userAgent)) os = "iOS";
else if (/Macintosh|Mac OS X/.test(userAgent)) os = "macOS";
else if (/Linux/.test(userAgent)) os = "Linux";
return os ? `${browser} на ${os}` : browser;
}
export function PushSubscriptionsSettings() {
const [subscriptions, setSubscriptions] = useState<Subscription[] | null>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/push/subscriptions")
.then((res) => res.json())
.then((data) => setSubscriptions(data.subscriptions ?? []))
.catch(() => setError("Не удалось загрузить список устройств"));
}, []);
async function unsubscribe(id: string) {
setRemovingId(id);
setError(null);
try {
const res = await fetch(`/api/push/subscriptions/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error();
setSubscriptions((prev) => (prev ? prev.filter((s) => s.id !== id) : prev));
} catch {
setError("Не удалось отписать устройство — попробуйте ещё раз");
} finally {
setRemovingId(null);
}
}
return (
<div className="card p-4">
<p className="mb-1 text-sm font-semibold">Push-уведомления</p>
<p className="mb-3 text-sm text-text-muted">
Устройства/браузеры, подписанные на уведомления этого аккаунта. Если один и тот же браузер получает
уведомление дважды или трижды, скорее всего в нескольких браузерах на этом компьютере отдельно разрешены
уведомления для сайта — отпишите лишние здесь.
</p>
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
{subscriptions === null ? (
<p className="flex items-center gap-1.5 text-sm text-text-faint">
<Loader2 size={14} className="animate-spin" />
Загрузка…
</p>
) : subscriptions.length === 0 ? (
<p className="text-sm text-text-faint">Нет подписанных устройств.</p>
) : (
<ul className="flex flex-col gap-2">
{subscriptions.map((sub) => (
<li
key={sub.id}
className="flex items-center justify-between gap-2 rounded-md border border-border px-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm">{describeUserAgent(sub.userAgent)}</p>
<p className="text-xs text-text-faint">Подписан {formatRelativeTime(new Date(sub.createdAt).getTime())}</p>
</div>
<button
type="button"
disabled={removingId === sub.id}
onClick={() => unsubscribe(sub.id)}
className="btn btn-ghost shrink-0 px-2.5 py-1.5 text-xs"
>
<BellOff size={13} />
Отписать
</button>
</li>
))}
</ul>
)}
</div>
);
}
+2 -1
View File
@@ -11,6 +11,7 @@ const subscribeSchema = z.object({
p256dh: z.string().min(1), p256dh: z.string().min(1),
auth: z.string().min(1), auth: z.string().min(1),
}), }),
userAgent: z.string().max(500).optional(),
}); });
export async function POST(request: Request) { export async function POST(request: Request) {
@@ -23,7 +24,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 }); return NextResponse.json({ error: "Invalid input" }, { status: 400 });
} }
await savePushSubscription(session.user.id, parsed.data); await savePushSubscription(session.user.id, parsed.data, parsed.data.userAgent);
return NextResponse.json({ ok: true }, { status: 201 }); return NextResponse.json({ ok: true }, { status: 201 });
} }
@@ -0,0 +1,15 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/require";
import { deleteOwnPushSubscription } from "@/lib/push/service";
/** Unsubscribes one of the current user's own devices/browsers — scoped by userId, so a user can never remove someone else's. */
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
await deleteOwnPushSubscription(session.user.id, id);
return NextResponse.json({ ok: true });
}
+14
View File
@@ -0,0 +1,14 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/require";
import { listPushSubscriptionsForUser } from "@/lib/push/service";
/** The current user's own subscribed devices/browsers, for the notification settings list. */
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
const subscriptions = await listPushSubscriptionsForUser(session.user.id);
return NextResponse.json({ subscriptions });
}
@@ -0,0 +1 @@
ALTER TABLE `push_subscriptions` ADD `user_agent` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -78,6 +78,13 @@
"when": 1787213219014, "when": 1787213219014,
"tag": "0010_curved_jack_murdock", "tag": "0010_curved_jack_murdock",
"breakpoints": true "breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1788763637424,
"tag": "0011_cute_boomer",
"breakpoints": true
} }
] ]
} }
+5
View File
@@ -287,5 +287,10 @@ export const pushSubscriptions = sqliteTable("push_subscriptions", {
endpoint: text("endpoint").notNull().unique(), endpoint: text("endpoint").notNull().unique(),
p256dh: text("p256dh").notNull(), p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(), auth: text("auth").notNull(),
// Raw navigator.userAgent at subscribe time, purely for display — lets a
// user tell their two subscribed browsers apart when unsubscribing one
// (e.g. Windows + both Chrome and Edge granted notification permission,
// each getting its own OS toast for the same event).
userAgent: text("user_agent"),
createdAt: timestamps.createdAt, createdAt: timestamps.createdAt,
}); });
+1 -1
View File
@@ -57,7 +57,7 @@ export async function ensurePushSubscribed(): Promise<void> {
const saveRes = await fetch("/api/push/subscribe", { const saveRes = await fetch("/api/push/subscribe", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription.toJSON()), body: JSON.stringify({ ...subscription.toJSON(), userAgent: navigator.userAgent }),
}); });
if (saveRes.ok) { if (saveRes.ok) {
console.log("[push] subscription saved server-side — done"); console.log("[push] subscription saved server-side — done");
+24 -4
View File
@@ -1,4 +1,4 @@
import { eq, inArray } from "drizzle-orm"; import { eq, inArray, and } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { pushSubscriptions, users } from "@/lib/db/schema"; import { pushSubscriptions, users } from "@/lib/db/schema";
import type { TicketDTO, MessageDTO } from "@/lib/tickets/types"; import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
@@ -10,13 +10,17 @@ export interface PushSubscriptionKeys {
} }
/** Re-subscribing from the same browser (e.g. after clearing site data and re-granting) overwrites the old row via the endpoint's unique constraint, rather than accumulating stale duplicates. */ /** Re-subscribing from the same browser (e.g. after clearing site data and re-granting) overwrites the old row via the endpoint's unique constraint, rather than accumulating stale duplicates. */
export async function savePushSubscription(userId: string, sub: PushSubscriptionKeys): Promise<void> { export async function savePushSubscription(
userId: string,
sub: PushSubscriptionKeys,
userAgent?: string,
): Promise<void> {
await db await db
.insert(pushSubscriptions) .insert(pushSubscriptions)
.values({ userId, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth }) .values({ userId, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth, userAgent })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: pushSubscriptions.endpoint, target: pushSubscriptions.endpoint,
set: { userId, p256dh: sub.keys.p256dh, auth: sub.keys.auth }, set: { userId, p256dh: sub.keys.p256dh, auth: sub.keys.auth, userAgent },
}); });
} }
@@ -24,6 +28,22 @@ export async function deletePushSubscriptionByEndpoint(endpoint: string): Promis
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint)); await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
} }
/** A user's own subscribed devices/browsers, for the "manage notifications" settings list — never exposes the endpoint/keys, only what's useful to tell rows apart. */
export async function listPushSubscriptionsForUser(userId: string) {
const rows = await db.query.pushSubscriptions.findMany({
where: eq(pushSubscriptions.userId, userId),
orderBy: (t, { desc }) => desc(t.createdAt),
});
return rows.map((r) => ({ id: r.id, userAgent: r.userAgent, createdAt: r.createdAt }));
}
/** Deletes one of the current user's own subscriptions by id — scoped so a user can only ever remove their own. */
export async function deleteOwnPushSubscription(userId: string, id: string): Promise<void> {
await db
.delete(pushSubscriptions)
.where(and(eq(pushSubscriptions.id, id), eq(pushSubscriptions.userId, userId)));
}
async function sendPushToUsers(userIds: string[], payload: { title: string; body: string; url: string }): Promise<void> { async function sendPushToUsers(userIds: string[], payload: { title: string; body: string; url: string }): Promise<void> {
if (userIds.length === 0 || !ensureVapidConfigured()) return; if (userIds.length === 0 || !ensureVapidConfigured()) return;