mfa and api wiring
This commit is contained in:
@ -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.
|
||||
|
||||
23
README.md
23
README.md
@ -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
222
src/main/apiIpc.js
Normal 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 || ''
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
@ -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', () => {
|
||||
|
||||
@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user