Web platform scaffold, applicationId change to com.roundingmobile.sshwb

ApplicationId changed from com.roundingmobile.sshworkbench to
com.roundingmobile.sshwb (Firebase auto-key blocked Play Console
registration). Namespace stays com.roundingmobile.sshworkbench.
Web platform in www/: Docker stack (nginx+Node.js+MariaDB), landing
page, login (OAuth+email/pw), dashboard (vault sync+session logs),
API routes, MariaDB schema.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jima 2026-04-12 14:47:01 +02:00
parent c4ead07fa4
commit 56b875b9fc
32 changed files with 4069 additions and 4 deletions

View file

@ -33,7 +33,7 @@ android {
versionCode = 39
versionName = "0.0.39"
applicationId = "com.roundingmobile.sshworkbench"
applicationId = "com.roundingmobile.sshwb"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk {

View file

@ -24,7 +24,7 @@ object DevConfig {
const val DEV_DEFAULTS = true
const val USE_DOWNLOADS_DIR = true
private const val ADB_INPUT_ACTION = "com.roundingmobile.sshworkbench.INPUT"
private const val ADB_INPUT_ACTION = "com.roundingmobile.sshwb.INPUT"
private var adbReceiver: BroadcastReceiver? = null
fun registerAdbReceiver(activity: AppCompatActivity, mainViewModel: MainViewModel) {

View file

@ -5,6 +5,8 @@
## Recently Completed (2026-04-12)
- ~~Package name change~~ — applicationId changed from `com.roundingmobile.sshworkbench` to `com.roundingmobile.sshwb` (Firebase auto-key conflict blocked Play Console registration). Namespace stays `com.roundingmobile.sshworkbench`. Registered in Google Play Console.
- ~~Web platform scaffolding~~`www/` folder with Docker stack (nginx + Node.js/Express + MariaDB), landing page, login page (email/pw + OAuth), dashboard (vault + session logs). API routes for auth, vault CRUD, log management. MariaDB schema with users, vaults, logs, teams, permissions, snippets, audit_log.
- ~~Vault settings export/import~~ — optional "Include settings" checkbox (unchecked by default) in both Save Vault Locally and Export Vault. Exports 56 DataStore prefs (keyboard, display, QuickBar customization, HW actions). Import auto-restores settings. `EXPORTABLE_*_KEYS` lists in `TerminalPrefsKeys` define what's backed up.
- ~~Jump chain pro message fix~~ — upgrade dialog now says "Jump host chaining" instead of "Jump Host" so free users understand single jump hosts work, only chaining is pro-gated
- ~~Free vault import gate~~ — free users can only import local vault saves (MODE_LOCAL), not pro-exported vaults (MODE_PASSWORD/MODE_QR). Clear error message in 5 locales.
@ -83,3 +85,11 @@
- Add clipboard auto-clear timer for sensitive copies — HIGH security finding from audit 2026-04-11, needs UX decisions
- Implement session logging — per-session toggle (tab 3-dot menu), global default OFF, ANSI-stripped text to ZIP, SAF folder picker, organized by connection alias. Solves tmux/screen buffer limitation. Design agreed 2026-04-12, see `project_session_logging_design.md`.
- Register dev app in Firebase Console for analytics/crashlytics on dev builds
- Create new Firebase project for `com.roundingmobile.sshwb` (old `ssh-workbench` project deleted, 30-day wait). Add SHA-1 and SHA-256 fingerprints. Download new google-services.json.
- Create Google Cloud project for web OAuth (Google + GitHub). Configure OAuth consent screen + credentials for sshworkbench.app.
- Wire up OAuth callback routes in Node.js API (Google + GitHub passport flow)
- Set up Docker on duero for full web stack deployment
- Web: documentation section with search (convert existing docs to web pages)
- Web: Stripe integration for Pro tier billing
- Web: team management UI (roles: Owner/Admin/Member/Viewer)
- Update CLAUDE.md and docs with new package name `com.roundingmobile.sshwb`

View file

@ -8,9 +8,9 @@ from pathlib import Path
# --- Config ---
ZEBRA_SERIAL = "22160523026079"
PKG = "com.roundingmobile.sshworkbench.dev"
PKG = "com.roundingmobile.sshwb.dev"
ACTIVITY = "com.roundingmobile.sshworkbench.ui.MainActivity"
ACTION = "com.roundingmobile.sshworkbench.INPUT"
ACTION = "com.roundingmobile.sshwb.INPUT"
LOG_PATH = "/sdcard/Download/SshWorkbench/sshworkbench_debug.txt"
SCRIPTS_DIR = Path(__file__).resolve().parent.parent

20
www/.env.example Normal file
View file

@ -0,0 +1,20 @@
NODE_ENV=development
# MariaDB
MYSQL_ROOT_PASSWORD=changeme
MYSQL_DATABASE=sshworkbench
MYSQL_USER=swb
MYSQL_PASSWORD=changeme
# JWT
JWT_SECRET=changeme-use-a-64-char-random-string
# OAuth (future)
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
# Stripe (future)
# STRIPE_SECRET_KEY=
# STRIPE_WEBHOOK_SECRET=

6
www/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
.env
db_data/
*.log
.DS_Store
app/dist/

3
www/app/.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
npm-debug.log
.env

12
www/app/Dockerfile Normal file
View file

@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY src/ ./src/
EXPOSE 3000
CMD ["node", "src/index.js"]

1097
www/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
www/app/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "sshworkbench-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"mariadb": "^3.4.0"
}
}

28
www/app/src/config/db.js Normal file
View file

@ -0,0 +1,28 @@
import { createPool } from "mariadb";
const pool = createPool({
host: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT || "3306"),
user: process.env.DB_USER || "swb",
password: process.env.DB_PASSWORD || "changeme",
database: process.env.DB_NAME || "sshworkbench",
connectionLimit: 10,
idleTimeout: 60000,
});
export async function query(sql, params) {
let conn;
try {
conn = await pool.getConnection();
return await conn.query(sql, params);
} finally {
if (conn) conn.release();
}
}
export async function healthCheck() {
const rows = await query("SELECT 1 AS ok");
return rows[0]?.ok === 1;
}
export default pool;

31
www/app/src/index.js Normal file
View file

@ -0,0 +1,31 @@
import "dotenv/config";
import express from "express";
import helmet from "helmet";
import cors from "cors";
import healthRouter from "./routes/health.js";
import authRouter from "./routes/auth.js";
import vaultRouter from "./routes/vault.js";
import logsRouter from "./routes/logs.js";
const app = express();
const port = parseInt(process.env.PORT || "3000");
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: "10mb" }));
app.use(express.raw({ type: "application/octet-stream", limit: "50mb" }));
// Routes
app.use("/api", healthRouter);
app.use("/api", authRouter);
app.use("/api", vaultRouter);
app.use("/api", logsRouter);
// 404
app.use("/api", (_req, res) => {
res.status(404).json({ error: "Not found" });
});
app.listen(port, "0.0.0.0", () => {
console.log(`SSH Workbench API listening on :${port}`);
});

View file

@ -0,0 +1,27 @@
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET || "dev-secret-change-in-production";
export function signToken(payload, remember) {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: remember ? "30d" : "24h",
});
}
export function verifyToken(token) {
return jwt.verify(token, JWT_SECRET);
}
export function requireAuth(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith("Bearer ")) {
return res.status(401).json({ error: "Authentication required" });
}
const token = header.slice(7);
try {
req.user = verifyToken(token);
next();
} catch (err) {
return res.status(401).json({ error: "Invalid or expired token" });
}
}

106
www/app/src/routes/auth.js Normal file
View file

@ -0,0 +1,106 @@
import { Router } from "express";
import bcrypt from "bcrypt";
import { query } from "../config/db.js";
import { signToken } from "../middleware/auth.js";
const router = Router();
// POST /api/auth/login — email + password
router.post("/auth/login", async (req, res) => {
const { email, password, remember } = req.body;
if (!email || !password) {
return res.status(400).json({ error: "Email and password are required." });
}
try {
const rows = await query("SELECT * FROM users WHERE email = ? LIMIT 1", [
email.toLowerCase().trim(),
]);
if (rows.length === 0) {
return res.status(401).json({ error: "Invalid email or password." });
}
const user = rows[0];
if (!user.password_hash) {
// OAuth-only account
return res.status(401).json({
error: "This account uses Google or GitHub login. Use the appropriate button.",
});
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.status(401).json({ error: "Invalid email or password." });
}
const token = signToken(
{ id: Number(user.id), email: user.email, tier: user.tier },
!!remember
);
res.json({
token,
user: {
id: Number(user.id),
email: user.email,
name: user.name,
tier: user.tier,
avatar_url: user.avatar_url,
},
});
} catch (err) {
console.error("Login error:", err);
res.status(500).json({ error: "Server error. Please try again." });
}
});
// POST /api/auth/logout — invalidate (client-side token discard)
router.post("/auth/logout", (_req, res) => {
// JWT is stateless — client discards the token.
// For server-side invalidation, we'd add to a blocklist table (future).
res.json({ ok: true });
});
// GET /api/auth/me — verify token and return user info
router.get("/auth/me", async (req, res) => {
const header = req.headers.authorization;
if (!header || !header.startsWith("Bearer ")) {
return res.status(401).json({ error: "Not authenticated" });
}
try {
const { verifyToken } = await import("../middleware/auth.js");
const payload = verifyToken(header.slice(7));
const rows = await query(
"SELECT id, email, name, tier, avatar_url, storage_used_bytes, storage_limit_bytes FROM users WHERE id = ?",
[payload.id]
);
if (rows.length === 0) {
return res.status(401).json({ error: "User not found" });
}
const u = rows[0];
res.json({
id: Number(u.id),
email: u.email,
name: u.name,
tier: u.tier,
avatar_url: u.avatar_url,
storage_used: Number(u.storage_used_bytes),
storage_limit: Number(u.storage_limit_bytes),
});
} catch (err) {
res.status(401).json({ error: "Invalid or expired token" });
}
});
// Placeholder OAuth routes (redirect to providers — requires client IDs)
router.get("/auth/google", (_req, res) => {
res.status(501).json({ error: "Google OAuth not configured yet. Register in the app." });
});
router.get("/auth/github", (_req, res) => {
res.status(501).json({ error: "GitHub OAuth not configured yet. Register in the app." });
});
export default router;

View file

@ -0,0 +1,23 @@
import { Router } from "express";
import { healthCheck } from "../config/db.js";
const router = Router();
router.get("/health", async (_req, res) => {
try {
const dbOk = await healthCheck();
res.json({
status: "ok",
db: dbOk ? "connected" : "error",
uptime: Math.floor(process.uptime()),
});
} catch (err) {
res.status(503).json({
status: "error",
db: "disconnected",
message: err.message,
});
}
});
export default router;

135
www/app/src/routes/logs.js Normal file
View file

@ -0,0 +1,135 @@
import { Router } from "express";
import { query } from "../config/db.js";
import { requireAuth } from "../middleware/auth.js";
const router = Router();
// GET /api/logs — list user's logs + storage info
router.get("/logs", requireAuth, async (req, res) => {
try {
const logs = await query(
`SELECT id, connection_alias, filename, size_bytes, locked,
retention_days, expires_at, uploaded_at
FROM terminal_logs WHERE user_id = ?
ORDER BY uploaded_at DESC LIMIT 200`,
[req.user.id]
);
const userRow = (
await query(
"SELECT storage_used_bytes, storage_limit_bytes FROM users WHERE id = ?",
[req.user.id]
)
)[0];
const settings = await query(
"SELECT log_retention_days FROM user_settings WHERE user_id = ?",
[req.user.id]
);
res.json({
logs: logs.map((l) => ({
id: Number(l.id),
connection_alias: l.connection_alias,
filename: l.filename,
size_bytes: Number(l.size_bytes),
locked: !!l.locked,
retention_days: l.retention_days,
expires_at: l.expires_at,
uploaded_at: l.uploaded_at,
})),
storage_used: Number(userRow.storage_used_bytes),
storage_limit: Number(userRow.storage_limit_bytes),
retention_days: settings.length > 0 ? settings[0].log_retention_days : 90,
});
} catch (err) {
console.error("Logs list error:", err);
res.status(500).json({ error: "Server error" });
}
});
// GET /api/logs/:id/download — download encrypted log
router.get("/logs/:id/download", requireAuth, async (req, res) => {
try {
const rows = await query(
"SELECT filename, storage_path FROM terminal_logs WHERE id = ? AND user_id = ?",
[req.params.id, req.user.id]
);
if (rows.length === 0) {
return res.status(404).json({ error: "Log not found" });
}
// In production, serve from disk via storage_path
// For now, return 501
res.status(501).json({ error: "Log download not yet implemented (needs file storage)" });
} catch (err) {
console.error("Log download error:", err);
res.status(500).json({ error: "Server error" });
}
});
// DELETE /api/logs/:id — delete a log
router.delete("/logs/:id", requireAuth, async (req, res) => {
try {
const rows = await query(
"SELECT id, size_bytes FROM terminal_logs WHERE id = ? AND user_id = ?",
[req.params.id, req.user.id]
);
if (rows.length === 0) {
return res.status(404).json({ error: "Log not found" });
}
await query("DELETE FROM terminal_logs WHERE id = ?", [rows[0].id]);
await query(
"UPDATE users SET storage_used_bytes = GREATEST(0, CAST(storage_used_bytes AS SIGNED) - ?) WHERE id = ?",
[Number(rows[0].size_bytes), req.user.id]
);
res.json({ ok: true });
} catch (err) {
console.error("Log delete error:", err);
res.status(500).json({ error: "Server error" });
}
});
// POST /api/logs/:id/lock — toggle lock
router.post("/logs/:id/lock", requireAuth, async (req, res) => {
try {
const rows = await query(
"SELECT id, locked FROM terminal_logs WHERE id = ? AND user_id = ?",
[req.params.id, req.user.id]
);
if (rows.length === 0) {
return res.status(404).json({ error: "Log not found" });
}
const newLocked = !rows[0].locked;
await query(
"UPDATE terminal_logs SET locked = ?, expires_at = ? WHERE id = ?",
[newLocked, newLocked ? null : new Date(Date.now() + 90 * 86400000), rows[0].id]
);
res.json({ ok: true, locked: newLocked });
} catch (err) {
console.error("Log lock error:", err);
res.status(500).json({ error: "Server error" });
}
});
// PATCH /api/settings — update user settings
router.patch("/settings", requireAuth, async (req, res) => {
const { log_retention_days } = req.body;
if (log_retention_days === undefined) {
return res.status(400).json({ error: "Nothing to update" });
}
const days = Math.max(1, Math.min(365, parseInt(log_retention_days) || 90));
try {
await query(
`INSERT INTO user_settings (user_id, log_retention_days)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE log_retention_days = ?`,
[req.user.id, days, days]
);
res.json({ ok: true, log_retention_days: days });
} catch (err) {
console.error("Settings update error:", err);
res.status(500).json({ error: "Server error" });
}
});
export default router;

135
www/app/src/routes/vault.js Normal file
View file

@ -0,0 +1,135 @@
import { Router } from "express";
import { query } from "../config/db.js";
import { requireAuth } from "../middleware/auth.js";
const router = Router();
// GET /api/vault — get user's vault metadata
router.get("/vault", requireAuth, async (req, res) => {
try {
const rows = await query(
"SELECT id, name, includes_settings, version, size_bytes, created_at, updated_at FROM vaults WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1",
[req.user.id]
);
if (rows.length === 0) {
return res.json({ vault: null });
}
const v = rows[0];
res.json({
vault: {
id: Number(v.id),
name: v.name,
includes_settings: !!v.includes_settings,
version: v.version,
size_bytes: Number(v.size_bytes),
created_at: v.created_at,
updated_at: v.updated_at,
},
});
} catch (err) {
console.error("Vault fetch error:", err);
res.status(500).json({ error: "Server error" });
}
});
// GET /api/vault/download — download vault ciphertext blob
router.get("/vault/download", requireAuth, async (req, res) => {
try {
const rows = await query(
"SELECT ciphertext, name FROM vaults WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1",
[req.user.id]
);
if (rows.length === 0) {
return res.status(404).json({ error: "No vault found" });
}
const filename = `${rows[0].name || "vault"}.swb`;
res.set("Content-Type", "application/octet-stream");
res.set("Content-Disposition", `attachment; filename="${filename}"`);
res.send(rows[0].ciphertext);
} catch (err) {
console.error("Vault download error:", err);
res.status(500).json({ error: "Server error" });
}
});
// PUT /api/vault — upload/replace vault (free: max 1)
router.put("/vault", requireAuth, async (req, res) => {
try {
const { name, includes_settings } = req.query;
const ciphertext = req.body; // raw binary
if (!ciphertext || ciphertext.length === 0) {
return res.status(400).json({ error: "Empty vault" });
}
// Free tier: max 1 vault
const user = (
await query("SELECT tier FROM users WHERE id = ?", [req.user.id])
)[0];
const existing = await query(
"SELECT id, size_bytes FROM vaults WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1",
[req.user.id]
);
if (existing.length > 0) {
// Replace existing vault
await query(
"UPDATE vaults SET ciphertext = ?, size_bytes = ?, version = version + 1, name = COALESCE(?, name), includes_settings = COALESCE(?, includes_settings) WHERE id = ?",
[ciphertext, ciphertext.length, name || null, includes_settings, existing[0].id]
);
// Update storage
const diff = ciphertext.length - Number(existing[0].size_bytes);
await query(
"UPDATE users SET storage_used_bytes = GREATEST(0, CAST(storage_used_bytes AS SIGNED) + ?) WHERE id = ?",
[diff, req.user.id]
);
} else {
// Create new vault
if (user.tier === "free") {
const count = (
await query("SELECT COUNT(*) AS c FROM vaults WHERE user_id = ?", [req.user.id])
)[0].c;
if (count >= 1) {
return res.status(403).json({ error: "Free accounts can store one vault. Upgrade to Pro for more." });
}
}
await query(
"INSERT INTO vaults (user_id, name, ciphertext, size_bytes, includes_settings) VALUES (?, ?, ?, ?, ?)",
[req.user.id, name || "Default", ciphertext, ciphertext.length, includes_settings !== "false"]
);
await query(
"UPDATE users SET storage_used_bytes = storage_used_bytes + ? WHERE id = ?",
[ciphertext.length, req.user.id]
);
}
res.json({ ok: true });
} catch (err) {
console.error("Vault upload error:", err);
res.status(500).json({ error: "Server error" });
}
});
// DELETE /api/vault — delete vault
router.delete("/vault", requireAuth, async (req, res) => {
try {
const existing = await query(
"SELECT id, size_bytes FROM vaults WHERE user_id = ? LIMIT 1",
[req.user.id]
);
if (existing.length > 0) {
await query("DELETE FROM vaults WHERE id = ?", [existing[0].id]);
await query(
"UPDATE users SET storage_used_bytes = GREATEST(0, CAST(storage_used_bytes AS SIGNED) - ?) WHERE id = ?",
[Number(existing[0].size_bytes), req.user.id]
);
}
res.json({ ok: true });
} catch (err) {
console.error("Vault delete error:", err);
res.status(500).json({ error: "Server error" });
}
});
export default router;

151
www/db/init.sql Normal file
View file

@ -0,0 +1,151 @@
-- SSH Workbench — Database Schema
-- MariaDB 11+
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
-- Users
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NULL, -- NULL for OAuth-only users
name VARCHAR(255) NULL,
avatar_url VARCHAR(500) NULL,
oauth_provider VARCHAR(50) NULL, -- 'google', 'github'
oauth_id VARCHAR(255) NULL,
tier ENUM('free','pro') NOT NULL DEFAULT 'free',
stripe_customer_id VARCHAR(255) NULL,
storage_used_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
storage_limit_bytes BIGINT UNSIGNED NOT NULL DEFAULT 524288000, -- 500 MB
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY idx_oauth (oauth_provider, oauth_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Auth sessions (JWT refresh tokens)
CREATE TABLE auth_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
token_hash VARCHAR(255) NOT NULL UNIQUE,
user_agent VARCHAR(500) NULL,
ip_address VARCHAR(45) NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- User settings
CREATE TABLE user_settings (
user_id BIGINT UNSIGNED PRIMARY KEY,
log_retention_days INT UNSIGNED NOT NULL DEFAULT 90,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Vaults (free: 1, pro: unlimited)
CREATE TABLE vaults (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT 'Default',
ciphertext LONGBLOB NOT NULL, -- E2E encrypted vault blob
includes_settings BOOLEAN NOT NULL DEFAULT TRUE,
version INT UNSIGNED NOT NULL DEFAULT 1,
size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Terminal session logs (encrypted, stored on disk, metadata here)
CREATE TABLE terminal_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
connection_alias VARCHAR(255) NOT NULL,
filename VARCHAR(255) NOT NULL,
size_bytes BIGINT UNSIGNED NOT NULL,
storage_path VARCHAR(500) NOT NULL, -- path to encrypted file on disk
locked BOOLEAN NOT NULL DEFAULT FALSE,
retention_days INT UNSIGNED NULL, -- NULL = use user default
expires_at TIMESTAMP NULL, -- NULL = locked (never expires)
uploaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_expires (user_id, expires_at),
INDEX idx_cleanup (expires_at, locked)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Teams (pro only)
CREATE TABLE teams (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
owner_id BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Team members with roles
CREATE TABLE team_members (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
team_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
role ENUM('owner','admin','member','viewer') NOT NULL DEFAULT 'member',
invited_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
accepted_at TIMESTAMP NULL,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY idx_team_user (team_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Vault-to-team assignments
CREATE TABLE team_vaults (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
team_id BIGINT UNSIGNED NOT NULL,
vault_id BIGINT UNSIGNED NOT NULL,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY (vault_id) REFERENCES vaults(id) ON DELETE CASCADE,
UNIQUE KEY idx_team_vault (team_id, vault_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Per-user vault permissions (for shared vaults)
CREATE TABLE vault_permissions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
vault_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
permission ENUM('read','write') NOT NULL DEFAULT 'read',
granted_by BIGINT UNSIGNED NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (vault_id) REFERENCES vaults(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (granted_by) REFERENCES users(id),
UNIQUE KEY idx_vault_user (vault_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Global snippets (team-shared, encrypted)
CREATE TABLE snippets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
team_id BIGINT UNSIGNED NULL, -- NULL = personal
user_id BIGINT UNSIGNED NOT NULL, -- creator
name VARCHAR(255) NOT NULL,
command_ciphertext BLOB NOT NULL, -- E2E encrypted
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_team (team_id),
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Audit log (append-only, admin-visible)
CREATE TABLE audit_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
team_id BIGINT UNSIGNED NULL,
user_id BIGINT UNSIGNED NULL,
action VARCHAR(100) NOT NULL, -- 'member.invited', 'vault.shared', etc.
detail JSON NULL, -- metadata (no secrets)
ip_address VARCHAR(45) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_team_time (team_id, created_at),
INDEX idx_user_time (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

51
www/docker-compose.yml Normal file
View file

@ -0,0 +1,51 @@
version: "3.8"
services:
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./web:/usr/share/nginx/html:ro
depends_on:
- app
restart: unless-stopped
app:
build: ./app
expose:
- "3000"
environment:
- NODE_ENV=${NODE_ENV:-development}
- DB_HOST=db
- DB_PORT=3306
- DB_USER=${MYSQL_USER:-swb}
- DB_PASSWORD=${MYSQL_PASSWORD:-changeme}
- DB_NAME=${MYSQL_DATABASE:-sshworkbench}
- JWT_SECRET=${JWT_SECRET:-dev-secret-change-in-production}
- PORT=3000
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: mariadb:11
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-changeme}
- MYSQL_DATABASE=${MYSQL_DATABASE:-sshworkbench}
- MYSQL_USER=${MYSQL_USER:-swb}
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-changeme}
volumes:
- db_data:/var/lib/mysql
- ./db/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
db_data:

34
www/nginx/default.conf Normal file
View file

@ -0,0 +1,34 @@
server {
listen 80;
server_name localhost;
# Static site
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# API proxy
location /api/ {
proxy_pass http://app:3000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Cache static assets
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
root /usr/share/nginx/html;
expires 7d;
add_header Cache-Control "public, immutable";
}
}

278
www/web/css/auth.css Normal file
View file

@ -0,0 +1,278 @@
/* ============================================================
Auth Login page
============================================================ */
.auth-page {
min-height: 100svh;
display: flex;
align-items: center;
justify-content: center;
padding: calc(var(--nav-h) + 24px) 16px 40px;
background:
radial-gradient(ellipse at 50% 30%, rgba(121,220,220,0.04) 0%, transparent 60%),
var(--bg);
}
.auth-card {
width: 100%;
max-width: 420px;
background: var(--bg-card);
border: 1px solid var(--divider);
border-radius: var(--radius-lg);
padding: 32px 24px;
}
.auth-header {
text-align: center;
margin-bottom: 28px;
}
.auth-icon {
width: 56px;
height: 56px;
border-radius: 14px;
margin-bottom: 16px;
}
.auth-title {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 6px;
}
.auth-sub {
font-size: 0.9rem;
color: var(--text-sec);
}
/* OAuth buttons */
.auth-oauth {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 24px;
}
.btn-oauth {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
min-height: 48px;
padding: 12px 16px;
background: var(--bg-alt);
border: 1px solid var(--divider);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
-webkit-tap-highlight-color: transparent;
}
.btn-oauth:hover {
background: var(--bg-card-h);
border-color: var(--text-muted);
}
.btn-oauth:active { transform: scale(0.98); }
.oauth-icon { width: 20px; height: 20px; flex-shrink: 0; }
/* Divider */
.auth-divider {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
color: var(--text-muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.auth-divider::before,
.auth-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--divider);
}
/* Form */
.auth-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-sec);
}
.form-group input[type="email"],
.form-group input[type="password"],
.form-group input[type="text"] {
width: 100%;
padding: 12px 14px;
background: var(--bg);
border: 1px solid var(--divider);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
min-height: 48px;
}
.form-group input:focus {
border-color: var(--teal);
box-shadow: 0 0 0 3px var(--teal-glow);
}
.form-group input::placeholder {
color: var(--text-muted);
}
/* Password toggle */
.password-wrapper {
position: relative;
}
.password-wrapper input { padding-right: 48px; }
.password-toggle {
position: absolute;
right: 4px; top: 50%;
transform: translateY(-50%);
width: 40px; height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
padding: 8px;
-webkit-tap-highlight-color: transparent;
}
.password-toggle:hover { color: var(--text-sec); }
.password-toggle svg { width: 20px; height: 20px; }
.password-toggle .eye-closed { display: none; }
.password-wrapper.show-pw .eye-open { display: none; }
.password-wrapper.show-pw .eye-closed { display: block; }
/* Remember + forgot row */
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--text-sec);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
min-height: 44px;
}
.checkbox-label input[type="checkbox"] {
display: none;
}
.checkbox-custom {
width: 18px; height: 18px;
border: 2px solid var(--text-muted);
border-radius: 4px;
flex-shrink: 0;
position: relative;
transition: background 0.2s, border-color 0.2s;
}
.checkbox-label input:checked + .checkbox-custom {
background: var(--teal);
border-color: var(--teal);
}
.checkbox-label input:checked + .checkbox-custom::after {
content: '';
position: absolute;
left: 4px; top: 1px;
width: 6px; height: 10px;
border: solid var(--bg);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.form-link {
font-size: 0.85rem;
color: var(--text-muted);
min-height: 44px;
display: inline-flex;
align-items: center;
}
.form-link:hover { color: var(--teal); }
/* Error message */
.form-error {
font-size: 0.85rem;
color: var(--red);
min-height: 0;
overflow: hidden;
transition: min-height 0.2s;
}
.form-error:empty { display: none; }
/* Full-width button */
.btn--full {
width: 100%;
justify-content: center;
position: relative;
}
.btn--small {
padding: 8px 16px;
font-size: 0.8rem;
min-height: 36px;
}
.btn--danger {
background: transparent;
color: var(--red);
border: 1px solid rgba(191, 97, 106, 0.3);
}
.btn--danger:hover {
background: rgba(191, 97, 106, 0.1);
color: var(--red);
border-color: var(--red);
}
/* Spinner */
.btn-spinner {
display: inline-block;
width: 18px; height: 18px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
position: absolute;
}
.btn-spinner[hidden] { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Footer note */
.auth-footer {
text-align: center;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--divider);
font-size: 0.85rem;
color: var(--text-muted);
}
.auth-footer strong {
color: var(--teal);
font-weight: 600;
}
/* --- Responsive --- */
@media (min-width: 640px) {
.auth-card {
padding: 40px 36px;
}
}

312
www/web/css/dashboard.css Normal file
View file

@ -0,0 +1,312 @@
/* ============================================================
Dashboard
============================================================ */
.dash {
min-height: 100svh;
padding: calc(var(--nav-h) + 24px) 0 60px;
background: var(--bg);
}
.dash-inner {
max-width: 760px;
margin: 0 auto;
padding: 0 16px;
}
/* Nav user section */
.nav-user {
display: flex;
align-items: center;
gap: 12px;
}
.nav-user-name {
font-size: 0.85rem;
color: var(--text-sec);
display: none;
}
/* Header */
.dash-header {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 28px;
}
.dash-greeting {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.dash-greeting h1 {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.5px;
}
.dash-tier {
font-family: var(--mono);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
padding: 3px 10px;
border-radius: 12px;
background: rgba(121, 220, 220, 0.12);
color: var(--teal);
}
.dash-tier.pro {
background: rgba(212, 168, 67, 0.15);
color: var(--gold);
}
/* Storage bar */
.dash-storage {
display: flex;
align-items: center;
gap: 12px;
}
.storage-bar {
flex: 1;
height: 6px;
background: var(--divider);
border-radius: 3px;
overflow: hidden;
max-width: 200px;
}
.storage-fill {
height: 100%;
background: var(--teal);
border-radius: 3px;
transition: width 0.3s;
}
.storage-fill.warn { background: var(--amber); }
.storage-fill.full { background: var(--red); }
.storage-label {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
}
/* Tabs */
.dash-tabs {
display: flex;
gap: 4px;
margin-bottom: 24px;
border-bottom: 1px solid var(--divider);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.dash-tab {
padding: 12px 20px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
font-family: var(--font);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
min-height: 48px;
transition: color 0.2s, border-color 0.2s;
-webkit-tap-highlight-color: transparent;
}
.dash-tab:hover { color: var(--text-sec); }
.dash-tab.active {
color: var(--teal);
border-bottom-color: var(--teal);
}
/* Panels */
.dash-panel {
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.panel-header {
margin-bottom: 20px;
}
.panel-header h2 {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: -0.3px;
margin-bottom: 4px;
}
.panel-hint {
font-size: 0.85rem;
color: var(--text-muted);
}
.panel-header-row {
display: flex;
flex-direction: column;
gap: 12px;
}
/* Empty states */
.vault-empty,
.logs-empty {
text-align: center;
padding: 48px 20px;
background: var(--bg-card);
border: 1px dashed var(--divider);
border-radius: var(--radius-lg);
}
.empty-icon {
width: 48px; height: 48px;
margin: 0 auto 16px;
color: var(--text-muted);
}
.empty-icon svg { width: 100%; height: 100%; }
.vault-empty p,
.logs-empty p { color: var(--text-sec); font-size: 0.9rem; }
.empty-hint { color: var(--text-muted) !important; font-size: 0.85rem !important; margin-top: 8px; }
/* Vault card */
.vault-card {
background: var(--bg-card);
border: 1px solid var(--divider);
border-radius: var(--radius-lg);
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.vault-name {
font-size: 1rem;
font-weight: 600;
}
.vault-meta {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--text-muted);
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.vault-sep { color: var(--divider); }
.vault-actions {
display: flex;
gap: 8px;
}
/* Log settings */
.log-settings {
display: flex;
align-items: center;
}
.log-retention-label {
font-size: 0.8rem;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 8px;
}
.select-small {
background: var(--bg);
border: 1px solid var(--divider);
border-radius: 6px;
color: var(--text);
font-family: var(--font);
font-size: 0.8rem;
padding: 6px 10px;
min-height: 36px;
outline: none;
cursor: pointer;
}
.select-small:focus {
border-color: var(--teal);
}
/* Log list */
.logs-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.log-item {
background: var(--bg-card);
border: 1px solid var(--divider);
border-radius: var(--radius);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.log-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.log-alias {
font-size: 0.9rem;
font-weight: 600;
word-break: break-all;
}
.log-lock {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
padding: 4px;
min-width: 32px; min-height: 32px;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
}
.log-lock:hover { color: var(--teal); }
.log-lock.locked { color: var(--amber); }
.log-lock svg { width: 18px; height: 18px; }
.log-meta {
font-family: var(--mono);
font-size: 0.72rem;
color: var(--text-muted);
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.log-actions {
display: flex;
gap: 8px;
}
/* --- Responsive --- */
@media (min-width: 640px) {
.nav-user-name { display: inline; }
.dash-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.vault-card {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.log-item {
flex-direction: row;
align-items: center;
}
.log-top { flex: 1; }
.panel-header-row {
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
}
}
@media (min-width: 960px) {
.dash-inner {
padding: 0 24px;
}
}

706
www/web/css/style.css Normal file
View file

@ -0,0 +1,706 @@
/* ============================================================
SSH Workbench Landing Page
Mobile-first, dark terminal aesthetic
============================================================ */
/* --- Reset & Base --- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0A0E14;
--bg-alt: #10141A;
--bg-card: #181C22;
--bg-card-h: #1E2430;
--teal: #79DCDC;
--teal-dim: #4AADAD;
--teal-glow: rgba(121, 220, 220, 0.12);
--gold: #D4A843;
--green: #4EC9B0;
--amber: #E5A84B;
--violet: #B48EAD;
--red: #BF616A;
--blue: #81A1C1;
--text: #E4ECEC;
--text-sec: #A0ACAC;
--text-muted: #7A8888;
--divider: #252B35;
--radius: 10px;
--radius-lg: 16px;
--font: 'Space Grotesk', system-ui, -apple-system, sans-serif;
--mono: 'JetBrains Mono', 'Cascadia Mono', 'Fira Mono', monospace;
--max-w: 1120px;
--nav-h: 60px;
}
html {
font-size: 16px;
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
a { color: var(--teal); text-decoration: none; }
a:hover { color: var(--teal-dim); }
img { max-width: 100%; display: block; }
/* --- Utility --- */
.section-inner {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 20px;
}
/* --- Nav --- */
.nav {
position: fixed;
top: 0; left: 0; right: 0;
height: var(--nav-h);
z-index: 100;
background: transparent;
transition: background 0.3s, box-shadow 0.3s;
}
.nav--scrolled {
background: rgba(10, 14, 20, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 1px 0 var(--divider);
}
.nav-inner {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 20px;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-logo {
display: flex;
align-items: center;
gap: 10px;
color: var(--text);
font-weight: 600;
font-size: 1rem;
letter-spacing: -0.3px;
}
.nav-logo:hover { color: var(--text); }
.nav-logo-img {
width: 32px; height: 32px;
border-radius: 8px;
}
.nav-logo-text {
display: none;
}
/* Hamburger */
.nav-toggle {
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 44px; height: 44px;
background: none;
border: none;
cursor: pointer;
padding: 10px;
-webkit-tap-highlight-color: transparent;
}
.nav-toggle span {
display: block;
width: 22px; height: 2px;
background: var(--text);
border-radius: 2px;
transition: transform 0.3s, opacity 0.3s;
}
.nav-toggle.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.nav-toggle.open span:nth-child(2) { opacity: 0; }
.nav-toggle.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
/* Mobile nav menu */
.nav-links {
position: fixed;
top: var(--nav-h);
left: 0; right: 0;
background: rgba(10, 14, 20, 0.98);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
list-style: none;
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 4px;
transform: translateY(-110%);
opacity: 0;
transition: transform 0.35s ease, opacity 0.3s;
border-bottom: 1px solid var(--divider);
}
.nav-links.open {
transform: translateY(0);
opacity: 1;
}
.nav-links li a {
display: block;
padding: 14px 16px;
color: var(--text-sec);
font-size: 1rem;
font-weight: 500;
border-radius: var(--radius);
transition: background 0.2s, color 0.2s;
}
.nav-links li a:hover,
.nav-links li a:active {
background: var(--teal-glow);
color: var(--teal);
}
.nav-btn--outline {
border: 1px solid var(--divider) !important;
text-align: center;
margin-top: 8px;
}
.nav-btn--muted { color: var(--text-muted) !important; }
/* --- Buttons --- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 14px 28px;
border-radius: var(--radius);
font-family: var(--font);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: -0.2px;
cursor: pointer;
transition: background 0.2s, transform 0.15s, box-shadow 0.2s;
border: none;
min-height: 48px; /* touch target */
-webkit-tap-highlight-color: transparent;
}
.btn:active { transform: scale(0.97); }
.btn--primary {
background: var(--teal);
color: var(--bg);
}
.btn--primary:hover {
background: var(--teal-dim);
color: var(--bg);
box-shadow: 0 0 24px var(--teal-glow);
}
.btn--ghost {
background: transparent;
color: var(--teal);
border: 1px solid var(--divider);
}
.btn--ghost:hover {
border-color: var(--teal-dim);
background: var(--teal-glow);
color: var(--teal);
}
.btn--large {
padding: 16px 36px;
font-size: 1.05rem;
}
/* --- Hero --- */
.hero {
min-height: 100svh;
display: flex;
align-items: center;
padding: calc(var(--nav-h) + 24px) 0 40px;
background:
radial-gradient(ellipse at 50% 0%, rgba(121,220,220,0.06) 0%, transparent 60%),
var(--bg);
}
.hero-inner {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 20px;
width: 100%;
}
.hero-badge {
display: inline-block;
padding: 6px 14px;
background: var(--teal-glow);
color: var(--teal);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
border-radius: 20px;
border: 1px solid rgba(121, 220, 220, 0.15);
margin-bottom: 20px;
}
.hero-title {
font-size: clamp(2rem, 7vw, 3.8rem);
font-weight: 700;
line-height: 1.1;
letter-spacing: -1.5px;
margin-bottom: 18px;
}
.hero-sub {
font-size: clamp(1rem, 2.5vw, 1.2rem);
color: var(--text-sec);
max-width: 540px;
line-height: 1.7;
margin-bottom: 32px;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 48px;
}
/* Terminal mockup */
.hero-terminal {
width: 100%;
max-width: 600px;
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--divider);
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.terminal-bar {
display: flex;
align-items: center;
gap: 7px;
padding: 10px 14px;
background: #1A1F28;
border-bottom: 1px solid var(--divider);
}
.terminal-dot {
width: 10px; height: 10px;
border-radius: 50%;
}
.terminal-dot--red { background: #BF616A; }
.terminal-dot--yellow { background: #EBCB8B; }
.terminal-dot--green { background: #A3BE8C; }
.terminal-title {
margin-left: 8px;
font-family: var(--mono);
font-size: 0.75rem;
color: var(--text-muted);
}
.terminal-body {
background: var(--bg-alt);
padding: 16px;
font-family: var(--mono);
font-size: clamp(0.72rem, 2vw, 0.85rem);
line-height: 1.8;
}
.terminal-line { white-space: nowrap; overflow: hidden; }
.t-prompt { color: var(--teal); }
.t-muted { color: var(--text-muted); }
.t-green { color: var(--green); }
.t-cursor { color: var(--teal); animation: none; }
.t-cursor--off { opacity: 0; }
/* --- Stats --- */
.stats {
padding: 48px 0;
border-top: 1px solid var(--divider);
border-bottom: 1px solid var(--divider);
background: var(--bg-alt);
}
.stats-inner {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 20px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
text-align: center;
}
.stat-num {
display: block;
font-size: 1.8rem;
font-weight: 700;
color: var(--teal);
letter-spacing: -1px;
font-family: var(--mono);
}
.stat-label {
display: block;
font-size: 0.8rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-top: 4px;
}
/* --- Features --- */
.features {
padding: 80px 0;
background: var(--bg);
}
.section-title {
font-size: clamp(1.6rem, 4vw, 2.4rem);
font-weight: 700;
letter-spacing: -1px;
margin-bottom: 12px;
}
.section-sub {
color: var(--text-sec);
font-size: 1rem;
max-width: 500px;
margin-bottom: 48px;
line-height: 1.6;
}
.feature-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.feature-card {
background: var(--bg-card);
border: 1px solid var(--divider);
border-radius: var(--radius-lg);
padding: 28px 24px;
transition: border-color 0.3s, transform 0.2s, background 0.3s;
}
.feature-card:hover {
border-color: var(--teal-dim);
background: var(--bg-card-h);
}
.feature-icon {
width: 40px; height: 40px;
color: var(--teal);
margin-bottom: 16px;
}
.feature-icon svg { width: 100%; height: 100%; }
.feature-title {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: -0.3px;
margin-bottom: 8px;
}
.feature-desc {
font-size: 0.9rem;
color: var(--text-sec);
line-height: 1.65;
}
/* --- Security --- */
.security {
padding: 80px 0;
background: var(--bg-alt);
}
.security-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.security-item {
padding: 24px;
border-left: 3px solid var(--teal);
background: var(--bg-card);
border-radius: 0 var(--radius) var(--radius) 0;
}
.security-badge {
display: inline-block;
font-family: var(--mono);
font-size: 0.7rem;
font-weight: 600;
color: var(--teal);
letter-spacing: 1.5px;
margin-bottom: 10px;
}
.security-item h4 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 8px;
letter-spacing: -0.3px;
}
.security-item p {
font-size: 0.85rem;
color: var(--text-sec);
line-height: 1.65;
}
/* --- Roadmap --- */
.roadmap {
padding: 80px 0;
background: var(--bg);
}
.roadmap-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
.roadmap-card {
background: var(--bg-card);
border: 1px solid var(--divider);
border-radius: var(--radius-lg);
padding: 28px 24px;
}
.roadmap-status {
display: inline-block;
font-family: var(--mono);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
padding: 3px 10px;
border-radius: 12px;
margin-bottom: 14px;
}
.roadmap-status--active {
background: rgba(78, 201, 176, 0.15);
color: var(--green);
}
.roadmap-status--next {
background: rgba(121, 220, 220, 0.12);
color: var(--teal);
}
.roadmap-status--future {
background: rgba(180, 142, 173, 0.12);
color: var(--violet);
}
.roadmap-card h3 {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: -0.3px;
margin-bottom: 8px;
}
.roadmap-card p {
font-size: 0.9rem;
color: var(--text-sec);
line-height: 1.65;
}
/* --- CTA --- */
.cta {
padding: 80px 0;
background:
radial-gradient(ellipse at 50% 100%, rgba(121,220,220,0.06) 0%, transparent 60%),
var(--bg-alt);
text-align: center;
border-top: 1px solid var(--divider);
}
.cta-icon { margin-bottom: 20px; }
.cta-app-icon {
width: 72px; height: 72px;
border-radius: 18px;
margin: 0 auto;
box-shadow: 0 8px 30px rgba(0,0,0,0.4);
}
.cta-title {
font-size: clamp(1.6rem, 4vw, 2.4rem);
font-weight: 700;
letter-spacing: -1px;
margin-bottom: 12px;
}
.cta-sub {
color: var(--text-sec);
font-size: 1rem;
margin-bottom: 32px;
}
.cta-actions {
display: flex;
justify-content: center;
}
/* --- Footer --- */
.footer {
padding: 40px 0;
border-top: 1px solid var(--divider);
background: var(--bg);
}
.footer-inner {
max-width: var(--max-w);
margin: 0 auto;
padding: 0 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
text-align: center;
}
.footer-brand {
display: flex;
align-items: center;
gap: 10px;
}
.footer-icon {
width: 28px; height: 28px;
border-radius: 7px;
}
.footer-company {
font-size: 0.85rem;
color: var(--text-muted);
}
.footer-links {
display: flex;
gap: 24px;
}
.footer-links a {
font-size: 0.85rem;
color: var(--text-muted);
transition: color 0.2s;
}
.footer-links a:hover { color: var(--teal); }
.footer-copy {
font-size: 0.75rem;
color: var(--text-muted);
}
/* --- Reveal animation --- */
[data-reveal] {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.5s ease, transform 0.5s ease;
}
[data-reveal].revealed {
opacity: 1;
transform: translateY(0);
}
/* ============================================================
Responsive Tablet (640px+)
============================================================ */
@media (min-width: 640px) {
.nav-logo-text { display: inline; }
.stats-inner {
grid-template-columns: repeat(4, 1fr);
}
.feature-grid {
grid-template-columns: repeat(2, 1fr);
}
.security-grid {
grid-template-columns: repeat(2, 1fr);
}
.roadmap-grid {
grid-template-columns: repeat(2, 1fr);
}
.hero-terminal {
max-width: 560px;
}
}
/* ============================================================
Responsive Desktop (960px+)
============================================================ */
@media (min-width: 960px) {
:root { --nav-h: 64px; }
/* Desktop nav */
.nav-toggle { display: none; }
.nav-links {
position: static;
flex-direction: row;
align-items: center;
transform: none;
opacity: 1;
background: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
padding: 0;
gap: 4px;
border-bottom: none;
}
.nav-links li a {
padding: 8px 16px;
font-size: 0.9rem;
}
.nav-btn--outline {
margin-top: 0 !important;
}
/* Hero side-by-side */
.hero-inner {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto auto;
align-items: center;
gap: 0 48px;
}
.hero-badge { grid-column: 1; }
.hero-title { grid-column: 1; }
.hero-sub { grid-column: 1; }
.hero-actions { grid-column: 1; }
.hero-terminal {
grid-column: 2;
grid-row: 1 / span 4;
max-width: 100%;
justify-self: end;
}
.feature-grid {
grid-template-columns: repeat(4, 1fr);
}
.security-grid {
grid-template-columns: repeat(4, 1fr);
}
.roadmap-grid {
grid-template-columns: repeat(3, 1fr);
}
.feature-card:hover {
transform: translateY(-3px);
}
.footer-inner {
flex-direction: row;
justify-content: space-between;
text-align: left;
}
}
/* ============================================================
Responsive Large (1200px+)
============================================================ */
@media (min-width: 1200px) {
.hero-title {
font-size: 3.8rem;
}
.stat-num {
font-size: 2.2rem;
}
}
/* ============================================================
Touch & accessibility
============================================================ */
@media (hover: none) {
.feature-card:hover {
transform: none;
border-color: var(--divider);
background: var(--bg-card);
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
html { scroll-behavior: auto; }
[data-reveal] {
opacity: 1;
transform: none;
}
}
/* Ensure touch targets are at least 44px */
@media (pointer: coarse) {
.nav-links li a { min-height: 48px; display: flex; align-items: center; }
.btn { min-height: 48px; }
.footer-links a { padding: 8px 4px; min-height: 44px; display: inline-flex; align-items: center; }
}

126
www/web/dashboard.html Normal file
View file

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard &mdash; SSH Workbench</title>
<link rel="icon" type="image/png" href="/images/icon-192.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/auth.css">
<link rel="stylesheet" href="/css/dashboard.css">
</head>
<body>
<!-- Nav -->
<nav class="nav nav--scrolled">
<div class="nav-inner">
<a href="/" class="nav-logo">
<img src="/images/icon-192.png" alt="SSH Workbench" class="nav-logo-img">
<span class="nav-logo-text">SSH Workbench</span>
</a>
<div class="nav-user" id="nav-user">
<span class="nav-user-name" id="user-name"></span>
<button class="btn btn--ghost btn--small" id="btn-logout">Log Out</button>
</div>
</div>
</nav>
<main class="dash">
<div class="dash-inner">
<!-- User info bar -->
<div class="dash-header">
<div class="dash-greeting">
<h1 id="greeting">Dashboard</h1>
<span class="dash-tier" id="user-tier">Free</span>
</div>
<div class="dash-storage">
<div class="storage-bar">
<div class="storage-fill" id="storage-fill" style="width: 0%"></div>
</div>
<span class="storage-label" id="storage-label">0 MB / 500 MB</span>
</div>
</div>
<!-- Tabs -->
<div class="dash-tabs">
<button class="dash-tab active" data-tab="vault">Vault</button>
<button class="dash-tab" data-tab="logs">Session Logs</button>
</div>
<!-- Vault Panel -->
<section class="dash-panel" id="panel-vault">
<div class="panel-header">
<h2>Your Vault</h2>
<p class="panel-hint">Your encrypted vault synced from the app. Free accounts can store one vault.</p>
</div>
<div class="vault-empty" id="vault-empty">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<p>No vault uploaded yet.</p>
<p class="empty-hint">Open SSH Workbench on your device and register to sync your vault here.</p>
</div>
<div class="vault-card" id="vault-card" hidden>
<div class="vault-info">
<div class="vault-name" id="vault-name">Default</div>
<div class="vault-meta">
<span id="vault-size">0 KB</span>
<span class="vault-sep">&middot;</span>
<span>Settings included</span>
<span class="vault-sep">&middot;</span>
<span id="vault-updated">Never</span>
</div>
</div>
<div class="vault-actions">
<button class="btn btn--ghost btn--small" id="btn-download-vault">Download</button>
<button class="btn btn--danger btn--small" id="btn-delete-vault">Delete</button>
</div>
</div>
</section>
<!-- Logs Panel -->
<section class="dash-panel" id="panel-logs" hidden>
<div class="panel-header">
<div class="panel-header-row">
<div>
<h2>Session Logs</h2>
<p class="panel-hint">Encrypted terminal logs uploaded from the app. Only you can read them.</p>
</div>
<div class="log-settings">
<label class="log-retention-label">
Auto-delete after
<select id="retention-days" class="select-small">
<option value="30">30 days</option>
<option value="60">60 days</option>
<option value="90" selected>90 days</option>
<option value="180">180 days</option>
<option value="365">1 year</option>
</select>
</label>
</div>
</div>
</div>
<div class="logs-empty" id="logs-empty">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
</div>
<p>No session logs yet.</p>
<p class="empty-hint">Enable session logging in the app to upload encrypted logs here.</p>
</div>
<div class="logs-list" id="logs-list" hidden>
<!-- Populated by JS -->
</div>
</section>
</div>
</main>
<script src="/js/dashboard.js"></script>
</body>
</html>

BIN
www/web/images/icon-144.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
www/web/images/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

250
www/web/index.html Normal file
View file

@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSH Workbench &mdash; Professional SSH Terminal for Android</title>
<meta name="description" content="A professional Android SSH terminal with a custom keyboard system, terminal engine, and zero-knowledge vault encryption.">
<link rel="icon" type="image/png" href="/images/icon-192.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<!-- Nav -->
<nav class="nav" id="nav">
<div class="nav-inner">
<a href="#" class="nav-logo">
<img src="/images/icon-192.png" alt="SSH Workbench" class="nav-logo-img">
<span class="nav-logo-text">SSH Workbench</span>
</a>
<button class="nav-toggle" id="nav-toggle" aria-label="Menu">
<span></span><span></span><span></span>
</button>
<ul class="nav-links" id="nav-links">
<li><a href="#features">Features</a></li>
<li><a href="#security">Security</a></li>
<li><a href="#" class="nav-btn nav-btn--muted" title="Coming soon">Docs</a></li>
<li><a href="/login.html" class="nav-btn nav-btn--outline">Log In</a></li>
</ul>
</div>
</nav>
<!-- Hero -->
<section class="hero">
<div class="hero-inner">
<div class="hero-badge">Android SSH Terminal</div>
<h1 class="hero-title">The terminal,<br>reimagined for mobile.</h1>
<p class="hero-sub">
A professional SSH and Telnet client with its own terminal engine, custom keyboard system,
and zero-knowledge vault encryption. Built for people who live in the terminal.
</p>
<div class="hero-actions">
<a href="#features" class="btn btn--primary">Explore Features</a>
<a href="/login.html" class="btn btn--ghost">Log In</a>
</div>
<div class="hero-terminal">
<div class="terminal-bar">
<span class="terminal-dot terminal-dot--red"></span>
<span class="terminal-dot terminal-dot--yellow"></span>
<span class="terminal-dot terminal-dot--green"></span>
<span class="terminal-title">sshtest@duero ~ </span>
</div>
<div class="terminal-body" id="terminal-body">
<div class="terminal-line"><span class="t-prompt">$</span> ssh sshtest@duero</div>
<div class="terminal-line t-muted">Connecting to 10.10.0.39:22...</div>
<div class="terminal-line t-green">Host key verified (Ed25519:SHA256:...)</div>
<div class="terminal-line t-green">Authentication successful.</div>
<div class="terminal-line"><span class="t-prompt">sshtest@duero:~$</span> <span class="t-cursor">_</span></div>
</div>
</div>
</div>
</section>
<!-- Stats -->
<section class="stats">
<div class="stats-inner">
<div class="stat">
<span class="stat-num">2000+</span>
<span class="stat-label">Unit Tests</span>
</div>
<div class="stat">
<span class="stat-num">20</span>
<span class="stat-label">Color Themes</span>
</div>
<div class="stat">
<span class="stat-num">10</span>
<span class="stat-label">Terminal Fonts</span>
</div>
<div class="stat">
<span class="stat-num">11/11</span>
<span class="stat-label">vttest Passing</span>
</div>
</div>
</section>
<!-- Features -->
<section class="features" id="features">
<div class="section-inner">
<h2 class="section-title">What Makes It Different</h2>
<p class="section-sub">A custom terminal engine and keyboard system designed specifically for mobile SSH work.</p>
<div class="feature-grid">
<div class="feature-card" data-reveal>
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M6 21h12"/><path d="M12 17v4"/><path d="M6 9l3 3-3 3"/><path d="M12 15h4"/></svg>
</div>
<h3 class="feature-title">Own Terminal Engine</h3>
<p class="feature-desc">
Full VT100/VT220/xterm parser with VT52 compatibility mode.
256-color and 24-bit truecolor, alternate screen buffer, bracketed paste, mouse reporting.
All vttest conformance tests passing.
</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 8h1"/><path d="M10 8h1"/><path d="M14 8h1"/><path d="M18 8h1"/><path d="M5 12h2"/><path d="M9 12h2"/><path d="M13 12h2"/><path d="M17 12h2"/><path d="M8 16h8"/></svg>
</div>
<h3 class="feature-title">Custom Keyboard</h3>
<p class="feature-desc">
Canvas-rendered, JSON-driven layouts with QuickBar, app shortcuts for vim/nano/tmux/screen,
modifier keys, language packs, and a mini numpad with NumBlok toggle.
</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/><path d="M9 17l3-3 3 3"/><path d="M12 14v-4"/></svg>
</div>
<h3 class="feature-title">SFTP Browser</h3>
<p class="feature-desc">
Full file manager with multi-select, batch delete, hidden file toggle,
breadcrumb navigation. Each SFTP tab runs its own independent SSH connection.
</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>
</div>
<h3 class="feature-title">Vault Security</h3>
<p class="feature-desc">
Argon2id key derivation + AES-256-GCM encryption. Passwords in EncryptedSharedPreferences.
Private keys never in plaintext. Zero-knowledge by design.
</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8h2a2 2 0 0 1 2 2v10"/><path d="M2 4h2a2 2 0 0 1 2 2v10"/><path d="M6 16h12"/><circle cx="9" cy="10" r="2"/><circle cx="15" cy="10" r="2"/><path d="M11 10h2"/></svg>
</div>
<h3 class="feature-title">Port Forwarding</h3>
<p class="feature-desc">
Local, remote, and dynamic SOCKS5 tunnels. Configured per connection,
auto-activated on connect. Full RFC 1928/1929 SOCKS5 compliance.
</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</div>
<h3 class="feature-title">SSH &amp; Telnet</h3>
<p class="feature-desc">
SSH with multi-hop jump host chains and cycle detection.
Telnet for legacy systems &mdash; direct or tunneled via SSH jump host for secure access to older infrastructure.
</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="5" cy="6" r="2"/><circle cx="19" cy="6" r="2"/><circle cx="5" cy="18" r="2"/><circle cx="19" cy="18" r="2"/><path d="M12 9V4"/><path d="M9.5 13.5L6 17"/><path d="M14.5 13.5L18 17"/></svg>
</div>
<h3 class="feature-title">20 Themes &amp; 10 Fonts</h3>
<p class="feature-desc">
Catppuccin, Dracula, Nord, Tokyo Night, Solarized, Gruvbox, and more.
JetBrains Mono, Fira Mono, Cascadia, Hack. Per-connection theme overrides.
</p>
</div>
<div class="feature-card" data-reveal>
<div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
</div>
<h3 class="feature-title">Multi-Session Tabs</h3>
<p class="feature-desc">
Multiple simultaneous SSH, Telnet, and local shell sessions with tab switching,
auto-reconnect, disconnect notifications, and session recovery.
</p>
</div>
</div>
</div>
</section>
<!-- Security -->
<section class="security" id="security">
<div class="section-inner">
<h2 class="section-title">Security First</h2>
<p class="section-sub">Four independent security audits. OWASP-aligned. Every layer hardened.</p>
<div class="security-grid">
<div class="security-item" data-reveal>
<div class="security-badge">CRYPTO</div>
<h4>Argon2id + AES-256-GCM</h4>
<p>JNI native crypto with volatile secure_zero on all sensitive memory. Compiler hardened with -fstack-protector-strong.</p>
</div>
<div class="security-item" data-reveal>
<div class="security-badge">TOFU</div>
<h4>Host Key Verification</h4>
<p>Trust-on-first-use with algorithm-prefixed fingerprints. Default REJECT when no UI handler. Key change alerts with user confirmation.</p>
</div>
<div class="security-item" data-reveal>
<div class="security-badge">STORAGE</div>
<h4>EncryptedSharedPreferences</h4>
<p>AES-256-GCM for values, AES-256-SIV for keys. Private SSH keys never in Room DB or plaintext. Password CharArrays zeroed after use.</p>
</div>
<div class="security-item" data-reveal>
<div class="security-badge">NETWORK</div>
<h4>System CAs Only</h4>
<p>Cleartext traffic disabled globally. No custom certificate authorities. Paste sanitization prevents escape injection attacks.</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="cta">
<div class="section-inner">
<div class="cta-icon">
<img src="/images/icon-192.png" alt="SSH Workbench" class="cta-app-icon">
</div>
<h2 class="cta-title">Coming to Google Play</h2>
<p class="cta-sub">Register in the app to sync your vault across devices.</p>
<div class="cta-actions">
<a href="/login.html" class="btn btn--primary btn--large">Log In</a>
</div>
</div>
</section>
<!-- Footer -->
<footer class="footer">
<div class="footer-inner">
<div class="footer-brand">
<img src="/images/icon-144.png" alt="" class="footer-icon">
<span class="footer-company">Rounding Mobile Technologies S.L.</span>
</div>
<div class="footer-links">
<a href="#features">Features</a>
<a href="#security">Security</a>
<a href="/login.html">Log In</a>
</div>
<div class="footer-copy">&copy; 2026 Rounding Mobile Technologies S.L.</div>
</div>
</footer>
<script src="/js/main.js"></script>
</body>
</html>

89
www/web/js/auth.js Normal file
View file

@ -0,0 +1,89 @@
const API = "/api";
// Password show/hide
const togglePw = document.getElementById("toggle-pw");
const pwWrapper = togglePw?.closest(".password-wrapper");
const pwInput = document.getElementById("password");
togglePw?.addEventListener("click", () => {
const show = !pwWrapper.classList.contains("show-pw");
pwWrapper.classList.toggle("show-pw", show);
pwInput.type = show ? "text" : "password";
});
// Lost password (placeholder)
document.getElementById("forgot-link")?.addEventListener("click", (e) => {
e.preventDefault();
alert("Password recovery is not available yet.\nPlease use the app to reset your password.");
});
// Login form
const form = document.getElementById("login-form");
const errorEl = document.getElementById("form-error");
const btnLogin = document.getElementById("btn-login");
form?.addEventListener("submit", async (e) => {
e.preventDefault();
const email = form.email.value.trim();
const password = form.password.value;
const remember = form.remember.checked;
if (!email || !password) {
showError("Please enter your email and password.");
return;
}
setLoading(true);
try {
const res = await fetch(`${API}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, remember }),
});
const data = await res.json();
if (!res.ok) {
showError(data.error || "Login failed. Please try again.");
return;
}
// Store token
const storage = remember ? localStorage : sessionStorage;
storage.setItem("swb_token", data.token);
storage.setItem("swb_user", JSON.stringify(data.user));
window.location.href = "/dashboard.html";
} catch (err) {
showError("Unable to connect to server. Please try again later.");
} finally {
setLoading(false);
}
});
// OAuth — Google
document.getElementById("btn-google")?.addEventListener("click", () => {
// Will redirect to /api/auth/google which redirects to Google OAuth
window.location.href = `${API}/auth/google`;
});
// OAuth — GitHub
document.getElementById("btn-github")?.addEventListener("click", () => {
window.location.href = `${API}/auth/github`;
});
function showError(msg) {
if (errorEl) errorEl.textContent = msg;
}
function setLoading(on) {
if (!btnLogin) return;
const text = btnLogin.querySelector(".btn-text");
const spinner = btnLogin.querySelector(".btn-spinner");
btnLogin.disabled = on;
if (text) text.style.visibility = on ? "hidden" : "visible";
if (spinner) spinner.hidden = !on;
}
// If already logged in, redirect to dashboard
(function checkSession() {
const token = localStorage.getItem("swb_token") || sessionStorage.getItem("swb_token");
if (token) {
window.location.href = "/dashboard.html";
}
})();

266
www/web/js/dashboard.js Normal file
View file

@ -0,0 +1,266 @@
const API = "/api";
// Auth check
function getToken() {
return localStorage.getItem("swb_token") || sessionStorage.getItem("swb_token");
}
function getUser() {
const raw = localStorage.getItem("swb_user") || sessionStorage.getItem("swb_user");
return raw ? JSON.parse(raw) : null;
}
function authHeaders() {
return { Authorization: `Bearer ${getToken()}`, "Content-Type": "application/json" };
}
function logout() {
localStorage.removeItem("swb_token");
localStorage.removeItem("swb_user");
sessionStorage.removeItem("swb_token");
sessionStorage.removeItem("swb_user");
window.location.href = "/login.html";
}
// Redirect if not logged in
if (!getToken()) {
window.location.href = "/login.html";
}
// Init
const user = getUser();
if (user) {
document.getElementById("user-name").textContent = user.name || user.email;
document.getElementById("greeting").textContent = `Welcome, ${user.name || user.email.split("@")[0]}`;
const tierEl = document.getElementById("user-tier");
if (user.tier === "pro") {
tierEl.textContent = "Pro";
tierEl.classList.add("pro");
}
}
// Logout
document.getElementById("btn-logout")?.addEventListener("click", () => {
fetch(`${API}/auth/logout`, { method: "POST", headers: authHeaders() }).catch(() => {});
logout();
});
// Tabs
const tabs = document.querySelectorAll(".dash-tab");
const panels = {
vault: document.getElementById("panel-vault"),
logs: document.getElementById("panel-logs"),
};
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
const target = tab.dataset.tab;
tabs.forEach((t) => t.classList.toggle("active", t === tab));
Object.entries(panels).forEach(([key, panel]) => {
panel.hidden = key !== target;
});
});
});
// --- Vault ---
async function loadVault() {
try {
const res = await fetch(`${API}/vault`, { headers: authHeaders() });
if (res.status === 401) return logout();
const data = await res.json();
if (data.vault) {
showVault(data.vault);
}
} catch (e) {
// offline — show empty
}
}
function showVault(vault) {
document.getElementById("vault-empty").hidden = true;
const card = document.getElementById("vault-card");
card.hidden = false;
document.getElementById("vault-name").textContent = vault.name || "Default";
document.getElementById("vault-size").textContent = formatBytes(vault.size_bytes);
document.getElementById("vault-updated").textContent = formatDate(vault.updated_at);
}
document.getElementById("btn-download-vault")?.addEventListener("click", async () => {
try {
const res = await fetch(`${API}/vault/download`, { headers: authHeaders() });
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "vault.swb";
a.click();
URL.revokeObjectURL(url);
} catch (e) {
alert("Download failed.");
}
});
document.getElementById("btn-delete-vault")?.addEventListener("click", async () => {
if (!confirm("Delete your vault? This cannot be undone.")) return;
try {
const res = await fetch(`${API}/vault`, { method: "DELETE", headers: authHeaders() });
if (res.ok) {
document.getElementById("vault-card").hidden = true;
document.getElementById("vault-empty").hidden = false;
}
} catch (e) {
alert("Delete failed.");
}
});
// --- Logs ---
async function loadLogs() {
try {
const res = await fetch(`${API}/logs`, { headers: authHeaders() });
if (res.status === 401) return logout();
const data = await res.json();
updateStorage(data.storage_used, data.storage_limit);
if (data.logs && data.logs.length > 0) {
renderLogs(data.logs);
}
if (data.retention_days) {
document.getElementById("retention-days").value = String(data.retention_days);
}
} catch (e) {
// offline
}
}
function updateStorage(used, limit) {
const pct = limit > 0 ? (used / limit) * 100 : 0;
const fill = document.getElementById("storage-fill");
fill.style.width = Math.min(pct, 100) + "%";
fill.classList.toggle("warn", pct > 70);
fill.classList.toggle("full", pct > 90);
document.getElementById("storage-label").textContent =
`${formatBytes(used)} / ${formatBytes(limit)}`;
}
function renderLogs(logs) {
document.getElementById("logs-empty").hidden = true;
const list = document.getElementById("logs-list");
list.hidden = false;
list.innerHTML = logs
.map(
(log) => `
<div class="log-item" data-id="${log.id}">
<div class="log-top">
<div>
<div class="log-alias">${esc(log.connection_alias)}</div>
<div class="log-meta">
<span>${formatBytes(log.size_bytes)}</span>
<span class="vault-sep">&middot;</span>
<span>${formatDate(log.uploaded_at)}</span>
${log.locked ? '<span class="vault-sep">&middot;</span><span style="color:var(--amber)">Locked</span>' : ""}
${log.expires_at ? `<span class="vault-sep">&middot;</span><span>Expires ${formatDate(log.expires_at)}</span>` : ""}
</div>
</div>
<button class="log-lock ${log.locked ? "locked" : ""}" title="${log.locked ? "Unlock" : "Lock (never delete)"}" data-action="lock" data-id="${log.id}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
${
log.locked
? '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>'
: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>'
}
</svg>
</button>
</div>
<div class="log-actions">
<button class="btn btn--ghost btn--small" data-action="download" data-id="${log.id}">Download</button>
<button class="btn btn--danger btn--small" data-action="delete" data-id="${log.id}">Delete</button>
</div>
</div>
`
)
.join("");
// Event delegation
list.addEventListener("click", handleLogAction);
}
async function handleLogAction(e) {
const btn = e.target.closest("[data-action]");
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (action === "download") {
try {
const res = await fetch(`${API}/logs/${id}/download`, { headers: authHeaders() });
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `session-log-${id}.zip`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
alert("Download failed.");
}
} else if (action === "delete") {
if (!confirm("Delete this log? This cannot be undone.")) return;
try {
const res = await fetch(`${API}/logs/${id}`, { method: "DELETE", headers: authHeaders() });
if (res.ok) {
btn.closest(".log-item").remove();
const remaining = document.querySelectorAll(".log-item");
if (remaining.length === 0) {
document.getElementById("logs-list").hidden = true;
document.getElementById("logs-empty").hidden = false;
}
loadLogs(); // refresh storage
}
} catch (e) {
alert("Delete failed.");
}
} else if (action === "lock") {
try {
const res = await fetch(`${API}/logs/${id}/lock`, { method: "POST", headers: authHeaders() });
if (res.ok) loadLogs();
} catch (e) {
// ignore
}
}
}
// Retention change
document.getElementById("retention-days")?.addEventListener("change", async (e) => {
const days = parseInt(e.target.value);
try {
await fetch(`${API}/settings`, {
method: "PATCH",
headers: authHeaders(),
body: JSON.stringify({ log_retention_days: days }),
});
} catch (e) {
// ignore
}
});
// Helpers
function formatBytes(bytes) {
if (!bytes || bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
function formatDate(iso) {
if (!iso) return "Never";
const d = new Date(iso);
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
function esc(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
// Load data
loadVault();
loadLogs();

60
www/web/js/main.js Normal file
View file

@ -0,0 +1,60 @@
// Mobile nav toggle
const toggle = document.getElementById("nav-toggle");
const links = document.getElementById("nav-links");
toggle.addEventListener("click", () => {
links.classList.toggle("open");
toggle.classList.toggle("open");
});
// Close mobile nav on link click
links.querySelectorAll("a").forEach((a) => {
a.addEventListener("click", () => {
links.classList.remove("open");
toggle.classList.remove("open");
});
});
// Smooth scroll for anchor links
document.querySelectorAll('a[href^="#"]').forEach((a) => {
a.addEventListener("click", (e) => {
const id = a.getAttribute("href");
if (id === "#") return;
e.preventDefault();
const el = document.querySelector(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
history.replaceState(null, "", id);
}
});
});
// Nav background on scroll
const nav = document.getElementById("nav");
const onScroll = () => {
nav.classList.toggle("nav--scrolled", window.scrollY > 40);
};
window.addEventListener("scroll", onScroll, { passive: true });
onScroll();
// Reveal on scroll
const reveals = document.querySelectorAll("[data-reveal]");
if (reveals.length) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("revealed");
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" }
);
reveals.forEach((el) => observer.observe(el));
}
// Terminal cursor blink
const cursor = document.querySelector(".t-cursor");
if (cursor) {
setInterval(() => cursor.classList.toggle("t-cursor--off"), 530);
}

90
www/web/login.html Normal file
View file

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Log In &mdash; SSH Workbench</title>
<meta name="description" content="Log in to SSH Workbench to sync your vault and manage session logs.">
<link rel="icon" type="image/png" href="/images/icon-192.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/auth.css">
</head>
<body>
<!-- Nav (minimal) -->
<nav class="nav nav--scrolled">
<div class="nav-inner">
<a href="/" class="nav-logo">
<img src="/images/icon-192.png" alt="SSH Workbench" class="nav-logo-img">
<span class="nav-logo-text">SSH Workbench</span>
</a>
</div>
</nav>
<main class="auth-page">
<div class="auth-card">
<div class="auth-header">
<img src="/images/icon-192.png" alt="" class="auth-icon">
<h1 class="auth-title">Welcome back</h1>
<p class="auth-sub">Log in with your SSH Workbench account.</p>
</div>
<!-- OAuth -->
<div class="auth-oauth">
<button class="btn-oauth" id="btn-google">
<svg class="oauth-icon" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
Continue with Google
</button>
<button class="btn-oauth" id="btn-github">
<svg class="oauth-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
Continue with GitHub
</button>
</div>
<div class="auth-divider"><span>or</span></div>
<!-- Email/Password -->
<form class="auth-form" id="login-form">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email"
placeholder="you@example.com">
</div>
<div class="form-group">
<label for="password">Password</label>
<div class="password-wrapper">
<input type="password" id="password" name="password" required
autocomplete="current-password" placeholder="Your password">
<button type="button" class="password-toggle" id="toggle-pw" aria-label="Show password">
<svg class="eye-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="eye-closed" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><path d="M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>
</button>
</div>
</div>
<div class="form-row">
<label class="checkbox-label">
<input type="checkbox" id="remember" name="remember" checked>
<span class="checkbox-custom"></span>
Remember me
</label>
<a href="#" class="form-link" id="forgot-link">Lost password?</a>
</div>
<div class="form-error" id="form-error"></div>
<button type="submit" class="btn btn--primary btn--full" id="btn-login">
<span class="btn-text">Log In</span>
<span class="btn-spinner" hidden></span>
</button>
</form>
<div class="auth-footer">
<p>Don't have an account? <strong>Register in the app</strong> to get started.</p>
</div>
</div>
</main>
<script src="/js/auth.js"></script>
</body>
</html>