mfa and api wiring
This commit is contained in:
@ -38,6 +38,8 @@ export function normalizeSiteUrl(raw) {
|
||||
* @param {string} [options.method='GET'] - HTTP method
|
||||
* @param {string} [options.token] - Bearer token
|
||||
* @param {Record<string, string>} [options.form] - urlencoded body
|
||||
* @param {Record<string, string|Blob>} [options.multipart] - multipart fields (avatar)
|
||||
* @param {Record<string, string|number>} [options.query] - query string
|
||||
* @return {Promise<object>} - parsed JSON
|
||||
*/
|
||||
export async function ttpRequest(options) {
|
||||
@ -46,17 +48,46 @@ export async function ttpRequest(options) {
|
||||
const method = options.method || 'GET'
|
||||
const token = options.token
|
||||
const form = options.form
|
||||
const url = `${siteUrl}${path}`
|
||||
const multipart = options.multipart
|
||||
const headers = { Accept: 'application/json' }
|
||||
let url = `${siteUrl}${path}`
|
||||
let body
|
||||
|
||||
if (options.query && typeof options.query === 'object') {
|
||||
const qs = new URLSearchParams()
|
||||
Object.entries(options.query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return
|
||||
}
|
||||
qs.set(key, String(value))
|
||||
})
|
||||
const encoded = qs.toString()
|
||||
if (encoded) {
|
||||
url += (path.includes('?') ? '&' : '?') + encoded
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (form) {
|
||||
if (multipart instanceof FormData) {
|
||||
body = multipart
|
||||
} else if (multipart) {
|
||||
const data = new FormData()
|
||||
Object.entries(multipart).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return
|
||||
}
|
||||
data.append(key, value)
|
||||
})
|
||||
body = data
|
||||
} else if (form) {
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
body = new URLSearchParams(form).toString()
|
||||
} else if (method === 'POST') {
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
body = 'submit=1'
|
||||
}
|
||||
|
||||
let response
|
||||
@ -81,12 +112,13 @@ export async function ttpRequest(options) {
|
||||
* Map a TTP API error string to a short user-facing line.
|
||||
*
|
||||
* @param {string} code - API `error` value
|
||||
* @param {unknown} [errors] - optional Check user errors
|
||||
* @return {string} - message for the login form
|
||||
*/
|
||||
export function apiErrorMessage(code) {
|
||||
export function apiErrorMessage(code, errors) {
|
||||
switch (code) {
|
||||
case 'malformed input':
|
||||
return 'Username and password are required.'
|
||||
return firstUserError(errors) || 'Check the form and try again.'
|
||||
case 'bad credentials':
|
||||
return 'Those credentials were not accepted.'
|
||||
case 'invalid token':
|
||||
@ -96,18 +128,83 @@ export function apiErrorMessage(code) {
|
||||
return 'That API token has expired.'
|
||||
case 'IRDK':
|
||||
return 'The site could not refresh this token.'
|
||||
case 'no valid MFA methods':
|
||||
return 'This account has no usable MFA method. Contact support.'
|
||||
case 'invalid MFA':
|
||||
return 'That authentication code was not accepted.'
|
||||
case 'MFA expired':
|
||||
return 'This sign-in challenge expired. Sign in again.'
|
||||
case 'Could not send MFA code':
|
||||
return 'Could not send an authentication code. Try another method.'
|
||||
case 'Choose an MFA method':
|
||||
return 'Choose how you want to authenticate.'
|
||||
case 'user token required':
|
||||
return 'This action needs a personal (user) token, not an app token.'
|
||||
default:
|
||||
return code || 'The site returned an error.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First string from an API `errors` blob.
|
||||
*
|
||||
* @param {unknown} errors - Check user errors
|
||||
* @return {string}
|
||||
*/
|
||||
function firstUserError(errors) {
|
||||
if (!errors) {
|
||||
return ''
|
||||
}
|
||||
if (typeof errors === 'string') {
|
||||
return errors
|
||||
}
|
||||
if (Array.isArray(errors)) {
|
||||
for (const item of errors) {
|
||||
const text = firstUserError(item)
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
if (typeof errors === 'object') {
|
||||
for (const value of Object.values(errors)) {
|
||||
const text = firstUserError(value)
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Token, MFA challenge, or a thrown Error from an API auth body.
|
||||
*
|
||||
* @param {object} data - parsed JSON
|
||||
* @param {string} [missingToken] - error when neither token nor mfa is present
|
||||
* @return {{token?: string, mfa?: object}}
|
||||
*/
|
||||
function authResult(data, missingToken) {
|
||||
if (data.error) {
|
||||
throw new Error(apiErrorMessage(data.error, data.errors))
|
||||
}
|
||||
if (data.mfa && typeof data.mfa === 'object') {
|
||||
return { mfa: data.mfa }
|
||||
}
|
||||
if (!data.token) {
|
||||
throw new Error(missingToken || 'The site did not return a token.')
|
||||
}
|
||||
return { token: data.token }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in with username and password. POST api/login.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} username - TTP username
|
||||
* @param {string} password - TTP password
|
||||
* @return {Promise<string>} - user token
|
||||
* @return {Promise<{token?: string, mfa?: object}>} - token or MFA challenge
|
||||
*/
|
||||
export async function loginWithPassword(siteUrl, username, password) {
|
||||
const data = await ttpRequest({
|
||||
@ -117,15 +214,76 @@ export async function loginWithPassword(siteUrl, username, password) {
|
||||
form: { username, password }
|
||||
})
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(apiErrorMessage(data.error))
|
||||
if (data.error === 'malformed input' && !data.errors) {
|
||||
throw new Error('Username and password are required.')
|
||||
}
|
||||
|
||||
if (!data.token) {
|
||||
throw new Error('The site did not return a token.')
|
||||
}
|
||||
return authResult(data)
|
||||
}
|
||||
|
||||
return data.token
|
||||
/**
|
||||
* Path for a pending MFA challenge.
|
||||
*
|
||||
* @param {string} loginCode - capability id from api/login
|
||||
* @param {string} [suffix] - extra path (e.g. /reset)
|
||||
* @return {string}
|
||||
*/
|
||||
function mfaPath(loginCode, suffix) {
|
||||
const base = `/api/login/mfa/${encodeURIComponent(loginCode)}`
|
||||
return suffix ? `${base}${suffix}` : base
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a 6-digit MFA code. POST api/login/mfa/{loginCode}.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} loginCode - pending challenge id
|
||||
* @param {string} authCode - submitted code
|
||||
* @return {Promise<{token?: string, mfa?: object}>}
|
||||
*/
|
||||
export async function submitMfaCode(siteUrl, loginCode, authCode) {
|
||||
const data = await ttpRequest({
|
||||
siteUrl,
|
||||
path: mfaPath(loginCode),
|
||||
method: 'POST',
|
||||
form: { auth_code: authCode }
|
||||
})
|
||||
return authResult(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick an MFA method. POST api/login/mfa/{loginCode}.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} loginCode - pending challenge id
|
||||
* @param {string} method - mfa_phone, mfa_email, or mfa_app
|
||||
* @return {Promise<{token?: string, mfa?: object}>}
|
||||
*/
|
||||
export async function selectMfaMethod(siteUrl, loginCode, method) {
|
||||
const data = await ttpRequest({
|
||||
siteUrl,
|
||||
path: mfaPath(loginCode),
|
||||
method: 'POST',
|
||||
form: { mfaMethodSelect: method }
|
||||
})
|
||||
return authResult(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the chosen MFA method. POST api/login/mfa/{loginCode}/reset.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} loginCode - pending challenge id
|
||||
* @return {Promise<{token?: string, mfa?: object}>}
|
||||
*/
|
||||
export async function resetMfaMethod(siteUrl, loginCode) {
|
||||
const data = await ttpRequest({
|
||||
siteUrl,
|
||||
path: mfaPath(loginCode, '/reset'),
|
||||
method: 'POST',
|
||||
form: { submit: '1' }
|
||||
})
|
||||
return authResult(data)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -160,3 +318,301 @@ export async function findUser(siteUrl, token, idOrUsername) {
|
||||
export function isDeadTokenError(error) {
|
||||
return error === 'invalid token' || error === 'invalid secret' || error === 'token expired'
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a user-facing Error from an API JSON body when `error` is set.
|
||||
*
|
||||
* @param {object} data - parsed JSON
|
||||
* @return {object} - data when there is no error
|
||||
*/
|
||||
export function unwrapApi(data) {
|
||||
if (data?.error) {
|
||||
const err = new Error(apiErrorMessage(data.error, data.errors))
|
||||
if (isDeadTokenError(data.error)) {
|
||||
err.code = 'DEAD_TOKEN'
|
||||
}
|
||||
err.apiError = data.error
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Blob for an avatar sent over IPC.
|
||||
*
|
||||
* @param {object} [avatar] - name, type, data (ArrayBuffer or typed array)
|
||||
* @return {Blob|null}
|
||||
*/
|
||||
export function avatarBlob(avatar) {
|
||||
if (!avatar?.data) {
|
||||
return null
|
||||
}
|
||||
const bytes = Buffer.isBuffer(avatar.data)
|
||||
? avatar.data
|
||||
: avatar.data instanceof ArrayBuffer
|
||||
? Buffer.from(avatar.data)
|
||||
: Buffer.from(avatar.data)
|
||||
return new Blob([bytes], { type: avatar.type || 'application/octet-stream' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Current user. GET api/profile.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function getProfile(siteUrl, token) {
|
||||
return ttpRequest({ siteUrl, path: '/api/profile', method: 'GET', token })
|
||||
}
|
||||
|
||||
/**
|
||||
* Save name, prefs, optional avatar. POST api/profile/update.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {Record<string, string>} fields - form fields
|
||||
* @param {object} [avatar] - IPC file payload
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function updateProfile(siteUrl, token, fields, avatar) {
|
||||
const multipart = { ...(fields || {}) }
|
||||
const file = avatarBlob(avatar)
|
||||
if (file) {
|
||||
const data = new FormData()
|
||||
Object.entries(multipart).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return
|
||||
}
|
||||
data.append(key, value)
|
||||
})
|
||||
data.append('avatar', file, avatar.name || 'avatar.jpg')
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: '/api/profile/update',
|
||||
method: 'POST',
|
||||
token,
|
||||
multipart: data
|
||||
})
|
||||
}
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: '/api/profile/update',
|
||||
method: 'POST',
|
||||
token,
|
||||
form: fields
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged notifications. GET api/notifications.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {number} [page=1] - pager page
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function listNotifications(siteUrl, token, page = 1) {
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: '/api/notifications',
|
||||
method: 'GET',
|
||||
token,
|
||||
query: { page }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a notification read. POST api/notifications/read/{id}.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {string|number} id - notification id
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function readNotification(siteUrl, token, id) {
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: `/api/notifications/read/${encodeURIComponent(id)}`,
|
||||
method: 'POST',
|
||||
token
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a notification. POST api/notifications/delete/{id}.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {string|number} id - notification id
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function deleteNotification(siteUrl, token, id) {
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: `/api/notifications/delete/${encodeURIComponent(id)}`,
|
||||
method: 'POST',
|
||||
token
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged inbox. GET api/messages.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {number} [page=1] - pager page
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function listMessages(siteUrl, token, page = 1) {
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: '/api/messages',
|
||||
method: 'GET',
|
||||
token,
|
||||
query: { page }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One conversation. GET api/messages/view/{id}.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {string|number} id - conversation id
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function viewMessage(siteUrl, token, id) {
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: `/api/messages/view/${encodeURIComponent(id)}`,
|
||||
method: 'GET',
|
||||
token
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a conversation. POST api/messages/create.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {string} toUser - username
|
||||
* @param {string} message - body
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function createMessage(siteUrl, token, toUser, message) {
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: '/api/messages/create',
|
||||
method: 'POST',
|
||||
token,
|
||||
form: { toUser, message }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply in a conversation. POST api/messages/reply/{id}.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {string|number} id - conversation id
|
||||
* @param {string} message - body
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function replyMessage(siteUrl, token, id, message) {
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: `/api/messages/reply/${encodeURIComponent(id)}`,
|
||||
method: 'POST',
|
||||
token,
|
||||
form: { message }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a conversation read. POST api/messages/read/{id}.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {string|number} id - conversation id
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function readMessage(siteUrl, token, id) {
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: `/api/messages/read/${encodeURIComponent(id)}`,
|
||||
method: 'POST',
|
||||
token
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a conversation. POST api/messages/delete/{id}.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {string|number} id - conversation id
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function deleteMessage(siteUrl, token, id) {
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: `/api/messages/delete/${encodeURIComponent(id)}`,
|
||||
method: 'POST',
|
||||
token
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Site search. GET api/search.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {object} query - q, resource, page, results
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function searchSite(siteUrl, token, query) {
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: '/api/search',
|
||||
method: 'GET',
|
||||
token,
|
||||
query
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact form. POST api/contact.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {object} fields - name, entry, optional email
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function sendContact(siteUrl, token, fields) {
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: '/api/contact',
|
||||
method: 'POST',
|
||||
token,
|
||||
form: fields
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Bug report. POST api/bugreport.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {object} fields - url, ourl, repeat, entry
|
||||
* @return {Promise<object>}
|
||||
*/
|
||||
export async function sendBugreport(siteUrl, token, fields) {
|
||||
return ttpRequest({
|
||||
siteUrl,
|
||||
path: '/api/bugreport',
|
||||
method: 'POST',
|
||||
token,
|
||||
form: fields
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user