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