mfa and api wiring

This commit is contained in:
Joey Kimsey
2026-09-12 15:11:21 -04:00
parent 17d83f7209
commit 58ca586d94
11 changed files with 2065 additions and 134 deletions

View File

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Electron + electron-vite desktop shell with a login view and a connected workspace.
- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens.
- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. MFA accounts continue in-app (`api/login/mfa/{loginCode}`) until a token is issued. `loginCode` stays in the main process; a pasted Admin token skips MFA.
- Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token.
- Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, gender, newsletter, timezone, date/time, page size, dark mode) live in-app. Email, password, and phone open the connected site.
- Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, name, gender, newsletter, timezone, date/time, page size, dark mode) live in-app and save through `POST /api/profile/update`. Email, password, and phone open the connected site.
- After sign-in, Capsule loads `GET /api/profile` plus notifications and messages. Search, contact, and bug reports use the matching user-token endpoints. Disabled plugins show an unavailable note instead of demo data.

View File

@ -6,7 +6,7 @@ This replaces the old `TempusToolkit` Electron stub. The keepers were the login
## Run it
From this folder (Windows source checkout is fine <EFBFBD> Capsule is Node, not PHP):
From this folder (Windows source checkout is fine ? Capsule is Node, not PHP):
```bash
npm install
@ -17,15 +17,22 @@ npm run dev
## How auth works
All HTTP runs in the **main process**. The renderer never sees the token and never talks to the site directly, so TTP<EFBFBD>s same-origin CORS policy does not apply.
All HTTP runs in the **main process**. The renderer never sees the token and never talks to the site directly, so TTP?s same-origin CORS policy does not apply.
| Action | Endpoint | Notes |
|--------|----------|-------|
| Password sign-in | `POST /api/login` | `username` + `password`, `application/x-www-form-urlencoded`. Same limiter as browser login. No CSRF, no Turnstile. |
| Confirm identity | `GET /api/users/find/{username}` | Bearer token. Returns a user id only. |
| Existing token | Admin ? Tokens | Personal or app token. Username is optional and only used for that find call. |
| Password sign-in | `POST /api/login` | `username` + `password`, `application/x-www-form-urlencoded`. Same limiter as browser login. No CSRF, no Turnstile. MFA accounts return `{ mfa }` instead of a token. |
| MFA code / method | `POST /api/login/mfa/{loginCode}` | `auth_code` or `mfaMethodSelect`. `loginCode` stays in the main process. |
| MFA reset | `POST /api/login/mfa/{loginCode}/reset` | Clears the chosen method so the picker shows again. |
| Confirm identity | `GET /api/profile` | Bearer user token. Also used to hydrate username after login. `GET /api/users/find/{username}` remains available. |
| Workspace | `GET /api/notifications`, `GET /api/messages` | First page after connect. Plugin-off responses show as unavailable. |
| Search | `GET /api/search` | Header search. `q`, `resource`, `page`. |
| Profile save | `POST /api/profile/update` | Name, avatar, prefs. |
| Mail / notices | `POST /api/messages/?`, `POST /api/notifications/?` | View, reply, create, read, delete. |
| Contact / bugs | `POST /api/contact`, `POST /api/bugreport` | Dashboard forms when those plugins are enabled. |
| Existing token | Admin ? Tokens | Personal or app token. A user token hydrates the workspace; an app token can connect but cannot call the user API. |
The token is stored under Electron `userData` (`session.json`). `safeStorage` encrypts it when the OS keychain is available.
The token is stored under Electron `userData` (`session.json`). `safeStorage` encrypts it when the OS keychain is available. MFA `loginCode` is not stored.
App-facing pairing notes live with the PHP app: `repos/ttp/docs/capsule.md`.
@ -35,9 +42,9 @@ App-facing pairing notes live with the PHP app: `repos/ttp/docs/capsule.md`.
|------|-----|
| `src/main/` | Window, session file, TTP HTTP, IPC |
| `src/preload/` | `window.capsule` bridge |
| `src/renderer/` | Login, TTP-styled chrome, search / notifications / messages / profile |
| `src/renderer/` | Login, MFA, TTP-styled chrome, and live API views |
The logged-in header follows the public TTP shell (`text-bg-dark`, FA 6.7.1, Bootstrap 5.3). Search stays visible and centered. Account is a top-right dropdown like the site. Notifications and messages are the same bell / envelope menus. Profile edit covers User CP preferences except email, password, and phone <EFBFBD> those open `{site}/usercp/<EFBFBD>`. Lists are placeholders until the API step.
The logged-in header follows the public TTP shell (`text-bg-dark`, FA 6.7.1, Bootstrap 5.3). Search stays visible and centered. Account is a top-right dropdown like the site. Notifications and messages are the same bell / envelope menus. Profile edit covers User CP preferences except email, password, and phone ? those open `{site}/usercp/?`. Lists load from the site API after sign-in.
## Remote

222
src/main/apiIpc.js Normal file
View File

@ -0,0 +1,222 @@
/**
* IPC handlers for signed-in TTP API calls. Token stays in main.
*/
import { ipcMain } from 'electron'
import {
apiErrorMessage,
createMessage,
deleteMessage,
deleteNotification,
getProfile,
isDeadTokenError,
listMessages,
listNotifications,
readMessage,
readNotification,
replyMessage,
searchSite,
sendBugreport,
sendContact,
unwrapApi,
updateProfile,
viewMessage
} from './ttpClient.js'
import { publicSession, readSession, writeLastSite, writeSession } from './sessionStore.js'
/**
* Live site + token, or throw.
*
* @return {object} - stored session
*/
function requireSession() {
const session = readSession()
if (!session?.token || !session.siteUrl) {
throw new Error('Sign in first.')
}
return session
}
/**
* Clear the stored token when the API rejected it.
*
* @param {object} session - stored session
* @param {Error} err - thrown API error
* @return {void}
*/
function dropDeadToken(session, err) {
if (err?.code !== 'DEAD_TOKEN') {
return
}
writeLastSite(session.siteUrl)
}
/**
* Run a signed-in API call and unwrap `{ error }`.
*
* @param {(session: object) => Promise<object>} work - HTTP call
* @return {Promise<object>}
*/
async function runUserApi(work) {
const session = requireSession()
try {
return unwrapApi(await work(session))
} catch (err) {
dropDeadToken(session, err)
throw err
}
}
/**
* List payload, or an unavailable marker when the plugin is off.
*
* @param {object} data - parsed JSON
* @return {object}
*/
function optionalList(data) {
if (!data?.error) {
return data
}
if (isDeadTokenError(data.error)) {
unwrapApi(data)
}
return {
items: [],
unread: 0,
page: 1,
pages: 0,
total: 0,
unavailable: true,
error: apiErrorMessage(data.error, data.errors)
}
}
/**
* Register user-token API IPC. Call once after app ready.
*
* @return {void}
*/
export function registerApiIpc() {
ipcMain.handle('capsule:workspace', async () => {
const session = requireSession()
try {
const profile = await getProfile(session.siteUrl, session.token)
unwrapApi(profile)
session.username = profile.user?.username || session.username
session.userId = profile.user?.id ?? session.userId
session.apiReady = true
writeSession(session)
const [notifications, messages] = await Promise.all([
listNotifications(session.siteUrl, session.token, 1),
listMessages(session.siteUrl, session.token, 1)
])
return {
session: publicSession(session),
profile,
notifications: optionalList(notifications),
messages: optionalList(messages)
}
} catch (err) {
dropDeadToken(session, err)
throw err
}
})
ipcMain.handle('capsule:profile', async () => {
return runUserApi((session) => getProfile(session.siteUrl, session.token))
})
ipcMain.handle('capsule:updateProfile', async (_event, payload) => {
return runUserApi((session) =>
updateProfile(session.siteUrl, session.token, payload?.fields || {}, payload?.avatar)
)
})
ipcMain.handle('capsule:notifications', async (_event, payload) => {
const session = requireSession()
try {
return optionalList(
await listNotifications(session.siteUrl, session.token, payload?.page || 1)
)
} catch (err) {
dropDeadToken(session, err)
throw err
}
})
ipcMain.handle('capsule:notificationRead', async (_event, payload) => {
return runUserApi((session) => readNotification(session.siteUrl, session.token, payload?.id))
})
ipcMain.handle('capsule:notificationDelete', async (_event, payload) => {
return runUserApi((session) => deleteNotification(session.siteUrl, session.token, payload?.id))
})
ipcMain.handle('capsule:messages', async (_event, payload) => {
const session = requireSession()
try {
return optionalList(await listMessages(session.siteUrl, session.token, payload?.page || 1))
} catch (err) {
dropDeadToken(session, err)
throw err
}
})
ipcMain.handle('capsule:messageView', async (_event, payload) => {
return runUserApi((session) => viewMessage(session.siteUrl, session.token, payload?.id))
})
ipcMain.handle('capsule:messageCreate', async (_event, payload) => {
return runUserApi((session) =>
createMessage(session.siteUrl, session.token, payload?.toUser, payload?.message)
)
})
ipcMain.handle('capsule:messageReply', async (_event, payload) => {
return runUserApi((session) =>
replyMessage(session.siteUrl, session.token, payload?.id, payload?.message)
)
})
ipcMain.handle('capsule:messageRead', async (_event, payload) => {
return runUserApi((session) => readMessage(session.siteUrl, session.token, payload?.id))
})
ipcMain.handle('capsule:messageDelete', async (_event, payload) => {
return runUserApi((session) => deleteMessage(session.siteUrl, session.token, payload?.id))
})
ipcMain.handle('capsule:search', async (_event, payload) => {
return runUserApi((session) =>
searchSite(session.siteUrl, session.token, {
q: payload?.q || '',
resource: payload?.resource || 'all',
page: payload?.page || 1,
results: payload?.results || ''
})
)
})
ipcMain.handle('capsule:contact', async (_event, payload) => {
return runUserApi((session) =>
sendContact(session.siteUrl, session.token, {
name: payload?.name || '',
entry: payload?.entry || '',
email: payload?.email || ''
})
)
})
ipcMain.handle('capsule:bugreport', async (_event, payload) => {
return runUserApi((session) =>
sendBugreport(session.siteUrl, session.token, {
url: payload?.url || '',
ourl: payload?.ourl || '',
repeat: payload?.repeat ? 'true' : 'false',
entry: payload?.entry || ''
})
)
})
}

View File

@ -1,6 +1,7 @@
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'path'
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import { registerApiIpc } from './apiIpc.js'
import { registerSessionIpc } from './sessionIpc.js'
/**
@ -48,6 +49,7 @@ app.whenReady().then(() => {
optimizer.watchWindowShortcuts(window)
})
registerSessionIpc()
registerApiIpc()
createWindow()
app.on('activate', () => {

View File

@ -1,11 +1,22 @@
/**
* IPC handlers for login, token connect, logout, and session reads.
* IPC handlers for login, MFA, token connect, logout, and session reads.
*/
import { ipcMain } from 'electron'
import { findUser, isDeadTokenError, loginWithPassword, normalizeSiteUrl } from './ttpClient.js'
import {
getProfile,
isDeadTokenError,
loginWithPassword,
normalizeSiteUrl,
resetMfaMethod,
selectMfaMethod,
submitMfaCode
} from './ttpClient.js'
import { publicSession, readSession, writeLastSite, writeSession } from './sessionStore.js'
/** In-memory MFA challenge. Never written to session.json. */
let pendingMfa = null
/**
* Build a stored session after a successful auth.
*
@ -19,30 +30,32 @@ import { publicSession, readSession, writeLastSite, writeSession } from './sessi
async function persistConnection(fields) {
const siteUrl = fields.siteUrl
const token = fields.token
const username = String(fields.username || '').trim()
let username = String(fields.username || '').trim()
const authMethod = fields.authMethod
let userId = null
let apiReady = false
if (username) {
try {
const found = await findUser(siteUrl, token, username)
if (isDeadTokenError(found.error)) {
const profile = await getProfile(siteUrl, token)
if (profile.error) {
if (isDeadTokenError(profile.error)) {
const dead = new Error(
found.error === 'token expired' ? 'That API token has expired.' : 'That API token was not accepted.'
profile.error === 'token expired' ? 'That API token has expired.' : 'That API token was not accepted.'
)
dead.code = 'DEAD_TOKEN'
throw dead
}
userId = found.userId
apiReady = found.userId !== null
} else if (profile.user) {
username = profile.user.username || username
userId = profile.user.id ?? null
apiReady = true
}
} catch (err) {
if (err?.code === 'DEAD_TOKEN') {
throw err
}
apiReady = false
}
}
const session = {
siteUrl,
@ -57,6 +70,106 @@ async function persistConnection(fields) {
return publicSession(session)
}
/**
* MFA fields the renderer may see. loginCode stays in main.
*
* @param {object} mfa - API challenge payload
* @return {object} - public pending-MFA session
*/
/**
* Challenge fields the renderer may see. No loginCode.
*
* @param {object} mfa - API or stored challenge
* @return {object}
*/
function publicMfaFields(mfa) {
return {
method: mfa?.method || '',
methods: Array.isArray(mfa?.methods) ? mfa.methods : [],
prompt: mfa?.prompt || 'Choose how you want to authenticate.'
}
}
/**
* MFA fields the renderer may see. loginCode stays in main.
*
* @param {object} [mfa] - public challenge fields
* @return {object} - public pending-MFA session
*/
function publicPendingMfa(mfa) {
const challenge = mfa || pendingMfa?.mfa || {}
return {
connected: false,
pendingMfa: true,
siteUrl: pendingMfa?.siteUrl || '',
username: pendingMfa?.username || '',
lastSiteUrl: pendingMfa?.siteUrl || '',
mfa: publicMfaFields(challenge)
}
}
/**
* Remember a live MFA challenge in process memory.
*
* @param {string} siteUrl - canonical site base
* @param {string} username - TTP username
* @param {object} mfa - API challenge payload
* @return {object} - public pending-MFA session
*/
function rememberPendingMfa(siteUrl, username, mfa) {
pendingMfa = {
siteUrl,
username,
loginCode: mfa.loginCode,
mfa: publicMfaFields(mfa)
}
return publicPendingMfa(pendingMfa.mfa)
}
/**
* Drop the in-memory MFA challenge.
*
* @return {void}
*/
function clearPendingMfa() {
pendingMfa = null
}
/**
* Turn a token-or-mfa API result into a public session.
*
* @param {object} result - { token } or { mfa }
* @return {Promise<object>}
*/
async function finishAuthResult(result) {
if (result?.mfa) {
if (!pendingMfa) {
throw new Error('Sign in first.')
}
if (result.mfa.loginCode) {
pendingMfa.loginCode = result.mfa.loginCode
}
pendingMfa.mfa = publicMfaFields(result.mfa)
return publicPendingMfa(pendingMfa.mfa)
}
const siteUrl = pendingMfa.siteUrl
const username = pendingMfa.username
clearPendingMfa()
return persistConnection({ siteUrl, token: result.token, username, authMethod: 'password' })
}
/**
* Require a live in-memory MFA challenge.
*
* @return {object} - pending row
*/
function requirePendingMfa() {
if (!pendingMfa?.loginCode || !pendingMfa.siteUrl) {
throw new Error('Sign in first.')
}
return pendingMfa
}
/**
* Register session IPC. Call once after app ready.
*
@ -64,6 +177,9 @@ async function persistConnection(fields) {
*/
export function registerSessionIpc() {
ipcMain.handle('capsule:session', () => {
if (pendingMfa) {
return publicPendingMfa(pendingMfa.mfa)
}
return publicSession(readSession())
})
@ -76,8 +192,65 @@ export function registerSessionIpc() {
throw new Error('Username and password are required.')
}
const token = await loginWithPassword(siteUrl, username, password)
return persistConnection({ siteUrl, token, username, authMethod: 'password' })
clearPendingMfa()
const result = await loginWithPassword(siteUrl, username, password)
if (result.mfa) {
return rememberPendingMfa(siteUrl, username, result.mfa)
}
return persistConnection({ siteUrl, token: result.token, username, authMethod: 'password' })
})
ipcMain.handle('capsule:mfaChallenge', async (_event, payload) => {
const pending = requirePendingMfa()
const authCode = String(payload?.authCode || '').replace(/\D/g, '')
if (authCode.length < 6) {
throw new Error('Please enter your authentication code.')
}
try {
return await finishAuthResult(await submitMfaCode(pending.siteUrl, pending.loginCode, authCode))
} catch (err) {
if (String(err?.message || '').includes('expired')) {
clearPendingMfa()
}
throw err
}
})
ipcMain.handle('capsule:mfaSelect', async (_event, payload) => {
const pending = requirePendingMfa()
const method = String(payload?.method || '')
if (!method) {
throw new Error('Choose how you want to authenticate.')
}
try {
return await finishAuthResult(await selectMfaMethod(pending.siteUrl, pending.loginCode, method))
} catch (err) {
if (String(err?.message || '').includes('expired')) {
clearPendingMfa()
}
throw err
}
})
ipcMain.handle('capsule:mfaReset', async () => {
const pending = requirePendingMfa()
try {
return await finishAuthResult(await resetMfaMethod(pending.siteUrl, pending.loginCode))
} catch (err) {
if (String(err?.message || '').includes('expired')) {
clearPendingMfa()
}
throw err
}
})
ipcMain.handle('capsule:mfaCancel', () => {
const lastSiteUrl = pendingMfa?.siteUrl || ''
clearPendingMfa()
if (lastSiteUrl) {
writeLastSite(lastSiteUrl)
}
return publicSession(readSession())
})
ipcMain.handle('capsule:connectToken', async (_event, payload) => {
@ -89,6 +262,7 @@ export function registerSessionIpc() {
throw new Error('Paste an API token.')
}
clearPendingMfa()
return persistConnection({ siteUrl, token, username, authMethod: 'token' })
})
@ -98,19 +272,21 @@ export function registerSessionIpc() {
return publicSession(session)
}
if (!session.username) {
return publicSession(session)
}
try {
const found = await findUser(session.siteUrl, session.token, session.username)
if (isDeadTokenError(found.error)) {
const profile = await getProfile(session.siteUrl, session.token)
if (profile.error) {
if (isDeadTokenError(profile.error)) {
writeLastSite(session.siteUrl)
return publicSession(readSession())
}
session.apiReady = false
writeSession(session)
return publicSession(session)
}
session.userId = found.userId
session.apiReady = found.userId !== null
session.username = profile.user?.username || session.username
session.userId = profile.user?.id ?? session.userId
session.apiReady = true
writeSession(session)
return publicSession(session)
} catch {
@ -120,7 +296,9 @@ export function registerSessionIpc() {
ipcMain.handle('capsule:logout', () => {
const session = readSession()
writeLastSite(session?.siteUrl || session?.lastSiteUrl || '')
const lastSiteUrl = pendingMfa?.siteUrl || session?.siteUrl || session?.lastSiteUrl || ''
clearPendingMfa()
writeLastSite(lastSiteUrl)
return publicSession(readSession())
})
}

View File

@ -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
})
}

View File

@ -14,12 +14,50 @@ const capsule = {
* Sign in with a TTP username and password.
*
* @param {object} payload - siteUrl, username, password
* @return {Promise<object>} - public session
* @return {Promise<object>} - public session or pending MFA
*/
login(payload) {
return ipcRenderer.invoke('capsule:login', payload)
},
/**
* Submit a 6-digit MFA code for the in-memory challenge.
*
* @param {object} payload - authCode
* @return {Promise<object>} - public session or pending MFA
*/
mfaChallenge(payload) {
return ipcRenderer.invoke('capsule:mfaChallenge', payload)
},
/**
* Pick an MFA method for the in-memory challenge.
*
* @param {object} payload - method key
* @return {Promise<object>} - public session or pending MFA
*/
mfaSelect(payload) {
return ipcRenderer.invoke('capsule:mfaSelect', payload)
},
/**
* Clear the chosen MFA method so the picker shows again.
*
* @return {Promise<object>} - pending MFA
*/
mfaReset() {
return ipcRenderer.invoke('capsule:mfaReset')
},
/**
* Drop the in-memory MFA challenge and return to login.
*
* @return {Promise<object>} - public session
*/
mfaCancel() {
return ipcRenderer.invoke('capsule:mfaCancel')
},
/**
* Connect with an existing API token from Admin ? Tokens.
*
@ -46,6 +84,154 @@ const capsule = {
*/
logout() {
return ipcRenderer.invoke('capsule:logout')
},
/**
* Profile plus first page of notifications and messages.
*
* @return {Promise<object>}
*/
workspace() {
return ipcRenderer.invoke('capsule:workspace')
},
/**
* Current user. GET api/profile.
*
* @return {Promise<object>}
*/
profile() {
return ipcRenderer.invoke('capsule:profile')
},
/**
* Save prefs and optional avatar. POST api/profile/update.
*
* @param {object} payload - fields, optional avatar { name, type, data }
* @return {Promise<object>}
*/
updateProfile(payload) {
return ipcRenderer.invoke('capsule:updateProfile', payload)
},
/**
* Paged notifications.
*
* @param {object} [payload] - page
* @return {Promise<object>}
*/
notifications(payload) {
return ipcRenderer.invoke('capsule:notifications', payload)
},
/**
* Mark a notification read.
*
* @param {object} payload - id
* @return {Promise<object>}
*/
notificationRead(payload) {
return ipcRenderer.invoke('capsule:notificationRead', payload)
},
/**
* Soft-delete a notification.
*
* @param {object} payload - id
* @return {Promise<object>}
*/
notificationDelete(payload) {
return ipcRenderer.invoke('capsule:notificationDelete', payload)
},
/**
* Paged inbox.
*
* @param {object} [payload] - page
* @return {Promise<object>}
*/
messages(payload) {
return ipcRenderer.invoke('capsule:messages', payload)
},
/**
* One conversation thread.
*
* @param {object} payload - id
* @return {Promise<object>}
*/
messageView(payload) {
return ipcRenderer.invoke('capsule:messageView', payload)
},
/**
* Start a conversation.
*
* @param {object} payload - toUser, message
* @return {Promise<object>}
*/
messageCreate(payload) {
return ipcRenderer.invoke('capsule:messageCreate', payload)
},
/**
* Reply in a conversation.
*
* @param {object} payload - id, message
* @return {Promise<object>}
*/
messageReply(payload) {
return ipcRenderer.invoke('capsule:messageReply', payload)
},
/**
* Mark a conversation read.
*
* @param {object} payload - id
* @return {Promise<object>}
*/
messageRead(payload) {
return ipcRenderer.invoke('capsule:messageRead', payload)
},
/**
* Hide a conversation.
*
* @param {object} payload - id
* @return {Promise<object>}
*/
messageDelete(payload) {
return ipcRenderer.invoke('capsule:messageDelete', payload)
},
/**
* Site search.
*
* @param {object} payload - q, resource, page
* @return {Promise<object>}
*/
search(payload) {
return ipcRenderer.invoke('capsule:search', payload)
},
/**
* Contact form submit.
*
* @param {object} payload - name, entry, email
* @return {Promise<object>}
*/
contact(payload) {
return ipcRenderer.invoke('capsule:contact', payload)
},
/**
* Bug report submit.
*
* @param {object} payload - url, ourl, repeat, entry
* @return {Promise<object>}
*/
bugreport(payload) {
return ipcRenderer.invoke('capsule:bugreport', payload)
}
}

View File

@ -201,7 +201,7 @@
<div class="mx-auto p-4 rounded context-main-bg capsule-login-card">
<h1 class="h3 mb-3 text-center">Connect a site</h1>
<p class="text-muted text-center mb-4">
Sign in to any Tempus Project install. Capsule talks to that site<EFBFBD>s API and keeps
Sign in to any Tempus Project install. Capsule talks to that site?s API and keeps
the session on this machine.
</p>
<form id="login-form">
@ -286,6 +286,52 @@
</div>
</section>
<section id="view-mfa" class="view" hidden>
<div class="container pt-4">
<div class="mx-auto p-4 rounded context-main-bg capsule-login-card">
<h1 class="h3 mb-3 text-center">Verify it's you</h1>
<p id="mfa-prompt" class="text-muted text-center mb-4">
Choose how you want to authenticate.
</p>
<form id="mfa-method-form" hidden>
<fieldset>
<legend class="visually-hidden">Authentication method</legend>
<div id="mfa-methods" class="mb-3"></div>
</fieldset>
<p id="mfa-method-error" class="text-danger" hidden></p>
<button id="mfa-method-submit" class="btn btn-primary w-100" type="submit">
Continue
</button>
</form>
<form id="mfa-code-form" hidden>
<div class="mb-3">
<label class="form-label" for="mfa-code">Authentication code</label>
<input
id="mfa-code"
class="form-control"
name="authCode"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="8"
required
/>
</div>
<p id="mfa-code-error" class="text-danger" hidden></p>
<button id="mfa-code-submit" class="btn btn-primary w-100" type="submit">
Continue
</button>
<button id="mfa-reset" class="btn btn-link w-100 mt-2" type="button" hidden>
Choose another method
</button>
</form>
<p class="text-center mt-4 mb-0">
<button id="mfa-cancel" type="button" class="btn btn-link">Cancel</button>
</p>
</div>
</div>
</section>
<section id="view-stub" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
@ -297,6 +343,7 @@
<section id="view-home" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h3">Dashboard</h1>
<h1 class="h3">Workspace</h1>
<p class="text-muted" id="home-note">
Search, notifications, messages, and profile are here. Site API wiring is the next
@ -306,6 +353,51 @@
Connected to <a id="home-site" href="#" target="_blank" rel="noreferrer"></a>
as <strong id="home-username"></strong>.
</p>
<p id="home-plugins" class="small text-muted mb-4"></p>
<div class="row g-4">
<div class="col-md-6" id="contact-panel" hidden>
<h2 class="h5">Contact</h2>
<form id="contact-form">
<div class="mb-2">
<label class="form-label" for="contact-name">Name</label>
<input id="contact-name" class="form-control" name="name" type="text" required />
</div>
<div class="mb-2">
<label class="form-label" for="contact-email">Email</label>
<input id="contact-email" class="form-control" name="email" type="email" />
</div>
<div class="mb-2">
<label class="form-label" for="contact-entry">Message</label>
<textarea id="contact-entry" class="form-control" name="entry" rows="3" required></textarea>
</div>
<p id="contact-status" class="small" hidden></p>
<button class="btn btn-primary" type="submit">Send</button>
</form>
</div>
<div class="col-md-6" id="bugreport-panel" hidden>
<h2 class="h5">Bug report</h2>
<form id="bugreport-form">
<div class="mb-2">
<label class="form-label" for="bug-url">Page URL</label>
<input id="bug-url" class="form-control" name="url" type="url" required />
</div>
<div class="mb-2">
<label class="form-label" for="bug-ourl">Expected URL <span class="text-muted">(optional)</span></label>
<input id="bug-ourl" class="form-control" name="ourl" type="url" />
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="bug-repeat" />
<label class="form-check-label" for="bug-repeat">This happens every time</label>
</div>
<div class="mb-2">
<label class="form-label" for="bug-entry">What happened</label>
<textarea id="bug-entry" class="form-control" name="entry" rows="3" required></textarea>
</div>
<p id="bug-status" class="small" hidden></p>
<button class="btn btn-primary" type="submit">Send report</button>
</form>
</div>
</div>
</div>
</div>
</section>
@ -315,7 +407,13 @@
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h3">Search</h1>
<p class="text-muted" id="search-summary"></p>
<p class="mb-0 text-muted">Results will come from the site search API next.</p>
<p id="search-error" class="text-danger" hidden></p>
<div id="search-results" class="list-group mb-3"></div>
<nav id="search-pager" class="d-flex gap-2" hidden>
<button id="search-prev" type="button" class="btn btn-sm btn-outline-primary">Previous</button>
<span id="search-page-label" class="align-self-center small text-muted"></span>
<button id="search-next" type="button" class="btn btn-sm btn-outline-primary">Next</button>
</nav>
</div>
</div>
</section>
@ -324,6 +422,7 @@
<div class="m-2 m-lg-4">
<div class="col-12 col-sm-10 col-lg-8 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h3 text-center">Notifications</h1>
<p id="notifications-empty" class="text-muted text-center" hidden></p>
<table class="table">
<tbody id="notification-list"></tbody>
</table>
@ -334,12 +433,14 @@
<section id="view-messages" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-sm-10 col-lg-8 mx-auto p-4 rounded shadow-sm context-main-bg">
<div id="messages-inbox">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Messages</h1>
<button type="button" class="btn btn-sm btn-primary" disabled>
<a href="#/messages/new" class="btn btn-sm btn-primary" id="messages-new">
<i class="fa fa-fw fa-pen"></i> New message
</button>
</a>
</div>
<p id="messages-empty" class="text-muted" hidden></p>
<table class="table table-striped">
<thead>
<tr>
@ -351,6 +452,36 @@
<tbody id="message-list"></tbody>
</table>
</div>
<div id="messages-compose" hidden>
<h1 class="h3">New message</h1>
<form id="compose-form">
<div class="mb-3">
<label class="form-label" for="compose-to">Username</label>
<input id="compose-to" class="form-control" name="toUser" type="text" autocomplete="username" required />
</div>
<div class="mb-3">
<label class="form-label" for="compose-body">Message</label>
<textarea id="compose-body" class="form-control" name="message" rows="4" required></textarea>
</div>
<p id="compose-error" class="text-danger" hidden></p>
<button class="btn btn-primary" type="submit">Send</button>
<a href="#/messages" class="btn btn-link">Cancel</a>
</form>
</div>
<div id="messages-thread" hidden>
<button id="thread-back" type="button" class="btn btn-link px-0 mb-2">
<i class="fa fa-fw fa-arrow-left"></i> Inbox
</button>
<h1 class="h3" id="thread-title"></h1>
<p id="thread-error" class="text-danger" hidden></p>
<div id="thread-lines" class="capsule-thread mb-3"></div>
<form id="thread-reply">
<label class="form-label" for="thread-body">Reply</label>
<textarea id="thread-body" class="form-control mb-2" rows="3" required></textarea>
<button class="btn btn-primary" type="submit">Send</button>
</form>
</div>
</div>
</div>
</section>
@ -424,6 +555,10 @@
<label class="form-label d-block" for="settings-avatar-file">Avatar</label>
<input id="settings-avatar-file" class="form-control" type="file" accept="image/*" />
</div>
<div class="mb-3">
<label class="form-label" for="settings-name">Display name</label>
<input id="settings-name" class="form-control" name="name" type="text" autocomplete="name" />
</div>
<div class="mb-3">
<label class="form-label" for="settings-gender">Gender</label>
<select id="settings-gender" class="form-select" name="gender">
@ -458,9 +593,10 @@
<label class="form-check-label" for="settings-dark">Enable Dark-Mode viewing</label>
</div>
<p id="settings-note" class="small text-muted">
Saving will use the site API next. This form matches User CP preferences.
Email, password, and phone stay on the site.
</p>
<p id="settings-status" class="text-success" hidden></p>
<p id="settings-error" class="text-danger" hidden></p>
<button class="btn btn-lg btn-primary w-100" type="submit">Update</button>
</fieldset>
</form>

View File

@ -40,7 +40,7 @@ export const demoMessages = [
id: 'm2',
unread: false,
otherUser: 'Sam',
preview: 'Search should stay in the top middle <20> full width.',
preview: 'Search should stay in the top middle <20> full width.',
lastMessageAt: 'Monday'
}
]
@ -60,7 +60,8 @@ export const demoProfile = {
}
export const dateFormatOptions = [
{ label: 'January 8, 1991', value: 'F-j-Y' },
{ label: 'January 8, 1991', value: 'F j, Y' },
{ label: 'January 8, 1991 (hyphen)', value: 'F-j-Y' },
{ label: '8 January, 1991', value: 'j-F-Y' },
{ label: 'Jan 8, 1991', value: 'M-j-Y' },
{ label: '1-8-1991', value: 'n-j-Y' },

File diff suppressed because it is too large Load Diff

View File

@ -280,6 +280,28 @@ header .form-select:focus {
font-weight: 600;
}
.capsule-thread {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.capsule-bubble {
max-width: 80%;
padding: 0.65rem 0.85rem;
border-radius: var(--ttp-radius-sm);
background: var(--ttp-surface-alt);
}
.capsule-bubble.is-mine {
align-self: flex-end;
background: rgba(var(--ttp-primary-rgb), 0.16);
}
.capsule-row-link {
cursor: pointer;
}
@media (max-width: 900px) {
.capsule-header {
grid-template-columns: auto auto;