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 { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user