@@ -457,6 +501,7 @@ export default defineComponent({
const serverAddressLabel = computed(() => t("connect.serverAddress"));
const serverPortLabel = computed(() => t("connect.serverPort"));
const channelsPlaceholder = computed(() => t("connect.channelsPlaceholder"));
+ const serverPasswordHelp = computed(() => t("form.serverPasswordHelp"));
const tls = network?.tls ?? defaults?.tls ?? true;
// The server the deploy points at. Pinned when the host is locked.
const server = {
@@ -474,6 +519,7 @@ export default defineComponent({
sasl: defaults?.sasl === "plain" ? "plain" : "",
saslAccount: defaults?.saslAccount || "",
saslPassword: defaults?.saslPassword || "",
+ serverPassword: "",
});
// `lockHost` hides the server fields; `allowCustomServer: false` does the
@@ -503,6 +549,7 @@ export default defineComponent({
const rememberMe = ref(false);
const showSasl = ref(false);
+ const showServerPassword = ref(false);
const rememberPassword = ref(false);
const autoconnect = ref(false);
/** Push defaults to on for a new network: the flag means "register when
@@ -535,6 +582,7 @@ export default defineComponent({
const submitted = ref(null);
const notice = ref("");
const passwordInput = ref(null);
+ const serverPasswordInput = ref(null);
const prefill = (net: SavedNetwork) => {
form.host = net.host;
@@ -546,6 +594,8 @@ export default defineComponent({
form.saslAccount = net.saslAccount;
form.saslPassword = net.saslPassword;
showSasl.value = net.sasl === "plain";
+ form.serverPassword = net.serverPassword ?? "";
+ showServerPassword.value = !!net.hasServerPassword;
rememberPassword.value = !!net.rememberPassword;
autoconnect.value = !!net.autoconnect;
pushEnabled.value = net.pushEnabled !== false;
@@ -567,7 +617,7 @@ export default defineComponent({
const savedLink = savedLinkUuid ? saved.get(savedLinkUuid) : undefined;
const linkIgnored = firstParam(props.queryParams?.linkIgnored);
const fromLink = isTruthyParam(props.queryParams?.fromLink);
- let focusPassword = false;
+ let focusPassword: "sasl" | "server" | false = false;
if (savedLink) {
prefill(savedLink);
@@ -579,7 +629,10 @@ export default defineComponent({
if (savedLink.sasl === "plain" && !savedLink.saslPassword) {
notice.value = t("connect.enterPassword", {account: savedLink.saslAccount});
- focusPassword = true;
+ focusPassword = "sasl";
+ } else if (savedLink.hasServerPassword && !savedLink.serverPassword) {
+ notice.value = t("connect.enterServerPassword", {host: savedLink.host});
+ focusPassword = "server";
}
} else if (hasConnectParams) {
applyQueryParams(form, props.queryParams);
@@ -660,6 +713,7 @@ export default defineComponent({
const client = createNetwork({
...submitted.value,
uuid: (signInMode ? signInUuid() : selectedUuid.value) ?? undefined,
+ hasServerPassword: !!submitted.value.serverPassword,
pushEnabled: pushEnabled.value,
notifyEnabled: notifyEnabled.value,
...options,
@@ -713,8 +767,13 @@ export default defineComponent({
form.saslPassword = "";
}
+ if (!showServerPassword.value) {
+ form.serverPassword = "";
+ }
+
start({
- rememberPassword: showSasl.value && rememberPassword.value,
+ rememberPassword:
+ (showSasl.value || showServerPassword.value) && rememberPassword.value,
autoconnect: autoconnect.value,
});
};
@@ -741,8 +800,10 @@ export default defineComponent({
onMounted(() => {
// A link to a saved network whose password was not remembered:
// everything is filled in but the password, so put the cursor there.
- if (focusPassword) {
+ if (focusPassword === "sasl") {
passwordInput.value?.focus();
+ } else if (focusPassword === "server") {
+ serverPasswordInput.value?.focus();
}
});
@@ -764,6 +825,8 @@ export default defineComponent({
networkLabel,
showSavedNetworks,
showSasl,
+ showServerPassword,
+ serverPasswordHelp,
rememberPassword,
autoconnect,
pushEnabled,
@@ -775,6 +838,7 @@ export default defineComponent({
linkNotice,
fromLink,
passwordInput,
+ serverPasswordInput,
onSubmit,
cancelLink,
};
diff --git a/client/js/boot.ts b/client/js/boot.ts
index 8e0f76bd6..8983168ef 100644
--- a/client/js/boot.ts
+++ b/client/js/boot.ts
@@ -323,7 +323,7 @@ async function handleQueryParams(
// The connection would need a password nobody stored: back to the
// form, pre-filled from the saved entry, to type it.
- if (!live?.isConnected && entry.sasl === "plain" && !entry.saslPassword) {
+ if (!live?.isConnected && saved.needsPassword(entry)) {
await router.push({
name: "Connect",
query: {savedLink: entry.uuid, join: suggestion.join, fromLink: "1"},
diff --git a/client/js/helpers/linkTarget.ts b/client/js/helpers/linkTarget.ts
index 935272230..793e82f9c 100644
--- a/client/js/helpers/linkTarget.ts
+++ b/client/js/helpers/linkTarget.ts
@@ -16,7 +16,8 @@
* names some other host → refuse it, with a message;
* - anything else → the connect form, pre-filled, for the user to approve.
*
- * Secrets never ride along: a URL cannot supply `saslPassword`, and
+ * Secrets never ride along: a URL cannot supply `saslPassword` or
+ * `serverPassword` (the allow-list below), and
* `autoconnect` is not honoured from URLs at all — the user must click.
*
* Kept free of Vue/store/DOM imports so mocha covers it
diff --git a/client/js/helpers/settingsBackup.ts b/client/js/helpers/settingsBackup.ts
index 041d6223b..211950ddf 100644
--- a/client/js/helpers/settingsBackup.ts
+++ b/client/js/helpers/settingsBackup.ts
@@ -127,7 +127,7 @@ function storedBackupKeys(): string[] {
}
export interface CollectOptions {
- /** Keep `saslPassword` on the saved networks; off strips it. */
+ /** Keep `saslPassword` and `serverPassword` on the saved networks; off strips them. */
includePasswords?: boolean;
/** The live settings object; storage only holds `settings` once one has
* been changed, so the caller passes the store's, defaults and all. */
@@ -166,8 +166,9 @@ export function collectBackup(options: CollectOptions = {}): SettingsBackup {
return net;
}
- const {saslPassword, ...rest} = net as Record;
+ const {saslPassword, serverPassword, ...rest} = net as Record;
void saslPassword;
+ void serverPassword;
return {...rest, rememberPassword: false};
});
}
@@ -202,8 +203,10 @@ export function hasPasswords(backup: SettingsBackup): boolean {
(net) =>
typeof net === "object" &&
net !== null &&
- typeof (net as Record).saslPassword === "string" &&
- (net as Record).saslPassword !== ""
+ ["saslPassword", "serverPassword"].some((key) => {
+ const value = (net as Record)[key];
+ return typeof value === "string" && value !== "";
+ })
)
);
}
diff --git a/client/js/i18n/call-sites.ts b/client/js/i18n/call-sites.ts
index a9b279f50..9bbc148df 100644
--- a/client/js/i18n/call-sites.ts
+++ b/client/js/i18n/call-sites.ts
@@ -169,6 +169,7 @@ export const STATIC_CALL_SITES = new Set([
"connect.disconnected",
"connect.enabledCaps",
"connect.enterPassword",
+ "connect.enterServerPassword",
"connect.guestNickRequired",
"connect.guestSubmit",
"connect.guestTitle",
@@ -205,6 +206,9 @@ export const STATIC_CALL_SITES = new Set([
"connect.saslTrustedGaveUpHint",
"connect.server",
"connect.serverAddress",
+ "connect.serverPasswordHint",
+ "connect.serverPasswordNeeded",
+ "connect.serverPasswordRefused",
"connect.serverPort",
"connect.signInIntro",
"connect.signInSubmit",
@@ -314,6 +318,9 @@ export const STATIC_CALL_SITES = new Set([
"form.save",
"form.server",
"form.serverAddress",
+ "form.serverPassword",
+ "form.serverPasswordHelp",
+ "form.serverPasswordToggle",
"form.serverPort",
"form.servicesAccount",
"form.servicesAccountPlaceholder",
diff --git a/client/js/irc/client.ts b/client/js/irc/client.ts
index c28ce3589..9f4890f94 100644
--- a/client/js/irc/client.ts
+++ b/client/js/irc/client.ts
@@ -294,6 +294,8 @@ export interface IrcClientOptions extends ConnectOptions {
* way back to the connect screen to fix them.
*/
onSaslRejected?: () => void;
+ /** The server password was missing or refused (464 before 001): same effect. */
+ onServerPasswordRejected?: () => void;
}
/** What to try after a SASL login the deploy insists on did not happen. */
@@ -911,6 +913,8 @@ export class IrcClient {
sasl: next.sasl,
saslAccount: next.saslAccount,
saslPassword: next.saslPassword,
+ // Undefined: configured but not kept on disk, so keep the one in memory.
+ serverPassword: next.serverPassword ?? this.options.serverPassword,
};
const state = this.transport.state;
@@ -1202,6 +1206,11 @@ export class IrcClient {
this.send(line);
}
+ // Before NICK/USER, as registration requires; trailing, so spaces survive.
+ if (this.options.serverPassword) {
+ this.send(trailingLine("PASS", [this.options.serverPassword]));
+ }
+
this.send(formatLine({command: "NICK", params: [this.nick]}));
this.send(trailingLine("USER", [this.ident, "0", "*", this.options.realname || this.nick]));
}
@@ -1343,6 +1352,40 @@ export class IrcClient {
return true;
}
+ /**
+ * 464 before 001: the server (often a bouncer: ZNC answers a client with
+ * no PASS this way, then waits) wants a server password, or refused the
+ * one sent. Every connection would get the same answer, so this one
+ * stops, says why, and the entry stops connecting by itself. After 001 a
+ * 464 is an ordinary error (OPER with the wrong password).
+ */
+ serverPasswordFailed(): void {
+ if (this.welcomed || this.isQuitting) {
+ return;
+ }
+
+ const host = this.options.host;
+ const sent = !!this.options.serverPassword;
+ this.pushMessage(
+ this.lobby,
+ {
+ type: MessageType.ERROR,
+ text: sent
+ ? t("connect.serverPasswordRefused", {host})
+ : t("connect.serverPasswordNeeded", {host}),
+ },
+ true
+ );
+ this.pushMessage(this.lobby, {text: t("connect.serverPasswordHint")}, true);
+ this.closeExplained = true;
+ this.bus.dispatch("network:aborted", {
+ network: this.uuid,
+ reason: sent ? "server password refused" : "server password required",
+ });
+ this.options.onServerPasswordRejected?.();
+ this.disconnect(sent ? "Server password refused" : "Server password required");
+ }
+
/** Whether a SASL failure of this kind is worth another connection (see saslFailed). */
private saslRetries(cause: SaslFailureCause, trusted: boolean): boolean {
switch (cause) {
diff --git a/client/js/irc/handlers/numerics.ts b/client/js/irc/handlers/numerics.ts
index 98ad708c8..223fc6373 100644
--- a/client/js/irc/handlers/numerics.ts
+++ b/client/js/irc/handlers/numerics.ts
@@ -261,6 +261,12 @@ export const numericError: Handler = (client, msg) => {
pushError(client, error, error.channel);
};
+/** ERR_PASSWDMISMATCH: shown as any error; before 001 it also ends the connect. */
+const passwordMismatch: Handler = (client, msg) => {
+ numericError(client, msg);
+ client.serverPasswordFailed();
+};
+
export default {
"001": welcome,
"002": infoLine,
@@ -284,4 +290,5 @@ export default {
"396": hostHidden,
"432": badNick,
"433": badNick,
+ "464": passwordMismatch,
};
diff --git a/client/js/irc/manager.ts b/client/js/irc/manager.ts
index 6bc07a094..87f6116fa 100644
--- a/client/js/irc/manager.ts
+++ b/client/js/irc/manager.ts
@@ -54,6 +54,7 @@ export type CreateNetworkOptions = ConnectOptions &
| "pushEnabled"
| "notifyEnabled"
| "commands"
+ | "hasServerPassword"
>
>;
@@ -101,6 +102,15 @@ function rememberNetwork(options: CreateNetworkOptions): SavedNetwork {
return entry;
}
+/** A refused login: the saved entry stops connecting at page load (see createNetwork). */
+function stopAutoconnect(uuid: string): void {
+ const current = saved.get(uuid);
+
+ if (current?.autoconnect) {
+ saved.save({...current, autoconnect: false});
+ }
+}
+
/** Create (or reuse) the client for these options, save the config and connect. */
export function createNetwork(options: CreateNetworkOptions): IrcClient {
const entry = rememberNetwork(options);
@@ -125,13 +135,8 @@ export function createNetwork(options: CreateNetworkOptions): IrcClient {
// credentials would be refused again at every page load, and a
// network that never registers is not one the user can get back
// to the connect screen from.
- onSaslRejected() {
- const current = saved.get(entry.uuid);
-
- if (current?.autoconnect) {
- saved.save({...current, autoconnect: false});
- }
- },
+ onSaslRejected: () => stopAutoconnect(entry.uuid),
+ onServerPasswordRejected: () => stopAutoconnect(entry.uuid),
});
clients.set(entry.uuid, client);
// Load the network's ignore list now, which mirrors it for the push
@@ -193,7 +198,8 @@ export function autoconnectSavedNetworks(): IrcClient[] {
continue;
}
- if (entry.sasl === "plain" && !entry.saslPassword) {
+ // A password it needs was not kept: it waits for the connect form.
+ if (saved.needsPassword(entry)) {
continue;
}
diff --git a/client/js/irc/saved-networks.ts b/client/js/irc/saved-networks.ts
index 1bd6f80a5..a29bde7cb 100644
--- a/client/js/irc/saved-networks.ts
+++ b/client/js/irc/saved-networks.ts
@@ -7,8 +7,10 @@
* because network settings and sort, mute and collapse preferences are keyed
* by it (see docs/resources/bus-contract.md §"Store expectations").
*
- * The SASL password is only written to disk when `rememberPassword` is set;
- * otherwise `saslPassword` is stripped on save and the user is asked again.
+ * The SASL and server passwords are only written to disk when
+ * `rememberPassword` is set; otherwise `saslPassword` and `serverPassword`
+ * are stripped on save and the user is asked again (`hasServerPassword`
+ * remembers that there was one to ask for).
*
* Kept free of store imports so it runs under mocha; tests swap the storage
* backend with {@link useStorageBackend}.
@@ -51,8 +53,11 @@ export type SavedNetwork = ConnectOptions & {
name: string;
/** Connect at startup without visiting the connect form. */
autoconnect?: boolean;
- /** Persist `saslPassword`. Off by default so the password never hits disk. */
+ /** Persist `saslPassword` and `serverPassword`. Off by default so no password hits disk. */
rememberPassword?: boolean;
+ /** A server password is configured, even when it was not kept on disk:
+ * the connect form asks for it again (see {@link needsPassword}). */
+ hasServerPassword?: boolean;
/** Register this device's Web Push subscription with this network
* (`draft/webpush`). Entries stored before the flag existed read as
* enabled — push was unconditional then. */
@@ -266,6 +271,17 @@ export function normalize(raw: Record): SavedNetwork | undefine
commands: parseCommands(raw.commands),
};
+ // A line break or NUL would end the PASS line early (wire.ts refuses it).
+ const serverPassword = asString(raw.serverPassword).replace(/[\r\n\0]/g, "");
+
+ if (serverPassword) {
+ net.serverPassword = serverPassword;
+ }
+
+ if (serverPassword || asBoolean(raw.hasServerPassword)) {
+ net.hasServerPassword = true;
+ }
+
if (typeof raw.lastUsed === "number" && Number.isFinite(raw.lastUsed)) {
net.lastUsed = raw.lastUsed;
}
@@ -360,7 +376,14 @@ export function fromForm(data: Record, existing?: SavedNetwork)
}
}
- for (const flag of ["tls", "autoconnect", "rememberPassword", "pushEnabled", "notifyEnabled"]) {
+ for (const flag of [
+ "tls",
+ "autoconnect",
+ "rememberPassword",
+ "pushEnabled",
+ "notifyEnabled",
+ "hasServerPassword",
+ ]) {
if (!(flag in data)) {
base[flag] = false;
}
@@ -371,6 +394,14 @@ export function fromForm(data: Record, existing?: SavedNetwork)
base.saslPassword = existing.saslPassword;
}
+ // The server password's switch off clears it; on with the field blank
+ // keeps the stored one, as for SASL.
+ if (!base.hasServerPassword) {
+ delete base.serverPassword;
+ } else if (asString(data.serverPassword) === "" && existing?.serverPassword) {
+ base.serverPassword = existing.serverPassword;
+ }
+
// The services block travels whole: the key present with nothing in it
// is "None" (the loop above keeps the stored one for a missing key).
if ("services" in data) {
@@ -397,9 +428,23 @@ export function toConnectOptions(net: SavedNetwork): ConnectOptions {
sasl: net.sasl,
saslAccount: net.saslAccount,
saslPassword: net.saslPassword,
+ // Configured but not on disk: undefined, so a live client keeps the
+ // one it was given (IrcClient.applySettings).
+ serverPassword: net.serverPassword ?? (net.hasServerPassword ? undefined : ""),
};
}
+/**
+ * The entry cannot connect as saved: a password it needs was not kept on
+ * disk (`rememberPassword` off), so it waits for the connect form.
+ */
+export function needsPassword(net: SavedNetwork): boolean {
+ return (
+ (net.sasl === "plain" && !net.saslPassword) ||
+ (!!net.hasServerPassword && !net.serverPassword)
+ );
+}
+
// --------------------------------------------------------------- storage
function read(): SavedNetwork[] {
@@ -438,7 +483,13 @@ function write(entries: SavedNetwork[]): void {
/** Never let a password reach disk unless the user asked for it. */
function forStorage(net: SavedNetwork): SavedNetwork {
- return net.rememberPassword ? {...net} : {...net, saslPassword: ""};
+ if (net.rememberPassword) {
+ return {...net};
+ }
+
+ const {serverPassword: _dropped, ...rest} = net;
+ void _dropped;
+ return {...rest, saslPassword: ""};
}
function byRecency(a: SavedNetwork, b: SavedNetwork): number {
diff --git a/client/js/irc/types.ts b/client/js/irc/types.ts
index 66b2b6825..17a3d9c99 100644
--- a/client/js/irc/types.ts
+++ b/client/js/irc/types.ts
@@ -47,6 +47,12 @@ export type ConnectOptions = {
sasl: "" | "plain" | "external";
saslAccount: string;
saslPassword: string;
+ /**
+ * Server password, sent as `PASS` before `NICK`/`USER` when non-empty.
+ * Bouncers that predate client SASL want it (ZNC: `user/network:password`);
+ * independent of SASL, and both are sent when both are set.
+ */
+ serverPassword?: string;
/**
* Drop the connection when a configured SASL login does not succeed,
* instead of carrying on unauthenticated. Unset means "ask the deploy":
diff --git a/client/locales/en.json b/client/locales/en.json
index 5f1fb734b..ddab00f94 100644
--- a/client/locales/en.json
+++ b/client/locales/en.json
@@ -211,6 +211,7 @@
"connect.disconnected": "Disconnected.",
"connect.enabledCaps": "Enabled capabilities: {caps}",
"connect.enterPassword": "Enter the password for {account} to connect.",
+ "connect.enterServerPassword": "Enter the server password for {host}.",
"connect.guestNickRequired": "Choose a nick to connect as a guest.",
"connect.guestSubmit": "Connect as guest",
"connect.guestTitle": "No account?",
@@ -247,6 +248,9 @@
"connect.saslTrustedGaveUpHint": "This login has worked here before, so it is kept: connecting again tries it once more. If the password has changed, update it in this network's settings.",
"connect.server": "Server",
"connect.serverAddress": "Server address",
+ "connect.serverPasswordHint": "Set the server password in this network's settings. For ZNC it is username/network:password.",
+ "connect.serverPasswordNeeded": "{host} needs a server password before you can connect.",
+ "connect.serverPasswordRefused": "{host} refused the server password.",
"connect.serverPort": "Server port",
"connect.signInIntro": "",
"connect.signInSubmit": "Sign in",
@@ -356,6 +360,9 @@
"form.save": "Save network",
"form.server": "Server",
"form.serverAddress": "Server address",
+ "form.serverPassword": "Server password",
+ "form.serverPasswordHelp": "Only if the server or bouncer asks for one. For ZNC, enter username/network:password.",
+ "form.serverPasswordToggle": "This server needs a password",
"form.serverPort": "Server port",
"form.servicesAccount": "Account service",
"form.servicesAccountPlaceholder": "The bot that holds accounts",
diff --git a/client/locales/messages.pot b/client/locales/messages.pot
index 0690faf14..1ffae3444 100644
--- a/client/locales/messages.pot
+++ b/client/locales/messages.pot
@@ -1030,6 +1030,12 @@ msgctxt "connect.enterPassword"
msgid "Enter the password for {account} to connect."
msgstr ""
+#. Connect form notice when a link opens a saved network whose server password was not remembered. {host} is the server's host name.
+#: client/components/Windows/Connect.vue
+msgctxt "connect.enterServerPassword"
+msgid "Enter the server password for {host}."
+msgstr ""
+
#. Error notice shown under the guest sign-in form when the guest nick field was left empty or invalid.
#: client/components/Windows/Connect.vue:672
msgctxt "connect.guestNickRequired"
@@ -1251,6 +1257,24 @@ msgctxt "connect.serverAddress"
msgid "Server address"
msgstr ""
+#. Lobby hint after connect.serverPasswordNeeded / connect.serverPasswordRefused. 'username/network:password' is literal syntax: keep it untranslated.
+#: client/js/irc/client.ts
+msgctxt "connect.serverPasswordHint"
+msgid "Set the server password in this network's settings. For ZNC it is username/network:password."
+msgstr ""
+
+#. Lobby error: the server answered 464 (password required) during registration and no server password was set; the connection stopped. {host} is the configured host name.
+#: client/js/irc/client.ts
+msgctxt "connect.serverPasswordNeeded"
+msgid "{host} needs a server password before you can connect."
+msgstr ""
+
+#. Lobby error: the server answered 464 (password mismatch) to the server password that was sent; the connection stopped. {host} is the configured host name.
+#: client/js/irc/client.ts
+msgctxt "connect.serverPasswordRefused"
+msgid "{host} refused the server password."
+msgstr ""
+
#. aria-label of the numeric port input next to the server host field on the connect form.
#: client/components/Windows/Connect.vue:156
msgctxt "connect.serverPort"
@@ -1905,6 +1929,27 @@ msgctxt "form.serverAddress"
msgid "Server address"
msgstr ""
+#. Label of the server password field (IRC PASS), separate from the services account password.
+#: client/components/NetworkForm.vue
+#: client/components/Windows/Connect.vue
+msgctxt "form.serverPassword"
+msgid "Server password"
+msgstr ""
+
+#. Tooltip on the server password switch. 'username/network:password' is literal syntax the user types: keep it untranslated.
+#: client/components/NetworkForm.vue
+#: client/components/Windows/Connect.vue
+msgctxt "form.serverPasswordHelp"
+msgid "Only if the server or bouncer asks for one. For ZNC, enter username/network:password."
+msgstr ""
+
+#. Switch on the connect form and in a network's settings that shows the server password field (sent as PASS, before the login). Bouncers such as ZNC ask for it.
+#: client/components/NetworkForm.vue
+#: client/components/Windows/Connect.vue
+msgctxt "form.serverPasswordToggle"
+msgid "This server needs a password"
+msgstr ""
+
#. aria-label of the numeric port input next to the server host field on the network edit form.
#: client/components/NetworkForm.vue:76
msgctxt "form.serverPort"
diff --git a/client/locales/qqx.json b/client/locales/qqx.json
index 472cebd15..d0d0b3d92 100644
--- a/client/locales/qqx.json
+++ b/client/locales/qqx.json
@@ -211,6 +211,7 @@
"connect.disconnected": "ḋïśċöññéċţéḋ.ḋïśċöññéċţéḋ.",
"connect.enabledCaps": "éñàḃľéḋ ċàṗàḃïľïţïéś: {caps}éñàḃľéḋ ċàṗàḃïľïţïéś: {caps}",
"connect.enterPassword": "éñţéŕ ţḣé ṗàśśẃöŕḋ ḟöŕ {account} ţö ċöññéċţ.éñţéŕ ţḣé ṗàśśẃöŕḋ ḟöŕ {account} ţö ċöññéċţ.",
+ "connect.enterServerPassword": "éñţéŕ ţḣé śéŕṽéŕ ṗàśśẃöŕḋ ḟöŕ {host}.éñţéŕ ţḣé śéŕṽéŕ ṗàśśẃöŕḋ ḟöŕ {host}.",
"connect.guestNickRequired": "ċḣööśé à ñïċķ ţö ċöññéċţ àś à ġüéśţ.ċḣööśé à ñïċķ ţö ċöññéċţ àś à ġüéśţ.",
"connect.guestSubmit": "ċöññéċţ àś ġüéśţċöññéċţ àś ġüéśţ",
"connect.guestTitle": "ñö àċċöüñţ?ñö àċċöüñţ?",
@@ -247,6 +248,9 @@
"connect.saslTrustedGaveUpHint": "ţḣïś ľöġïñ ḣàś ẃöŕķéḋ ḣéŕé ḃéḟöŕé, śö ïţ ïś ķéṗţ: ċöññéċţïñġ àġàïñ ţŕïéś ïţ öñċé ṁöŕé. ïḟ ţḣé ṗàśśẃöŕḋ ḣàś ċḣàñġéḋ, üṗḋàţé ïţ ïñ ţḣïś ñéţẃöŕķ'ś śéţţïñġś.ţḣïś ľöġïñ ḣàś ẃöŕķéḋ ḣéŕé ḃéḟöŕé, śö ïţ ïś ķéṗţ: ċöññéċţïñġ àġàïñ ţŕïéś ïţ öñċé ṁöŕé. ïḟ ţḣé ṗàśśẃöŕḋ ḣàś ċḣàñġéḋ, üṗḋàţé ïţ ïñ ţḣïś ñéţẃöŕķ'ś śéţţïñġś.",
"connect.server": "śéŕṽéŕśéŕṽéŕ",
"connect.serverAddress": "śéŕṽéŕ àḋḋŕéśśśéŕṽéŕ àḋḋŕéśś",
+ "connect.serverPasswordHint": "śéţ ţḣé śéŕṽéŕ ṗàśśẃöŕḋ ïñ ţḣïś ñéţẃöŕķ'ś śéţţïñġś. ḟöŕ źñċ ïţ ïś üśéŕñàṁé/ñéţẃöŕķ:ṗàśśẃöŕḋ.śéţ ţḣé śéŕṽéŕ ṗàśśẃöŕḋ ïñ ţḣïś ñéţẃöŕķ'ś śéţţïñġś. ḟöŕ źñċ ïţ ïś üśéŕñàṁé/ñéţẃöŕķ:ṗàśśẃöŕḋ.",
+ "connect.serverPasswordNeeded": "{host} ñééḋś à śéŕṽéŕ ṗàśśẃöŕḋ ḃéḟöŕé ýöü ċàñ ċöññéċţ.{host} ñééḋś à śéŕṽéŕ ṗàśśẃöŕḋ ḃéḟöŕé ýöü ċàñ ċöññéċţ.",
+ "connect.serverPasswordRefused": "{host} ŕéḟüśéḋ ţḣé śéŕṽéŕ ṗàśśẃöŕḋ.{host} ŕéḟüśéḋ ţḣé śéŕṽéŕ ṗàśśẃöŕḋ.",
"connect.serverPort": "śéŕṽéŕ ṗöŕţśéŕṽéŕ ṗöŕţ",
"connect.signInSubmit": "śïġñ ïñśïġñ ïñ",
"connect.signInTitle": "śïġñ ïñśïġñ ïñ",
@@ -355,6 +359,9 @@
"form.save": "śàṽé ñéţẃöŕķśàṽé ñéţẃöŕķ",
"form.server": "śéŕṽéŕśéŕṽéŕ",
"form.serverAddress": "śéŕṽéŕ àḋḋŕéśśśéŕṽéŕ àḋḋŕéśś",
+ "form.serverPassword": "śéŕṽéŕ ṗàśśẃöŕḋśéŕṽéŕ ṗàśśẃöŕḋ",
+ "form.serverPasswordHelp": "öñľý ïḟ ţḣé śéŕṽéŕ öŕ ḃöüñċéŕ àśķś ḟöŕ öñé. ḟöŕ źñċ, éñţéŕ üśéŕñàṁé/ñéţẃöŕķ:ṗàśśẃöŕḋ.öñľý ïḟ ţḣé śéŕṽéŕ öŕ ḃöüñċéŕ àśķś ḟöŕ öñé. ḟöŕ źñċ, éñţéŕ üśéŕñàṁé/ñéţẃöŕķ:ṗàśśẃöŕḋ.",
+ "form.serverPasswordToggle": "ţḣïś śéŕṽéŕ ñééḋś à ṗàśśẃöŕḋţḣïś śéŕṽéŕ ñééḋś à ṗàśśẃöŕḋ",
"form.serverPort": "śéŕṽéŕ ṗöŕţśéŕṽéŕ ṗöŕţ",
"form.servicesAccount": "àċċöüñţ śéŕṽïċéàċċöüñţ śéŕṽïċé",
"form.servicesAccountPlaceholder": "ţḣé ḃöţ ţḣàţ ḣöľḋś àċċöüñţśţḣé ḃöţ ţḣàţ ḣöľḋś àċċöüñţś",
diff --git a/docs/resources/client-features.md b/docs/resources/client-features.md
index bff9bc475..ee7fec871 100644
--- a/docs/resources/client-features.md
+++ b/docs/resources/client-features.md
@@ -88,6 +88,10 @@ How the parts of the client behave and why, moved out of `CLAUDE.md` on 2026-11-
**File uploads** — `client/js/upload.ts` (`Uploader`, Vue-free behind the `UploadHost` interface, `test/irc/upload.ts`) posts straight to the deploy's `uploads` endpoint from `config.json` — unless the network advertises its own host (`draft/FILEHOST` ISUPPORT → `IrcClient.filehostUrl()`, kept on `serverOptions.FILEHOST`): then `UploadHost.filehost()` wins and `uploadFilehost` does a `TOKEN GENERATE FILEHOST #chan` (`irc/authtoken.ts` `TokenRequests`, answered by `handlers/token.ts` / `FAIL TOKEN` in `standard-replies.ts`) followed by a raw `POST` with `Authorization: Bearer` expecting `201` + `Location`; `test/irc/authtoken.ts`. `upload.ts` must stay free of `irc/manager` (it pulls in `document`), so `ChatInput.vue` hands `clientForNetwork` to `upload.mounted`. Every way in (drop, paste, the paperclip's `#upload-input`) lands in `triggerUpload`, which checks size/type, then **asks before sending**: `UploadHost.confirm(files)` → the `upload:confirm` eventbus request that `UploadPreview.vue` (mounted in `App.vue`) answers with the files the user kept. Progress is real (`XMLHttpRequest` via `xhrFetch`, wrapped into a `Response`; `UploadHost.progress` → `store.state.uploadProgress` → the `.upload-bar` strip above the input in `ChatInput.vue`, with cancel = `Uploader.abort()`, which is silent) — **but an upload listener forces a CORS preflight**, which curl-oriented uploaders (litterbox: `OPTIONS` → 405) refuse, so `xhrSend` retries a request that died before any progress as a plain POST and `Uploader.progressBlocked` remembers the endpoint for the page (indeterminate bar); a preset or deploy that knows better sets `uploads.progress: false` (litterbox does) and no preflight is ever tried. Never add headers or listeners to the upload request without checking the endpoint answers `OPTIONS`. The "remove metadata" setting re-encodes through a canvas, which keeps one frame, so `metadataPlan()` exempts GIF/SVG and anything `helpers/animatedImage.ts` sniffs as animated (WebP `VP8X` flag, PNG `acTL`, AVIF `avis`; `test/helpers/animatedImage.ts`); a re-encoded file takes the blob's real type and a matching extension (`fileNameForType`). See `docs/resources/branding.md` § Uploads.
+## Server password
+
+**Server password** — an optional `serverPassword` on a saved network (`ConnectOptions`, `irc/types.ts`), sent as `PASS :` after `CAP LS 302` and before `NICK`/`USER` (`IrcClient.onOpen`); nothing is sent when it is empty. It is for bouncers that predate client SASL: ZNC 1.9 and older answer a client with no PASS with `464` and wait (ZNC's form is `user/network:password`), and ZNC 1.10 takes SASL only with `saslplainauth` loaded. It is independent of SASL; both go out when both are set. It follows the SASL password's storage rules: on disk only with `rememberPassword` (`saved-networks.ts` `forStorage`), with `hasServerPassword` kept so `needsPassword()` sends a link or an autoconnect back to the connect form; stripped from a settings backup unless passwords are kept; never from a URL (`linkTarget.ts`). The push worker's own connection does not send it: none of the servers that need one offers a push Seance uses (`draft/webpush`); add it to the stash (`webpush.ts` `writeStash`) and `swIrcOpen` when one does. In the edit form the switch off clears it, and on with the field blank keeps the stored (or in-memory) one (`fromForm`, `toConnectOptions` → `undefined`, `IrcClient.applySettings`). A `464` before `001` stops the connection instead of retrying (`IrcClient.serverPasswordFailed`, `handlers/numerics.ts`): the lobby says whether a password was missing or refused, and the entry stops connecting by itself; after `001` a 464 (OPER) is an ordinary error. Tests: `test/irc/server-password.ts`, `test/irc/saved-networks.ts`, `test/helpers/settingsBackup.ts`.
+
## Settings backup
**Settings backup** — Settings → General → Backup and restore exports a gzipped JSON envelope (`.seance-settings`, `CompressionStream`, no dependency) of the preference keys in localStorage — `settings` (the store's live object, since the key is only written once a setting changes), saved networks (passwords stripped unless the box is ticked), sort orders, mutes, ignore lists, trusted media, recent reactions, command aliases, per-channel translation state (`thelounge.translate`), oper notice routing (`thelounge.oper`), do not disturb (`thelounge.dnd`), Lua scripts (`thelounge.scripts`, restored switched off) — and restoring one replaces exactly those keys and reloads (the file records the platform it was made on, and `matchSystemTextSize`/`matchSystemAppearance`, whose defaults differ between the shells and the web, take the restoring platform's default when they held only the source's: `helpers/platformDefaults.ts`); the envelope's `version` is `2` and `KEY_SINCE` says which key arrived when, so an older file leaves a key it could never have carried (`thelounge.translate`) alone rather than clearing it, and a newer file is refused (`thelounge.oper` and `thelounge.scripts` came upstream without a bump and count from 1); `thelounge.sts`, `thelounge.push*`, `thelounge.mentions`, `thelounge.querylog.*` and `thelounge.state.*` are never carried or written. `client/js/helpers/settingsBackup.ts` is Vue-free (`test/helpers/settingsBackup.ts`); browser check `tools/scenarios/settings-backup.mjs`. See `docs/resources/settings-backup.md`.
diff --git a/test/helpers/settingsBackup.ts b/test/helpers/settingsBackup.ts
index 83bf7de33..8535c8da4 100644
--- a/test/helpers/settingsBackup.ts
+++ b/test/helpers/settingsBackup.ts
@@ -142,6 +142,21 @@ describe("settings backup (helpers/settingsBackup.ts)", function () {
expect(keptNets[0].saslPassword).to.equal("hunter2");
});
+ it("strips a server password too, and counts it as a password", function () {
+ store.set(
+ "thelounge.networks",
+ JSON.stringify([{uuid: "z", host: "znc.example", serverPassword: "bob/rig:pw"}])
+ );
+
+ const stripped = collectBackup();
+ const nets = stripped.entries["thelounge.networks"] as Record[];
+ expect(nets[0]).to.not.have.property("serverPassword");
+ expect(hasPasswords(stripped)).to.equal(false);
+
+ const kept = collectBackup({includePasswords: true});
+ expect(hasPasswords(kept)).to.equal(true);
+ });
+
it("takes the live settings over the stored ones", function () {
seed();
const backup = collectBackup({settings: {theme: "day", coloredNicks: true}});
diff --git a/test/irc/saved-networks.ts b/test/irc/saved-networks.ts
index 3dccd97d7..1c6f808d2 100644
--- a/test/irc/saved-networks.ts
+++ b/test/irc/saved-networks.ts
@@ -421,6 +421,7 @@ describe("saved-networks", function () {
"sasl",
"saslAccount",
"saslPassword",
+ "serverPassword",
"tls",
]);
expect(saved.displayName(entry())).to.equal("irc.example.org");
@@ -534,4 +535,67 @@ describe("saved-networks", function () {
expect(saved.get(net.uuid)?.presenceMode).to.equal(undefined);
expect(saved.presenceModeOf(saved.get(net.uuid))).to.equal("global");
});
+ describe("server password", function () {
+ it("is kept on disk only with rememberPassword, and marked when it is not", function () {
+ saved.save(entry({serverPassword: "bob/rig:pw", rememberPassword: true}));
+ expect(saved.get(entry().uuid)?.serverPassword).to.equal("bob/rig:pw");
+ expect(saved.get(entry().uuid)?.hasServerPassword).to.equal(true);
+ expect(saved.needsPassword(saved.get(entry().uuid) as SavedNetwork)).to.equal(false);
+
+ saved.save(entry({serverPassword: "bob/rig:pw", rememberPassword: false}));
+ const raw = backend.get(saved.STORAGE_KEY) as string;
+ expect(raw).to.not.include("bob/rig:pw");
+ const stored = saved.get(entry().uuid) as SavedNetwork;
+ expect(stored).to.not.have.property("serverPassword");
+ expect(stored.hasServerPassword).to.equal(true);
+ expect(saved.needsPassword(stored)).to.equal(true);
+ });
+
+ it("is absent from an entry that never had one", function () {
+ const net = saved.normalize(entry()) as SavedNetwork;
+ expect(net).to.not.have.property("serverPassword");
+ expect(net).to.not.have.property("hasServerPassword");
+ expect(saved.needsPassword(net)).to.equal(false);
+ expect(saved.toConnectOptions(net).serverPassword).to.equal("");
+ });
+
+ it("drops CR, LF and NUL, which would end the PASS line", function () {
+ const net = saved.normalize(entry({serverPassword: "a\r\nb\0c"})) as SavedNetwork;
+ expect(net.serverPassword).to.equal("abc");
+ });
+
+ it("is undefined in the connect options when configured but not kept", function () {
+ const net = saved.normalize(entry({hasServerPassword: true})) as SavedNetwork;
+ expect(saved.toConnectOptions(net).serverPassword).to.equal(undefined);
+ expect(
+ saved.toConnectOptions(
+ saved.normalize(entry({serverPassword: "x"})) as SavedNetwork
+ ).serverPassword
+ ).to.equal("x");
+ });
+
+ it("edit form: the switch off clears it, on and blank keeps it", function () {
+ const existing = saved.normalize(
+ entry({serverPassword: "old", rememberPassword: true})
+ ) as SavedNetwork;
+
+ const kept = saved.fromForm(
+ {...existing, hasServerPassword: true, serverPassword: ""},
+ existing
+ );
+ expect(kept.serverPassword).to.equal("old");
+
+ const changed = saved.fromForm(
+ {...existing, hasServerPassword: true, serverPassword: "new"},
+ existing
+ );
+ expect(changed.serverPassword).to.equal("new");
+
+ const {hasServerPassword: _off, ...withoutSwitch} = existing;
+ void _off;
+ const cleared = saved.fromForm({...withoutSwitch, serverPassword: "old"}, existing);
+ expect(cleared).to.not.have.property("serverPassword");
+ expect(cleared).to.not.have.property("hasServerPassword");
+ });
+ });
});
diff --git a/test/irc/server-password.ts b/test/irc/server-password.ts
new file mode 100644
index 000000000..d80c06215
--- /dev/null
+++ b/test/irc/server-password.ts
@@ -0,0 +1,84 @@
+import {expect} from "chai";
+import sinon from "sinon";
+import {register, setup} from "./support";
+
+describe("server password (PASS)", function () {
+ it("goes out after CAP LS and before NICK and USER", function () {
+ const h = setup({serverPassword: "bob/rig:pass word"});
+ h.client.connect();
+ h.transport.open();
+
+ expect(h.transport.sent).to.deep.equal([
+ "CAP LS 302",
+ "PASS :bob/rig:pass word",
+ "NICK alice",
+ "USER alice 0 * :alice",
+ ]);
+ });
+
+ it("is not sent when empty", function () {
+ const h = setup({serverPassword: ""});
+ h.client.connect();
+ h.transport.open();
+
+ expect(h.transport.sent.some((l) => l.startsWith("PASS"))).to.equal(false);
+ });
+
+ it("464 during registration stops the connection and says why", function () {
+ const rejected = sinon.spy();
+ const h = setup({onServerPasswordRejected: rejected});
+ h.client.connect();
+ h.transport.open();
+ h.sent();
+ h.transport.line(":irc.znc.in 464 alice :Password required");
+
+ expect(h.sent()).to.deep.equal(["QUIT :Server password required"]);
+ expect(rejected.calledOnce).to.equal(true);
+ expect(h.payloads("network:aborted")).to.have.length(1);
+ const texts = h.messages().map((m) => m.text ?? "");
+ expect(texts.some((t) => /needs a server password/.test(t))).to.equal(true);
+ expect(h.transport.state).to.equal("closed");
+ });
+
+ it("464 to a password that was sent says it was refused", function () {
+ const h = setup({serverPassword: "wrong"});
+ h.client.connect();
+ h.transport.open();
+ h.transport.line(":irc.znc.in 464 alice :Invalid Password");
+
+ const texts = h.messages().map((m) => m.text ?? "");
+ expect(texts.some((t) => /refused the server password/.test(t))).to.equal(true);
+ expect(h.transport.sent).to.include("QUIT :Server password refused");
+ });
+
+ it("464 after 001 is an ordinary error (OPER with the wrong password)", function () {
+ const rejected = sinon.spy();
+ const h = setup({onServerPasswordRejected: rejected});
+ register(h);
+ h.transport.line(":irc.test 464 alice :Password incorrect");
+
+ expect(rejected.called).to.equal(false);
+ expect(h.payloads("network:aborted")).to.have.length(0);
+ expect(h.sent().some((l) => l.startsWith("QUIT"))).to.equal(false);
+ expect(h.lastMessage().error).to.equal("password_mismatch");
+ });
+
+ it("an edit that leaves it undefined keeps the one in memory", function () {
+ const h = setup({serverPassword: "kept"});
+ h.client.applySettings({
+ host: "irc.test",
+ port: 8443,
+ tls: true,
+ nick: "alice",
+ join: "#seance",
+ sasl: "",
+ saslAccount: "",
+ saslPassword: "",
+ serverPassword: undefined,
+ });
+ h.client.connect();
+ h.transport.open();
+
+ expect(h.transport.sent).to.include("PASS :kept");
+ });
+});