Make the app an installable PWA with phone layout and Web Push

Add a generated manifest, icon set, offline page and service worker that never caches authenticated HTML, plus install and update UI. Turn the rail into a drawer below md with container-query internals, dvh and safe-area handling, and reconnect the socket after visibility/network changes. Add Web Push subscriptions, presence-aware delivery, notification settings and tests.
This commit is contained in:
2026-10-19 17:12:16 +01:00
parent 61098a114c
commit dbfe48796b
100 changed files with 3573 additions and 117 deletions
+17
View File
@@ -17,6 +17,8 @@ import Layout from "../layouts/main";
import AuthLayout from "../layouts/auth";
import BotsLayout from "../layouts/bots";
import ThemeInitScript from "../components/general/theme-init-script";
import PwaInitScript from "../components/general/pwa-init-script";
import ServiceWorkerRegistrar from "../components/general/service-worker-registrar";
export const AppContext = React.createContext<AppContextObject>(
{} as AppContextObject,
@@ -57,6 +59,7 @@ export default function App({
return (
<AppContext.Provider value={appContextObject}>
<BotsLayout>{children}</BotsLayout>
<ServiceWorkerRegistrar />
</AppContext.Provider>
);
}
@@ -68,6 +71,7 @@ export default function App({
return (
<AppContext.Provider value={appContextObject}>
<AuthLayout>{children}</AuthLayout>
<ServiceWorkerRegistrar />
</AppContext.Provider>
);
}
@@ -86,6 +90,7 @@ export default function App({
>
{children}
</Layout>
<ServiceWorkerRegistrar />
</AppContext.Provider>
);
}
@@ -94,6 +99,18 @@ export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
return (
<>
<ThemeInitScript />
<PwaInitScript />
<meta name="application-name" content={SiteData["SiteName"]} />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-title"
content={SiteData["SiteName"]}
/>
<meta
name="apple-mobile-web-app-status-bar-style"
content="default"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
@@ -30,7 +30,7 @@ export default function BotProfileModal({ bot }: Props) {
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
size={40}
size={35}
/>
);
@@ -65,7 +65,9 @@ export default function BotProfileModal({ bot }: Props) {
: `${name}'s core instructions apply everywhere. What it does in one workspace is set on that workspace's page.`}
</Span>
</Stack>
{open ? <BotForm bot={bot} onCancel={() => setOpen(false)} /> : null}
{open ? (
<BotForm bot={bot} onCancel={() => setOpen(false)} />
) : null}
</Modal>
);
}
@@ -14,7 +14,7 @@ export default function ComposerAttachments({ pending, onRemove }: Props) {
if (!pending.length) return null;
return (
<Row className="w-full max-w-container mx-auto gap-2! px-6 pb-3">
<Row className="w-full max-w-container mx-auto gap-2! px-3 md:px-6 pb-3">
{pending.map((item) => (
<ComposerAttachmentChip
key={item.key}
@@ -38,7 +38,7 @@ export default function ComposerTools({ bot, workspace_id, initial }: Props) {
if (!has_tools && !computers.length) return null;
return (
<Row className="w-full max-w-container mx-auto gap-1.5! items-center px-6 pb-3 -mt-3">
<Row className="w-full max-w-container mx-auto gap-1.5! items-center px-3 md:px-6 pb-3 -mt-3">
{has_tools ? (
<Span className="text-[12px] leading-4 text-ink-secondary">
Tools
@@ -10,6 +10,7 @@ import type {
import sendBotMessage from "../(functions)/send-bot-message";
import useComposerAttachments from "../(hooks)/use-composer-attachments";
import useComposerDraft from "../(hooks)/use-composer-draft";
import useOnlineStatus from "@/src/hooks/use-online-status";
import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments";
import ComposerSendButton from "./composer-send-button";
@@ -40,11 +41,13 @@ export default function Composer({ bot, workspace_id, extensions, onSent }: Prop
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>();
const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
const online = useOnlineStatus();
const can_send =
Boolean(text.trim() || files.media_ids.length) &&
!sending &&
!files.uploading;
!files.uploading &&
online;
async function send() {
if (!can_send || !bot.id) return;
@@ -98,7 +101,7 @@ export default function Composer({ bot, workspace_id, extensions, onSent }: Prop
onRemove={files.remove}
/>
<Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline">
<Row className="w-full max-w-container mx-auto h-[88px] gap-4! flex-nowrap items-center px-6">
<Row className="w-full max-w-container mx-auto h-[88px] gap-3! md:gap-4! flex-nowrap items-center px-3 md:px-6">
<ComposerAttachButton
disabled={files.full}
onFiles={files.add}
@@ -15,6 +15,7 @@ import BotProfileModal from "./bot-profile-modal";
import BotSessionsModal from "./bot-sessions-modal";
import NewSessionButton from "./new-session-button";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
import HeaderMetaItem from "./header-meta-item";
type Props = {
@@ -50,13 +51,14 @@ export default function ConversationHeader({ bot, workspace, has_other_unread, h
: canManageCrew({ user_types: pageProps?.user_types });
return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
<Row className="h-16 shrink-0 gap-2! md:gap-3! flex-nowrap items-center px-3 md:px-6 border-0 border-b border-solid border-hairline">
<RailDrawerButton />
<BotProfileModal bot={bot} />
<Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full">
{bot.name}
</H1>
<Row className="min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
<Row className="hidden md:flex min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
{workspace?.id ? (
<HeaderMetaItem icon={Layers} label="Workspace" text={workspace.name || "Workspace"} />
) : null}
@@ -86,19 +88,23 @@ export default function ConversationHeader({ bot, workspace, has_other_unread, h
/>
<NewSessionButton bot_id={Number(bot.id)} workspace_id={workspace?.id || null} />
<BotComputersModal bot={bot} workspace_name={workspace?.id ? workspace.name || "This workspace" : null} />
<BotModelsModal
key={bot.id}
bot={bot}
workspace={workspace}
harnesses={harnesses}
onSaved={setHarnesses}
/>
{can_open_settings ? (
<HeaderIconButton
icon={Settings}
title={`${bot.name || "This bot"}'s settings`}
href={`/admin/settings/bots/${bot.id}`}
<span className="hidden md:inline-flex">
<BotModelsModal
key={bot.id}
bot={bot}
workspace={workspace}
harnesses={harnesses}
onSaved={setHarnesses}
/>
</span>
{can_open_settings ? (
<span className="hidden md:inline-flex">
<HeaderIconButton
icon={Settings}
title={`${bot.name || "This bot"}'s settings`}
href={`/admin/settings/bots/${bot.id}`}
/>
</span>
) : null}
</Row>
</Row>
@@ -39,7 +39,7 @@ export default function MessageThread({ bot, messages, progress, reply_progress,
role="log"
aria-live="polite"
aria-label={`Conversation with ${bot.name}`}
className="w-full max-w-container mx-auto gap-4! items-stretch px-6 pt-6 pb-6"
className="w-full max-w-container mx-auto gap-4! items-stretch px-3 md:px-6 pt-6 pb-6"
>
{groups.map((group) => (
<MessageGroupBlock
@@ -28,14 +28,22 @@ type Props = {
* settings page from the title. Every list uses it, so the row behaves the
* same, and the open button shows it in the viewer over the page.
*/
export default function ComputerListRow({ computer, badge, subtitle, children, linked = true }: Props) {
export default function ComputerListRow({
computer,
badge,
subtitle,
children,
linked = true,
}: Props) {
const name = computer.name || `Computer ${computer.id}`;
const ready = computer.status == "running";
return (
<SettingsListRow
avatar={<ComputerIconTile />}
href={linked ? `/admin/settings/computers/${computer.id}` : undefined}
href={
linked ? `/admin/settings/computers/${computer.id}` : undefined
}
title={name}
badge={badge}
subtitle={
@@ -3,6 +3,7 @@ import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import Span from "@moduletrace/twui/layout/Span";
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
type Props = {
title: string;
@@ -15,7 +16,8 @@ type Props = {
/**
* Same 64px header as a conversation, so moving between bots and settings
* doesn't shift the page.
* doesn't shift the page. Below `md` it also holds the rail's drawer
* button, since the rail is out of the layout there.
*/
export default function SettingsPaneHeader({
title,
@@ -23,7 +25,8 @@ export default function SettingsPaneHeader({
action,
}: Props) {
return (
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
<Row className="h-16 shrink-0 gap-2! md:gap-3! flex-nowrap items-center px-3 md:px-6 border-0 border-b border-solid border-hairline">
<RailDrawerButton />
<Stack className="min-w-0 gap-0!">
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate">
{title}
@@ -44,7 +44,7 @@ export default function SettingsShell({
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch">
<Stack
className={twMerge(
"w-full max-w-container mx-auto gap-0! items-stretch px-6 lg:px-10 pb-16",
"w-full max-w-container mx-auto gap-0! items-stretch px-4 md:px-6 lg:px-10 pb-16",
wide && "max-w-[1200px]",
)}
>
@@ -0,0 +1,46 @@
"use client";
import { Smartphone } from "lucide-react";
import type { PushDeviceOverview } from "@/src/types";
import IconTile from "@/src/components/general/icon-tile";
import relativeTime from "@/src/utils/relative-time";
import SettingsListRow from "../../(partials)/settings-list-row";
import SettingsRowAction from "../../(partials)/settings-row-action";
type Props = {
subscription: PushDeviceOverview;
removing: boolean;
onRemove: () => void;
};
/**
* One device in the Notifications list.
*/
export default function NotificationDeviceRow({
subscription,
removing,
onRemove,
}: Props) {
const last_used = subscription.last_used_at
? `Last used ${relativeTime({ at: subscription.last_used_at })} ago`
: "Not used yet";
return (
<SettingsListRow
avatar={<IconTile Icon={Smartphone} />}
title={subscription.device}
subtitle={
subscription.last_error
? `Last send failed: ${subscription.last_error}`
: `Added ${relativeTime({ at: subscription.created_at })} ago · ${last_used}`
}
>
<SettingsRowAction
title={`Remove ${subscription.device}`}
label={removing ? "Removing" : "Remove"}
disabled={removing}
onClick={onRemove}
/>
</SettingsListRow>
);
}
@@ -0,0 +1,99 @@
"use client";
import Stack from "@moduletrace/twui/layout/Stack";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button";
import Checkbox from "@moduletrace/twui/form/Checkbox";
import { useState } from "react";
import { BellRing, Send } from "lucide-react";
import type usePushNotifications from "@/src/hooks/use-push-notifications";
type Props = ReturnType<typeof usePushNotifications> & {
onChanged?: () => void;
};
const StateCopy: Record<string, string> = {
loading: "Checking this device.",
dev: "Notifications need the installed app. They work in a production build over https, or on localhost.",
unsupported:
"This browser can't receive notifications. On iPhone and iPad, add the app to the Home Screen first, then open it from there.",
"needs-install":
"Add the app to the Home Screen first. iPhone and iPad only allow notifications for installed apps.",
denied:
"Notifications are blocked for this site. Allow them in the browser's site settings, then reload this page.",
};
/**
* The switch for the device the settings are open on, and a test send.
*/
export default function NotificationsDeviceGroup({
state,
enabled,
busy,
error,
enable,
disable,
send_test,
onChanged,
}: Props) {
const [test_status, setTestStatus] = useState<string>();
const can_switch = state == "on" || state == "off";
const copy = error || StateCopy[state];
return (
<Stack className="w-full gap-4! items-stretch">
<Row className="w-full gap-3! flex-nowrap items-start">
<Checkbox
checked={enabled}
readOnly={!can_switch || busy}
label="Notifications on this device"
clickDispatchHandler={async (on) => {
if (!can_switch || busy) return;
const ok = on ? await enable() : await disable();
if (ok) onChanged?.();
}}
/>
</Row>
{copy ? (
<Span
role={error ? "alert" : "status"}
className="text-[13px] leading-[18px] text-ink-secondary"
>
{copy}
</Span>
) : null}
<Row className="gap-2! flex-nowrap">
<Button
title="Send a test notification to this user's devices"
type="button"
variant="ghost"
color="gray"
size="small"
disabled={!enabled || busy}
loading={busy}
beforeIcon={
enabled ? <Send size={15} /> : <BellRing size={15} />
}
onClick={async () => {
setTestStatus(undefined);
const ok = await send_test();
if (ok) setTestStatus("Test sent. It may take a few seconds to arrive.");
}}
>
Send a test
</Button>
</Row>
{test_status ? (
<Span
role="status"
className="text-[13px] leading-[18px] text-ink-secondary"
>
{test_status}
</Span>
) : null}
</Stack>
);
}
@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import Span from "@moduletrace/twui/layout/Span";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { PushDeviceOverview } from "@/src/types";
import SettingsList from "../../(partials)/settings-list";
import NotificationDeviceRow from "./notification-device-row";
type Props = {
subscriptions: PushDeviceOverview[];
status?: string;
onRemoved?: (msg?: string) => void;
};
/**
* Every device this user gets notifications on.
*/
export default function NotificationsDevicesGroup({
subscriptions,
status,
onRemoved,
}: Props) {
const [removing_id, setRemovingId] = useState<number>();
const remove = async (id: number) => {
setRemovingId(id);
const res = await fetchApi<{}, APIResponseObject>(
"/api/admin/push/remove-device",
{ method: "POST", body: { id } },
);
setRemovingId(undefined);
if (res.success) onRemoved?.("Device removed.");
};
return (
<>
<SettingsList
is_empty={!subscriptions.length}
empty="No device yet. Turn notifications on above."
>
{subscriptions.map((subscription) => (
<NotificationDeviceRow
key={subscription.id}
subscription={subscription}
removing={removing_id == subscription.id}
onRemove={() => remove(subscription.id)}
/>
))}
</SettingsList>
{status ? (
<Span role="status" className="text-[13px] leading-[18px] text-ink-secondary">
{status}
</Span>
) : null}
</>
);
}
@@ -0,0 +1,61 @@
"use client";
import { useContext, useState } from "react";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import SettingsGroup from "../../(partials)/settings-group";
import NotificationsDeviceGroup from "../(partials)/notifications-device-group";
import NotificationsDevicesGroup from "../(partials)/notifications-devices-group";
import { AppContext } from "@/src/pages/__root";
import usePushNotifications from "@/src/hooks/use-push-notifications";
import type { PushOverview } from "@/src/types";
/**
* Notifications settings: switch this device on or off, send a test, and
* see the other devices that receive notifications.
*/
export default function NotificationsSection() {
const { pageProps } = useContext(AppContext);
const [overview, setOverview] = useState<PushOverview>({
public_key: pageProps?.push_overview?.public_key || null,
subscriptions: pageProps?.push_overview?.subscriptions || [],
});
const [devices_status, setDevicesStatus] = useState<string>();
const push = usePushNotifications({ public_key: overview.public_key });
const refresh = async () => {
const res = await fetchApi<{}, APIResponseObject<PushOverview>>(
"/api/admin/push/overview",
{ method: "GET" },
);
if (res.success && res.singleRes) {
setOverview(res.singleRes);
}
};
return (
<>
<SettingsGroup
title="This device"
description="A notification arrives when a bot replies or needs you and the app is closed. While the app is open, nothing is sent."
>
<NotificationsDeviceGroup {...push} onChanged={refresh} />
</SettingsGroup>
<SettingsGroup
title="Devices"
description="Every device that receives notifications. Removing one stops it here."
>
<NotificationsDevicesGroup
subscriptions={overview.subscriptions}
status={devices_status}
onRemoved={(msg) => {
setDevicesStatus(msg);
refresh();
}}
/>
</SettingsGroup>
</>
);
}
@@ -0,0 +1,27 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabPushOverview from "@/src/functions/backend/db/push/grab-push-overview";
import { ensureVapidKeys } from "@/src/push/vapid";
import grabSettingsServerProps from "../(functions)/grab-settings-server-props";
/**
* The Notifications page: this user's devices and the public key a new
* device subscribes with.
*/
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabSettingsServerProps(params);
if (!settings.props?.person?.id) return settings;
const keys = await ensureVapidKeys().catch(() => null);
return {
props: {
...settings.props,
push_overview: await grabPushOverview({
user_id: settings.props.person.id,
public_key: keys?.public_key || null,
}),
},
};
};
@@ -0,0 +1,16 @@
import NotificationsSection from "./(sections)/notifications-section";
import SettingsShell from "../(partials)/settings-shell";
import settingsPageMeta from "../(functions)/settings-page-meta";
export default function AdminSettingsNotificationsPage() {
return (
<SettingsShell
title="Notifications"
description="When a bot needs you, and which devices hear about it."
>
<NotificationsSection />
</SettingsShell>
);
}
export const meta = settingsPageMeta({ label: "Notifications" });
+41
View File
@@ -0,0 +1,41 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabPushOverview from "@/src/functions/backend/db/push/grab-push-overview";
import { ensureVapidKeys } from "@/src/push/vapid";
import AppError from "@/src/utils/app-error";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import type { PushOverview } from "@/src/types";
/**
* The Notifications page refreshes this user's devices after a change
* without a reload.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<PushOverview>
> = async ({ req }) => {
try {
if (req.method != "GET") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
return {
success: true,
singleRes: await grabPushOverview({
user_id: user.id,
public_key: (await ensureVapidKeys().catch(() => null))
?.public_key,
}),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+43
View File
@@ -0,0 +1,43 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deletePushSubscriptionById from "@/src/functions/backend/db/push/delete-push-subscription-by-id";
import { PushSubscriptionIdRequestSchema } from "@/src/schemas/push-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Removes one of the session user's devices from the settings list. Its
* browser keeps its own subscription until it turns notifications off
* there, which also removes it again on the next subscribe/unsubscribe.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { id } = parseWithSchema({
schema: PushSubscriptionIdRequestSchema,
data: body,
});
await deletePushSubscriptionById({ user_id: user.id, id });
return { success: true, msg: "Device removed." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+49
View File
@@ -0,0 +1,49 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import savePushSubscription from "@/src/functions/backend/db/push/save-push-subscription";
import { SavePushSubscriptionSchema } from "@/src/schemas/push-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Saves this device's push subscription for the session user. The
* endpoint is unique, so subscribing again only refreshes the keys.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const data = parseWithSchema({
schema: SavePushSubscriptionSchema,
data: body,
});
await savePushSubscription({
user_id: user.id,
endpoint: data.endpoint,
p256dh: data.keys.p256dh,
auth: data.keys.auth,
user_agent: data.user_agent,
platform: data.platform,
});
return { success: true, msg: "Notifications are on for this device." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+54
View File
@@ -0,0 +1,54 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabPushSubscriptions from "@/src/functions/backend/db/push/grab-push-subscriptions";
import deliverToUser from "@/src/push/deliver-to-user";
import AppError from "@/src/utils/app-error";
import safeErrorMessage from "@/src/utils/safe-error-message";
import { SiteData } from "@/src/data/site-data";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Sends one notification to every device this user has subscribed, so
* they can check that notifications arrive without waiting for a bot.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const subscriptions = await grabPushSubscriptions({
user_id: user.id,
});
if (!subscriptions.length) {
throw new AppError(
`Turn notifications on for this device first, then try again.`,
);
}
await deliverToUser({
user_id: user.id,
payload: {
title: `${SiteData["SiteName"]} notifications are on`,
body: "This is what a bot's message will look like.",
url: "/admin/settings/notifications",
tag: "cobble-test",
},
});
return { success: true, msg: "Test sent." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+42
View File
@@ -0,0 +1,42 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import deletePushSubscription from "@/src/functions/backend/db/push/delete-push-subscription";
import { PushEndpointRequestSchema } from "@/src/schemas/push-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Forgets this device. The browser also drops its own subscription first,
* so the two never disagree for long.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { endpoint } = parseWithSchema({
schema: PushEndpointRequestSchema,
data: body,
});
await deletePushSubscription({ user_id: user.id, endpoint });
return { success: true, msg: "Notifications are off for this device." };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+1 -1
View File
@@ -21,7 +21,7 @@ export default function LogoutPage() {
return (
<Section
className={twMerge(
"h-screen w-screen px-0 items-center justify-center",
"h-dvh w-screen px-0 items-center justify-center",
)}
>
<Center>