Initial commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
14
CHANGELOG.md
Normal file
14
CHANGELOG.md
Normal file
@ -0,0 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Capsule are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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.
|
||||
- Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Joey Kimsey
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
44
README.md
Normal file
44
README.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Capsule
|
||||
|
||||
Desktop companion for [The Tempus Project](https://TheTempusProject.com). Point it at a TTP site, sign in with a username and password or paste an API token, and work against that install from the desktop.
|
||||
|
||||
This replaces the old `TempusToolkit` Electron stub. The keepers were the login ? stored-token ? home flow. Hardcoded LAN URLs, `nodeIntegration`, and the leftover WoW-folder picker did not come along.
|
||||
|
||||
## Run it
|
||||
|
||||
From this folder (Windows source checkout is fine <20> Capsule is Node, not PHP):
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`npm start` previews a production build. There is no local TTP site on the Windows checkout. Login needs a reachable install (typically the Ubuntu host) with `api/apiAccessPersonal` on for user-token calls.
|
||||
|
||||
## 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<54>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. |
|
||||
|
||||
The token is stored under Electron `userData` (`session.json`). `safeStorage` encrypts it when the OS keychain is available.
|
||||
|
||||
App-facing pairing notes live with the PHP app: `repos/ttp/docs/capsule.md`.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Job |
|
||||
|------|-----|
|
||||
| `src/main/` | Window, session file, TTP HTTP, IPC |
|
||||
| `src/preload/` | `window.capsule` bridge |
|
||||
| `src/renderer/` | Login view and connected workspace |
|
||||
|
||||
## Remote
|
||||
|
||||
This checkout starts with no git remote. When the repo exists on the project host:
|
||||
|
||||
`ssh://git@git.thetempusproject.com:2222/the-tempus-project/capsule.git`
|
||||
7
electron.vite.config.mjs
Normal file
7
electron.vite.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'electron-vite'
|
||||
|
||||
export default defineConfig({
|
||||
main: {},
|
||||
preload: {},
|
||||
renderer: {}
|
||||
})
|
||||
3008
package-lock.json
generated
Normal file
3008
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "capsule",
|
||||
"version": "0.1.0",
|
||||
"description": "Desktop companion for The Tempus Project sites",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Joey Kimsey <Joey@thetempusproject.com>",
|
||||
"homepage": "https://TheTempusProject.com",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"start": "electron-vite preview",
|
||||
"build": "electron-vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/utils": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^39.2.6",
|
||||
"electron-vite": "^5.0.0",
|
||||
"vite": "^7.2.6"
|
||||
}
|
||||
}
|
||||
64
src/main/index.js
Normal file
64
src/main/index.js
Normal file
@ -0,0 +1,64 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
|
||||
import { registerSessionIpc } from './sessionIpc.js'
|
||||
|
||||
/**
|
||||
* Open the main Capsule window.
|
||||
*
|
||||
* @return {void}
|
||||
*/
|
||||
function createWindow() {
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 920,
|
||||
height: 640,
|
||||
minWidth: 760,
|
||||
minHeight: 520,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
title: 'Capsule',
|
||||
backgroundColor: '#0c1929',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId('com.thetempusproject.capsule')
|
||||
app.on('browser-window-created', (_event, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
registerSessionIpc()
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
126
src/main/sessionIpc.js
Normal file
126
src/main/sessionIpc.js
Normal file
@ -0,0 +1,126 @@
|
||||
/**
|
||||
* IPC handlers for login, token connect, logout, and session reads.
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { findUser, isDeadTokenError, loginWithPassword, normalizeSiteUrl } from './ttpClient.js'
|
||||
import { publicSession, readSession, writeLastSite, writeSession } from './sessionStore.js'
|
||||
|
||||
/**
|
||||
* Build a stored session after a successful auth.
|
||||
*
|
||||
* @param {object} fields - connection fields
|
||||
* @param {string} fields.siteUrl - canonical site base
|
||||
* @param {string} fields.token - user or app token
|
||||
* @param {string} [fields.username] - username used to sign in
|
||||
* @param {string} fields.authMethod - password or token
|
||||
* @return {Promise<object>} - public session
|
||||
*/
|
||||
async function persistConnection(fields) {
|
||||
const siteUrl = fields.siteUrl
|
||||
const token = fields.token
|
||||
const username = String(fields.username || '').trim()
|
||||
const authMethod = fields.authMethod
|
||||
let userId = null
|
||||
let apiReady = false
|
||||
|
||||
if (username) {
|
||||
try {
|
||||
const found = await findUser(siteUrl, token, username)
|
||||
if (isDeadTokenError(found.error)) {
|
||||
const dead = new Error(
|
||||
found.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
|
||||
}
|
||||
}
|
||||
|
||||
const session = {
|
||||
siteUrl,
|
||||
lastSiteUrl: siteUrl,
|
||||
username,
|
||||
userId,
|
||||
authMethod,
|
||||
apiReady,
|
||||
token
|
||||
}
|
||||
writeSession(session)
|
||||
return publicSession(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register session IPC. Call once after app ready.
|
||||
*
|
||||
* @return {void}
|
||||
*/
|
||||
export function registerSessionIpc() {
|
||||
ipcMain.handle('capsule:session', () => {
|
||||
return publicSession(readSession())
|
||||
})
|
||||
|
||||
ipcMain.handle('capsule:login', async (_event, payload) => {
|
||||
const siteUrl = normalizeSiteUrl(payload?.siteUrl)
|
||||
const username = String(payload?.username || '').trim()
|
||||
const password = String(payload?.password || '')
|
||||
|
||||
if (!username || !password) {
|
||||
throw new Error('Username and password are required.')
|
||||
}
|
||||
|
||||
const token = await loginWithPassword(siteUrl, username, password)
|
||||
return persistConnection({ siteUrl, token, username, authMethod: 'password' })
|
||||
})
|
||||
|
||||
ipcMain.handle('capsule:connectToken', async (_event, payload) => {
|
||||
const siteUrl = normalizeSiteUrl(payload?.siteUrl)
|
||||
const token = String(payload?.token || '').trim()
|
||||
const username = String(payload?.username || '').trim()
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Paste an API token.')
|
||||
}
|
||||
|
||||
return persistConnection({ siteUrl, token, username, authMethod: 'token' })
|
||||
})
|
||||
|
||||
ipcMain.handle('capsule:verify', async () => {
|
||||
const session = readSession()
|
||||
if (!session?.token || !session.siteUrl) {
|
||||
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())
|
||||
}
|
||||
|
||||
session.userId = found.userId
|
||||
session.apiReady = found.userId !== null
|
||||
writeSession(session)
|
||||
return publicSession(session)
|
||||
} catch {
|
||||
return publicSession(session)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('capsule:logout', () => {
|
||||
const session = readSession()
|
||||
writeLastSite(session?.siteUrl || session?.lastSiteUrl || '')
|
||||
return publicSession(readSession())
|
||||
})
|
||||
}
|
||||
118
src/main/sessionStore.js
Normal file
118
src/main/sessionStore.js
Normal file
@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Persist the connected TTP site and token under Electron userData.
|
||||
* The token is encrypted with safeStorage when the OS keychain is available.
|
||||
*/
|
||||
|
||||
import { app, safeStorage } from 'electron'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
/**
|
||||
* Path to the session file in userData.
|
||||
*
|
||||
* @return {string} - absolute path
|
||||
*/
|
||||
export function sessionFilePath() {
|
||||
return join(app.getPath('userData'), 'session.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the raw session object from disk. Token is decrypted into memory.
|
||||
*
|
||||
* @return {object|null} - session, or null when none is stored
|
||||
*/
|
||||
export function readSession() {
|
||||
const file = sessionFilePath()
|
||||
if (!existsSync(file)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(file, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const session = { ...parsed }
|
||||
if (session.tokenEnc && safeStorage.isEncryptionAvailable()) {
|
||||
try {
|
||||
session.token = safeStorage.decryptString(Buffer.from(session.tokenEnc, 'base64'))
|
||||
} catch {
|
||||
session.token = ''
|
||||
}
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a session. Encrypts the token when safeStorage is available.
|
||||
*
|
||||
* @param {object} session - fields to persist
|
||||
* @return {void}
|
||||
*/
|
||||
export function writeSession(session) {
|
||||
const file = sessionFilePath()
|
||||
mkdirSync(dirname(file), { recursive: true })
|
||||
|
||||
const payload = { ...session }
|
||||
const token = payload.token
|
||||
delete payload.token
|
||||
delete payload.tokenEnc
|
||||
|
||||
if (token) {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
payload.tokenEnc = safeStorage.encryptString(token).toString('base64')
|
||||
} else {
|
||||
payload.token = token
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the last site URL after logout so the login form can refill it.
|
||||
*
|
||||
* @param {string} lastSiteUrl - canonical site base
|
||||
* @return {void}
|
||||
*/
|
||||
export function writeLastSite(lastSiteUrl) {
|
||||
writeSession({ lastSiteUrl })
|
||||
}
|
||||
|
||||
/**
|
||||
* Session fields the renderer may see. Never includes the token.
|
||||
*
|
||||
* @param {object|null} session - stored session
|
||||
* @return {object} - public connection state
|
||||
*/
|
||||
export function publicSession(session) {
|
||||
if (!session) {
|
||||
return {
|
||||
connected: false,
|
||||
siteUrl: '',
|
||||
username: '',
|
||||
userId: null,
|
||||
authMethod: '',
|
||||
apiReady: false,
|
||||
lastSiteUrl: ''
|
||||
}
|
||||
}
|
||||
|
||||
const token = session.token || ''
|
||||
return {
|
||||
connected: Boolean(token),
|
||||
siteUrl: session.siteUrl || '',
|
||||
username: session.username || '',
|
||||
userId: session.userId ?? null,
|
||||
authMethod: session.authMethod || '',
|
||||
apiReady: Boolean(session.apiReady),
|
||||
lastSiteUrl: session.lastSiteUrl || session.siteUrl || ''
|
||||
}
|
||||
}
|
||||
162
src/main/ttpClient.js
Normal file
162
src/main/ttpClient.js
Normal file
@ -0,0 +1,162 @@
|
||||
/**
|
||||
* HTTP calls to a TTP site. Runs in the main process so CORS does not apply.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize a TTP site URL to scheme + host + optional path, no trailing slash.
|
||||
*
|
||||
* @param {string} raw - what the user typed
|
||||
* @return {string} - canonical site base
|
||||
*/
|
||||
export function normalizeSiteUrl(raw) {
|
||||
const trimmed = String(raw ?? '').trim()
|
||||
if (!trimmed) {
|
||||
throw new Error('Enter the site URL.')
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`)
|
||||
} catch {
|
||||
throw new Error('Enter a valid site URL.')
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error('Site URL must start with http:// or https://.')
|
||||
}
|
||||
|
||||
const path = parsed.pathname.replace(/\/+$/, '')
|
||||
return `${parsed.origin}${path === '/' ? '' : path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a TTP API path and parse JSON.
|
||||
*
|
||||
* @param {object} options - request
|
||||
* @param {string} options.siteUrl - canonical site base
|
||||
* @param {string} options.path - path starting with /api/
|
||||
* @param {string} [options.method='GET'] - HTTP method
|
||||
* @param {string} [options.token] - Bearer token
|
||||
* @param {Record<string, string>} [options.form] - urlencoded body
|
||||
* @return {Promise<object>} - parsed JSON
|
||||
*/
|
||||
export async function ttpRequest(options) {
|
||||
const siteUrl = options.siteUrl
|
||||
const path = options.path
|
||||
const method = options.method || 'GET'
|
||||
const token = options.token
|
||||
const form = options.form
|
||||
const url = `${siteUrl}${path}`
|
||||
const headers = { Accept: 'application/json' }
|
||||
let body
|
||||
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (form) {
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||
body = new URLSearchParams(form).toString()
|
||||
}
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await fetch(url, { method, headers, body, redirect: 'follow' })
|
||||
} catch {
|
||||
throw new Error('Could not reach that site.')
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`The site did not return API JSON (${response.status}).`)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a TTP API error string to a short user-facing line.
|
||||
*
|
||||
* @param {string} code - API `error` value
|
||||
* @return {string} - message for the login form
|
||||
*/
|
||||
export function apiErrorMessage(code) {
|
||||
switch (code) {
|
||||
case 'malformed input':
|
||||
return 'Username and password are required.'
|
||||
case 'bad credentials':
|
||||
return 'Those credentials were not accepted.'
|
||||
case 'invalid token':
|
||||
case 'invalid secret':
|
||||
return 'That API token was not accepted.'
|
||||
case 'token expired':
|
||||
return 'That API token has expired.'
|
||||
case 'IRDK':
|
||||
return 'The site could not refresh this token.'
|
||||
default:
|
||||
return code || 'The site returned an error.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in with username and password. POST api/login.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} username - TTP username
|
||||
* @param {string} password - TTP password
|
||||
* @return {Promise<string>} - user token
|
||||
*/
|
||||
export async function loginWithPassword(siteUrl, username, password) {
|
||||
const data = await ttpRequest({
|
||||
siteUrl,
|
||||
path: '/api/login',
|
||||
method: 'POST',
|
||||
form: { username, password }
|
||||
})
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(apiErrorMessage(data.error))
|
||||
}
|
||||
|
||||
if (!data.token) {
|
||||
throw new Error('The site did not return a token.')
|
||||
}
|
||||
|
||||
return data.token
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a user id. GET api/users/find/{id|username}.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @param {string} token - Bearer token
|
||||
* @param {string} idOrUsername - user id or username
|
||||
* @return {Promise<{userId: number|string|null, error: string}>} - id on success
|
||||
*/
|
||||
export async function findUser(siteUrl, token, idOrUsername) {
|
||||
const path = `/api/users/find/${encodeURIComponent(idOrUsername)}`
|
||||
const data = await ttpRequest({ siteUrl, path, method: 'GET', token })
|
||||
|
||||
if (data.error) {
|
||||
return { userId: null, error: String(data.error) }
|
||||
}
|
||||
|
||||
if (data.data == null) {
|
||||
return { userId: null, error: 'No user found.' }
|
||||
}
|
||||
|
||||
return { userId: data.data, error: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the API rejected the stored Bearer token itself.
|
||||
*
|
||||
* @param {string} error - API `error` value
|
||||
* @return {boolean} - true when the session should be cleared
|
||||
*/
|
||||
export function isDeadTokenError(error) {
|
||||
return error === 'invalid token' || error === 'invalid secret' || error === 'token expired'
|
||||
}
|
||||
52
src/preload/index.js
Normal file
52
src/preload/index.js
Normal file
@ -0,0 +1,52 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
const capsule = {
|
||||
/**
|
||||
* Read the public session. Never includes the token.
|
||||
*
|
||||
* @return {Promise<object>} - connection state
|
||||
*/
|
||||
session() {
|
||||
return ipcRenderer.invoke('capsule:session')
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with a TTP username and password.
|
||||
*
|
||||
* @param {object} payload - siteUrl, username, password
|
||||
* @return {Promise<object>} - public session
|
||||
*/
|
||||
login(payload) {
|
||||
return ipcRenderer.invoke('capsule:login', payload)
|
||||
},
|
||||
|
||||
/**
|
||||
* Connect with an existing API token from Admin ? Tokens.
|
||||
*
|
||||
* @param {object} payload - siteUrl, token, optional username
|
||||
* @return {Promise<object>} - public session
|
||||
*/
|
||||
connectToken(payload) {
|
||||
return ipcRenderer.invoke('capsule:connectToken', payload)
|
||||
},
|
||||
|
||||
/**
|
||||
* Recheck a stored token against api/users/find.
|
||||
*
|
||||
* @return {Promise<object>} - public session
|
||||
*/
|
||||
verify() {
|
||||
return ipcRenderer.invoke('capsule:verify')
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the stored token and return to the login view.
|
||||
*
|
||||
* @return {Promise<object>} - public session
|
||||
*/
|
||||
logout() {
|
||||
return ipcRenderer.invoke('capsule:logout')
|
||||
}
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('capsule', capsule)
|
||||
149
src/renderer/index.html
Normal file
149
src/renderer/index.html
Normal file
@ -0,0 +1,149 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:;"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Capsule</title>
|
||||
<link rel="stylesheet" href="./src/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true"></span>
|
||||
<div>
|
||||
<p class="brand-name">Capsule</p>
|
||||
<p class="brand-tag">The Tempus Project</p>
|
||||
</div>
|
||||
</div>
|
||||
<p id="top-status" class="top-status" hidden></p>
|
||||
</header>
|
||||
|
||||
<main class="stage">
|
||||
<section id="view-login" class="view" hidden>
|
||||
<div class="card">
|
||||
<h1>Connect a site</h1>
|
||||
<p class="lede">
|
||||
Sign in to any Tempus Project install. Capsule talks to that site<74>s API and
|
||||
keeps the session on this machine.
|
||||
</p>
|
||||
|
||||
<form id="login-form" class="form">
|
||||
<label class="field">
|
||||
<span>Site URL</span>
|
||||
<input
|
||||
id="login-site"
|
||||
name="siteUrl"
|
||||
type="url"
|
||||
autocomplete="url"
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Username</span>
|
||||
<input id="login-username" name="username" type="text" autocomplete="username" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p id="login-error" class="error" hidden></p>
|
||||
<button id="login-submit" class="btn btn-primary" type="submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
<details id="token-panel" class="token-panel">
|
||||
<summary>Use an API token instead</summary>
|
||||
<form id="token-form" class="form">
|
||||
<p class="hint">
|
||||
Paste a personal or app token from Admin ? Tokens. Username is optional and
|
||||
only used to confirm the token against <code>api/users/find</code>.
|
||||
</p>
|
||||
<label class="field">
|
||||
<span>Site URL</span>
|
||||
<input
|
||||
id="token-site"
|
||||
name="siteUrl"
|
||||
type="url"
|
||||
autocomplete="url"
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>API token</span>
|
||||
<input id="token-value" name="token" type="password" autocomplete="off" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Username <em>(optional)</em></span>
|
||||
<input id="token-username" name="username" type="text" autocomplete="username" />
|
||||
</label>
|
||||
<p id="token-error" class="error" hidden></p>
|
||||
<button id="token-submit" class="btn btn-secondary" type="submit">
|
||||
Connect with token
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="view-home" class="view" hidden>
|
||||
<div class="home-grid">
|
||||
<article class="card identity">
|
||||
<p class="eyebrow">Connected</p>
|
||||
<h1 id="home-username">Signed in</h1>
|
||||
<p id="home-site" class="site-line"></p>
|
||||
<dl class="meta">
|
||||
<div>
|
||||
<dt>User ID</dt>
|
||||
<dd id="home-userid"><EFBFBD></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Auth</dt>
|
||||
<dd id="home-method"><EFBFBD></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>API</dt>
|
||||
<dd id="home-api"><EFBFBD></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p id="home-note" class="hint" hidden></p>
|
||||
<div class="actions">
|
||||
<button id="logout-button" class="btn btn-secondary" type="button">Sign out</button>
|
||||
<a id="open-site" class="btn btn-ghost" href="#" target="_blank" rel="noreferrer">
|
||||
Open site
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card workspace">
|
||||
<p class="eyebrow">Workspace</p>
|
||||
<h2>Desktop tools land here</h2>
|
||||
<p class="lede">
|
||||
This area will host the full desktop experience for the plugins enabled on
|
||||
the connected site. The current API can sign you in, refresh a token, and
|
||||
look up a user id. Feature screens wait on those API expansions.
|
||||
</p>
|
||||
<ul class="plan">
|
||||
<li>Talk to a site of your choice with a user login or an API token</li>
|
||||
<li>Keep the token in the OS keychain, not in the renderer</li>
|
||||
<li>Load plugin-backed tools as the HTTP API grows</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="./src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
166
src/renderer/src/main.js
Normal file
166
src/renderer/src/main.js
Normal file
@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Login and home views. The token never enters this process.
|
||||
*/
|
||||
|
||||
const loginView = document.getElementById('view-login')
|
||||
const homeView = document.getElementById('view-home')
|
||||
const topStatus = document.getElementById('top-status')
|
||||
const loginForm = document.getElementById('login-form')
|
||||
const loginError = document.getElementById('login-error')
|
||||
const loginSubmit = document.getElementById('login-submit')
|
||||
const loginSite = document.getElementById('login-site')
|
||||
const loginUsername = document.getElementById('login-username')
|
||||
const tokenForm = document.getElementById('token-form')
|
||||
const tokenError = document.getElementById('token-error')
|
||||
const tokenSubmit = document.getElementById('token-submit')
|
||||
const tokenSite = document.getElementById('token-site')
|
||||
const tokenUsername = document.getElementById('token-username')
|
||||
const homeUsername = document.getElementById('home-username')
|
||||
const homeSite = document.getElementById('home-site')
|
||||
const homeUserId = document.getElementById('home-userid')
|
||||
const homeMethod = document.getElementById('home-method')
|
||||
const homeApi = document.getElementById('home-api')
|
||||
const homeNote = document.getElementById('home-note')
|
||||
const openSite = document.getElementById('open-site')
|
||||
const logoutButton = document.getElementById('logout-button')
|
||||
|
||||
/**
|
||||
* Show or hide a status/error line.
|
||||
*
|
||||
* @param {HTMLElement} el - message node
|
||||
* @param {string} [message] - text to show; empty hides the node
|
||||
* @return {void}
|
||||
*/
|
||||
function setMessage(el, message) {
|
||||
const text = String(message || '')
|
||||
el.hidden = text === ''
|
||||
el.textContent = text
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill both site URL fields from the last used host.
|
||||
*
|
||||
* @param {string} siteUrl - canonical site base
|
||||
* @return {void}
|
||||
*/
|
||||
function fillSiteFields(siteUrl) {
|
||||
if (!siteUrl) {
|
||||
return
|
||||
}
|
||||
loginSite.value = siteUrl
|
||||
tokenSite.value = siteUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the login or home view from a public session.
|
||||
*
|
||||
* @param {object} session - connection state from main
|
||||
* @return {void}
|
||||
*/
|
||||
function render(session) {
|
||||
const connected = Boolean(session?.connected)
|
||||
loginView.hidden = connected
|
||||
homeView.hidden = !connected
|
||||
fillSiteFields(session?.lastSiteUrl || session?.siteUrl || '')
|
||||
|
||||
if (!connected) {
|
||||
topStatus.hidden = true
|
||||
return
|
||||
}
|
||||
|
||||
const username = session.username || 'API token'
|
||||
homeUsername.textContent = session.username ? username : 'Signed in with token'
|
||||
homeSite.textContent = session.siteUrl
|
||||
homeUserId.textContent = session.userId == null ? '<27>' : String(session.userId)
|
||||
homeMethod.textContent = session.authMethod === 'token' ? 'API token' : 'Password'
|
||||
homeApi.textContent = session.apiReady ? 'Ready' : 'Limited'
|
||||
openSite.href = session.siteUrl
|
||||
topStatus.hidden = false
|
||||
topStatus.classList.add('is-ok')
|
||||
topStatus.textContent = 'Connected'
|
||||
|
||||
if (session.apiReady) {
|
||||
setMessage(homeNote, '')
|
||||
} else {
|
||||
setMessage(
|
||||
homeNote,
|
||||
'Signed in, but api/users/find did not confirm this user. Turn on personal API access on the site, or add a username when connecting with a token.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an auth IPC call and render the result or an error.
|
||||
*
|
||||
* @param {HTMLButtonElement} button - submit button to disable
|
||||
* @param {HTMLElement} errorEl - error line
|
||||
* @param {() => Promise<object>} work - IPC call
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async function runAuth(button, errorEl, work) {
|
||||
button.disabled = true
|
||||
setMessage(errorEl, '')
|
||||
try {
|
||||
render(await work())
|
||||
} catch (err) {
|
||||
setMessage(errorEl, err?.message || 'Sign-in failed.')
|
||||
} finally {
|
||||
button.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
loginForm.addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
runAuth(loginSubmit, loginError, () =>
|
||||
window.capsule.login({
|
||||
siteUrl: loginSite.value,
|
||||
username: loginUsername.value,
|
||||
password: document.getElementById('login-password').value
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
tokenForm.addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
runAuth(tokenSubmit, tokenError, () =>
|
||||
window.capsule.connectToken({
|
||||
siteUrl: tokenSite.value,
|
||||
token: document.getElementById('token-value').value,
|
||||
username: tokenUsername.value
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
logoutButton.addEventListener('click', async () => {
|
||||
render(await window.capsule.logout())
|
||||
})
|
||||
|
||||
openSite.addEventListener('click', (event) => {
|
||||
const href = openSite.getAttribute('href')
|
||||
if (!href || href === '#') {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Load any stored session, then confirm it against the site when possible.
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async function boot() {
|
||||
if (!window.capsule) {
|
||||
setMessage(loginError, 'Preload bridge is missing. Restart Capsule.')
|
||||
loginView.hidden = false
|
||||
return
|
||||
}
|
||||
|
||||
const session = await window.capsule.session()
|
||||
if (session.connected) {
|
||||
render(await window.capsule.verify())
|
||||
return
|
||||
}
|
||||
|
||||
render(session)
|
||||
}
|
||||
|
||||
boot()
|
||||
314
src/renderer/src/styles.css
Normal file
314
src/renderer/src/styles.css
Normal file
@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Capsule desktop chrome. Tokens follow the TTP brand (logo #3fa9f5, chrome #0c1929).
|
||||
*/
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--canvas: #08111c;
|
||||
--chrome: #0c1929;
|
||||
--surface: #122033;
|
||||
--surface-alt: #173049;
|
||||
--text: #e8eef5;
|
||||
--muted: #8aa0b5;
|
||||
--border: #274056;
|
||||
--brand: #3fa9f5;
|
||||
--primary: #1784c9;
|
||||
--primary-hover: #146ea8;
|
||||
--danger: #f07178;
|
||||
--ok: #3dd68c;
|
||||
--focus-ring: rgba(63, 169, 245, 0.35);
|
||||
--shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
|
||||
--radius: 0.85rem;
|
||||
--font: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--canvas);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(900px 420px at 10% -10%, rgba(63, 169, 245, 0.16), transparent 55%),
|
||||
radial-gradient(700px 360px at 100% 0%, rgba(23, 132, 201, 0.12), transparent 50%),
|
||||
var(--canvas);
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(12, 25, 41, 0.86);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 2rem;
|
||||
height: 1.15rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #7ec8f8, var(--brand) 55%, #1784c9);
|
||||
box-shadow: 0 0 0 4px rgba(63, 169, 245, 0.12), 0 8px 18px rgba(63, 169, 245, 0.25);
|
||||
}
|
||||
|
||||
.brand-name,
|
||||
.brand-tag,
|
||||
.eyebrow,
|
||||
h1,
|
||||
h2,
|
||||
p,
|
||||
dt,
|
||||
dd,
|
||||
label span,
|
||||
button,
|
||||
summary,
|
||||
li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.brand-tag,
|
||||
.eyebrow,
|
||||
.hint,
|
||||
.lede,
|
||||
.meta dt,
|
||||
.top-status {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.brand-tag,
|
||||
.eyebrow {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.top-status {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.top-status.is-ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.view {
|
||||
width: min(920px, 100%);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(18, 32, 51, 0.92);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.6rem 1.7rem 1.5rem;
|
||||
}
|
||||
|
||||
.card h1,
|
||||
.card h2 {
|
||||
margin: 0.35rem 0 0.7rem;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.lede {
|
||||
line-height: 1.55;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field em {
|
||||
font-style: normal;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
background: #0c1929;
|
||||
color: var(--text);
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--brand);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.token-panel {
|
||||
margin-top: 1.2rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.9rem;
|
||||
}
|
||||
|
||||
.token-panel summary {
|
||||
cursor: pointer;
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.token-panel .form {
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint code {
|
||||
font-size: 0.84em;
|
||||
}
|
||||
|
||||
.home-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 0.9fr) minmax(280px, 1.1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.identity h1 {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.site-line {
|
||||
color: var(--brand);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.meta dt {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.meta dd {
|
||||
margin: 0.2rem 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.plan {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--muted);
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.plan li::marker {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.home-grid,
|
||||
.meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user