diff --git a/CHANGELOG.md b/CHANGELOG.md index 971bb2b..fe9cecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 7254ba3..d48a16e 100644 --- a/README.md +++ b/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  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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 — those open `{site}/usercp/…`. 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 diff --git a/src/main/apiIpc.js b/src/main/apiIpc.js new file mode 100644 index 0000000..9d1073f --- /dev/null +++ b/src/main/apiIpc.js @@ -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} work - HTTP call + * @return {Promise} + */ +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 || '' + }) + ) + }) +} diff --git a/src/main/index.js b/src/main/index.js index ea81985..7586afe 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -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', () => { diff --git a/src/main/sessionIpc.js b/src/main/sessionIpc.js index c5c10a1..1441ccc 100644 --- a/src/main/sessionIpc.js +++ b/src/main/sessionIpc.js @@ -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,29 +30,31 @@ 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)) { + try { + 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 - } catch (err) { - if (err?.code === 'DEAD_TOKEN') { - throw err - } - apiReady = false + } 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 = { @@ -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} + */ +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)) { - writeLastSite(session.siteUrl) - return publicSession(readSession()) + 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()) }) } diff --git a/src/main/ttpClient.js b/src/main/ttpClient.js index 4f76168..7bae3d5 100644 --- a/src/main/ttpClient.js +++ b/src/main/ttpClient.js @@ -38,6 +38,8 @@ export function normalizeSiteUrl(raw) { * @param {string} [options.method='GET'] - HTTP method * @param {string} [options.token] - Bearer token * @param {Record} [options.form] - urlencoded body + * @param {Record} [options.multipart] - multipart fields (avatar) + * @param {Record} [options.query] - query string * @return {Promise} - 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} - 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} + */ +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} fields - form fields + * @param {object} [avatar] - IPC file payload + * @return {Promise} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +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} + */ +export async function sendBugreport(siteUrl, token, fields) { + return ttpRequest({ + siteUrl, + path: '/api/bugreport', + method: 'POST', + token, + form: fields + }) +} diff --git a/src/preload/index.js b/src/preload/index.js index 2755b25..eb18f70 100644 --- a/src/preload/index.js +++ b/src/preload/index.js @@ -14,12 +14,50 @@ const capsule = { * Sign in with a TTP username and password. * * @param {object} payload - siteUrl, username, password - * @return {Promise} - public session + * @return {Promise} - 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} - 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} - 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} - pending MFA + */ + mfaReset() { + return ipcRenderer.invoke('capsule:mfaReset') + }, + + /** + * Drop the in-memory MFA challenge and return to login. + * + * @return {Promise} - 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} + */ + workspace() { + return ipcRenderer.invoke('capsule:workspace') + }, + + /** + * Current user. GET api/profile. + * + * @return {Promise} + */ + 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} + */ + updateProfile(payload) { + return ipcRenderer.invoke('capsule:updateProfile', payload) + }, + + /** + * Paged notifications. + * + * @param {object} [payload] - page + * @return {Promise} + */ + notifications(payload) { + return ipcRenderer.invoke('capsule:notifications', payload) + }, + + /** + * Mark a notification read. + * + * @param {object} payload - id + * @return {Promise} + */ + notificationRead(payload) { + return ipcRenderer.invoke('capsule:notificationRead', payload) + }, + + /** + * Soft-delete a notification. + * + * @param {object} payload - id + * @return {Promise} + */ + notificationDelete(payload) { + return ipcRenderer.invoke('capsule:notificationDelete', payload) + }, + + /** + * Paged inbox. + * + * @param {object} [payload] - page + * @return {Promise} + */ + messages(payload) { + return ipcRenderer.invoke('capsule:messages', payload) + }, + + /** + * One conversation thread. + * + * @param {object} payload - id + * @return {Promise} + */ + messageView(payload) { + return ipcRenderer.invoke('capsule:messageView', payload) + }, + + /** + * Start a conversation. + * + * @param {object} payload - toUser, message + * @return {Promise} + */ + messageCreate(payload) { + return ipcRenderer.invoke('capsule:messageCreate', payload) + }, + + /** + * Reply in a conversation. + * + * @param {object} payload - id, message + * @return {Promise} + */ + messageReply(payload) { + return ipcRenderer.invoke('capsule:messageReply', payload) + }, + + /** + * Mark a conversation read. + * + * @param {object} payload - id + * @return {Promise} + */ + messageRead(payload) { + return ipcRenderer.invoke('capsule:messageRead', payload) + }, + + /** + * Hide a conversation. + * + * @param {object} payload - id + * @return {Promise} + */ + messageDelete(payload) { + return ipcRenderer.invoke('capsule:messageDelete', payload) + }, + + /** + * Site search. + * + * @param {object} payload - q, resource, page + * @return {Promise} + */ + search(payload) { + return ipcRenderer.invoke('capsule:search', payload) + }, + + /** + * Contact form submit. + * + * @param {object} payload - name, entry, email + * @return {Promise} + */ + contact(payload) { + return ipcRenderer.invoke('capsule:contact', payload) + }, + + /** + * Bug report submit. + * + * @param {object} payload - url, ourl, repeat, entry + * @return {Promise} + */ + bugreport(payload) { + return ipcRenderer.invoke('capsule:bugreport', payload) } } diff --git a/src/renderer/index.html b/src/renderer/index.html index 11eda84..b4bfeb5 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -201,7 +201,7 @@ + + @@ -324,6 +422,7 @@

Notifications

+
@@ -334,22 +433,54 @@ @@ -424,6 +555,10 @@
+
+ + +
+ +
` + }) + .join('') +} + +/** + * Fill in-app pages from workspace (or preview) data. * * @return {void} */ @@ -234,48 +430,169 @@ function renderPages() { const siteLink = document.getElementById('home-site') siteLink.textContent = session.siteUrl siteLink.href = session.siteUrl + document.getElementById('home-note').textContent = session.preview + ? 'Preview chrome. Sign in to a live site to load API data.' + : session.apiReady === false + ? 'Connected, but this token cannot use the user API (needs a personal token).' + : 'Search, notifications, messages, and profile load from this site.' + document.getElementById('home-plugins').textContent = plugins.length + ? `Enabled plugins: ${plugins.join(', ')}` + : 'Plugin list will show after profile loads.' - document.getElementById('notification-list').innerHTML = demoNotifications - .map( - (item) => ` - - ${item.html} - ${item.createdAt} - - + ` - ) - .join('') + ) + .join('') + } - document.getElementById('message-list').innerHTML = demoMessages - .map( - (item) => ` - - ${item.otherUser} - ${item.preview} - ${item.lastMessageAt} + const mailEmpty = document.getElementById('messages-empty') + if (messages.unavailable || messages.error) { + mailEmpty.hidden = false + mailEmpty.textContent = messages.error || 'Messages are not available on this site.' + document.getElementById('message-list').innerHTML = '' + } else if ((messages.items || []).length === 0) { + mailEmpty.hidden = false + mailEmpty.textContent = 'No conversations yet.' + document.getElementById('message-list').innerHTML = '' + } else { + mailEmpty.hidden = true + document.getElementById('message-list').innerHTML = messages.items + .map( + (item) => ` + + ${escapeHtml(item.otherUser || '')} + ${escapeHtml(item.preview || '')} + ${escapeHtml(formatTime(item.lastMessageAt))} ` - ) - .join('') + ) + .join('') + } - profile.username = session.username || profile.username - profile.usernamePretty = profile.username - document.getElementById('profile-name').textContent = profile.usernamePretty - document.getElementById('profile-registered').textContent = profile.registered - document.getElementById('profile-last-login').textContent = profile.lastLogin - document.getElementById('profile-gender').textContent = profile.gender + const displayName = profile.name || profile.username || session.username || '' + document.getElementById('profile-name').textContent = displayName + document.getElementById('profile-registered').textContent = profile.registered || '' + document.getElementById('profile-last-login').textContent = profile.lastLogin || '' + document.getElementById('profile-gender').textContent = profile.gender || '' + document.getElementById('settings-name').value = profile.name || '' document.getElementById('settings-gender').value = profile.gender document.getElementById('settings-newsletter').checked = profile.newsletter document.getElementById('settings-dark').checked = profile.darkMode - fillSelect(document.getElementById('settings-timezone'), timezoneOptions, profile.timezone) + fillSelect(document.getElementById('settings-timezone'), timezoneList(), profile.timezone) fillSelect(document.getElementById('settings-date'), dateFormatOptions, profile.dateFormat) fillSelect(document.getElementById('settings-time'), timeFormatOptions, profile.timeFormat) fillSelect(document.getElementById('settings-limit'), pageLimitOptions, profile.pageLimit) applyTheme(profile.darkMode) + + renderSearch() + renderThread() +} + +/** + * Search hits and pager. + * + * @return {void} + */ +function renderSearch() { + const error = searchState.error || '' + setMessage(document.getElementById('search-error'), error) + const results = document.getElementById('search-results') + const items = searchState.items || [] + if (!items.length) { + results.innerHTML = searchState.q + ? '
No results.
' + : '' + } else { + results.innerHTML = items + .map((item) => { + const url = item.url || siteHref(item.path || '') + return ` + ${escapeHtml(item.title || item.path || 'Result')} +
${escapeHtml(item.resource || '')}
+
${escapeHtml(item.summary || '')}
+
` + }) + .join('') + } + const pager = document.getElementById('search-pager') + const pages = Number(searchState.pages || 0) + pager.hidden = pages <= 1 + document.getElementById('search-page-label').textContent = + pages > 1 ? `Page ${searchState.page || 1} of ${pages}` : '' + document.getElementById('search-prev').disabled = Number(searchState.page || 1) <= 1 + document.getElementById('search-next').disabled = Number(searchState.page || 1) >= pages + if (Array.isArray(searchState.resources) && searchState.resources.length) { + fillSelect( + document.getElementById('search-resource'), + searchState.resources.map((item) => ({ + value: item.key || item.value || item, + label: item.label || item.key || item + })), + searchState.resource || 'all' + ) + } +} + +/** + * Conversation thread pane. + * + * @return {void} + */ +function renderThread() { + const lines = document.getElementById('thread-lines') + if (!thread) { + lines.innerHTML = '' + document.getElementById('thread-title').textContent = '' + return + } + document.getElementById('thread-title').textContent = thread.conversation?.otherUser + ? `With ${thread.conversation.otherUser}` + : 'Conversation' + lines.innerHTML = (thread.messages || []) + .map( + (item) => ` +
+
${escapeHtml(item.senderName || '')}  ${escapeHtml(formatTime(item.sent))}
+
${escapeHtml(item.body || '')}
+
` + ) + .join('') } /** @@ -296,7 +613,9 @@ function applyTheme(dark) { */ function showView(name) { Object.entries(views).forEach(([key, node]) => { - node.hidden = key !== name + if (node) { + node.hidden = key !== name + } }) } @@ -312,20 +631,36 @@ function setMainNav(path) { }) } +/** + * Show inbox, compose, or thread inside the messages view. + * + * @param {string} which - inbox, compose, thread + * @return {void} + */ +function showMessagesPane(which) { + document.getElementById('messages-inbox').hidden = which !== 'inbox' + document.getElementById('messages-compose').hidden = which !== 'compose' + document.getElementById('messages-thread').hidden = which !== 'thread' +} + /** * Route from the hash. Login when there is no session. * * @return {void} */ function route() { + if (session?.pendingMfa) { + showView('mfa') + return + } if (!session?.connected) { showView('login') return } const hash = window.location.hash.replace(/^#\/?/, '') - const [path] = hash.split('?') - setMainNav(path) + const [path, query] = hash.split('?') + setMainNav(path.split('/')[0] || 'dashboard') if (mainPages[path]) { document.getElementById('stub-title').textContent = mainPages[path] @@ -337,9 +672,29 @@ function route() { return } if (path === 'messages') { + showMessagesPane('inbox') showView('messages') return } + if (path === 'messages/new') { + showMessagesPane('compose') + showView('messages') + return + } + if (path.startsWith('messages/')) { + const id = decodeURIComponent(path.slice('messages/'.length)) + if (!id) { + showMessagesPane('inbox') + showView('messages') + return + } + showMessagesPane('thread') + showView('messages') + if (!session.preview && String(thread?.conversation?.id) !== String(id)) { + loadThread(id) + } + return + } if (path === 'profile') { showView('profile') return @@ -350,6 +705,21 @@ function route() { } if (path === 'search') { showView('search') + const params = new URLSearchParams(query || '') + const q = params.get('q') || '' + const resource = params.get('resource') || 'all' + const page = Number(params.get('page') || 1) + document.getElementById('search-q').value = q + if (resource) { + document.getElementById('search-resource').value = resource + } + if (!session.preview) { + loadSearch(q, resource, page) + } else { + document.getElementById('search-summary').textContent = q + ? `Preview search for ${q} in ${resource}.` + : `Choose a term to search ${resource}.` + } return } showView('home') @@ -366,6 +736,12 @@ function applySession(next) { if (!session?.connected) { window.location.hash = '' applyTheme(false) + plugins = [] + notifications = { items: [], unread: 0 } + messages = { items: [], unread: 0 } + thread = null + profile = { ...demoProfile } + pendingAvatar = null } else if (!window.location.hash) { window.location.hash = '#/' } @@ -373,11 +749,116 @@ function applySession(next) { loginSite.value = session.lastSiteUrl tokenSite.value = session.lastSiteUrl } + if (session?.pendingMfa) { + document.getElementById('login-password').value = '' + } + if (session?.preview) { + notifications = { items: demoNotifications, unread: demoNotifications.filter((item) => item.unread).length } + messages = { items: demoMessages, unread: demoMessages.filter((item) => item.unread).length } + profile = { ...demoProfile } + plugins = [] + } else if (session?.connected && window.capsule) { + loadWorkspace() + } renderChrome() + renderMfa() renderPages() route() } +/** + * Load profile, notifications, and messages from the site. + * + * @return {Promise} + */ +async function loadWorkspace() { + const seq = ++workspaceSeq + try { + const data = await runApi(() => window.capsule.workspace()) + if (!data || seq !== workspaceSeq || !session?.connected || session.preview) { + return + } + if (data.session) { + session = { ...session, ...data.session } + } + applyProfile(data.profile) + notifications = data.notifications || { items: [], unread: 0 } + messages = data.messages || { items: [], unread: 0 } + renderChrome() + renderPages() + } catch (err) { + if (seq !== workspaceSeq) { + return + } + document.getElementById('home-note').textContent = err?.message || 'Could not load this site.' + } +} + +/** + * Load one conversation. + * + * @param {string} id - conversation id + * @return {Promise} + */ +async function loadThread(id) { + setMessage(document.getElementById('thread-error'), '') + try { + const data = await runApi(() => window.capsule.messageView({ id })) + if (!data) { + return + } + thread = data + renderThread() + messages = (await runApi(() => window.capsule.messages({ page: 1 }))) || messages + renderChrome() + renderPages() + showMessagesPane('thread') + } catch (err) { + setMessage(document.getElementById('thread-error'), err?.message || 'Could not load that conversation.') + } +} + +/** + * Run site search and paint results. + * + * @param {string} q - query + * @param {string} resource - resource key + * @param {number} page - pager page + * @return {Promise} + */ +async function loadSearch(q, resource, page) { + document.getElementById('search-summary').textContent = q + ? `Searching ${q} in ${resource}.` + : `Choose a term to search ${resource}.` + if (!q) { + searchState = { ...searchState, items: [], q, resource, page: 1, pages: 0, error: '' } + renderSearch() + return + } + try { + const data = await runApi(() => window.capsule.search({ q, resource, page })) + if (!data) { + return + } + searchState = { + items: data.items || [], + q: data.q || q, + resource: data.resource || resource, + resources: data.resources || searchState.resources, + page: data.page || page, + pages: data.pages || 0, + total: data.total || 0, + error: '' + } + document.getElementById('search-summary').textContent = + `${searchState.total} result${searchState.total === 1 ? '' : 's'} for ${searchState.q}.` + renderSearch() + } catch (err) { + searchState = { ...searchState, items: [], q, resource, error: err?.message || 'Search failed.' } + renderSearch() + } +} + /** * Run an auth IPC call. * @@ -392,7 +873,16 @@ async function runAuth(button, errorEl, work) { try { applySession(await work()) } catch (err) { - setMessage(errorEl, err?.message || 'Sign-in failed.') + const msg = err?.message || 'Sign-in failed.' + if (/expired/i.test(msg) && session?.pendingMfa) { + applySession({ + connected: false, + lastSiteUrl: session.siteUrl || session.lastSiteUrl || '' + }) + setMessage(loginError, msg) + return + } + setMessage(errorEl, msg) } finally { button.disabled = false } @@ -413,6 +903,49 @@ loginForm.addEventListener('submit', (event) => { ) }) +document.getElementById('mfa-code-form').addEventListener('submit', (event) => { + event.preventDefault() + if (!window.capsule) { + setMessage(document.getElementById('mfa-code-error'), 'Preload bridge is missing. Restart Capsule.') + return + } + runAuth(document.getElementById('mfa-code-submit'), document.getElementById('mfa-code-error'), () => + window.capsule.mfaChallenge({ + authCode: document.getElementById('mfa-code').value + }) + ) +}) + +document.getElementById('mfa-method-form').addEventListener('submit', (event) => { + event.preventDefault() + if (!window.capsule) { + setMessage(document.getElementById('mfa-method-error'), 'Preload bridge is missing. Restart Capsule.') + return + } + const selected = document.querySelector('#mfa-methods input[name="mfaMethod"]:checked') + runAuth(document.getElementById('mfa-method-submit'), document.getElementById('mfa-method-error'), () => + window.capsule.mfaSelect({ method: selected?.value || '' }) + ) +}) + +document.getElementById('mfa-reset').addEventListener('click', () => { + if (!window.capsule) { + setMessage(document.getElementById('mfa-code-error'), 'Preload bridge is missing. Restart Capsule.') + return + } + runAuth(document.getElementById('mfa-reset'), document.getElementById('mfa-code-error'), () => + window.capsule.mfaReset() + ) +}) + +document.getElementById('mfa-cancel').addEventListener('click', async () => { + if (!window.capsule) { + applySession({ connected: false, lastSiteUrl: session?.siteUrl || session?.lastSiteUrl || '' }) + return + } + applySession(await window.capsule.mfaCancel()) +}) + tokenForm.addEventListener('submit', (event) => { event.preventDefault() if (!window.capsule) { @@ -445,33 +978,215 @@ document.getElementById('header-search').addEventListener('submit', (event) => { const q = document.getElementById('search-q').value.trim() const resource = document.getElementById('search-resource').value const params = new URLSearchParams({ q, resource }) - document.getElementById('search-summary').textContent = q - ? `Searching ${q} in ${resource}.` - : `Choose a term to search ${resource}.` + const next = `#/search?${params.toString()}` + if (window.location.hash === next) { + route() + return + } + window.location.hash = next +}) + +document.getElementById('search-prev').addEventListener('click', () => { + const page = Math.max(1, Number(searchState.page || 1) - 1) + const params = new URLSearchParams({ + q: searchState.q || document.getElementById('search-q').value, + resource: searchState.resource || 'all', + page: String(page) + }) window.location.hash = `#/search?${params.toString()}` - route() }) -document.getElementById('settings-form').addEventListener('submit', (event) => { +document.getElementById('search-next').addEventListener('click', () => { + const page = Number(searchState.page || 1) + 1 + const params = new URLSearchParams({ + q: searchState.q || document.getElementById('search-q').value, + resource: searchState.resource || 'all', + page: String(page) + }) + window.location.hash = `#/search?${params.toString()}` +}) + +document.getElementById('notification-list').addEventListener('click', async (event) => { + const read = event.target.closest('[data-note-read]') + const del = event.target.closest('[data-note-delete]') + if (!read && !del) { + return + } + const id = read?.dataset.noteRead || del?.dataset.noteDelete + try { + if (read) { + await runApi(() => window.capsule.notificationRead({ id })) + } else { + await runApi(() => window.capsule.notificationDelete({ id })) + } + notifications = (await runApi(() => window.capsule.notifications({ page: 1 }))) || notifications + renderChrome() + renderPages() + } catch (err) { + setMessage(document.getElementById('notifications-empty'), err?.message || 'Could not update that notification.') + document.getElementById('notifications-empty').hidden = false + } +}) + +document.getElementById('message-list').addEventListener('click', (event) => { + const row = event.target.closest('[data-message-id]') + if (!row) { + return + } + window.location.hash = `#/messages/${encodeURIComponent(row.dataset.messageId)}` +}) + +document.getElementById('thread-back').addEventListener('click', () => { + thread = null + window.location.hash = '#/messages' +}) + +document.getElementById('compose-form').addEventListener('submit', async (event) => { event.preventDefault() - profile.gender = document.getElementById('settings-gender').value - profile.newsletter = document.getElementById('settings-newsletter').checked - profile.timezone = document.getElementById('settings-timezone').value - profile.dateFormat = document.getElementById('settings-date').value - profile.timeFormat = document.getElementById('settings-time').value - profile.pageLimit = document.getElementById('settings-limit').value - profile.darkMode = document.getElementById('settings-dark').checked - applyTheme(profile.darkMode) - document.getElementById('profile-gender').textContent = profile.gender - setMessage( - document.getElementById('settings-status'), - 'Saved in Capsule for now. The site API will persist this next.' - ) + const button = event.target.querySelector('button[type="submit"]') + button.disabled = true + setMessage(document.getElementById('compose-error'), '') + try { + const data = await runApi(() => + window.capsule.messageCreate({ + toUser: document.getElementById('compose-to').value.trim(), + message: document.getElementById('compose-body').value + }) + ) + if (!data) { + return + } + document.getElementById('compose-form').reset() + window.location.hash = `#/messages/${encodeURIComponent(data.conversation?.id || '')}` + } catch (err) { + setMessage(document.getElementById('compose-error'), err?.message || 'Could not send that message.') + } finally { + button.disabled = false + } }) -document.getElementById('settings-avatar-file').addEventListener('change', (event) => { +document.getElementById('thread-reply').addEventListener('submit', async (event) => { + event.preventDefault() + const id = thread?.conversation?.id + if (!id) { + return + } + const button = event.target.querySelector('button[type="submit"]') + button.disabled = true + setMessage(document.getElementById('thread-error'), '') + try { + await runApi(() => + window.capsule.messageReply({ + id, + message: document.getElementById('thread-body').value + }) + ) + document.getElementById('thread-body').value = '' + await loadThread(id) + } catch (err) { + setMessage(document.getElementById('thread-error'), err?.message || 'Could not send that reply.') + } finally { + button.disabled = false + } +}) + +document.getElementById('contact-form').addEventListener('submit', async (event) => { + event.preventDefault() + const status = document.getElementById('contact-status') + const button = event.target.querySelector('button[type="submit"]') + button.disabled = true + setMessage(status, '') + try { + await runApi(() => + window.capsule.contact({ + name: document.getElementById('contact-name').value.trim(), + email: document.getElementById('contact-email').value.trim(), + entry: document.getElementById('contact-entry').value + }) + ) + document.getElementById('contact-entry').value = '' + setMessage(status, 'Sent.', 'text-success') + } catch (err) { + setMessage(status, err?.message || 'Could not send that message.', 'text-danger') + } finally { + button.disabled = false + } +}) + +document.getElementById('bugreport-form').addEventListener('submit', async (event) => { + event.preventDefault() + const status = document.getElementById('bug-status') + const button = event.target.querySelector('button[type="submit"]') + button.disabled = true + setMessage(status, '') + try { + await runApi(() => + window.capsule.bugreport({ + url: document.getElementById('bug-url').value.trim(), + ourl: document.getElementById('bug-ourl').value.trim(), + repeat: document.getElementById('bug-repeat').checked, + entry: document.getElementById('bug-entry').value + }) + ) + document.getElementById('bug-entry').value = '' + setMessage(status, 'Sent.', 'text-success') + } catch (err) { + setMessage(status, err?.message || 'Could not send that report.', 'text-danger') + } finally { + button.disabled = false + } +}) + +document.getElementById('settings-form').addEventListener('submit', async (event) => { + event.preventDefault() + const status = document.getElementById('settings-status') + const errorEl = document.getElementById('settings-error') + setMessage(status, '') + setMessage(errorEl, '') + if (session?.preview || !window.capsule) { + profile.gender = document.getElementById('settings-gender').value + profile.newsletter = document.getElementById('settings-newsletter').checked + profile.timezone = document.getElementById('settings-timezone').value + profile.dateFormat = document.getElementById('settings-date').value + profile.timeFormat = document.getElementById('settings-time').value + profile.pageLimit = document.getElementById('settings-limit').value + profile.darkMode = document.getElementById('settings-dark').checked + profile.name = document.getElementById('settings-name').value + applyTheme(profile.darkMode) + document.getElementById('profile-gender').textContent = profile.gender + setMessage(status, 'Saved in preview only.') + return + } + const fields = { + name: document.getElementById('settings-name').value.trim(), + gender: document.getElementById('settings-gender').value, + newsletter: document.getElementById('settings-newsletter').checked ? '1' : '0', + timezone: document.getElementById('settings-timezone').value, + dateFormat: document.getElementById('settings-date').value, + timeFormat: document.getElementById('settings-time').value, + pageLimit: document.getElementById('settings-limit').value, + darkMode: document.getElementById('settings-dark').checked ? '1' : '0' + } + try { + const data = await runApi(() => window.capsule.updateProfile({ fields, avatar: pendingAvatar })) + if (!data) { + return + } + pendingAvatar = null + document.getElementById('settings-avatar-file').value = '' + applyProfile(data) + renderChrome() + renderPages() + setMessage(status, 'Saved.') + } catch (err) { + setMessage(errorEl, err?.message || 'Could not save settings.') + } +}) + +document.getElementById('settings-avatar-file').addEventListener('change', async (event) => { const file = event.target.files?.[0] if (!file) { + pendingAvatar = null return } const url = URL.createObjectURL(file) @@ -480,6 +1195,11 @@ document.getElementById('settings-avatar-file').addEventListener('change', (even document.getElementById('header-avatar').src = url document.getElementById('header-avatar').hidden = false document.getElementById('header-avatar-fallback').hidden = true + pendingAvatar = { + name: file.name, + type: file.type, + data: await file.arrayBuffer() + } }) document.getElementById('settings-dark').addEventListener('change', (event) => { diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index b68d3e9..802a6a5 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -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;