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
|
### Added
|
||||||
|
|
||||||
- Electron + electron-vite desktop shell with a login view and a connected workspace.
|
- 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.
|
- 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
|
## 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
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@ -17,15 +17,22 @@ npm run dev
|
|||||||
|
|
||||||
## How auth works
|
## 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 |
|
| 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. |
|
| 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. |
|
||||||
| Confirm identity | `GET /api/users/find/{username}` | Bearer token. Returns a user id only. |
|
| MFA code / method | `POST /api/login/mfa/{loginCode}` | `auth_code` or `mfaMethodSelect`. `loginCode` stays in the main process. |
|
||||||
| Existing token | Admin ? Tokens | Personal or app token. Username is optional and only used for that find call. |
|
| 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`.
|
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/main/` | Window, session file, TTP HTTP, IPC |
|
||||||
| `src/preload/` | `window.capsule` bridge |
|
| `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
|
## 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 { app, BrowserWindow, shell } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
|
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
|
||||||
|
import { registerApiIpc } from './apiIpc.js'
|
||||||
import { registerSessionIpc } from './sessionIpc.js'
|
import { registerSessionIpc } from './sessionIpc.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -48,6 +49,7 @@ app.whenReady().then(() => {
|
|||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
registerSessionIpc()
|
registerSessionIpc()
|
||||||
|
registerApiIpc()
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
app.on('activate', () => {
|
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 { 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'
|
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.
|
* Build a stored session after a successful auth.
|
||||||
*
|
*
|
||||||
@ -19,30 +30,32 @@ import { publicSession, readSession, writeLastSite, writeSession } from './sessi
|
|||||||
async function persistConnection(fields) {
|
async function persistConnection(fields) {
|
||||||
const siteUrl = fields.siteUrl
|
const siteUrl = fields.siteUrl
|
||||||
const token = fields.token
|
const token = fields.token
|
||||||
const username = String(fields.username || '').trim()
|
let username = String(fields.username || '').trim()
|
||||||
const authMethod = fields.authMethod
|
const authMethod = fields.authMethod
|
||||||
let userId = null
|
let userId = null
|
||||||
let apiReady = false
|
let apiReady = false
|
||||||
|
|
||||||
if (username) {
|
|
||||||
try {
|
try {
|
||||||
const found = await findUser(siteUrl, token, username)
|
const profile = await getProfile(siteUrl, token)
|
||||||
if (isDeadTokenError(found.error)) {
|
if (profile.error) {
|
||||||
|
if (isDeadTokenError(profile.error)) {
|
||||||
const dead = new 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'
|
dead.code = 'DEAD_TOKEN'
|
||||||
throw dead
|
throw dead
|
||||||
}
|
}
|
||||||
userId = found.userId
|
} else if (profile.user) {
|
||||||
apiReady = found.userId !== null
|
username = profile.user.username || username
|
||||||
|
userId = profile.user.id ?? null
|
||||||
|
apiReady = true
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.code === 'DEAD_TOKEN') {
|
if (err?.code === 'DEAD_TOKEN') {
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
apiReady = false
|
apiReady = false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const session = {
|
const session = {
|
||||||
siteUrl,
|
siteUrl,
|
||||||
@ -57,6 +70,106 @@ async function persistConnection(fields) {
|
|||||||
return publicSession(session)
|
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.
|
* Register session IPC. Call once after app ready.
|
||||||
*
|
*
|
||||||
@ -64,6 +177,9 @@ async function persistConnection(fields) {
|
|||||||
*/
|
*/
|
||||||
export function registerSessionIpc() {
|
export function registerSessionIpc() {
|
||||||
ipcMain.handle('capsule:session', () => {
|
ipcMain.handle('capsule:session', () => {
|
||||||
|
if (pendingMfa) {
|
||||||
|
return publicPendingMfa(pendingMfa.mfa)
|
||||||
|
}
|
||||||
return publicSession(readSession())
|
return publicSession(readSession())
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -76,8 +192,65 @@ export function registerSessionIpc() {
|
|||||||
throw new Error('Username and password are required.')
|
throw new Error('Username and password are required.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = await loginWithPassword(siteUrl, username, password)
|
clearPendingMfa()
|
||||||
return persistConnection({ siteUrl, token, username, authMethod: 'password' })
|
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) => {
|
ipcMain.handle('capsule:connectToken', async (_event, payload) => {
|
||||||
@ -89,6 +262,7 @@ export function registerSessionIpc() {
|
|||||||
throw new Error('Paste an API token.')
|
throw new Error('Paste an API token.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearPendingMfa()
|
||||||
return persistConnection({ siteUrl, token, username, authMethod: 'token' })
|
return persistConnection({ siteUrl, token, username, authMethod: 'token' })
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -98,19 +272,21 @@ export function registerSessionIpc() {
|
|||||||
return publicSession(session)
|
return publicSession(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session.username) {
|
|
||||||
return publicSession(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const found = await findUser(session.siteUrl, session.token, session.username)
|
const profile = await getProfile(session.siteUrl, session.token)
|
||||||
if (isDeadTokenError(found.error)) {
|
if (profile.error) {
|
||||||
|
if (isDeadTokenError(profile.error)) {
|
||||||
writeLastSite(session.siteUrl)
|
writeLastSite(session.siteUrl)
|
||||||
return publicSession(readSession())
|
return publicSession(readSession())
|
||||||
}
|
}
|
||||||
|
session.apiReady = false
|
||||||
|
writeSession(session)
|
||||||
|
return publicSession(session)
|
||||||
|
}
|
||||||
|
|
||||||
session.userId = found.userId
|
session.username = profile.user?.username || session.username
|
||||||
session.apiReady = found.userId !== null
|
session.userId = profile.user?.id ?? session.userId
|
||||||
|
session.apiReady = true
|
||||||
writeSession(session)
|
writeSession(session)
|
||||||
return publicSession(session)
|
return publicSession(session)
|
||||||
} catch {
|
} catch {
|
||||||
@ -120,7 +296,9 @@ export function registerSessionIpc() {
|
|||||||
|
|
||||||
ipcMain.handle('capsule:logout', () => {
|
ipcMain.handle('capsule:logout', () => {
|
||||||
const session = readSession()
|
const session = readSession()
|
||||||
writeLastSite(session?.siteUrl || session?.lastSiteUrl || '')
|
const lastSiteUrl = pendingMfa?.siteUrl || session?.siteUrl || session?.lastSiteUrl || ''
|
||||||
|
clearPendingMfa()
|
||||||
|
writeLastSite(lastSiteUrl)
|
||||||
return publicSession(readSession())
|
return publicSession(readSession())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,8 @@ export function normalizeSiteUrl(raw) {
|
|||||||
* @param {string} [options.method='GET'] - HTTP method
|
* @param {string} [options.method='GET'] - HTTP method
|
||||||
* @param {string} [options.token] - Bearer token
|
* @param {string} [options.token] - Bearer token
|
||||||
* @param {Record<string, string>} [options.form] - urlencoded body
|
* @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
|
* @return {Promise<object>} - parsed JSON
|
||||||
*/
|
*/
|
||||||
export async function ttpRequest(options) {
|
export async function ttpRequest(options) {
|
||||||
@ -46,17 +48,46 @@ export async function ttpRequest(options) {
|
|||||||
const method = options.method || 'GET'
|
const method = options.method || 'GET'
|
||||||
const token = options.token
|
const token = options.token
|
||||||
const form = options.form
|
const form = options.form
|
||||||
const url = `${siteUrl}${path}`
|
const multipart = options.multipart
|
||||||
const headers = { Accept: 'application/json' }
|
const headers = { Accept: 'application/json' }
|
||||||
|
let url = `${siteUrl}${path}`
|
||||||
let body
|
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) {
|
if (token) {
|
||||||
headers.Authorization = `Bearer ${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'
|
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||||
body = new URLSearchParams(form).toString()
|
body = new URLSearchParams(form).toString()
|
||||||
|
} else if (method === 'POST') {
|
||||||
|
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||||
|
body = 'submit=1'
|
||||||
}
|
}
|
||||||
|
|
||||||
let response
|
let response
|
||||||
@ -81,12 +112,13 @@ export async function ttpRequest(options) {
|
|||||||
* Map a TTP API error string to a short user-facing line.
|
* Map a TTP API error string to a short user-facing line.
|
||||||
*
|
*
|
||||||
* @param {string} code - API `error` value
|
* @param {string} code - API `error` value
|
||||||
|
* @param {unknown} [errors] - optional Check user errors
|
||||||
* @return {string} - message for the login form
|
* @return {string} - message for the login form
|
||||||
*/
|
*/
|
||||||
export function apiErrorMessage(code) {
|
export function apiErrorMessage(code, errors) {
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case 'malformed input':
|
case 'malformed input':
|
||||||
return 'Username and password are required.'
|
return firstUserError(errors) || 'Check the form and try again.'
|
||||||
case 'bad credentials':
|
case 'bad credentials':
|
||||||
return 'Those credentials were not accepted.'
|
return 'Those credentials were not accepted.'
|
||||||
case 'invalid token':
|
case 'invalid token':
|
||||||
@ -96,18 +128,83 @@ export function apiErrorMessage(code) {
|
|||||||
return 'That API token has expired.'
|
return 'That API token has expired.'
|
||||||
case 'IRDK':
|
case 'IRDK':
|
||||||
return 'The site could not refresh this token.'
|
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:
|
default:
|
||||||
return code || 'The site returned an error.'
|
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.
|
* Sign in with username and password. POST api/login.
|
||||||
*
|
*
|
||||||
* @param {string} siteUrl - canonical site base
|
* @param {string} siteUrl - canonical site base
|
||||||
* @param {string} username - TTP username
|
* @param {string} username - TTP username
|
||||||
* @param {string} password - TTP password
|
* @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) {
|
export async function loginWithPassword(siteUrl, username, password) {
|
||||||
const data = await ttpRequest({
|
const data = await ttpRequest({
|
||||||
@ -117,15 +214,76 @@ export async function loginWithPassword(siteUrl, username, password) {
|
|||||||
form: { username, password }
|
form: { username, password }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error === 'malformed input' && !data.errors) {
|
||||||
throw new Error(apiErrorMessage(data.error))
|
throw new Error('Username and password are required.')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.token) {
|
return authResult(data)
|
||||||
throw new Error('The site did not return a token.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
export function isDeadTokenError(error) {
|
||||||
return error === 'invalid token' || error === 'invalid secret' || error === 'token expired'
|
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.
|
* Sign in with a TTP username and password.
|
||||||
*
|
*
|
||||||
* @param {object} payload - siteUrl, username, password
|
* @param {object} payload - siteUrl, username, password
|
||||||
* @return {Promise<object>} - public session
|
* @return {Promise<object>} - public session or pending MFA
|
||||||
*/
|
*/
|
||||||
login(payload) {
|
login(payload) {
|
||||||
return ipcRenderer.invoke('capsule: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.
|
* Connect with an existing API token from Admin ? Tokens.
|
||||||
*
|
*
|
||||||
@ -46,6 +84,154 @@ const capsule = {
|
|||||||
*/
|
*/
|
||||||
logout() {
|
logout() {
|
||||||
return ipcRenderer.invoke('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">
|
<div class="mx-auto p-4 rounded context-main-bg capsule-login-card">
|
||||||
<h1 class="h3 mb-3 text-center">Connect a site</h1>
|
<h1 class="h3 mb-3 text-center">Connect a site</h1>
|
||||||
<p class="text-muted text-center mb-4">
|
<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.
|
the session on this machine.
|
||||||
</p>
|
</p>
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
@ -286,6 +286,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<section id="view-stub" class="view" hidden>
|
||||||
<div class="m-2 m-lg-4">
|
<div class="m-2 m-lg-4">
|
||||||
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
|
<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>
|
<section id="view-home" class="view" hidden>
|
||||||
<div class="m-2 m-lg-4">
|
<div class="m-2 m-lg-4">
|
||||||
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
|
<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>
|
<h1 class="h3">Workspace</h1>
|
||||||
<p class="text-muted" id="home-note">
|
<p class="text-muted" id="home-note">
|
||||||
Search, notifications, messages, and profile are here. Site API wiring is the next
|
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>
|
Connected to <a id="home-site" href="#" target="_blank" rel="noreferrer"></a>
|
||||||
as <strong id="home-username"></strong>.
|
as <strong id="home-username"></strong>.
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -315,7 +407,13 @@
|
|||||||
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
|
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
|
||||||
<h1 class="h3">Search</h1>
|
<h1 class="h3">Search</h1>
|
||||||
<p class="text-muted" id="search-summary"></p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -324,6 +422,7 @@
|
|||||||
<div class="m-2 m-lg-4">
|
<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 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>
|
<h1 class="h3 text-center">Notifications</h1>
|
||||||
|
<p id="notifications-empty" class="text-muted text-center" hidden></p>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<tbody id="notification-list"></tbody>
|
<tbody id="notification-list"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -334,12 +433,14 @@
|
|||||||
<section id="view-messages" class="view" hidden>
|
<section id="view-messages" class="view" hidden>
|
||||||
<div class="m-2 m-lg-4">
|
<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 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">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h1 class="h3 mb-0">Messages</h1>
|
<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
|
<i class="fa fa-fw fa-pen"></i> New message
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<p id="messages-empty" class="text-muted" hidden></p>
|
||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -351,6 +452,36 @@
|
|||||||
<tbody id="message-list"></tbody>
|
<tbody id="message-list"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -424,6 +555,10 @@
|
|||||||
<label class="form-label d-block" for="settings-avatar-file">Avatar</label>
|
<label class="form-label d-block" for="settings-avatar-file">Avatar</label>
|
||||||
<input id="settings-avatar-file" class="form-control" type="file" accept="image/*" />
|
<input id="settings-avatar-file" class="form-control" type="file" accept="image/*" />
|
||||||
</div>
|
</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">
|
<div class="mb-3">
|
||||||
<label class="form-label" for="settings-gender">Gender</label>
|
<label class="form-label" for="settings-gender">Gender</label>
|
||||||
<select id="settings-gender" class="form-select" name="gender">
|
<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>
|
<label class="form-check-label" for="settings-dark">Enable Dark-Mode viewing</label>
|
||||||
</div>
|
</div>
|
||||||
<p id="settings-note" class="small text-muted">
|
<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>
|
||||||
<p id="settings-status" class="text-success" hidden></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>
|
<button class="btn btn-lg btn-primary w-100" type="submit">Update</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export const demoMessages = [
|
|||||||
id: 'm2',
|
id: 'm2',
|
||||||
unread: false,
|
unread: false,
|
||||||
otherUser: 'Sam',
|
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'
|
lastMessageAt: 'Monday'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -60,7 +60,8 @@ export const demoProfile = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const dateFormatOptions = [
|
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: '8 January, 1991', value: 'j-F-Y' },
|
||||||
{ label: 'Jan 8, 1991', value: 'M-j-Y' },
|
{ label: 'Jan 8, 1991', value: 'M-j-Y' },
|
||||||
{ label: '1-8-1991', value: 'n-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;
|
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) {
|
@media (max-width: 900px) {
|
||||||
.capsule-header {
|
.capsule-header {
|
||||||
grid-template-columns: auto auto;
|
grid-template-columns: auto auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user