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:
parent
c4ead07fa4
commit
56b875b9fc
32 changed files with 4069 additions and 4 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
10
docs/TODO.md
10
docs/TODO.md
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
20
www/.env.example
Normal 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
6
www/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
.env
|
||||
db_data/
|
||||
*.log
|
||||
.DS_Store
|
||||
app/dist/
|
||||
3
www/app/.dockerignore
Normal file
3
www/app/.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
12
www/app/Dockerfile
Normal file
12
www/app/Dockerfile
Normal 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
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
19
www/app/package.json
Normal 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
28
www/app/src/config/db.js
Normal 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
31
www/app/src/index.js
Normal 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}`);
|
||||
});
|
||||
27
www/app/src/middleware/auth.js
Normal file
27
www/app/src/middleware/auth.js
Normal 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
106
www/app/src/routes/auth.js
Normal 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;
|
||||
23
www/app/src/routes/health.js
Normal file
23
www/app/src/routes/health.js
Normal 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
135
www/app/src/routes/logs.js
Normal 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
135
www/app/src/routes/vault.js
Normal 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
151
www/db/init.sql
Normal 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
51
www/docker-compose.yml
Normal 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
34
www/nginx/default.conf
Normal 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
278
www/web/css/auth.css
Normal 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
312
www/web/css/dashboard.css
Normal 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
706
www/web/css/style.css
Normal 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
126
www/web/dashboard.html
Normal 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 — 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">·</span>
|
||||
<span>Settings included</span>
|
||||
<span class="vault-sep">·</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
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
BIN
www/web/images/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
BIN
www/web/images/icon-pro-192.png
Normal file
BIN
www/web/images/icon-pro-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
250
www/web/index.html
Normal file
250
www/web/index.html
Normal 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 — 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 & Telnet</h3>
|
||||
<p class="feature-desc">
|
||||
SSH with multi-hop jump host chains and cycle detection.
|
||||
Telnet for legacy systems — 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 & 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">© 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
89
www/web/js/auth.js
Normal 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
266
www/web/js/dashboard.js
Normal 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">·</span>
|
||||
<span>${formatDate(log.uploaded_at)}</span>
|
||||
${log.locked ? '<span class="vault-sep">·</span><span style="color:var(--amber)">Locked</span>' : ""}
|
||||
${log.expires_at ? `<span class="vault-sep">·</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
60
www/web/js/main.js
Normal 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
90
www/web/login.html
Normal 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 — 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue