mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
feat: let plugin notifications carry a link to the plugin's own server
This commit is contained in:
@@ -33,10 +33,11 @@ vi.mock('@/lib/plugin-secrets/proxy', async (orig) => {
|
||||
...real,
|
||||
proxyRequest: vi.fn(async (input: unknown) => { proxied.push(input); return new Response('ok'); }),
|
||||
adminCredentialStatus: vi.fn(async () => [{ credentialId: 'shared', configured: true }]),
|
||||
adminCredentialOrigins: vi.fn(async () => ['https://paperless.example.com']),
|
||||
};
|
||||
});
|
||||
|
||||
import { POST as saveCredential } from '../route';
|
||||
import { GET as listCredentials, POST as saveCredential } from '../route';
|
||||
import { POST as proxy } from '../../plugin-proxy/route';
|
||||
|
||||
const VALID = {
|
||||
@@ -60,6 +61,15 @@ beforeEach(() => {
|
||||
proxied.length = 0;
|
||||
});
|
||||
|
||||
describe('GET /api/plugin-secrets', () => {
|
||||
it("names the servers of set-up admin credentials for one plugin, for the host page's link check", async () => {
|
||||
const res = await listCredentials(new NextRequest('http://localhost/api/plugin-secrets?plugin=paperless'));
|
||||
expect(await res.json()).toMatchObject({ admin: [{ credentialId: 'shared', configured: true }], adminOrigins: ['https://paperless.example.com'] });
|
||||
const all = await listCredentials(new NextRequest('http://localhost/api/plugin-secrets'));
|
||||
expect((await all.json()).adminOrigins).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/plugin-secrets', () => {
|
||||
it('stores a credential for the server origin only', async () => {
|
||||
const res = await save(VALID);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { configManager } from '@/lib/admin/config-manager';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
||||
import { CREDENTIAL_ID_RE, checkInject, credentialOrigin } from '@/lib/plugin-secrets/credentials';
|
||||
import { adminCredentialStatus } from '@/lib/plugin-secrets/proxy';
|
||||
import { adminCredentialOrigins, adminCredentialStatus } from '@/lib/plugin-secrets/proxy';
|
||||
import {
|
||||
VaultFullError,
|
||||
deleteUserCredentials,
|
||||
@@ -35,9 +35,13 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid plugin id' }, { status: 400 });
|
||||
}
|
||||
const credentials = await listUserCredentials(user, pluginId);
|
||||
// For one plugin, also say which of its admin credentials are set up.
|
||||
const admin = pluginId ? await adminCredentialStatus(pluginId) : [];
|
||||
return NextResponse.json({ credentials, admin }, { headers: NO_STORE });
|
||||
// For one plugin, also say which of its admin credentials are set up, and
|
||||
// on which servers: the host page checks the links a plugin shows against
|
||||
// them (the plugin itself only gets yes/no, see proxyAccounts).
|
||||
const [admin, adminOrigins] = pluginId
|
||||
? await Promise.all([adminCredentialStatus(pluginId), adminCredentialOrigins(pluginId)])
|
||||
: [[], []];
|
||||
return NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,6 +77,25 @@ describe('notification bell', () => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a plugin's link under its entry, opening in a new tab without the opener", () => {
|
||||
act(() => {
|
||||
add({ level: 'info', title: 'Filed', body: '', pluginId: 'rss', pluginName: 'Paperless-ngx', action: { label: 'Open in Paperless', url: 'https://paperless.example.com/documents/12/details' } });
|
||||
add({ level: 'info', title: 'Odd', body: '', pluginId: 'rss', pluginName: 'X', action: { label: 'Run', url: 'javascript:alert(1)' } });
|
||||
});
|
||||
render(<NavigationRail collapsed />);
|
||||
fireEvent.click(bell());
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: 'title' });
|
||||
const links = within(panel).getAllByRole('link');
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toHaveTextContent('Open in Paperless');
|
||||
expect(links[0]).toHaveAttribute('href', 'https://paperless.example.com/documents/12/details');
|
||||
expect(links[0]).toHaveAttribute('target', '_blank');
|
||||
expect(links[0].getAttribute('rel')).toContain('noopener');
|
||||
// Not inside the entry's button.
|
||||
expect(links[0].closest('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks all read and clears', () => {
|
||||
act(() => {
|
||||
add({ level: 'warning', title: 'a', body: '' });
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { AlertCircle, AlertTriangle, Bell, CheckCheck, Puzzle, Trash2, X } from "@/components/icons";
|
||||
import { AlertCircle, AlertTriangle, Bell, CheckCheck, ExternalLink, Puzzle, Trash2, X } from "@/components/icons";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { isDocumentRTL } from "@/i18n/direction";
|
||||
@@ -130,6 +130,20 @@ export function NotificationPanel({ onClose, className, sheet = false }: { onClo
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
{entry.action && (
|
||||
// Lined up with the text above (padding + icon + gap). A link of
|
||||
// its own, outside the entry's button: one control can't hold another.
|
||||
<a
|
||||
href={entry.action.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => markRead(entry.id)}
|
||||
className="-mt-1 mb-1 ms-[38px] inline-flex items-center gap-1 rounded text-xs font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{entry.action.label}
|
||||
<ExternalLink className="w-3 h-3" aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -10,6 +10,11 @@ vi.mock('@/stores/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: (...a: unknown[]) => toastInfo(...a), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
const overview = vi.fn();
|
||||
vi.mock('../plugin-sandbox/connect-dialog', () => ({
|
||||
credentialOverview: (...a: unknown[]) => overview(...a),
|
||||
}));
|
||||
|
||||
import { dispatchApiCall } from '../plugin-sandbox/host-api';
|
||||
|
||||
function plugin(id: string, perms = ['ui:notifications']): InstalledPlugin {
|
||||
@@ -36,6 +41,8 @@ class FakeNotification {
|
||||
beforeEach(() => {
|
||||
resetPluginRateLimits();
|
||||
toastInfo.mockClear();
|
||||
overview.mockReset();
|
||||
overview.mockResolvedValue({ credentials: [], admin: [], adminOrigins: [] });
|
||||
FakeNotification.shown = [];
|
||||
FakeNotification.permission = 'granted';
|
||||
vi.stubGlobal('Notification', FakeNotification);
|
||||
@@ -140,3 +147,58 @@ describe('api.notifications.show in the notification panel', () => {
|
||||
expect(await entries()).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api.notifications.show with a link', () => {
|
||||
const PAPERLESS = { ...plugin('paperless', ['ui:notifications', 'http:proxy']), name: 'Paperless-ngx' };
|
||||
const link = (url: string) => ({ title: 'project-notes is in Paperless now', action: { label: 'Open in Paperless', url } });
|
||||
const entries = async () => (await import('@/stores/notification-center-store')).useNotificationCenterStore.getState().entries;
|
||||
|
||||
beforeEach(async () => {
|
||||
FakeNotification.permission = 'default';
|
||||
const { useNotificationCenterStore } = await import('@/stores/notification-center-store');
|
||||
useNotificationCenterStore.getState().clear();
|
||||
});
|
||||
|
||||
it('puts a button on the toast that opens the link in a new tab without a handle on the app', async () => {
|
||||
overview.mockResolvedValue({ credentials: [], admin: [{ credentialId: 'shared', configured: true }], adminOrigins: ['https://paperless.example.com'] });
|
||||
const open = vi.fn();
|
||||
vi.stubGlobal('open', open);
|
||||
expect(await call(PAPERLESS, link('https://paperless.example.com/documents/12/details'))).toBe('toast');
|
||||
const options = toastInfo.mock.calls[0][1] as { action: { label: string; onClick: () => void } };
|
||||
expect(options.action.label).toBe('Open in Paperless');
|
||||
options.action.onClick();
|
||||
expect(open).toHaveBeenCalledWith('https://paperless.example.com/documents/12/details', '_blank', 'noopener,noreferrer');
|
||||
expect((await entries())[0].action).toEqual({ label: 'Open in Paperless', url: 'https://paperless.example.com/documents/12/details' });
|
||||
});
|
||||
|
||||
it('accepts the server of a credential the user connected, and the manifest httpOrigins', async () => {
|
||||
overview.mockResolvedValue({
|
||||
credentials: [{ pluginId: 'paperless', credentialId: 'personal', label: 'P', origin: 'https://docs.alice.example', connected: true, updatedAt: '' }],
|
||||
admin: [], adminOrigins: [],
|
||||
});
|
||||
await call(PAPERLESS, link('https://docs.alice.example/documents/?title__icontains=x'));
|
||||
await call({ ...plugin('feeds'), httpOrigins: ['https://feeds.example.org'] }, link('https://feeds.example.org/item/1'));
|
||||
expect((await entries()).map((e) => e.action?.url)).toEqual([
|
||||
'https://feeds.example.org/item/1',
|
||||
'https://docs.alice.example/documents/?title__icontains=x',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses a link to a server the plugin does not talk to, and shows nothing', async () => {
|
||||
overview.mockResolvedValue({ credentials: [], admin: [], adminOrigins: ['https://paperless.example.com'] });
|
||||
await expect(call(PAPERLESS, link('https://paperless.example.com.evil.example/login'))).rejects.toThrow(/action.url must be on a server the plugin talks to/);
|
||||
await expect(call({ ...plugin('feeds'), httpOrigins: ['https://feeds.example.org'] }, link('https://phish.example/'))).rejects.toThrow(/action.url must be on a server/);
|
||||
expect(toastInfo).not.toHaveBeenCalled();
|
||||
expect(await entries()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses links that are not plain http(s)', async () => {
|
||||
overview.mockResolvedValue({ credentials: [], admin: [], adminOrigins: ['https://paperless.example.com'] });
|
||||
await expect(call(PAPERLESS, link('javascript:alert(1)'))).rejects.toThrow(/http or https/);
|
||||
await expect(call(PAPERLESS, link('/documents/1'))).rejects.toThrow(/absolute URL/);
|
||||
await expect(call(PAPERLESS, link('https://u:p@paperless.example.com/'))).rejects.toThrow(/user name or password/);
|
||||
await expect(call(PAPERLESS, { title: 'x', action: { label: '', url: 'https://paperless.example.com/' } })).rejects.toThrow(/label/);
|
||||
await expect(call(PAPERLESS, { title: 'x', action: { label: 'ok', url: 'https://paperless.example.com/', target: '_self' } })).rejects.toThrow(/unknown property "target"/);
|
||||
expect(toastInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,19 +124,27 @@ export async function listConnectedAccounts(pluginId?: string): Promise<Connecte
|
||||
return (await credentialOverview(pluginId)).credentials;
|
||||
}
|
||||
|
||||
export interface CredentialOverview {
|
||||
credentials: ConnectedAccount[];
|
||||
admin: { credentialId: string; configured: boolean }[];
|
||||
/** Servers of the admin credentials that are set up. For the host page only. */
|
||||
adminOrigins: string[];
|
||||
}
|
||||
|
||||
/** The user's connected accounts, and (for one plugin) which admin credentials are set up. */
|
||||
export async function credentialOverview(pluginId?: string): Promise<{ credentials: ConnectedAccount[]; admin: { credentialId: string; configured: boolean }[] }> {
|
||||
export async function credentialOverview(pluginId?: string): Promise<CredentialOverview> {
|
||||
const query = pluginId ? `?plugin=${encodeURIComponent(pluginId)}` : '';
|
||||
try {
|
||||
const res = await apiFetch(`/api/plugin-secrets${query}`);
|
||||
if (!res.ok) return { credentials: [], admin: [] };
|
||||
const body = await res.json() as { credentials?: ConnectedAccount[]; admin?: { credentialId: string; configured: boolean }[] };
|
||||
if (!res.ok) return { credentials: [], admin: [], adminOrigins: [] };
|
||||
const body = await res.json() as Partial<CredentialOverview>;
|
||||
return {
|
||||
credentials: Array.isArray(body.credentials) ? body.credentials : [],
|
||||
admin: Array.isArray(body.admin) ? body.admin : [],
|
||||
adminOrigins: Array.isArray(body.adminOrigins) ? body.adminOrigins.filter((o): o is string => typeof o === 'string') : [],
|
||||
};
|
||||
} catch {
|
||||
return { credentials: [], admin: [] };
|
||||
return { credentials: [], admin: [], adminOrigins: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
// Host side of api.notifications.show: a system notification when the user
|
||||
// has allowed them for the webmail, otherwise a toast. Every one names the
|
||||
// plugin that sent it, and a plugin may send only a few a minute.
|
||||
//
|
||||
// A notification may carry one link ("Open in Paperless"). The host draws
|
||||
// it, so it looks like the app's own: it may only point at a server the
|
||||
// plugin already talks to (its httpOrigins or the server of one of its proxy
|
||||
// credentials). Otherwise any plugin with ui:notifications could put an
|
||||
// app-styled button to any site in front of the user.
|
||||
|
||||
import type { InstalledPlugin, NotificationInput } from '../plugin-types';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
import { toastHooks } from '../plugin-hooks';
|
||||
import { readObject, readString, withinRate } from './host-api-input';
|
||||
import { recordNotification } from '@/stores/notification-center-store';
|
||||
import { credentialOverview } from './connect-dialog';
|
||||
|
||||
export const MAX_NOTIFICATION_TITLE = 120;
|
||||
export const MAX_NOTIFICATION_BODY = 300;
|
||||
export const NOTIFICATIONS_PER_MINUTE = 5;
|
||||
export const MAX_NOTIFICATION_ACTION_LABEL = 40;
|
||||
const MAX_ACTION_URL = 2048;
|
||||
const TAG_RE = /^[A-Za-z0-9._:-]{1,64}$/;
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- line breaks and other control characters become spaces
|
||||
@@ -22,9 +31,9 @@ const oneLine = (text: string) => text.replace(/[\u0000-\u001f\u007f]+/g, ' ').t
|
||||
* brings the webmail to the front and fires onNotificationClick for this
|
||||
* plugin only, with the tag it passed.
|
||||
*/
|
||||
export function doNotificationsShow(plugin: InstalledPlugin, input: unknown): 'system' | 'toast' {
|
||||
export async function doNotificationsShow(plugin: InstalledPlugin, input: unknown): Promise<'system' | 'toast'> {
|
||||
const method = 'notifications.show';
|
||||
const data = readObject(input, method, 'notification', ['title', 'body', 'tag']) as NotificationInput & Record<string, unknown>;
|
||||
const data = readObject(input, method, 'notification', ['title', 'body', 'tag', 'action']) as NotificationInput & Record<string, unknown>;
|
||||
const title = oneLine(readString(data.title, method, 'title', MAX_NOTIFICATION_TITLE, { min: 1 }));
|
||||
if (!title) throw new Error(`${method}: title must not be empty`);
|
||||
const body = data.body === undefined ? '' : oneLine(readString(data.body, method, 'body', MAX_NOTIFICATION_BODY));
|
||||
@@ -35,14 +44,18 @@ export function doNotificationsShow(plugin: InstalledPlugin, input: unknown): 's
|
||||
}
|
||||
tag = data.tag;
|
||||
}
|
||||
const action = data.action === undefined ? undefined : readAction(method, data.action);
|
||||
if (!withinRate('notifications', plugin.id, NOTIFICATIONS_PER_MINUTE)) {
|
||||
throw new Error(`${method}: at most ${NOTIFICATIONS_PER_MINUTE} notifications a minute`);
|
||||
}
|
||||
if (action && !(await pluginOrigins(plugin)).has(new URL(action.url).origin)) {
|
||||
throw new Error(`${method}: action.url must be on a server the plugin talks to (its httpOrigins or a proxy credential's server)`);
|
||||
}
|
||||
|
||||
const source = plugin.name || plugin.id;
|
||||
// Also listed in the app's notification panel (the bell), where a click
|
||||
// reaches the plugin the same way as a click on the system notification.
|
||||
recordNotification({ level: 'info', title, body, pluginId: plugin.id, pluginName: source, ...(tag ? { tag } : {}) });
|
||||
recordNotification({ level: 'info', title, body, pluginId: plugin.id, pluginName: source, ...(tag ? { tag } : {}), ...(action ? { action } : {}) });
|
||||
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||
try {
|
||||
const notification = new Notification(title, {
|
||||
@@ -61,6 +74,47 @@ export function doNotificationsShow(plugin: InstalledPlugin, input: unknown): 's
|
||||
// service worker; the toast below still tells the user.
|
||||
}
|
||||
}
|
||||
appToast.info(title, { message: body ? `${body} (${source})` : source });
|
||||
appToast.info(title, {
|
||||
message: body ? `${body} (${source})` : source,
|
||||
...(action ? { action: { label: action.label, onClick: () => openNotificationLink(action.url) } } : {}),
|
||||
});
|
||||
return 'toast';
|
||||
}
|
||||
|
||||
function readAction(method: string, value: unknown): { label: string; url: string } {
|
||||
const data = readObject(value, method, 'action', ['label', 'url']);
|
||||
const label = oneLine(readString(data.label, method, 'action.label', MAX_NOTIFICATION_ACTION_LABEL, { min: 1 }));
|
||||
if (!label) throw new Error(`${method}: action.label must not be empty`);
|
||||
const raw = readString(data.url, method, 'action.url', MAX_ACTION_URL, { min: 1 });
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`${method}: action.url must be an absolute URL`);
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error(`${method}: action.url must be http or https`);
|
||||
if (url.username || url.password) throw new Error(`${method}: action.url must not carry a user name or password`);
|
||||
return { label, url: url.href };
|
||||
}
|
||||
|
||||
/** Origins the plugin reaches: its manifest's httpOrigins and its credentials' servers. */
|
||||
async function pluginOrigins(plugin: InstalledPlugin): Promise<Set<string>> {
|
||||
const origins = new Set<string>();
|
||||
for (const o of plugin.httpOrigins ?? []) {
|
||||
try { origins.add(new URL(o).origin); } catch { /* invalid entries never matched anything */ }
|
||||
}
|
||||
if (plugin.permissions.includes('http:proxy')) {
|
||||
const { credentials, adminOrigins } = await credentialOverview(plugin.id);
|
||||
for (const c of credentials) if (c.pluginId === plugin.id && c.origin) origins.add(c.origin);
|
||||
for (const o of adminOrigins) origins.add(o);
|
||||
}
|
||||
return origins;
|
||||
}
|
||||
|
||||
/** Open a notification's link in a new tab, without giving that tab a handle on the webmail. */
|
||||
export function openNotificationLink(url: string): void {
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(url); } catch { return; }
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
|
||||
window.open(parsed.href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
@@ -97,6 +97,19 @@ export async function resolveCredential(user: VaultUser, pluginId: string, crede
|
||||
* set). No secret or address leaves the server; plugins learn only yes/no.
|
||||
*/
|
||||
export async function adminCredentialStatus(pluginId: string): Promise<{ credentialId: string; configured: boolean }[]> {
|
||||
return (await adminCredentials(pluginId)).map(({ credentialId, origin }) => ({ credentialId, configured: !!origin }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The servers of a plugin's admin credentials that are set up. Only for the
|
||||
* host page, which checks links a plugin shows against them: plugins
|
||||
* themselves still learn only yes/no (adminCredentialStatus).
|
||||
*/
|
||||
export async function adminCredentialOrigins(pluginId: string): Promise<string[]> {
|
||||
return (await adminCredentials(pluginId)).flatMap(({ origin }) => (origin ? [origin] : []));
|
||||
}
|
||||
|
||||
async function adminCredentials(pluginId: string): Promise<{ credentialId: string; origin: string | null }[]> {
|
||||
const plugin = await getPlugin(pluginId);
|
||||
const specs = (plugin as { credentials?: Record<string, CredentialSpec> } | null)?.credentials;
|
||||
if (!plugin || !plugin.enabled || !specs) return [];
|
||||
@@ -105,7 +118,7 @@ export async function adminCredentialStatus(pluginId: string): Promise<{ credent
|
||||
const user = { username: '', serverUrl: '' };
|
||||
return Promise.all(adminIds.map(async (credentialId) => ({
|
||||
credentialId,
|
||||
configured: !!(await resolveAdminOnly(user, pluginId, credentialId)),
|
||||
origin: (await resolveAdminOnly(user, pluginId, credentialId))?.origin ?? null,
|
||||
})));
|
||||
}
|
||||
|
||||
|
||||
@@ -740,6 +740,13 @@ export interface NotificationInput {
|
||||
* replaces the old one, and onNotificationClick hands the tag back.
|
||||
*/
|
||||
tag?: string;
|
||||
/**
|
||||
* A link the host shows as a button on the toast and as a link in the
|
||||
* notification panel, opened in a new tab. The URL must be http(s) and on
|
||||
* a server the plugin already talks to: one of its `httpOrigins`, or the
|
||||
* server of one of its proxy credentials.
|
||||
*/
|
||||
action?: { label: string; url: string };
|
||||
}
|
||||
|
||||
export interface FilterRuleView {
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface CenterEntry {
|
||||
pluginId?: string;
|
||||
pluginName?: string;
|
||||
tag?: string;
|
||||
/** A link the plugin added (checked by the host when it was shown). */
|
||||
action?: { label: string; url: string };
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
@@ -37,7 +39,21 @@ function isEntry(value: unknown): value is CenterEntry {
|
||||
const e = value as Record<string, unknown>;
|
||||
return typeof e.id === 'string' && typeof e.time === 'number' && typeof e.title === 'string'
|
||||
&& typeof e.body === 'string' && typeof e.read === 'boolean'
|
||||
&& (e.level === 'error' || e.level === 'warning' || e.level === 'info');
|
||||
&& (e.level === 'error' || e.level === 'warning' || e.level === 'info')
|
||||
&& (e.action === undefined || isLink(e.action));
|
||||
}
|
||||
|
||||
// Storage is the user's own, but a link is only ever shown when it is http(s).
|
||||
function isLink(value: unknown): value is { label: string; url: string } {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const { label, url } = value as Record<string, unknown>;
|
||||
if (typeof label !== 'string' || typeof url !== 'string') return false;
|
||||
try {
|
||||
const { protocol } = new URL(url);
|
||||
return protocol === 'https:' || protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function load(): CenterEntry[] {
|
||||
@@ -72,6 +88,7 @@ export const useNotificationCenterStore = create<NotificationCenterState>((set,
|
||||
body: input.body,
|
||||
...(input.pluginId ? { pluginId: input.pluginId, pluginName: input.pluginName ?? input.pluginId } : {}),
|
||||
...(input.tag ? { tag: input.tag } : {}),
|
||||
...(input.action && isLink(input.action) ? { action: { label: input.action.label, url: input.action.url } } : {}),
|
||||
read: false,
|
||||
};
|
||||
const entries = [entry, ...get().entries].slice(0, MAX_ENTRIES);
|
||||
|
||||
Reference in New Issue
Block a user