diff --git a/components/app.js b/components/app.js index 426ed92..cd304ac 100644 --- a/components/app.js +++ b/components/app.js @@ -9,6 +9,8 @@ import JoinForm from "./join-form.js"; import Help from "./help.js"; import NetworkForm from "./network-form.js"; import AuthForm from "./auth-form.js"; +import RegisterForm from "./register-form.js"; +import VerifyForm from "./verify-form.js"; import Composer from "./composer.js"; import ScrollManager from "./scroll-manager.js"; import Dialog from "./dialog.js"; @@ -193,6 +195,8 @@ export default class App extends Component { this.handleNetworkRemove = this.handleNetworkRemove.bind(this); this.handleDismissError = this.handleDismissError.bind(this); this.handleAuthSubmit = this.handleAuthSubmit.bind(this); + this.handleRegisterSubmit = this.handleRegisterSubmit.bind(this); + this.handleVerifySubmit = this.handleVerifySubmit.bind(this); this.saveReceipts = debounce(this.saveReceipts.bind(this), 500); @@ -1403,6 +1407,7 @@ export default class App extends Component { handleAuthSubmit(username, password) { let serverID = State.getActiveServerID(this.state); let client = this.clients.get(serverID); + // TODO: show auth status (pending/error) in dialog client.authenticate("PLAIN", { username, password }).then(() => { let firstClient = this.clients.values().next().value; if (client !== firstClient) { @@ -1424,6 +1429,51 @@ export default class App extends Component { this.dismissDialog(); } + handleRegisterClick(serverID) { + let client = this.clients.get(serverID); + let emailRequired = client.checkAccountRegistrationCap("email-required"); + this.openDialog("register", { emailRequired }); + } + + handleRegisterSubmit(email, password) { + let serverID = State.getActiveServerID(this.state); + let client = this.clients.get(serverID); + // TODO: show registration status (pending/error) in dialog + client.registerAccount(email, password).then((data) => { + this.dismissDialog(); + + if (data.verificationRequired) { + this.openDialog("verify", data); + } + + let firstClient = this.clients.values().next().value; + if (client !== firstClient) { + return; + } + + let autoconnect = store.autoconnect.load(); + if (!autoconnect) { + return; + } + + console.log("Saving account registration credentials"); + autoconnect = { + ...autoconnect, + saslPlain: { username: data.account, password }, + }; + store.autoconnect.put(autoconnect); + }); + } + + handleVerifySubmit(code) { + let serverID = State.getActiveServerID(this.state); + let client = this.clients.get(serverID); + // TODO: display verification status (pending/error) in dialog + client.verifyAccount(this.state.dialogData.account, code).then(() => { + this.dismissDialog(); + }); + } + handleAddNetworkClick() { this.openDialog("network"); } @@ -1614,6 +1664,19 @@ export default class App extends Component { `; break; + case "register": + dialog = html` + <${Dialog} title="Register a new ${getServerName(activeServer, activeBouncerNetwork, isBouncer)} account" onDismiss=${this.dismissDialog}> + <${RegisterForm} emailRequired=${dialogData.emailRequired} onSubmit=${this.handleRegisterSubmit}/> + + `; + break; + case "verify": + dialog = html` + <${Dialog} title="Verify ${getServerName(activeServer, activeBouncerNetwork, isBouncer)} account" onDismiss=${this.dismissDialog}> + <${VerifyForm} account=${dialogData.account} message=${dialogData.message} onSubmit=${this.handleVerifySubmit}/> + + `; } let error = null; @@ -1669,9 +1732,11 @@ export default class App extends Component { buffer=${activeBuffer} server=${activeServer} isBouncer=${isBouncer} + bouncerNetwork=${activeBouncerNetwork} onChannelClick=${this.handleChannelClick} onNickClick=${this.handleNickClick} onAuthClick=${() => this.handleAuthClick(activeBuffer.server)} + onRegisterClick=${() => this.handleRegisterClick(activeBuffer.server)} /> diff --git a/components/buffer.js b/components/buffer.js index 310654f..bf4a671 100644 --- a/components/buffer.js +++ b/components/buffer.js @@ -96,7 +96,7 @@ class LogLine extends Component { let lineClass = ""; let content; - let invitee, target; + let invitee, target, account; switch (msg.command) { case "NOTICE": case "PRIVMSG": @@ -205,12 +205,28 @@ class LogLine extends Component { content = linkify(stripANSI(msg.params[1]), onChannelClick); break; case irc.RPL_LOGGEDIN: - let account = msg.params[2]; + account = msg.params[2]; content = html`You are now authenticated as ${account}`; break; case irc.RPL_LOGGEDOUT: content = html`You are now unauthenticated`; break; + case "REGISTER": + account = msg.params[1]; + let reason = linkify(msg.params[2]); + switch (msg.params[0]) { + case "SUCCESS": + content = html`A new account has been created, you are now authenticated as ${account}`; + break; + case "VERIFICATION_REQUIRED": + content = html`A new account has been created, but further action is required to complete registration: ${reason}`; + break; + } + break; + case "VERIFY": + account = msg.params[1]; + content = html`The new account has been verified, you are now authenticated as ${account}`; + break; case irc.RPL_UMODEIS: let mode = msg.params[1]; if (mode) { @@ -233,6 +249,10 @@ class LogLine extends Component { content = html`${msg.command} ${linkify(msg.params.join(" "))}`; } + if (!content) { + return null; + } + return html`
<${Timestamp} date=${new Date(msg.tags.time)} url=${getMessageURL(buf, msg)}/> @@ -457,22 +477,37 @@ class ProtocolHandlerNagger extends Component { } } -function AuthNagger({ server, onClick }) { +function AccountNagger({ server, onAuthClick, onRegisterClick }) { let accDesc = "an account on this server"; if (server.isupport.has("NETWORK")) { accDesc = "a " + server.isupport.get("NETWORK") + " account"; } - function handleClick(event) { + function handleAuthClick(event) { event.preventDefault(); - onClick(); + onAuthClick(); + } + function handleRegisterClick(event) { + event.preventDefault(); + onRegisterClick(); + } + + let msg = [html` + You are unauthenticated on this server, + ${" "} + login + ${" "} + `]; + + if (server.supportsAccountRegistration) { + msg.push(html`or register ${accDesc}`); + } else { + msg.push(html`if you have ${accDesc}`); } return html`
- <${Timestamp}/> - ${" "} - You are unauthenticated on this server, login if you have ${accDesc} + <${Timestamp}/> ${msg}
`; } @@ -515,21 +550,29 @@ export default class Buffer extends Component { render() { let buf = this.props.buffer; - let server = this.props.server; if (!buf) { return null; } + let server = this.props.server; + let bouncerNetwork = this.props.bouncerNetwork; + let serverName = server.isupport.get("NETWORK"); + let children = []; if (buf.type == BufferType.SERVER) { children.push(html`<${NotificationNagger}/>`); } if (buf.type == BufferType.SERVER && this.props.isBouncer && !server.isupport.has("BOUNCER_NETID")) { - let name = server.isupport.get("NETWORK"); - children.push(html`<${ProtocolHandlerNagger} bouncerName=${name}/>`); + children.push(html`<${ProtocolHandlerNagger} bouncerName=${serverName}/>`); } if (buf.type == BufferType.SERVER && server.status == ServerStatus.REGISTERED && server.supportsSASLPlain && !server.account) { - children.push(html`<${AuthNagger} server=${server} onClick=${this.props.onAuthClick}/>`); + children.push(html` + <${AccountNagger} + server=${server} + onAuthClick=${this.props.onAuthClick} + onRegisterClick=${this.props.onRegisterClick} + /> + `); } let onChannelClick = this.props.onChannelClick; diff --git a/components/register-form.js b/components/register-form.js new file mode 100644 index 0000000..1c43395 --- /dev/null +++ b/components/register-form.js @@ -0,0 +1,54 @@ +import { html, Component } from "../lib/index.js"; + +export default class RegisterForm extends Component { + state = { + email: "", + password: "", + }; + + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + handleChange(event) { + let target = event.target; + let value = target.type == "checkbox" ? target.checked : target.value; + this.setState({ [target.name]: value }); + } + + handleSubmit(event) { + event.preventDefault(); + + this.props.onSubmit(this.state.email, this.state.password); + } + + render() { + return html` +
+ +

+ + +

+ + +
+ `; + } +} diff --git a/components/verify-form.js b/components/verify-form.js new file mode 100644 index 0000000..8589c3d --- /dev/null +++ b/components/verify-form.js @@ -0,0 +1,43 @@ +import { html, Component } from "../lib/index.js"; +import linkify from "../lib/linkify.js"; + +export default class RegisterForm extends Component { + state = { + code: "", + }; + + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + } + + handleChange(event) { + let target = event.target; + let value = target.type == "checkbox" ? target.checked : target.value; + this.setState({ [target.name]: value }); + } + + handleSubmit(event) { + event.preventDefault(); + + this.props.onSubmit(this.state.code); + } + + render() { + return html` +
+

Your account ${this.props.account} has been created, but a verification code is required to complete the registration:
${linkify(this.props.message)}

+ + +

+ + +
+ `; + } +} diff --git a/lib/client.js b/lib/client.js index 2353a74..c4f1beb 100644 --- a/lib/client.js +++ b/lib/client.js @@ -17,6 +17,7 @@ const permanentCaps = [ "server-time", "setname", + "draft/account-registration", "draft/chathistory", "draft/event-playback", "draft/extended-monitor", @@ -552,6 +553,14 @@ export default class Client extends EventTarget { return saslCap.split(",").includes(mech); } + checkAccountRegistrationCap(k) { + let v = this.availableCaps["draft/account-registration"]; + if (v === undefined) { + return false; + } + return v.split(",").includes(k); + } + requestCaps() { let wantCaps = [].concat(permanentCaps); if (!this.params.bouncerNetwork) { @@ -915,4 +924,45 @@ export default class Client extends EventTarget { } }); } + + registerAccount(email, password) { + let msg = { + command: "REGISTER", + params: ["*", email || "*", password], + }; + return this.roundtrip(msg, (msg) => { + switch (msg.command) { + case "REGISTER": + let result = msg.params[0]; + return { + verificationRequired: result === "VERIFICATION_REQUIRED", + account: msg.params[1], + message: msg.params[2], + }; + case "FAIL": + if (msg.params[0] === "REGISTER") { + throw msg; + } + break; + } + }); + } + + verifyAccount(account, code) { + let msg = { + command: "VERIFY", + params: [account, code], + }; + return this.roundtrip(msg, (msg) => { + switch (msg.command) { + case "VERIFY": + return { message: msg.params[2] }; + case "FAIL": + if (msg.params[0] === "VERIFY") { + throw msg; + } + break; + } + }); + } } diff --git a/state.js b/state.js index 33b3142..fb3507e 100644 --- a/state.js +++ b/state.js @@ -257,6 +257,7 @@ export const State = { users: new irc.CaseMapMap(null, irc.CaseMapping.RFC1459), account: null, supportsSASLPlain: false, + supportsAccountRegistration: false, }); return [id, { servers }]; }, @@ -348,11 +349,20 @@ export const State = { }), }; case "CAP": - return updateServer({ supportsSASLPlain: client.supportsSASL("PLAIN") }); + return updateServer({ + supportsSASLPlain: client.supportsSASL("PLAIN"), + supportsAccountRegistration: !!client.enabledCaps["draft/account-registration"], + }); case irc.RPL_LOGGEDIN: return updateServer({ account: msg.params[2] }); case irc.RPL_LOGGEDOUT: return updateServer({ account: null }); + case "REGISTER": + case "VERIFY": + if (msg.params[0] === "SUCCESS") { + return updateServer({ account: msg.params[1] }); + } + break; case irc.RPL_NOTOPIC: channel = msg.params[1]; return updateBuffer(channel, { topic: null });