mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
Some user-facing strings still said "Bulwark" on a deployment with its own
APP_NAME: Settings > General > Default apps (protocol handlers), the "Open
Bulwark" notification, the rate-limit banner and toast, the plugin connect
dialog, one plugin permission text and the connector page.
These strings now take an {appName} placeholder, filled by productName():
the configured APP_NAME, or "Bulwark" when it is unset or the default
"Webmail" (the same rule as the TOTP issuer and the About card, #1160).
A stock install reads as before.
The settings search (the settings page and api.app.searchSettings) indexes
these texts with the name filled in, so "bulwark" or the configured name
finds Default apps and no result carries the raw placeholder.
Locales: the 13 keys in every locale that has "Bulwark" in them. Languages
that inflect the name (cs, et, hu, ko, ro, sk, tr) use a neutral form such
as "{appName} uygulamasında" instead of a declined "Bulwark'ta"; a native
check of those is welcome.
Not changed on purpose: the mobile app, push relay, Lite, demo and admin
texts, which speak about Bulwark itself.
Closes #1314
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
102 lines
3.7 KiB
TypeScript
102 lines
3.7 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { useConfig } from '@/hooks/use-config';
|
|
import { resolveTarget, type ResolveResult } from '@/lib/connector/registry';
|
|
import { appPath } from '@/lib/deep-links';
|
|
import { replaceWindowLocation } from '@/lib/browser-navigation';
|
|
import { productName } from '@/lib/product-name';
|
|
|
|
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '';
|
|
|
|
/**
|
|
* Resolves a connector link and sends the browser to the real surface.
|
|
*
|
|
* Everything past the target name is read from `window.location` rather than
|
|
* handed down as a route prop: the Lite build is a static export with no
|
|
* server to read a query string, and on a server build it keeps the target out
|
|
* of the request the origin sees.
|
|
*
|
|
* It replaces rather than pushes, so Back returns to wherever the link was
|
|
* clicked instead of bouncing through here again.
|
|
*
|
|
* Authentication needs nothing special. The surface the user lands on does its
|
|
* own `saveRedirectAfterLogin()` + `redirectToLogin()`, and by then the
|
|
* address bar already holds the resolved path, so the login round-trip returns
|
|
* to the deep link. The admin area is the exception - see `?next=` below.
|
|
*/
|
|
export function ConnectorClient({ target }: { target: string }) {
|
|
const [result, setResult] = useState<ResolveResult | null>(null);
|
|
const name = productName(useConfig().appName);
|
|
|
|
useEffect(() => {
|
|
setResult(resolveTarget(target, new URLSearchParams(window.location.search)));
|
|
}, [target]);
|
|
|
|
const destination = useMemo(() => {
|
|
if (result?.kind !== 'ok') return null;
|
|
return { url: appPath(result.resolution.path) };
|
|
}, [result]);
|
|
|
|
useEffect(() => {
|
|
if (!destination) return;
|
|
// Nothing is appended to the URL. An admin who is not signed in gets
|
|
// bounced by the admin layout, which parks this destination in its own
|
|
// `?next=` - adding one here would nest inside that one and be refused.
|
|
replaceWindowLocation(destination.url);
|
|
}, [destination]);
|
|
|
|
if (result === null || result.kind === 'ok') {
|
|
return <Shell>Opening…</Shell>;
|
|
}
|
|
|
|
// Neither case 404s. A link that this version cannot honour should say so
|
|
// and offer the app, not look like a broken server.
|
|
if (result.kind === 'unknown-target') {
|
|
return (
|
|
<Shell>
|
|
<h1 className="text-xl font-medium text-foreground">This link is newer than this {name}</h1>
|
|
<p className="mt-3 text-sm text-muted-foreground">
|
|
It opens something {APP_VERSION ? `version ${APP_VERSION} ` : 'this version '}
|
|
does not know about. Updating {name} should teach it.
|
|
</p>
|
|
<Actions name={name} />
|
|
</Shell>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Shell>
|
|
<h1 className="text-xl font-medium text-foreground">That link is not quite right</h1>
|
|
<p className="mt-3 text-sm text-muted-foreground">
|
|
It asks for {result.target.label}, but its{' '}
|
|
{/* The parameter is named, never echoed: its value is attacker-controlled. */}
|
|
<code className="rounded bg-muted px-1 py-0.5">{result.param}</code> is missing or
|
|
malformed. Nothing was opened.
|
|
</p>
|
|
<Actions name={name} />
|
|
</Shell>
|
|
);
|
|
}
|
|
|
|
function Actions({ name }: { name: string }) {
|
|
return (
|
|
<p className="mt-6">
|
|
<a
|
|
href={appPath('/mail')}
|
|
className="inline-flex h-9 items-center rounded-sm border border-border px-4 text-sm hover:bg-muted"
|
|
>
|
|
Open {name}
|
|
</a>
|
|
</p>
|
|
);
|
|
}
|
|
|
|
function Shell({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex min-h-dvh items-center justify-center bg-background p-6">
|
|
<div className="w-full max-w-md text-center text-muted-foreground">{children}</div>
|
|
</div>
|
|
);
|
|
}
|