diff --git a/src/Client.ts b/src/Client.ts index 8dccdab8..f6cb2fd8 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -1,7 +1,7 @@ import { Accessor, Setter, createSignal } from "solid-js"; import EventEmitter from "eventemitter3"; -import { API, DataCreateAccount, Role } from "revolt-api"; +import { API, Role } from "revolt-api"; import type { DataLogin, RevoltConfig } from "revolt-api"; import { Channel, Emoji, Message, Server, ServerMember, User } from "./classes"; @@ -16,6 +16,7 @@ import { SessionCollection, UserCollection, } from "./collections"; +import { AccountCollection } from "./collections/AccountCollection"; import { ConnectionState, EventClient, @@ -143,6 +144,7 @@ export type ClientOptions = Partial & { * Revolt.js Clients */ export class Client extends EventEmitter { + readonly account; readonly bots; readonly channels; readonly channelUnreads; @@ -213,6 +215,7 @@ export class Client extends EventEmitter { this.connectionFailureCount = connectionFailureCount; this.#setConnectionFailureCount = setConnectionFailureCount; + this.account = new AccountCollection(this); this.bots = new BotCollection(this); this.channels = new ChannelCollection(this); this.channelUnreads = new ChannelUnreadCollection(this); @@ -334,15 +337,6 @@ export class Client extends EventEmitter { this.connect(); } - /** - * Register for a new account - * @param data Registration data object - * @returns A promise containing a registration response object - */ - register(data: DataCreateAccount) { - return this.api.post("/auth/account/create", data); - } - /** * Prepare a markdown-based message to be displayed to the user as plain text. * @param source Source markdown text diff --git a/src/classes/MFA.ts b/src/classes/MFA.ts new file mode 100644 index 00000000..dd16abd1 --- /dev/null +++ b/src/classes/MFA.ts @@ -0,0 +1,171 @@ +import { SetStoreFunction, createStore } from "solid-js/store"; + +import { + MFAResponse, + MultiFactorStatus, + MFATicket as TicketType, +} from "revolt-api"; + +import { Client } from ".."; + +/** + * Multi-Factor Authentication + */ +export class MFA { + #client: Client; + #store: [MultiFactorStatus, SetStoreFunction]; + + /** + * Construct MFA helper + * @param client Client + * @param state State + */ + constructor(client: Client, state: MultiFactorStatus) { + this.#client = client; + this.#store = createStore(state); + } + + /** + * Whether authenticator app is enabled + */ + get authenticatorEnabled() { + return this.#store[0].totp_mfa; + } + + /** + * Whether recovery codes are enabled + */ + get recoveryEnabled() { + return this.#store[0].recovery_active; + } + + /** + * Create an MFA ticket + * @param params + * @returns Token + */ + createTicket(params: MFAResponse) { + return this.#client.api + .put("/auth/mfa/ticket", params) + .then((ticket) => new MFATicket(this.#client, ticket)); + } + + /** + * Enable authenticator using token generated from secret found earlier + * @param token Token + */ + enableAuthenticator(token: string) { + return this.#client.api.put("/auth/mfa/totp", { totp_code: token }); + } +} + +/** + * MFA Ticket + */ +export class MFATicket { + #client: Client; + #ticket: TicketType; + #used = false; + + /** + * Construct MFA Ticket + * @param client Client + * @param ticket Ticket + */ + constructor(client: Client, ticket: TicketType) { + this.#client = client; + this.#ticket = ticket; + } + + /** + * Token + */ + get token() { + return this.#ticket.token; + } + + /** + * Use the ticket + */ + #consume() { + if (this.#used) throw "Already used this ticket!"; + this.#used = true; + } + + /** + * Fetch recovery codes + * @returns List of codes + */ + fetchRecoveryCodes() { + this.#consume(); + return this.#client.api.post("/auth/mfa/recovery", undefined, { + headers: { + "X-MFA-Ticket": this.token, + }, + }); + } + + /** + * Generate new set of recovery codes + * @returns List of codes + */ + generateRecoveryCodes() { + this.#consume(); + return this.#client.api.patch("/auth/mfa/recovery", undefined, { + headers: { + "X-MFA-Ticket": this.token, + }, + }); + } + + /** + * Generate new authenticator secret + * @returns Secret + */ + generateAuthenticatorSecret() { + this.#consume(); + return this.#client.api + .post("/auth/mfa/totp", undefined, { + headers: { + "X-MFA-Ticket": this.token, + }, + }) + .then((response) => response.secret); + } + + /** + * Disable authenticator + */ + disableAuthenticator() { + this.#consume(); + return this.#client.api.delete("/auth/mfa/totp", undefined, { + headers: { + "X-MFA-Ticket": this.token, + }, + }); + } + + /** + * Disable account + */ + disableAccount() { + this.#consume(); + return this.#client.api.post("/auth/account/disable", undefined, { + headers: { + "X-MFA-Ticket": this.token, + }, + }); + } + + /** + * Delete account + */ + deleteAccount() { + this.#consume(); + return this.#client.api.post("/auth/account/delete", undefined, { + headers: { + "X-MFA-Ticket": this.token, + }, + }); + } +} diff --git a/src/collections/AccountCollection.ts b/src/collections/AccountCollection.ts new file mode 100644 index 00000000..a869a2d2 --- /dev/null +++ b/src/collections/AccountCollection.ts @@ -0,0 +1,164 @@ +import { DataCreateAccount, WebPushSubscription } from "revolt-api"; + +import { Client } from ".."; +import { MFA } from "../classes/MFA"; + +/** + * Utility functions for working with accounts + */ +export class AccountCollection { + readonly client: Client; + + /** + * Create generic class collection + * @param client Client + */ + constructor(client: Client) { + this.client = client; + } + + /** + * Fetch current account email + * @returns Email + */ + fetchEmail() { + return this.client.api + .get("/auth/account/") + .then((account) => account.email); + } + + /** + * Create a MFA helper + */ + async mfa() { + const state = await this.client.api.get("/auth/mfa/"); + return new MFA(this.client, state); + } + + /** + * Create a new account + * @param data Account details + */ + create(data: DataCreateAccount) { + return this.client.api.post("/auth/account/create", data); + } + + /** + * Resend email verification + * @param email Email + * @param captcha Captcha if enabled + */ + reverify(email: string, captcha?: string) { + return this.client.api.post("/auth/account/reverify", { email, captcha }); + } + + /** + * Send password reset email + * @param email Email + * @param captcha Captcha if enabled + */ + resetPassword(email: string, captcha?: string) { + return this.client.api.post("/auth/account/reset_password", { + email, + captcha, + }); + } + + /** + * Verify an account given the code + * @param code Verification code + */ + verify(code: string) { + return this.client.api.post(`/auth/account/verify/${code}`); + } + + /** + * Confirm account deletion + * @param token Deletion token + */ + confirmDelete(token: string) { + return this.client.api.put("/auth/account/delete", { token }); + } + + /** + * Confirm password reset + * @param token Token + * @param newPassword New password + * @param removeSessions Whether to remove existing sessions + */ + confirmPasswordReset( + token: string, + newPassword: string, + removeSessions: boolean + ) { + return this.client.api.patch("/auth/account/reset_password", { + token, + password: newPassword, + remove_sessions: removeSessions, + }); + } + + /** + * Change account password + * @param newPassword New password + * @param currentPassword Current password + */ + changePassword(newPassword: string, currentPassword: string) { + return this.client.api.patch("/auth/account/change/password", { + password: newPassword, + current_password: currentPassword, + }); + } + + /** + * Change account email + * @param newEmail New email + * @param currentPassword Current password + */ + changeEmail(newEmail: string, currentPassword: string) { + return this.client.api.patch("/auth/account/change/email", { + email: newEmail, + current_password: currentPassword, + }); + } + + /** + * Fetch settings + * @param keys Keys + * @returns Settings + */ + fetchSettings(keys: string[]) { + return this.client.api.post("/sync/settings/fetch", { keys }) as Promise< + Record + >; + } + + /* eslint-disable @typescript-eslint/no-explicit-any */ + /** + * Set settings + * @param settings Settings + * @param timestamp Timestamp + */ + setSettings(settings: Record, timestamp = +new Date()) { + return this.client.api.post("/sync/settings/set", { + ...settings, + timestamp, + }); + } + /* eslint-enable @typescript-eslint/no-explicit-any */ + + /** + * Create a new Web Push subscription + * @param subscription Subscription + */ + webPushSubscribe(subscription: WebPushSubscription) { + return this.client.api.post("/push/subscribe", subscription); + } + + /** + * Remove existing Web Push subscription + */ + webPushUnsubscribe() { + return this.client.api.post("/push/unsubscribe"); + } +} diff --git a/src/collections/SessionCollection.ts b/src/collections/SessionCollection.ts index 94dcc9e8..d53ad4fd 100644 --- a/src/collections/SessionCollection.ts +++ b/src/collections/SessionCollection.ts @@ -20,6 +20,16 @@ export class SessionCollection extends ClassCollection< return data.map((session) => this.getOrCreate(session._id, session)); } + /** + * Delete all sessions, optionally including self + * @param revokeSelf Whether to remove current session too + */ + async deleteAll(revokeSelf = false) { + await this.client.api.delete("/auth/session/all", { + revoke_self: revokeSelf, + }); + } + /** * Get or create * @param id Id