feat: add dev startup script, fix push notifications, and scope test sends to device
- pnpm dev:local/dev:reset: orchestrate DB container, migrations, seed, and Next.js dev server in one command; Caddy snippet + docs for HTTPS via dev.ginnoir.com - Fix dev login on HTTPS: set both authjs.session-token and __Secure-authjs.session-token so Auth.js finds the session regardless of cookie name resolution - Suppress hydration mismatch on <html> caused by pre-paint script changing data-nav before React hydrates - VAPID startup warning if keys not configured; remove dead NEXT_PUBLIC_VAPID_PUBLIC_KEY var - PushOptIn: hydrate subscription state on mount; reuse existing subscription on iOS to avoid redundant prompts - sendPushToEndpoint: new function to send to a single device endpoint - sendTestNotification: scoped to the calling device's endpoint (ownership-verified) instead of all user subscriptions
This commit is contained in:
@@ -149,6 +149,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
data-density={density}
|
||||
data-nav={initialDataNav}
|
||||
className={cn(fontVars, isDark ? "dark" : "")}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
|
||||
|
||||
+10
-6
@@ -2,7 +2,7 @@ import { signIn } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { DEV_LOGIN_COOKIE, isDevLoginEnabled } from "@/lib/dev-login-config";
|
||||
import { isDevLoginEnabled } from "@/lib/dev-login-config";
|
||||
import { createDevSession } from "@/lib/dev-login";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
|
||||
@@ -37,13 +37,17 @@ export default function LoginPage() {
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
// Auth.js may resolve either "authjs.session-token" (HTTP/dev) or
|
||||
// "__Secure-authjs.session-token" (HTTPS) depending on AUTH_URL,
|
||||
// trustHost, and proxy headers. Set both so the session is found
|
||||
// regardless — this is dev-only code, correctness > elegance.
|
||||
const { sessionToken, expires } = await createDevSession();
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(DEV_LOGIN_COOKIE, sessionToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
expires,
|
||||
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
|
||||
cookieStore.set("authjs.session-token", sessionToken, base);
|
||||
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
|
||||
...base,
|
||||
secure: true,
|
||||
});
|
||||
redirect("/");
|
||||
}}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { pushSubscriptions } from "@/modules/_core/schema";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { sendPush } from "@/modules/_core/push";
|
||||
import { sendPushToEndpoint } from "@/modules/_core/push";
|
||||
|
||||
type PushSubscriptionJSON = {
|
||||
endpoint: string;
|
||||
@@ -35,9 +35,16 @@ export async function unsubscribeFromPush(endpoint: string): Promise<void> {
|
||||
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)));
|
||||
}
|
||||
|
||||
export async function sendTestNotification(): Promise<void> {
|
||||
export async function sendTestNotification(endpoint: string): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
await sendPush(user.id, {
|
||||
// Verify the endpoint belongs to the current user before sending.
|
||||
const [owned] = await db
|
||||
.select({ id: pushSubscriptions.id })
|
||||
.from(pushSubscriptions)
|
||||
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)))
|
||||
.limit(1);
|
||||
if (!owned) return;
|
||||
await sendPushToEndpoint(endpoint, {
|
||||
title: "famapp test",
|
||||
body: "Push notifications are working!",
|
||||
url: "/settings",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useState, useTransition, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
subscribeToPush,
|
||||
@@ -23,6 +23,20 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [testSent, setTestSent] = useState(false);
|
||||
|
||||
// Hydrate subscription state from the browser on mount so the UI reflects
|
||||
// reality even when the user navigates away and returns to Settings.
|
||||
useEffect(() => {
|
||||
if (!vapidKey || !("serviceWorker" in navigator) || !("PushManager" in window)) return;
|
||||
navigator.serviceWorker.ready.then((reg) =>
|
||||
reg.pushManager.getSubscription().then((existing) => {
|
||||
if (existing) {
|
||||
setEndpoint(existing.endpoint);
|
||||
setStatus("subscribed");
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, [vapidKey]);
|
||||
|
||||
if (!vapidKey) return null;
|
||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||
return (
|
||||
@@ -35,10 +49,15 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
|
||||
async function subscribe() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||
});
|
||||
// Reuse an existing browser subscription rather than calling subscribe()
|
||||
// again — avoids redundant prompts and inconsistent behaviour on iOS.
|
||||
const existing = await registration.pushManager.getSubscription();
|
||||
const sub =
|
||||
existing ??
|
||||
(await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||
}));
|
||||
const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } };
|
||||
startTransition(async () => {
|
||||
await subscribeToPush(json, navigator.userAgent);
|
||||
@@ -63,8 +82,9 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
|
||||
}
|
||||
|
||||
function sendTest() {
|
||||
if (!endpoint) return;
|
||||
startTransition(async () => {
|
||||
await sendTestNotification();
|
||||
await sendTestNotification(endpoint);
|
||||
setTestSent(true);
|
||||
setTimeout(() => setTestSent(false), 3000);
|
||||
});
|
||||
|
||||
@@ -2,5 +2,15 @@ export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { startReminderWorker } = await import("@/modules/_core/reminders");
|
||||
startReminderWorker();
|
||||
|
||||
const vapidSubject = process.env["VAPID_SUBJECT"];
|
||||
const vapidPublic = process.env["VAPID_PUBLIC_KEY"];
|
||||
const vapidPrivate = process.env["VAPID_PRIVATE_KEY"];
|
||||
if (!vapidSubject || !vapidPublic || !vapidPrivate) {
|
||||
console.warn(
|
||||
"[famapp] VAPID keys not configured — web push notifications are disabled. " +
|
||||
"Run `pnpm vapid:generate` and add VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY to .env",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,6 @@ export type { QuickAddItem, SerializedQuickAddItem, SerializedWidgetMeta } from
|
||||
export { logActivity, logShareActivity } from "./activity";
|
||||
export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
|
||||
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
|
||||
export { sendPush } from "./push";
|
||||
export { sendPush, sendPushToEndpoint } from "./push";
|
||||
export { notify } from "./notify";
|
||||
export { scheduleReminder, cancelReminder, listReminders, startReminderWorker } from "./reminders";
|
||||
|
||||
@@ -14,6 +14,32 @@ function ensureVapidConfigured() {
|
||||
webPush.setVapidDetails(subject, publicKey, privateKey);
|
||||
}
|
||||
|
||||
export async function sendPushToEndpoint(
|
||||
endpoint: string,
|
||||
payload: { title: string; body: string; url?: string },
|
||||
) {
|
||||
ensureVapidConfigured();
|
||||
const [sub] = await db
|
||||
.select()
|
||||
.from(pushSubscriptions)
|
||||
.where(eq(pushSubscriptions.endpoint, endpoint))
|
||||
.limit(1);
|
||||
if (!sub) return;
|
||||
try {
|
||||
await webPush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||
JSON.stringify({ title: payload.title, body: payload.body, url: payload.url ?? "/" }),
|
||||
);
|
||||
} catch (err) {
|
||||
const status = (err as { statusCode?: number }).statusCode;
|
||||
if (status === 404 || status === 410) {
|
||||
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
|
||||
} else {
|
||||
logger.error({ err }, "push delivery failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPush(
|
||||
userId: string,
|
||||
payload: { title: string; body: string; url?: string },
|
||||
|
||||
Reference in New Issue
Block a user