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:
+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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user