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:
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 { ChangePasswordForm } from "./change-password-form";
|
||||
import { TwoFactorSettings } from "./two-factor-settings";
|
||||
import { PushSubscriptionsSettings } from "./push-subscriptions-settings";
|
||||
|
||||
export default async function AccountSettingsPage() {
|
||||
const session = await getCurrentSession();
|
||||
@@ -15,6 +16,9 @@ export default async function AccountSettingsPage() {
|
||||
<TwoFactorSettings initialEnabled={session.user.totpEnabled} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<PushSubscriptionsSettings />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const subscribeSchema = z.object({
|
||||
p256dh: z.string().min(1),
|
||||
auth: z.string().min(1),
|
||||
}),
|
||||
userAgent: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -23,7 +24,7 @@ export async function POST(request: Request) {
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -78,6 +78,13 @@
|
||||
"when": 1787213219014,
|
||||
"tag": "0010_curved_jack_murdock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1788763637424,
|
||||
"tag": "0011_cute_boomer",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -287,5 +287,10 @@ export const pushSubscriptions = sqliteTable("push_subscriptions", {
|
||||
endpoint: text("endpoint").notNull().unique(),
|
||||
p256dh: text("p256dh").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,
|
||||
});
|
||||
@@ -57,7 +57,7 @@ export async function ensurePushSubscribed(): Promise<void> {
|
||||
const saveRes = await fetch("/api/push/subscribe", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
body: JSON.stringify({ ...subscription.toJSON(), userAgent: navigator.userAgent }),
|
||||
});
|
||||
if (saveRes.ok) {
|
||||
console.log("[push] subscription saved server-side — done");
|
||||
|
||||
+24
-4
@@ -1,4 +1,4 @@
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { eq, inArray, and } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { pushSubscriptions, users } from "@/lib/db/schema";
|
||||
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. */
|
||||
export async function savePushSubscription(userId: string, sub: PushSubscriptionKeys): Promise<void> {
|
||||
export async function savePushSubscription(
|
||||
userId: string,
|
||||
sub: PushSubscriptionKeys,
|
||||
userAgent?: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.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({
|
||||
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));
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
if (userIds.length === 0 || !ensureVapidConfigured()) return;
|
||||
|
||||
|
||||
Reference in new issue
Block a user