Offer the SASL settings the pane already claimed to have

The daemon has done SASL for a long time. There was simply no way to
turn it on: the account card offered autojoin, a NickServ password and
the proxy switch, and nothing else - while the settings pane told people
that "SASL, autojoin and NickServ auto-identify are per-account instead,
from that account's own entry in the Accounts pane". Two of those three
were true.

So the card carries them now, behind one switch because they are one
decision, and sent as one call because the daemon stores them together -
a username applied without the password it goes with is a state that
could decide a connection attempt on its own.

The plaintext allowance only appears on a connection that has no
encryption, which the daemon now reports. On a TLS account it decides
nothing, and a switch that appears to weaken something while doing
nothing is worse than no switch. Where it does appear it says what it
actually means: SASL PLAIN is the password with base64 round it, and
without the allowance connecting is refused rather than done quietly in
the clear.

Submodule moves to the matching daemon.
This commit is contained in:
2026-08-28 09:13:22 -04:00
parent 543fab5fe5
commit e080e5f076
4 changed files with 131 additions and 1 deletions
+1 -1
Submodule nobilis updated: e0c66ca0c9...d4b6ab89f3
+15
View File
@@ -816,6 +816,21 @@ img.composer-emoji {
gap: var(--spacing-s);
}
/* The SASL fields read as one decision rather than three loose settings, so
they are boxed together and indent under the switch that turns them on. */
.sasl-block {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
padding: var(--spacing-s);
border: 1px solid var(--outline-light);
border-radius: var(--corner-radius);
}
.sasl-block .field {
margin-left: calc(16px + var(--spacing-s));
}
.qr-image {
width: 220px;
height: 220px;
@@ -274,6 +274,7 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
call('setAccountAutojoin', { accountId: account.id, channels })
}
/>
<IrcSasl account={account} call={call} />
<LabeledInput
label="NickServ password"
type="password"
@@ -322,6 +323,115 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
)
}
/**
* Signing in to an IRC network with SASL rather than by messaging NickServ.
*
* Worth preferring where it exists: it happens during registration, so the
* account is identified before the first channel is joined - which is what
* makes cloaks and invite-only channels work on the first attempt instead of
* after a race with NickServ that IRC gives no way to wait on.
*
* All four fields are one settings call, because the daemon stores them
* together and sending them apart would let a half-applied change - a username
* without the password it goes with - decide a connection attempt.
*/
function IrcSasl({
account,
call
}: {
account: Account
call: (method: string, params: Record<string, unknown>) => void
}): JSX.Element {
const [username, setUsername] = useState(account.saslUsername)
const [password, setPassword] = useState('')
const [allowPlaintext, setAllowPlaintext] = useState(account.allowPlaintextSasl)
const apply = (patch: { enabled?: boolean; saslUser?: string; allowPlaintextSasl?: boolean }): void =>
call('setAccountSasl', {
accountId: account.id,
enabled: account.saslEnabled,
saslUser: username,
// Empty means "leave the stored one alone" - the daemon never reads a
// password back out, so there is nothing to prefill and clearing the
// box must not be read as clearing the credential.
password,
allowPlaintextSasl: allowPlaintext,
...patch
})
return (
<div className="sasl-block">
<label className="checkbox-row">
<input
type="checkbox"
checked={account.saslEnabled}
onChange={(e) => apply({ enabled: e.target.checked })}
/>
<span>
Authenticate with SASL
<span className="small muted">
{' '}
— identifies during connection, before any channel is joined
</span>
</span>
</label>
{account.saslEnabled && (
<>
<LabeledInput
label="SASL username"
defaultValue={account.saslUsername}
placeholder={account.id.split('@')[0] || 'your account name'}
onCommit={(value) => {
setUsername(value)
apply({ saslUser: value })
}}
/>
<LabeledInput
label="SASL password"
type="password"
placeholder={account.hasPassword ? '(set)' : 'unset'}
onCommit={(value) => {
setPassword(value)
call('setAccountSasl', {
accountId: account.id,
enabled: true,
saslUser: username,
password: value,
allowPlaintextSasl: allowPlaintext
})
}}
/>
{/* Only where it is a live question. On a TLS connection the
allowance decides nothing, and offering it there would be a
switch that appears to weaken something and does not. */}
{!account.ssl && (
<label className="checkbox-row">
<input
type="checkbox"
checked={account.allowPlaintextSasl}
onChange={(e) => {
setAllowPlaintext(e.target.checked)
apply({ allowPlaintextSasl: e.target.checked })
}}
/>
<span>
Send SASL credentials over this unencrypted connection
<span className="small muted">
{' '}
— SASL PLAIN is the password with base64 round it, not encryption. Without
this, connecting is refused rather than done quietly in the clear.
</span>
</span>
</label>
)}
</>
)}
</div>
)
}
function LabeledInput({
label,
defaultValue,
+5
View File
@@ -16,6 +16,11 @@ export interface Account {
saslEnabled: boolean
saslUsername: string
allowPlaintextSasl: boolean
/**
* Whether the connection is encrypted. Only IRC is ever false - every other
* service here is HTTPS or WSS by construction.
*/
ssl: boolean
hasPassword: boolean
avatarUrl?: string
sockchatRooms?: SockChatRoom[]