An account's addresses, and a way to close it

The addresses sit above the sessions and keys because they are the
plainer fact about an account - and because an account with no address
on it is one nobody can get back into, which the empty state says
outright.

Email only for adding: a phone number needs an identity server to send
the message and hold the binding, and this client has none. An existing
one is still listed and can still be removed, which is the half that
matters for taking an address off.

Closing the account is last on the panel and separated by being last,
since nothing else there is irreversible. It says what it actually does
- the address can never be used again, and encrypted messages become
unreadable to anyone without the keys - and it says plainly that
removing the account from moho is the other, reversible thing.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-13 13:42:08 -04:00
co-authored by Claude Opus 5
parent e6c7a40af2
commit d612fccb1e
4 changed files with 294 additions and 1 deletions
+1 -1
Submodule nobilis updated: 4f7ba9bc21...2c3c48407e
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'
import { Icon } from './Icon'
import { MatrixAddresses } from './MatrixAddresses'
import { MatrixDeactivate } from './MatrixDeactivate'
import { useChat, usePref, useStore } from '../state/hooks'
import { resolveMediaUrl } from '../lib/util'
import type { Account, MatrixDevice } from '../../../shared/wire'
@@ -554,6 +556,11 @@ export function MatrixAccountTools({ account }: { account: Account }): JSX.Eleme
</div>
))}
{/* How this account is reached and recovered. Above the sessions and
keys because it is the plainer fact about an account, and because
an account with no address on it is one nobody can get back into. */}
<MatrixAddresses account={account} />
{/* The emoji comparison is drawn over the whole window now rather
than in here: a verification can arrive from another session, or
from another person through a room you share, and this panel is
@@ -632,6 +639,18 @@ export function MatrixAccountTools({ account }: { account: Account }): JSX.Eleme
Restore
</button>
</div>
{/* Last, and separated from everything above it by being last: nothing
else on this panel is irreversible. */}
<div className="setting-row">
<div className="setting-text">
<div>Closing this account</div>
<div className="small muted">
Removing the account from moho leaves it on the homeserver. This does not.
</div>
</div>
</div>
<MatrixDeactivate account={account} />
</div>
)
}
@@ -0,0 +1,186 @@
import { useEffect, useState } from 'react'
import { Icon } from './Icon'
import { useStore } from '../state/hooks'
import type { Account } from '../../../shared/wire'
interface ThirdPartyId {
medium: string
address: string
addedAt?: number | null
}
/**
* The addresses that can reach this account.
*
* An email or a phone number bound to a Matrix account is how a forgotten
* password is recovered and how somebody who knows your email can find you.
* Neither could be seen from here, which meant an account made in moho had no
* way to recover, and one made elsewhere gave no sign of what it was already
* carrying.
*
* Adding an email is the two steps it really is - the homeserver mails a
* link, and the account changes only once somebody says they have followed
* it. A single button would have to either lie about what happened or sit
* there blocking on an inbox.
*/
export function MatrixAddresses({ account }: { account: Account }): JSX.Element {
const store = useStore()
const [ids, setIds] = useState<ThirdPartyId[] | null>(null)
const [adding, setAdding] = useState('')
/** The half-finished add: what the server called it, and our own secret. */
const [pending, setPending] = useState<{ sid: string; clientSecret: string; address: string } | null>(null)
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const refresh = (): void => {
void window.moho
.rpc<{ threepids: ThirdPartyId[] }>('matrixThirdPartyIds', { accountId: account.id })
.then((answer) => setIds(answer.threepids))
// Quietly: an account still connecting has no answer yet, and an older
// daemon has no such method.
.catch(() => setIds([]))
}
useEffect(refresh, [account.id])
const sendLink = (): void => {
const address = adding.trim()
if (!address) return
setBusy(true)
void window.moho
.rpc<{ sid: string; clientSecret: string }>('matrixRequestEmailToken', {
accountId: account.id,
address
})
.then((answer) => {
setPending({ ...answer, address })
store.toast('info', `Sent a link to ${address}`)
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
const finish = (): void => {
if (!pending) return
setBusy(true)
void window.moho
.rpc('matrixAddThirdPartyId', {
accountId: account.id,
sid: pending.sid,
clientSecret: pending.clientSecret,
password
})
.then(() => {
store.toast('info', `${pending.address} added`)
setPending(null)
setAdding('')
setPassword('')
refresh()
})
// The usual failure here is having pressed this before opening the
// mail, which the server answers with an error saying so - shown as it
// came, because the server's words are the accurate ones.
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
const remove = (id: ThirdPartyId): void => {
void window.moho
.rpc('matrixRemoveThirdPartyId', {
accountId: account.id,
medium: id.medium,
address: id.address
})
.then(() => {
store.toast('info', `${id.address} removed`)
refresh()
})
.catch((e: Error) => store.toast('error', e.message))
}
return (
<>
<div className="setting-row">
<div className="setting-text">
<div>Addresses</div>
<div className="small muted">
How this account is reached and recovered. Anyone who knows one of these can find you
on Matrix.
</div>
</div>
</div>
{ids?.length === 0 && (
<p className="small muted">
Nothing bound to this account. Without an email there is no way to recover a forgotten
password.
</p>
)}
{ids?.map((id) => (
<div key={`${id.medium}:${id.address}`} className="device-row">
<Icon name={id.medium === 'email' ? 'mail' : 'smartphone'} size={18} />
<div className="setting-text">
<div className="ellipsis">{id.address}</div>
{id.addedAt ? (
<div className="small muted">Added {new Date(id.addedAt * 1000).toLocaleDateString()}</div>
) : null}
</div>
<button type="button" className="button subtle" onClick={() => remove(id)}>
Remove
</button>
</div>
))}
{/* Email only. A phone number needs an identity server to send the
message and to hold the binding, and this client has none - an
existing one is still listed and can still be removed, which is the
half that matters for taking an address off. */}
{!pending ? (
<div className="field-row">
<input
className="text-field"
type="email"
placeholder="Add an email address"
value={adding}
onChange={(e) => setAdding(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendLink()}
/>
<button type="button" className="button subtle" disabled={!adding.trim() || busy} onClick={sendLink}>
Send a link
</button>
</div>
) : (
<div className="device-delete">
<p className="small muted">
Open the link sent to {pending.address}, then finish here. Your homeserver asks for
your password before it will add the address.
</p>
<div className="field-row">
<input
className="text-field"
type="password"
placeholder="Account password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && password && finish()}
/>
<button type="button" className="button" disabled={!password || busy} onClick={finish}>
I have opened it
</button>
<button
type="button"
className="button subtle"
onClick={() => {
setPending(null)
setPassword('')
}}
>
Cancel
</button>
</div>
</div>
)}
</>
)
}
@@ -0,0 +1,88 @@
import { useState } from 'react'
import { useStore } from '../state/hooks'
import type { Account } from '../../../shared/wire'
/**
* Closing a Matrix account for good.
*
* An account registered in moho could only be closed from another client,
* which is a small gap that is only ever noticed at the worst moment.
*
* The job here is to be honest rather than gentle. This cannot be undone, the
* user id can never be signed in to or reused, the account's device keys go
* with it, and everything it said in an encrypted room becomes unreadable to
* anybody who did not already hold the keys. Two deliberate steps and the
* password, which is what the homeserver asks for anyway.
*/
export function MatrixDeactivate({ account }: { account: Account }): JSX.Element {
const store = useStore()
const [open, setOpen] = useState(false)
const [password, setPassword] = useState('')
/** The spec's "redact my messages too", which a server may decline. */
const [erase, setErase] = useState(false)
const [busy, setBusy] = useState(false)
const close = (): void => {
setBusy(true)
void window.moho
.rpc('deactivateMatrixAccount', { accountId: account.id, password, erase })
.then(() => {
store.toast('info', 'That account is closed')
void store.refreshAccounts()
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => {
setBusy(false)
setPassword('')
})
}
if (!open) {
return (
<div className="button-row">
<button type="button" className="button subtle danger" onClick={() => setOpen(true)}>
Close this account…
</button>
</div>
)
}
return (
<div className="device-delete">
<p className="small">
This closes <strong>{account.id.replace(/^matrix:/, '')}</strong> on its homeserver. It
cannot be undone, the address can never be used again, and anything this account said in
an encrypted room becomes unreadable to anyone who does not already have the keys.
</p>
{/* Its own choice, because it is a much larger one - and a server is
allowed to ignore it, which is why this says "ask" rather than
promising anything. */}
<label className="small muted checkbox-row">
<input type="checkbox" checked={erase} onChange={(e) => setErase(e.target.checked)} />
Also ask the server to remove the messages this account sent
</label>
<div className="field-row">
<input
className="text-field"
type="password"
placeholder="Account password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="button" className="button danger" disabled={!password || busy} onClick={close}>
Close it for good
</button>
<button
type="button"
className="button subtle"
onClick={() => {
setOpen(false)
setPassword('')
}}
>
Cancel
</button>
</div>
</div>
)
}