@fyresmith/hive-server 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -17
- package/assets/plugin/hive/main.js +16384 -0
- package/assets/plugin/hive/manifest.json +9 -0
- package/assets/plugin/hive/styles.css +1040 -0
- package/assets/template-vault/.obsidian/app.json +1 -0
- package/assets/template-vault/.obsidian/appearance.json +1 -0
- package/assets/template-vault/.obsidian/core-plugins.json +33 -0
- package/assets/template-vault/.obsidian/graph.json +22 -0
- package/assets/template-vault/.obsidian/workspace.json +206 -0
- package/assets/template-vault/Welcome.md +5 -0
- package/cli/commands/env.js +4 -4
- package/cli/commands/managed.js +22 -17
- package/cli/commands/root.js +48 -2
- package/cli/commands/tunnel.js +1 -16
- package/cli/constants.js +1 -13
- package/cli/core/context.js +3 -22
- package/cli/env-file.js +15 -33
- package/cli/flows/doctor.js +1 -6
- package/cli/flows/setup.js +106 -33
- package/cli/tunnel.js +1 -4
- package/index.js +92 -39
- package/lib/accountState.js +189 -0
- package/lib/authTokens.js +75 -0
- package/lib/bundleBuilder.js +169 -0
- package/lib/dashboardAuth.js +80 -0
- package/lib/managedState.js +262 -55
- package/lib/setupOrchestrator.js +76 -0
- package/lib/yjsServer.js +12 -11
- package/package.json +3 -2
- package/routes/auth.js +403 -78
- package/routes/dashboard.js +590 -0
- package/routes/managed.js +0 -163
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { urlencoded } from 'express';
|
|
3
|
+
import { platform } from 'os';
|
|
4
|
+
import { execFile } from 'child_process';
|
|
5
|
+
import { promisify } from 'util';
|
|
6
|
+
import { authenticateAccount } from '../lib/accountState.js';
|
|
7
|
+
import {
|
|
8
|
+
createInvite,
|
|
9
|
+
loadManagedState,
|
|
10
|
+
removeMember,
|
|
11
|
+
revokeInvite,
|
|
12
|
+
} from '../lib/managedState.js';
|
|
13
|
+
import { createVaultAtParent, initializeOwnerManagedVault } from '../lib/setupOrchestrator.js';
|
|
14
|
+
import { loadEnvFile, normalizeEnv, writeEnvFile } from '../cli/env-file.js';
|
|
15
|
+
import {
|
|
16
|
+
clearDashboardCookie,
|
|
17
|
+
getDashboardSession,
|
|
18
|
+
requireDashboardAuth,
|
|
19
|
+
setDashboardCookie,
|
|
20
|
+
signDashboardSessionToken,
|
|
21
|
+
} from '../lib/dashboardAuth.js';
|
|
22
|
+
|
|
23
|
+
const router = Router();
|
|
24
|
+
router.use(urlencoded({ extended: false }));
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
|
|
27
|
+
function getVaultPath() {
|
|
28
|
+
const value = String(process.env.VAULT_PATH ?? '').trim();
|
|
29
|
+
if (!value) throw new Error('VAULT_PATH env var is required');
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getConfiguredVaultPath() {
|
|
34
|
+
return String(process.env.VAULT_PATH ?? '').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getEnvFilePath(req) {
|
|
38
|
+
return String(req.app.locals.hiveEnvFile ?? process.env.HIVE_ENV_FILE ?? '').trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getServerUrl(req) {
|
|
42
|
+
return process.env.HIVE_SERVER_URL?.trim() || `${req.protocol}://${req.get('host')}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function escapeHtml(value) {
|
|
46
|
+
return String(value ?? '')
|
|
47
|
+
.replaceAll('&', '&')
|
|
48
|
+
.replaceAll('<', '<')
|
|
49
|
+
.replaceAll('>', '>')
|
|
50
|
+
.replaceAll('"', '"')
|
|
51
|
+
.replaceAll("'", ''');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const BASE_STYLES = `
|
|
55
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
56
|
+
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #222; }
|
|
57
|
+
.topbar { background: #1a1a2e; color: #fff; display: flex; align-items: center; gap: 24px; padding: 0 24px; height: 52px; }
|
|
58
|
+
.topbar .brand { font-weight: 700; font-size: 1.1rem; color: #fff; text-decoration: none; }
|
|
59
|
+
.topbar nav { display: flex; gap: 16px; flex: 1; }
|
|
60
|
+
.topbar nav a { color: #ccc; text-decoration: none; font-size: 0.9rem; padding: 4px 8px; border-radius: 4px; }
|
|
61
|
+
.topbar nav a.active, .topbar nav a:hover { color: #fff; background: rgba(255,255,255,0.1); }
|
|
62
|
+
.topbar .signout { margin-left: auto; }
|
|
63
|
+
.topbar .signout button { background: transparent; border: 1px solid #666; color: #ccc; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 0.85rem; }
|
|
64
|
+
.topbar .signout button:hover { border-color: #aaa; color: #fff; }
|
|
65
|
+
.content { max-width: 960px; margin: 32px auto; padding: 0 24px; }
|
|
66
|
+
h1 { margin: 0 0 24px; font-size: 1.5rem; }
|
|
67
|
+
h2 { margin: 0 0 16px; font-size: 1.1rem; }
|
|
68
|
+
.card { background: #fff; border: 1px solid #ddd; border-radius: 10px; padding: 20px; margin-bottom: 20px; }
|
|
69
|
+
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
70
|
+
.stat { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 16px; text-align: center; }
|
|
71
|
+
.stat .value { font-size: 2rem; font-weight: 700; color: #2d6cdf; }
|
|
72
|
+
.stat .label { font-size: 0.85rem; color: #666; margin-top: 4px; }
|
|
73
|
+
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
|
74
|
+
th { text-align: left; padding: 8px 12px; background: #f0f0f0; border-bottom: 2px solid #ddd; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: #555; }
|
|
75
|
+
td { padding: 10px 12px; border-bottom: 1px solid #eee; vertical-align: middle; }
|
|
76
|
+
tr:last-child td { border-bottom: none; }
|
|
77
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
|
78
|
+
.badge-pending { background: #e8f4fd; color: #1a6fa8; }
|
|
79
|
+
.badge-claimed { background: #e8f7ee; color: #1a7a3c; }
|
|
80
|
+
.badge-revoked { background: #fdf0f0; color: #c0392b; }
|
|
81
|
+
.badge-used { background: #f5f0ff; color: #6c3fc5; }
|
|
82
|
+
.badge-owner { background: #fff3e0; color: #b35c00; }
|
|
83
|
+
.btn { display: inline-block; padding: 6px 14px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.85rem; text-decoration: none; }
|
|
84
|
+
.btn-primary { background: #2d6cdf; color: #fff; }
|
|
85
|
+
.btn-primary:hover { background: #2459b8; }
|
|
86
|
+
.btn-danger { background: #c0392b; color: #fff; }
|
|
87
|
+
.btn-danger:hover { background: #a93226; }
|
|
88
|
+
.btn-secondary { background: #f0f0f0; color: #333; border: 1px solid #ccc; }
|
|
89
|
+
.btn-secondary:hover { background: #e0e0e0; }
|
|
90
|
+
.mono { font-family: monospace; font-size: 0.82rem; word-break: break-all; color: #444; }
|
|
91
|
+
.muted { color: #888; font-size: 0.85rem; }
|
|
92
|
+
.actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
93
|
+
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; }
|
|
94
|
+
.alert-error { background: #fdf0f0; color: #c0392b; border: 1px solid #f5c6c6; }
|
|
95
|
+
label { display: block; font-weight: 600; margin-bottom: 6px; font-size: 0.9rem; }
|
|
96
|
+
input[type=email], input[type=password] { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 8px; margin-bottom: 14px; font-size: 1rem; }
|
|
97
|
+
input[type=email]:focus, input[type=password]:focus { outline: none; border-color: #2d6cdf; }
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
function dashboardPage(title, bodyHtml, { activeNav = '' } = {}) {
|
|
101
|
+
const nav = (href, label) => {
|
|
102
|
+
const isActive = activeNav === label ? ' class="active"' : '';
|
|
103
|
+
return `<a href="${href}"${isActive}>${escapeHtml(label)}</a>`;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return `<!DOCTYPE html>
|
|
107
|
+
<html lang="en">
|
|
108
|
+
<head>
|
|
109
|
+
<meta charset="UTF-8">
|
|
110
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
111
|
+
<title>Hive Dashboard — ${escapeHtml(title)}</title>
|
|
112
|
+
<style>${BASE_STYLES}</style>
|
|
113
|
+
</head>
|
|
114
|
+
<body>
|
|
115
|
+
<div class="topbar">
|
|
116
|
+
<a class="brand" href="/dashboard/overview">Hive</a>
|
|
117
|
+
<nav>
|
|
118
|
+
${nav('/dashboard/overview', 'Overview')}
|
|
119
|
+
${nav('/dashboard/invites', 'Invites')}
|
|
120
|
+
${nav('/dashboard/members', 'Members')}
|
|
121
|
+
</nav>
|
|
122
|
+
<div class="signout">
|
|
123
|
+
<form method="POST" action="/dashboard/logout">
|
|
124
|
+
<button type="submit">Sign Out</button>
|
|
125
|
+
</form>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
<div class="content">
|
|
129
|
+
${bodyHtml}
|
|
130
|
+
</div>
|
|
131
|
+
</body>
|
|
132
|
+
</html>`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function loginPage(error) {
|
|
136
|
+
return `<!DOCTYPE html>
|
|
137
|
+
<html lang="en">
|
|
138
|
+
<head>
|
|
139
|
+
<meta charset="UTF-8">
|
|
140
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
141
|
+
<title>Hive Dashboard — Sign In</title>
|
|
142
|
+
<style>
|
|
143
|
+
${BASE_STYLES}
|
|
144
|
+
.login-wrap { max-width: 400px; margin: 80px auto; padding: 0 16px; }
|
|
145
|
+
.login-card { background: #fff; border: 1px solid #ddd; border-radius: 12px; padding: 32px; }
|
|
146
|
+
.login-card h1 { margin: 0 0 24px; font-size: 1.3rem; }
|
|
147
|
+
</style>
|
|
148
|
+
</head>
|
|
149
|
+
<body>
|
|
150
|
+
<div class="login-wrap">
|
|
151
|
+
<div class="login-card">
|
|
152
|
+
<h1>Hive Dashboard</h1>
|
|
153
|
+
${error ? `<div class="alert alert-error">${escapeHtml(error)}</div>` : ''}
|
|
154
|
+
<form method="POST" action="/dashboard/login">
|
|
155
|
+
<label for="email">Email</label>
|
|
156
|
+
<input type="email" id="email" name="email" required autofocus placeholder="owner@example.com">
|
|
157
|
+
<label for="password">Password</label>
|
|
158
|
+
<input type="password" id="password" name="password" required>
|
|
159
|
+
<button class="btn btn-primary" type="submit" style="width:100%;padding:12px">Sign In</button>
|
|
160
|
+
</form>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</body>
|
|
164
|
+
</html>`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function setupPage(error) {
|
|
168
|
+
return `<!DOCTYPE html>
|
|
169
|
+
<html lang="en">
|
|
170
|
+
<head>
|
|
171
|
+
<meta charset="UTF-8">
|
|
172
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
173
|
+
<title>Hive — Set Up Your Vault</title>
|
|
174
|
+
<style>
|
|
175
|
+
${BASE_STYLES}
|
|
176
|
+
.setup-wrap { max-width: 480px; margin: 60px auto; padding: 0 16px; }
|
|
177
|
+
.setup-card { background: #fff; border: 1px solid #ddd; border-radius: 12px; padding: 32px; }
|
|
178
|
+
.setup-card h1 { margin: 0 0 8px; font-size: 1.3rem; }
|
|
179
|
+
.setup-card .subtitle { color: #666; margin: 0 0 24px; font-size: 0.9rem; }
|
|
180
|
+
.section-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: #888; margin: 20px 0 12px; font-weight: 700; }
|
|
181
|
+
hr { border: none; border-top: 1px solid #eee; margin: 20px 0; }
|
|
182
|
+
</style>
|
|
183
|
+
</head>
|
|
184
|
+
<body>
|
|
185
|
+
<div class="setup-wrap">
|
|
186
|
+
<div class="setup-card">
|
|
187
|
+
<h1>Set Up Your Hive Vault</h1>
|
|
188
|
+
<p class="subtitle">Configure your vault and create the owner account. This only runs once.</p>
|
|
189
|
+
${error ? `<div class="alert alert-error">${escapeHtml(error)}</div>` : ''}
|
|
190
|
+
<form method="POST" action="/dashboard/setup">
|
|
191
|
+
<p class="section-label">Vault</p>
|
|
192
|
+
<label for="vaultName">Vault display name</label>
|
|
193
|
+
<input type="text" id="vaultName" name="vaultName" required autofocus placeholder='e.g. "Team Vault"' style="width:100%;padding:10px;border:1px solid #ccc;border-radius:8px;margin-bottom:14px;font-size:1rem">
|
|
194
|
+
<label for="vaultParentPath">Vault location (parent folder)</label>
|
|
195
|
+
<div style="display:flex;gap:8px;margin-bottom:14px">
|
|
196
|
+
<input type="text" id="vaultParentPath" name="vaultParentPath" required placeholder="/Users/you/Documents" style="width:100%;padding:10px;border:1px solid #ccc;border-radius:8px;font-size:1rem">
|
|
197
|
+
<button class="btn btn-secondary" type="button" id="pickFolderBtn" style="white-space:nowrap">Open Folder…</button>
|
|
198
|
+
</div>
|
|
199
|
+
<hr>
|
|
200
|
+
<p class="section-label">Owner account</p>
|
|
201
|
+
<label for="email">Email</label>
|
|
202
|
+
<input type="email" id="email" name="email" required placeholder="you@example.com">
|
|
203
|
+
<label for="displayName">Display name</label>
|
|
204
|
+
<input type="text" id="displayName" name="displayName" required placeholder="Your name">
|
|
205
|
+
<label for="password">Password</label>
|
|
206
|
+
<input type="password" id="password" name="password" required minlength="8">
|
|
207
|
+
<button class="btn btn-primary" type="submit" style="width:100%;padding:12px;margin-top:4px">Set Up Hive</button>
|
|
208
|
+
</form>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
<script>
|
|
212
|
+
(() => {
|
|
213
|
+
const btn = document.getElementById('pickFolderBtn');
|
|
214
|
+
const input = document.getElementById('vaultParentPath');
|
|
215
|
+
if (!btn || !input) return;
|
|
216
|
+
btn.addEventListener('click', async () => {
|
|
217
|
+
btn.disabled = true;
|
|
218
|
+
const old = btn.textContent;
|
|
219
|
+
btn.textContent = 'Choosing...';
|
|
220
|
+
try {
|
|
221
|
+
const res = await fetch('/dashboard/setup/pick-folder', { method: 'POST' });
|
|
222
|
+
const payload = await res.json().catch(() => null);
|
|
223
|
+
if (!res.ok || !payload?.ok || !payload.path) {
|
|
224
|
+
throw new Error(payload?.error || 'Could not choose folder');
|
|
225
|
+
}
|
|
226
|
+
input.value = payload.path;
|
|
227
|
+
} catch (err) {
|
|
228
|
+
alert(err.message || 'Could not choose folder');
|
|
229
|
+
} finally {
|
|
230
|
+
btn.disabled = false;
|
|
231
|
+
btn.textContent = old || 'Open Folder…';
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
})();
|
|
235
|
+
</script>
|
|
236
|
+
</body>
|
|
237
|
+
</html>`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function inviteStatusBadge(invite) {
|
|
241
|
+
if (invite.revokedAt) return '<span class="badge badge-revoked">Revoked</span>';
|
|
242
|
+
if (invite.downloadTicketUsedAt) return '<span class="badge badge-used">Bundle Used</span>';
|
|
243
|
+
if (invite.usedAt) return '<span class="badge badge-claimed">Claimed</span>';
|
|
244
|
+
return '<span class="badge badge-pending">Pending</span>';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function requireOwnerSession(req, res) {
|
|
248
|
+
if (!getConfiguredVaultPath()) {
|
|
249
|
+
res.redirect('/dashboard/setup');
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const state = await loadManagedState(getVaultPath());
|
|
253
|
+
if (!state) {
|
|
254
|
+
res.redirect('/dashboard/setup');
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
if (req.dashboardSession?.accountId !== state.ownerId) {
|
|
258
|
+
clearDashboardCookie(req, res);
|
|
259
|
+
res.redirect('/dashboard/login');
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
return state;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// GET /dashboard
|
|
266
|
+
router.get('/', async (req, res) => {
|
|
267
|
+
if (!getConfiguredVaultPath()) return res.redirect('/dashboard/setup');
|
|
268
|
+
let state = null;
|
|
269
|
+
try { state = await loadManagedState(getVaultPath()); } catch { /* uninitialized */ }
|
|
270
|
+
if (!state) return res.redirect('/dashboard/setup');
|
|
271
|
+
|
|
272
|
+
const session = getDashboardSession(req);
|
|
273
|
+
if (session && session.accountId === state.ownerId) return res.redirect('/dashboard/overview');
|
|
274
|
+
return res.redirect('/dashboard/login');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// GET /dashboard/setup
|
|
278
|
+
router.get('/setup', async (req, res) => {
|
|
279
|
+
if (!getConfiguredVaultPath()) {
|
|
280
|
+
return res.send(setupPage());
|
|
281
|
+
}
|
|
282
|
+
let state = null;
|
|
283
|
+
try { state = await loadManagedState(getVaultPath()); } catch { /* uninitialized */ }
|
|
284
|
+
if (state) return res.redirect('/dashboard/login');
|
|
285
|
+
res.send(setupPage());
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// POST /dashboard/setup/pick-folder
|
|
289
|
+
router.post('/setup/pick-folder', async (req, res) => {
|
|
290
|
+
if (platform() !== 'darwin') {
|
|
291
|
+
return res.status(400).json({ ok: false, error: 'Folder picker is currently supported on macOS only. Enter path manually.' });
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const { stdout } = await execFileAsync('osascript', [
|
|
295
|
+
'-e',
|
|
296
|
+
'POSIX path of (choose folder with prompt "Choose parent folder for your Hive vault")',
|
|
297
|
+
]);
|
|
298
|
+
const path = String(stdout ?? '').trim();
|
|
299
|
+
if (!path) {
|
|
300
|
+
return res.status(400).json({ ok: false, error: 'No folder selected.' });
|
|
301
|
+
}
|
|
302
|
+
return res.json({ ok: true, path });
|
|
303
|
+
} catch {
|
|
304
|
+
return res.status(400).json({ ok: false, error: 'Folder selection cancelled.' });
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// POST /dashboard/setup
|
|
309
|
+
router.post('/setup', async (req, res) => {
|
|
310
|
+
const configuredPath = getConfiguredVaultPath();
|
|
311
|
+
if (configuredPath) {
|
|
312
|
+
let state = null;
|
|
313
|
+
try { state = await loadManagedState(configuredPath); } catch { /* uninitialized */ }
|
|
314
|
+
if (state) return res.redirect('/dashboard/login');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let state = null;
|
|
318
|
+
if (configuredPath) {
|
|
319
|
+
try { state = await loadManagedState(configuredPath); } catch { /* uninitialized */ }
|
|
320
|
+
if (state) return res.redirect('/dashboard/login');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const vaultName = String(req.body?.vaultName ?? '').trim();
|
|
324
|
+
const vaultParentPath = String(req.body?.vaultParentPath ?? '').trim();
|
|
325
|
+
const email = String(req.body?.email ?? '').trim();
|
|
326
|
+
const displayName = String(req.body?.displayName ?? '').trim();
|
|
327
|
+
const password = String(req.body?.password ?? '');
|
|
328
|
+
|
|
329
|
+
if (!vaultName || !vaultParentPath || !email || !displayName || !password) {
|
|
330
|
+
return res.send(setupPage('All fields are required.'));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const envFile = getEnvFilePath(req);
|
|
335
|
+
if (!envFile) {
|
|
336
|
+
return res.send(setupPage('Could not determine env file path.'));
|
|
337
|
+
}
|
|
338
|
+
const vaultPath = await createVaultAtParent({
|
|
339
|
+
parentPath: vaultParentPath,
|
|
340
|
+
vaultName,
|
|
341
|
+
});
|
|
342
|
+
const existingEnv = normalizeEnv(await loadEnvFile(envFile));
|
|
343
|
+
const nextEnv = {
|
|
344
|
+
...existingEnv,
|
|
345
|
+
VAULT_PATH: vaultPath,
|
|
346
|
+
};
|
|
347
|
+
await writeEnvFile(envFile, nextEnv);
|
|
348
|
+
process.env.VAULT_PATH = vaultPath;
|
|
349
|
+
|
|
350
|
+
const { account } = await initializeOwnerManagedVault({
|
|
351
|
+
vaultPath,
|
|
352
|
+
vaultName,
|
|
353
|
+
ownerEmail: email,
|
|
354
|
+
ownerDisplayName: displayName,
|
|
355
|
+
ownerPassword: password,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (typeof req.app.locals.activateRealtime === 'function') {
|
|
359
|
+
await req.app.locals.activateRealtime();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const token = signDashboardSessionToken(account.id);
|
|
363
|
+
setDashboardCookie(req, res, token);
|
|
364
|
+
return res.redirect('/dashboard/overview');
|
|
365
|
+
} catch (err) {
|
|
366
|
+
return res.send(setupPage(err instanceof Error ? err.message : 'Setup failed. Please try again.'));
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// GET /dashboard/login
|
|
371
|
+
router.get('/login', async (req, res) => {
|
|
372
|
+
if (!getConfiguredVaultPath()) return res.redirect('/dashboard/setup');
|
|
373
|
+
let state = null;
|
|
374
|
+
try { state = await loadManagedState(getVaultPath()); } catch { /* uninitialized */ }
|
|
375
|
+
if (!state) return res.redirect('/dashboard/setup');
|
|
376
|
+
|
|
377
|
+
const session = getDashboardSession(req);
|
|
378
|
+
if (session && session.accountId === state.ownerId) return res.redirect('/dashboard/overview');
|
|
379
|
+
res.send(loginPage());
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// POST /dashboard/login
|
|
383
|
+
router.post('/login', async (req, res) => {
|
|
384
|
+
const state = await loadManagedState(getVaultPath());
|
|
385
|
+
if (!state) {
|
|
386
|
+
return res.redirect('/dashboard/setup');
|
|
387
|
+
}
|
|
388
|
+
const email = String(req.body?.email ?? '').trim();
|
|
389
|
+
const password = String(req.body?.password ?? '');
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
const account = await authenticateAccount({ vaultPath: getVaultPath(), email, password });
|
|
393
|
+
if (account.id !== state.ownerId) {
|
|
394
|
+
return res.send(loginPage('Access denied. Owner credentials required.'));
|
|
395
|
+
}
|
|
396
|
+
const token = signDashboardSessionToken(account.id);
|
|
397
|
+
setDashboardCookie(req, res, token);
|
|
398
|
+
return res.redirect('/dashboard/overview');
|
|
399
|
+
} catch (err) {
|
|
400
|
+
return res.send(loginPage(err instanceof Error ? err.message : 'Sign in failed.'));
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// POST /dashboard/logout
|
|
405
|
+
router.post('/logout', (req, res) => {
|
|
406
|
+
clearDashboardCookie(req, res);
|
|
407
|
+
res.redirect('/dashboard/login');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// GET /dashboard/overview
|
|
411
|
+
router.get('/overview', requireDashboardAuth, async (req, res) => {
|
|
412
|
+
try {
|
|
413
|
+
const state = await requireOwnerSession(req, res);
|
|
414
|
+
if (!state) return;
|
|
415
|
+
|
|
416
|
+
const inviteList = Object.values(state.invites ?? {});
|
|
417
|
+
const pendingCount = inviteList.filter((i) => !i.usedAt && !i.revokedAt).length;
|
|
418
|
+
const memberCount = Object.keys(state.members ?? {}).length;
|
|
419
|
+
|
|
420
|
+
const body = `
|
|
421
|
+
<h1>${escapeHtml(state.vaultName ?? 'Hive Vault')}</h1>
|
|
422
|
+
<div class="stat-grid">
|
|
423
|
+
<div class="stat">
|
|
424
|
+
<div class="value">${memberCount}</div>
|
|
425
|
+
<div class="label">Members</div>
|
|
426
|
+
</div>
|
|
427
|
+
<div class="stat">
|
|
428
|
+
<div class="value">${inviteList.length}</div>
|
|
429
|
+
<div class="label">Total Invites</div>
|
|
430
|
+
</div>
|
|
431
|
+
<div class="stat">
|
|
432
|
+
<div class="value">${pendingCount}</div>
|
|
433
|
+
<div class="label">Pending Invites</div>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
<div class="card">
|
|
437
|
+
<h2>Details</h2>
|
|
438
|
+
<table>
|
|
439
|
+
<tr><td><strong>Vault Name</strong></td><td>${escapeHtml(state.vaultName ?? '(not set)')}</td></tr>
|
|
440
|
+
<tr><td><strong>Vault ID</strong></td><td class="mono">${escapeHtml(state.vaultId)}</td></tr>
|
|
441
|
+
<tr><td><strong>Initialized</strong></td><td>${escapeHtml(state.initializedAt)}</td></tr>
|
|
442
|
+
<tr><td><strong>Owner ID</strong></td><td class="mono">${escapeHtml(state.ownerId)}</td></tr>
|
|
443
|
+
</table>
|
|
444
|
+
</div>
|
|
445
|
+
`;
|
|
446
|
+
res.send(dashboardPage('Overview', body, { activeNav: 'Overview' }));
|
|
447
|
+
} catch (err) {
|
|
448
|
+
res.status(500).send(dashboardPage('Error', `<p>${escapeHtml(err instanceof Error ? err.message : String(err))}</p>`));
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// GET /dashboard/invites
|
|
453
|
+
router.get('/invites', requireDashboardAuth, async (req, res) => {
|
|
454
|
+
try {
|
|
455
|
+
const state = await requireOwnerSession(req, res);
|
|
456
|
+
if (!state) return;
|
|
457
|
+
|
|
458
|
+
const invites = Object.values(state.invites ?? {}).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
459
|
+
const serverUrl = getServerUrl(req);
|
|
460
|
+
|
|
461
|
+
const rows = invites.length === 0
|
|
462
|
+
? '<tr><td colspan="4" class="muted" style="text-align:center;padding:24px">No invites yet.</td></tr>'
|
|
463
|
+
: invites.map((invite) => {
|
|
464
|
+
const isPending = !invite.usedAt && !invite.revokedAt;
|
|
465
|
+
const claimUrl = `${serverUrl}/auth/claim?code=${encodeURIComponent(invite.code)}`;
|
|
466
|
+
const copyBtn = isPending
|
|
467
|
+
? `<button class="btn btn-secondary" onclick="navigator.clipboard.writeText(${JSON.stringify(claimUrl)}).then(()=>this.textContent='Copied!').catch(()=>{})" style="font-size:0.75rem;padding:4px 10px">Copy</button>`
|
|
468
|
+
: '';
|
|
469
|
+
const revokeBtn = isPending
|
|
470
|
+
? `<form method="POST" action="/dashboard/invites/revoke" style="display:inline">
|
|
471
|
+
<input type="hidden" name="code" value="${escapeHtml(invite.code)}">
|
|
472
|
+
<button class="btn btn-danger" type="submit" style="font-size:0.75rem;padding:4px 10px" onclick="return confirm('Revoke invite ${escapeHtml(invite.code)}?')">Revoke</button>
|
|
473
|
+
</form>`
|
|
474
|
+
: '';
|
|
475
|
+
const urlCell = isPending
|
|
476
|
+
? `<span class="mono">${escapeHtml(claimUrl)}</span>`
|
|
477
|
+
: `<span class="muted">${escapeHtml(invite.usedAt ? 'Claimed' : 'Revoked')}</span>`;
|
|
478
|
+
|
|
479
|
+
return `<tr>
|
|
480
|
+
<td class="mono">${escapeHtml(invite.code)}</td>
|
|
481
|
+
<td>${inviteStatusBadge(invite)}</td>
|
|
482
|
+
<td>${urlCell}</td>
|
|
483
|
+
<td><div class="actions">${copyBtn}${revokeBtn}</div></td>
|
|
484
|
+
</tr>`;
|
|
485
|
+
}).join('');
|
|
486
|
+
|
|
487
|
+
const body = `
|
|
488
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
|
489
|
+
<h1 style="margin:0">Invites</h1>
|
|
490
|
+
<form method="POST" action="/dashboard/invites/create">
|
|
491
|
+
<button class="btn btn-primary" type="submit">+ Create Invite</button>
|
|
492
|
+
</form>
|
|
493
|
+
</div>
|
|
494
|
+
<div class="card" style="padding:0;overflow:hidden">
|
|
495
|
+
<table>
|
|
496
|
+
<thead><tr><th>Code</th><th>Status</th><th>Claim URL</th><th>Actions</th></tr></thead>
|
|
497
|
+
<tbody>${rows}</tbody>
|
|
498
|
+
</table>
|
|
499
|
+
</div>
|
|
500
|
+
`;
|
|
501
|
+
res.send(dashboardPage('Invites', body, { activeNav: 'Invites' }));
|
|
502
|
+
} catch (err) {
|
|
503
|
+
res.status(500).send(dashboardPage('Error', `<p>${escapeHtml(err instanceof Error ? err.message : String(err))}</p>`));
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// POST /dashboard/invites/create
|
|
508
|
+
router.post('/invites/create', requireDashboardAuth, async (req, res) => {
|
|
509
|
+
try {
|
|
510
|
+
const state = await requireOwnerSession(req, res);
|
|
511
|
+
if (!state) return;
|
|
512
|
+
await createInvite({
|
|
513
|
+
vaultPath: getVaultPath(),
|
|
514
|
+
createdBy: state.ownerId,
|
|
515
|
+
});
|
|
516
|
+
res.redirect('/dashboard/invites');
|
|
517
|
+
} catch (err) {
|
|
518
|
+
res.status(500).send(dashboardPage('Error', `<p>${escapeHtml(err instanceof Error ? err.message : String(err))}</p>`));
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// POST /dashboard/invites/revoke
|
|
523
|
+
router.post('/invites/revoke', requireDashboardAuth, async (req, res) => {
|
|
524
|
+
const code = String(req.body?.code ?? '').trim();
|
|
525
|
+
try {
|
|
526
|
+
const state = await requireOwnerSession(req, res);
|
|
527
|
+
if (!state) return;
|
|
528
|
+
await revokeInvite({ vaultPath: getVaultPath(), code });
|
|
529
|
+
res.redirect('/dashboard/invites');
|
|
530
|
+
} catch (err) {
|
|
531
|
+
res.status(500).send(dashboardPage('Error', `<p>${escapeHtml(err instanceof Error ? err.message : String(err))}</p>`));
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// GET /dashboard/members
|
|
536
|
+
router.get('/members', requireDashboardAuth, async (req, res) => {
|
|
537
|
+
try {
|
|
538
|
+
const state = await requireOwnerSession(req, res);
|
|
539
|
+
if (!state) return;
|
|
540
|
+
|
|
541
|
+
const members = Object.values(state.members ?? {}).sort((a, b) => a.addedAt.localeCompare(b.addedAt));
|
|
542
|
+
|
|
543
|
+
const rows = members.length === 0
|
|
544
|
+
? '<tr><td colspan="3" class="muted" style="text-align:center;padding:24px">No members yet.</td></tr>'
|
|
545
|
+
: members.map((member) => {
|
|
546
|
+
const isOwner = member.id === state.ownerId;
|
|
547
|
+
const badge = isOwner ? '<span class="badge badge-owner">Owner</span>' : '';
|
|
548
|
+
const removeBtn = isOwner
|
|
549
|
+
? ''
|
|
550
|
+
: `<form method="POST" action="/dashboard/members/remove" style="display:inline">
|
|
551
|
+
<input type="hidden" name="userId" value="${escapeHtml(member.id)}">
|
|
552
|
+
<button class="btn btn-danger" type="submit" style="font-size:0.75rem;padding:4px 10px" onclick="return confirm('Remove member ${escapeHtml(member.username)}?')">Remove</button>
|
|
553
|
+
</form>`;
|
|
554
|
+
return `<tr>
|
|
555
|
+
<td>${escapeHtml(member.username)} ${badge}</td>
|
|
556
|
+
<td class="mono muted">${escapeHtml(member.id)}</td>
|
|
557
|
+
<td class="muted">${escapeHtml(member.addedAt)}</td>
|
|
558
|
+
<td>${removeBtn}</td>
|
|
559
|
+
</tr>`;
|
|
560
|
+
}).join('');
|
|
561
|
+
|
|
562
|
+
const body = `
|
|
563
|
+
<h1>Members</h1>
|
|
564
|
+
<div class="card" style="padding:0;overflow:hidden">
|
|
565
|
+
<table>
|
|
566
|
+
<thead><tr><th>Name</th><th>User ID</th><th>Joined</th><th>Actions</th></tr></thead>
|
|
567
|
+
<tbody>${rows}</tbody>
|
|
568
|
+
</table>
|
|
569
|
+
</div>
|
|
570
|
+
`;
|
|
571
|
+
res.send(dashboardPage('Members', body, { activeNav: 'Members' }));
|
|
572
|
+
} catch (err) {
|
|
573
|
+
res.status(500).send(dashboardPage('Error', `<p>${escapeHtml(err instanceof Error ? err.message : String(err))}</p>`));
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// POST /dashboard/members/remove
|
|
578
|
+
router.post('/members/remove', requireDashboardAuth, async (req, res) => {
|
|
579
|
+
const userId = String(req.body?.userId ?? '').trim();
|
|
580
|
+
try {
|
|
581
|
+
const state = await requireOwnerSession(req, res);
|
|
582
|
+
if (!state) return;
|
|
583
|
+
await removeMember({ vaultPath: getVaultPath(), userId });
|
|
584
|
+
res.redirect('/dashboard/members');
|
|
585
|
+
} catch (err) {
|
|
586
|
+
res.status(500).send(dashboardPage('Error', `<p>${escapeHtml(err instanceof Error ? err.message : String(err))}</p>`));
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
export default router;
|