OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking commits / b802d6c

Google Calendar integration, admin panel, provider creation UX, and hardening fixes
- Google Calendar: OAuth2 flow with fixed redirect URI for proxy compatibility, calendar picker UI, FreeBusy slot filtering, scope narrowed to freebusy+calendarlist - Admin panel: users list with permissions, providers with owner/assign/unlink/toggle, system stats dashboard - Provider creation: self-service form when no provider linked, auto-slug from name - Settings: two-column card layout, calendar list in scrollable bordered box - Auth fixes: permissions.global → permissions.admin across all routes, role check in /api/auth/me, gcal status graceful without auth - Path prefix: detect prefix from URL for reverse proxy (7rnx.net) compatibility, identity.js challenge fetch uses prefix - Input validation: time (HH:MM), date (YYYY-MM-DD), duration (1-480), buffer (0-120), weekly windows start < end - Race condition: getOrCreateClient re-reads after batch to handle concurrent requests - Performance: provider-by-user index with scan fallback and auto-backfill - CORS: added 7rnx.net to allowed origins - Dashboard views use /api/dashboard/provider instead of /api/providers[0] - Removed auto-claim of unclaimed providers on dashboard visit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
b802d6cb8224df927a222cd4dcbe9235c18e42dd
by plankatron <fleeky@gmail.com> · 2026-03-23T21:04:48.000Z

Changed files (13) +772 -139

lib/gcal.js +1 -1
……@@ -10,7 +10,7 @@ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
1010const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'
1111const GOOGLE_FREEBUSY_URL = 'https://www.googleapis.com/calendar/v3/freeBusy'
1212const GOOGLE_CALENDARLIST_URL = 'https://www.googleapis.com/calendar/v3/users/me/calendarList'
13const SCOPE = 'https://www.googleapis.com/auth/calendar.readonly'
13const SCOPE = 'https://www.googleapis.com/auth/calendar.freebusy https://www.googleapis.com/auth/calendar.calendarlist.readonly'
1414
1515export class GoogleCalendarClient {
1616 constructor (options = {}) {
lib/routes/auth.js +1 -3
……@@ -50,9 +50,7 @@ export function registerAuthRoutes (router, server) {
5050 method: auth.method,
5151 permissions: auth.permissions,
5252 username: user ? user.username : auth.username,
53 role: auth.permissions && auth.permissions.global
54 ? (auth.permissions.global.includes('admin') ? 'admin' : 'user')
55 : 'user'
53 role: auth.permissions && auth.permissions.admin ? 'admin' : 'user'
5654 })
5755 })
5856}
lib/routes/availability.js +37 -3
……@@ -5,6 +5,12 @@
55import { json, readJson, notFound } from '../response.js'
66import { requireAuth } from './auth-helpers.js'
77
8const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/
9const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
10
11function validTime (s) { return typeof s === 'string' && TIME_RE.test(s) }
12function validDate (s) { return typeof s === 'string' && DATE_RE.test(s) }
13
814export function registerAvailabilityRoutes (router, server) {
915 // Get provider availability (public — needed for slot calculation)
1016 router.get('/api/providers/:slug/availability', async (req, res) => {
……@@ -35,11 +41,31 @@ export function registerAvailabilityRoutes (router, server) {
3541 const providerEntry = await server.db.get(`provider:${providerId}`)
3642 if (!providerEntry) return notFound(res, 'Provider not found')
3743 if (providerEntry.value.userId !== auth.userId &&
38 !auth.permissions.global.includes('admin')) {
44 !auth.permissions.admin) {
3945 json(res, { error: 'Not your provider' }, 403)
4046 return
4147 }
4248
49 // Validate bufferMinutes
50 if (bufferMinutes != null && (typeof bufferMinutes !== 'number' || bufferMinutes < 0 || bufferMinutes > 120)) {
51 return json(res, { error: 'bufferMinutes must be 0-120' }, 400)
52 }
53
54 // Validate weekly time windows
55 if (weekly) {
56 for (const [day, windows] of Object.entries(weekly)) {
57 if (!Array.isArray(windows)) continue
58 for (const w of windows) {
59 if (!validTime(w.start) || !validTime(w.end)) {
60 return json(res, { error: `Invalid time format in ${day}: use HH:MM (e.g. "09:00")` }, 400)
61 }
62 if (w.start >= w.end) {
63 return json(res, { error: `${day}: start must be before end` }, 400)
64 }
65 }
66 }
67 }
68
4369 const availability = {
4470 providerId,
4571 timezone: timezone || providerEntry.value.timezone || 'America/New_York',
……@@ -84,8 +110,16 @@ export function registerAvailabilityRoutes (router, server) {
84110 const tz = url.searchParams.get('tz') || 'UTC'
85111
86112 if (!date && !weekOf) {
87 json(res, { error: 'date or weekOf query param required' }, 400)
88 return
113 return json(res, { error: 'date or weekOf query param required' }, 400)
114 }
115 if (date && !validDate(date)) {
116 return json(res, { error: 'Invalid date format: use YYYY-MM-DD' }, 400)
117 }
118 if (weekOf && !validDate(weekOf)) {
119 return json(res, { error: 'Invalid weekOf format: use YYYY-MM-DD' }, 400)
120 }
121 if (duration < 1 || duration > 480 || isNaN(duration)) {
122 return json(res, { error: 'duration must be 1-480 minutes' }, 400)
89123 }
90124
91125 if (weekOf) {
lib/routes/bookings.js +28 -8
……@@ -6,6 +6,9 @@ import { json, readJson, notFound } from '../response.js'
66import { requireAuth, optionalAuth } from './auth-helpers.js'
77import { calculateSlots, getExternalBusy } from './availability.js'
88
9const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/
10const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
11
912export function registerBookingRoutes (router, server) {
1013 // Create booking (authenticated client)
1114 router.post('/api/bookings', async (req, res) => {
……@@ -15,8 +18,16 @@ export function registerBookingRoutes (router, server) {
1518 const { providerId, packageId, date, startTime, endTime, displayName, email } = body
1619
1720 if (!providerId || !packageId || !date || !startTime) {
18 json(res, { error: 'providerId, packageId, date, and startTime required' }, 400)
19 return
21 return json(res, { error: 'providerId, packageId, date, and startTime required' }, 400)
22 }
23 if (!DATE_RE.test(date)) {
24 return json(res, { error: 'Invalid date format: use YYYY-MM-DD' }, 400)
25 }
26 if (!TIME_RE.test(startTime)) {
27 return json(res, { error: 'Invalid startTime format: use HH:MM' }, 400)
28 }
29 if (endTime && !TIME_RE.test(endTime)) {
30 return json(res, { error: 'Invalid endTime format: use HH:MM' }, 400)
2031 }
2132
2233 // Get package
……@@ -92,7 +103,7 @@ export function registerBookingRoutes (router, server) {
92103 const isClient = booking.clientId === auth.userId
93104 const isProvider = await isProviderOwner(server, booking.providerId, auth.userId)
94105
95 if (!isClient && !isProvider && !auth.permissions.global.includes('admin')) {
106 if (!isClient && !isProvider && !auth.permissions.admin) {
96107 json(res, { error: 'Not authorized' }, 403)
97108 return
98109 }
……@@ -112,8 +123,13 @@ export function registerBookingRoutes (router, server) {
112123 const { date, startTime } = body
113124
114125 if (!date || !startTime) {
115 json(res, { error: 'date and startTime required' }, 400)
116 return
126 return json(res, { error: 'date and startTime required' }, 400)
127 }
128 if (!DATE_RE.test(date)) {
129 return json(res, { error: 'Invalid date format: use YYYY-MM-DD' }, 400)
130 }
131 if (!TIME_RE.test(startTime)) {
132 return json(res, { error: 'Invalid startTime format: use HH:MM' }, 400)
117133 }
118134
119135 const entry = await server.db.get(`booking:${req.params.id}`)
……@@ -263,11 +279,13 @@ function addMinutesToTime (timeStr, minutes) {
263279
264280async function getOrCreateClient (server, auth, displayName, email, providerId) {
265281 const clientKey = `client-by-identity:${auth.identityId}`
266 const existing = await server.db.get(clientKey)
267282
283 // Check-then-create with re-check after batch to handle races.
284 // Two concurrent requests may both pass the first check, but the second
285 // batch write is idempotent (same clientId derived from userId).
286 const existing = await server.db.get(clientKey)
268287 if (existing) return existing.value
269288
270 // Create new client
271289 const clientId = auth.userId || server.generateId('cli')
272290 const client = {
273291 id: clientId,
……@@ -283,7 +301,9 @@ async function getOrCreateClient (server, auth, displayName, email, providerId)
283301 await batch.put(`clients-by-provider:${providerId}:${clientId}`, clientId)
284302 await batch.flush()
285303
286 return clientId
304 // Re-read to handle race — if another request wrote first, return theirs
305 const recheck = await server.db.get(clientKey)
306 return recheck ? recheck.value : clientId
287307}
288308
289309async function isProviderOwner (server, providerId, userId) {
lib/routes/dashboard.js +164 -18
……@@ -13,22 +13,7 @@ export function registerDashboardRoutes (router, server) {
1313 const auth = await requireAuth(req, server, 'read')
1414 let providerId = await getProviderIdForUser(server, auth.userId)
1515
16 if (!providerId) {
17 // Check if there are any unclaimed providers (userId = 'admin' or missing)
18 for await (const entry of server.db.createReadStream({
19 gte: 'provider:',
20 lte: 'provider:~'
21 })) {
22 const p = entry.value
23 if (p.userId === 'admin' || !p.userId) {
24 // Claim this provider
25 p.userId = auth.userId
26 await server.db.put(`provider:${p.id}`, p)
27 providerId = p.id
28 break
29 }
30 }
31 }
16 // Auto-claim removed — use admin panel to assign providers
3217
3318 if (!providerId) {
3419 return json(res, { provider: null })
……@@ -259,6 +244,158 @@ export function registerDashboardRoutes (router, server) {
259244 json(res, userEntry ? userEntry.value : { userId: auth.userId })
260245 })
261246
247 // ---- Admin API ----
248
249 // List all users with their linked provider
250 router.get('/api/admin/users', async (req, res) => {
251 const auth = await requireAuth(req, server, 'admin')
252
253 const users = []
254 for await (const entry of server.db.createReadStream({ gte: 'user:', lte: 'user:~' })) {
255 const u = entry.value
256 // Find linked provider
257 let provider = null
258 for await (const pe of server.db.createReadStream({ gte: 'provider:', lte: 'provider:~' })) {
259 if (pe.value.userId === u.userId) { provider = pe.value; break }
260 }
261 // Find identities
262 const identities = []
263 for await (const ie of server.db.createReadStream({ gte: 'identity:', lte: 'identity:~' })) {
264 if (ie.value.userId === u.userId) identities.push({ id: ie.value.identityId, publicKey: ie.value.publicKey, method: ie.value.method })
265 }
266 users.push({
267 userId: u.userId,
268 username: u.username,
269 name: u.name || u.displayName,
270 email: u.email,
271 globalPermissions: u.globalPermissions,
272 provider: provider ? { id: provider.id, slug: provider.slug, displayName: provider.displayName } : null,
273 identities,
274 createdAt: u.createdAt
275 })
276 }
277 json(res, users)
278 })
279
280 // List all providers with owner info
281 router.get('/api/admin/providers', async (req, res) => {
282 const auth = await requireAuth(req, server, 'admin')
283
284 const providers = []
285 for await (const entry of server.db.createReadStream({ gte: 'provider:', lte: 'provider:~' })) {
286 const p = entry.value
287 // Find owner
288 let owner = null
289 if (p.userId) {
290 const userEntry = await server.db.get(`user:${p.userId}`)
291 if (userEntry) owner = { userId: userEntry.value.userId, username: userEntry.value.username, name: userEntry.value.name || userEntry.value.displayName }
292 }
293 // Count bookings
294 let bookingCount = 0
295 for await (const _ of server.db.createReadStream({ gte: `bookings-by-provider:${p.id}:`, lte: `bookings-by-provider:${p.id}:~` })) {
296 bookingCount++
297 }
298 // Gcal status
299 const gcalEntry = await server.db.get(`gcal:provider:${p.id}`)
300 const gcalConnected = gcalEntry ? gcalEntry.value.connected : false
301
302 providers.push({ ...p, owner, bookingCount, gcalConnected })
303 }
304 json(res, providers)
305 })
306
307 // Unlink provider from user (set userId to null)
308 router.put('/api/admin/providers/:providerId/unlink', async (req, res) => {
309 const auth = await requireAuth(req, server, 'admin')
310 const entry = await server.db.get(`provider:${req.params.providerId}`)
311 if (!entry) return notFound(res, 'Provider not found')
312
313 const provider = entry.value
314 const oldUserId = provider.userId
315 provider.userId = null
316 provider.updatedAt = Date.now()
317 await server.db.put(`provider:${provider.id}`, provider)
318 // Remove user→provider index
319 if (oldUserId) await server.db.del(`provider-by-user:${oldUserId}`)
320 json(res, { ok: true, provider })
321 })
322
323 // Assign provider to user
324 router.put('/api/admin/providers/:providerId/assign', async (req, res) => {
325 const auth = await requireAuth(req, server, 'admin')
326 const body = await readJson(req)
327 const { userId } = body
328 if (!userId) return json(res, { error: 'userId required' }, 400)
329
330 const entry = await server.db.get(`provider:${req.params.providerId}`)
331 if (!entry) return notFound(res, 'Provider not found')
332
333 const userEntry = await server.db.get(`user:${userId}`)
334 if (!userEntry) return notFound(res, 'User not found')
335
336 const provider = entry.value
337 // Remove old user index if reassigning
338 if (provider.userId) await server.db.del(`provider-by-user:${provider.userId}`)
339 provider.userId = userId
340 provider.updatedAt = Date.now()
341 await server.db.put(`provider:${provider.id}`, provider)
342 // Add new user→provider index
343 await server.db.put(`provider-by-user:${userId}`, provider.id)
344 json(res, { ok: true, provider })
345 })
346
347 // Toggle provider active/disabled
348 router.put('/api/admin/providers/:providerId/toggle', async (req, res) => {
349 const auth = await requireAuth(req, server, 'admin')
350 const entry = await server.db.get(`provider:${req.params.providerId}`)
351 if (!entry) return notFound(res, 'Provider not found')
352
353 const provider = entry.value
354 provider.active = !provider.active
355 provider.updatedAt = Date.now()
356 await server.db.put(`provider:${provider.id}`, provider)
357 json(res, { ok: true, active: provider.active })
358 })
359
360 // Update user permissions
361 router.put('/api/admin/users/:userId/permissions', async (req, res) => {
362 const auth = await requireAuth(req, server, 'admin')
363 const body = await readJson(req)
364 const { permissions } = body
365 if (!Array.isArray(permissions)) return json(res, { error: 'permissions array required' }, 400)
366
367 const entry = await server.db.get(`user:${req.params.userId}`)
368 if (!entry) return notFound(res, 'User not found')
369
370 const user = entry.value
371 user.globalPermissions = permissions
372 user.notebooks = [{ name: '*', permissions }]
373 user.updatedAt = Date.now()
374 await server.db.put(`user:${user.userId}`, user)
375 json(res, { ok: true, user })
376 })
377
378 // System info
379 router.get('/api/admin/system', async (req, res) => {
380 const auth = await requireAuth(req, server, 'admin')
381
382 let userCount = 0
383 for await (const _ of server.db.createReadStream({ gte: 'user:', lte: 'user:~' })) userCount++
384 let providerCount = 0
385 for await (const _ of server.db.createReadStream({ gte: 'provider:', lte: 'provider:~' })) providerCount++
386 let bookingCount = 0
387 for await (const _ of server.db.createReadStream({ gte: 'booking:', lte: 'booking:~' })) bookingCount++
388
389 json(res, {
390 version: '0.1.0',
391 uptime: process.uptime ? process.uptime() : 0,
392 users: userCount,
393 providers: providerCount,
394 bookings: bookingCount,
395 gcalConfigured: server.gcalConfigured
396 })
397 })
398
262399 // ---- HTML Pages ----
263400
264401 // Dashboard home
……@@ -325,11 +462,20 @@ export function registerDashboardRoutes (router, server) {
325462// ---- Helpers ----
326463
327464async function getProviderIdForUser (server, userId) {
465 // Fast path: use index
466 const indexed = await server.db.get(`provider-by-user:${userId}`)
467 if (indexed) return indexed.value
468
469 // Fallback: scan (for providers created before index existed)
328470 for await (const entry of server.db.createReadStream({
329471 gte: 'provider:',
330472 lte: 'provider:~'
331473 })) {
332 if (entry.value.userId === userId) return entry.value.id
474 if (entry.value.userId === userId) {
475 // Backfill index
476 await server.db.put(`provider-by-user:${userId}`, entry.value.id)
477 return entry.value.id
478 }
333479 }
334480 return null
335481}
……@@ -406,7 +552,7 @@ function serveDashboardPage (res, server, view, params = {}) {
406552 <a href="${prefix}/profile" class="nav-item ${view === 'profile' ? 'active' : ''}">
407553 <span class="nav-icon">&#128272;</span> Profile
408554 </a>
409 <a href="${prefix}/admin" class="nav-item ${view === 'admin' || view.startsWith('admin') ? 'active' : ''}" id="admin-link" style="display:none">
555 <a href="${prefix}/admin" class="nav-item ${view === 'admin' || view.startsWith('admin') ? 'active' : ''}" id="admin-link">
410556 <span class="nav-icon">&#128737;</span> Admin
411557 </a>
412558 </div>
lib/routes/gcal.js +23 -11
……@@ -16,7 +16,18 @@ export function registerGcalRoutes (router, server) {
1616 return json(res, { configured: false, connected: false })
1717 }
1818
19 const auth = await requireAuth(req, server, 'write')
19 // Try auth — if not signed in, just return configured status
20 let auth
21 try {
22 const result = await server.authManager.authorizeRequest(req, 'write')
23 if (!result.authorized || !result.auth) {
24 return json(res, { configured: true, connected: false })
25 }
26 auth = result.auth
27 } catch {
28 return json(res, { configured: true, connected: false })
29 }
30
2031 const providerId = await getProviderIdForUser(server, auth.userId)
2132 if (!providerId) return json(res, { configured: true, connected: false })
2233
……@@ -52,11 +63,8 @@ export function registerGcalRoutes (router, server) {
5263 createdAt: Date.now()
5364 })
5465
55 // Build redirect URI from request host
56 const host = req.headers.host || 'localhost:4050'
57 const protocol = req.headers['x-forwarded-proto'] || 'http'
58 const redirectUri = `${protocol}://${host}${server.pathPrefix}/api/gcal/callback`
59
66 // OAuth redirect URI must be a fixed public URL (Google blocks .local, private IPs)
67 const redirectUri = server.gcalRedirectUri
6068 const url = gcal.getAuthUrl(redirectUri, state)
6169 json(res, { url })
6270 })
……@@ -101,10 +109,8 @@ export function registerGcalRoutes (router, server) {
101109 // Consume nonce
102110 await server.db.del(`gcal:state:${nonce}`)
103111
104 // Exchange code for tokens
105 const host = req.headers.host || 'localhost:4050'
106 const protocol = req.headers['x-forwarded-proto'] || 'http'
107 const redirectUri = `${protocol}://${host}${server.pathPrefix}/api/gcal/callback`
112 // Must match the same fixed redirect URI used in auth-url
113 const redirectUri = server.gcalRedirectUri
108114
109115 try {
110116 const tokens = await gcal.exchangeCode(code, redirectUri)
……@@ -189,11 +195,17 @@ export function registerGcalRoutes (router, server) {
189195}
190196
191197async function getProviderIdForUser (server, userId) {
198 const indexed = await server.db.get(`provider-by-user:${userId}`)
199 if (indexed) return indexed.value
200
192201 for await (const entry of server.db.createReadStream({
193202 gte: 'provider:',
194203 lte: 'provider:~'
195204 })) {
196 if (entry.value.userId === userId) return entry.value.id
205 if (entry.value.userId === userId) {
206 await server.db.put(`provider-by-user:${userId}`, entry.value.id)
207 return entry.value.id
208 }
197209 }
198210 return null
199211}
lib/routes/packages.js +2 -2
……@@ -61,7 +61,7 @@ export function registerPackageRoutes (router, server) {
6161 const providerEntry = await server.db.get(`provider:${providerId}`)
6262 if (!providerEntry) return notFound(res, 'Provider not found')
6363 if (providerEntry.value.userId !== auth.userId &&
64 !auth.permissions.global.includes('admin')) {
64 !auth.permissions.admin) {
6565 json(res, { error: 'Not your provider' }, 403)
6666 return
6767 }
……@@ -105,7 +105,7 @@ export function registerPackageRoutes (router, server) {
105105 // Verify ownership
106106 const providerEntry = await server.db.get(`provider:${pkg.providerId}`)
107107 if (providerEntry.value.userId !== auth.userId &&
108 !auth.permissions.global.includes('admin')) {
108 !auth.permissions.admin) {
109109 json(res, { error: 'Not your provider' }, 403)
110110 return
111111 }
lib/routes/providers.js +4 -3
……@@ -32,9 +32,9 @@ export function registerProviderRoutes (router, server) {
3232 json(res, providers)
3333 })
3434
35 // Create provider (admin or first user)
35 // Create provider (any authenticated user)
3636 router.post('/api/providers', async (req, res) => {
37 const auth = await requireAuth(req, server, 'admin')
37 const auth = await requireAuth(req, server, 'write')
3838 const body = await readJson(req)
3939
4040 const { displayName, slug, bio, timezone, specialties, socialLinks } = body
……@@ -69,6 +69,7 @@ export function registerProviderRoutes (router, server) {
6969 const batch = server.db.batch()
7070 await batch.put(`provider:${provider.id}`, provider)
7171 await batch.put(`provider-by-slug:${slug}`, provider.id)
72 await batch.put(`provider-by-user:${auth.userId}`, provider.id)
7273 await batch.flush()
7374
7475 json(res, provider, 201)
……@@ -88,7 +89,7 @@ export function registerProviderRoutes (router, server) {
8889 const provider = entry.value
8990
9091 // Check ownership
91 if (provider.userId !== auth.userId && !auth.permissions.global.includes('admin')) {
92 if (provider.userId !== auth.userId && !auth.permissions.admin) {
9293 json(res, { error: 'Not your provider' }, 403)
9394 return
9495 }
lib/server.js +5 -0
……@@ -65,6 +65,10 @@ export class HyperBookingServer {
6565 })
6666 this.gcalConfigured = !!gcalClientId
6767
68 // Fixed OAuth redirect URI (Google blocks .local and private IPs)
69 const gcalRedirectUri = options.gcalRedirectUri || (typeof process !== 'undefined' && process.env && process.env.GCAL_REDIRECT_URI)
70 this.gcalRedirectUri = gcalRedirectUri || null
71
6872 // Router
6973 this.router = new Router(this)
7074
……@@ -150,6 +154,7 @@ export class HyperBookingServer {
150154 if (origin.match(/^https?:\/\/192\.168\./)) return true
151155 if (origin.match(/^https?:\/\/10\./)) return true
152156 if (origin.match(/^https?:\/\/172\.(1[6-9]|2\d|3[01])\./)) return true
157 if (origin === 'https://7rnx.net') return true
153158 return false
154159 }
155160
web/css/dashboard.css +87 -3
……@@ -288,16 +288,100 @@
288288}
289289.add-window:hover { text-decoration: underline; }
290290
291/* Settings form sections */
291/* Settings layout */
292.settings-grid {
293 display: grid;
294 grid-template-columns: 1fr 1fr;
295 gap: 1.5rem;
296 max-width: 900px;
297}
298
299.settings-grid .settings-full {
300 grid-column: 1 / -1;
301}
302
292303.settings-section {
293 margin-bottom: 2rem;
304 background: white;
305 border: 1px solid var(--border);
306 border-radius: var(--radius);
307 padding: 1.25rem 1.5rem;
308 text-align: left;
294309}
295310
296311.settings-section h3 {
297 font-size: 1.1rem;
312 font-size: 1rem;
313 font-weight: 600;
298314 margin-bottom: 1rem;
299315 padding-bottom: 0.5rem;
300316 border-bottom: 1px solid var(--border);
317 color: var(--text);
318}
319
320.settings-section label {
321 font-size: 0.8rem;
322 color: var(--text-secondary);
323 margin-bottom: 0.2rem;
324}
325
326.settings-section input,
327.settings-section select,
328.settings-section textarea {
329 margin-bottom: 0.75rem;
330 padding: 0.6rem 0.75rem;
331 font-size: 0.9rem;
332}
333
334.settings-section textarea {
335 resize: vertical;
336}
337
338.settings-row {
339 display: flex;
340 gap: 1rem;
341 align-items: flex-start;
342}
343
344.settings-row > div {
345 flex: 1;
346}
347
348.settings-info {
349 font-size: 0.85rem;
350 color: var(--text-secondary);
351 margin-bottom: 0.5rem;
352}
353
354.settings-info a {
355 color: var(--accent);
356 font-weight: 500;
357}
358
359.settings-badge {
360 display: inline-block;
361 font-size: 0.8rem;
362 font-weight: 500;
363 padding: 0.15rem 0.5rem;
364 border-radius: 10px;
365}
366
367.settings-badge.success {
368 background: #e6f4ea;
369 color: var(--success);
370}
371
372.settings-badge.neutral {
373 background: #f0f0f0;
374 color: var(--text-secondary);
375}
376
377@media (max-width: 768px) {
378 .settings-grid {
379 grid-template-columns: 1fr;
380 }
381 .settings-row {
382 flex-direction: column;
383 gap: 0;
384 }
301385}
302386
303387/* Progress bar */
web/js/app.js +15 -1
……@@ -5,6 +5,20 @@
55
66/* global HyperBookingIdentity */
77
8// ---- Path Prefix ----
9// Detect prefix from URL so /api/* calls work behind reverse proxies like 7rnx.net/hyper-booking/
10const PATH_PREFIX = (function () {
11 const path = window.location.pathname
12 const idx = path.indexOf('/web/')
13 if (idx > 0) return path.substring(0, idx)
14 const dashIdx = path.indexOf('/dashboard')
15 if (dashIdx > 0) return path.substring(0, dashIdx)
16 // Check for known prefix patterns
17 const match = path.match(/^(\/[^/]+)\//)
18 if (match && match[1] !== '/api' && match[1] !== '/web') return match[1]
19 return ''
20})()
21
822// ---- Utilities ----
923
1024function showToast (message, type = 'info') {
……@@ -22,7 +36,7 @@ async function api (method, path, body) {
2236 }
2337 if (body) opts.body = JSON.stringify(body)
2438
25 const res = await fetch(path, opts)
39 const res = await fetch(PATH_PREFIX + path, opts)
2640 const data = await res.json()
2741 if (!res.ok) throw new Error(data.error || 'Request failed')
2842 return data
web/js/dashboard.js +392 -85
……@@ -34,13 +34,7 @@ async function initDashboard (view, params) {
3434 try {
3535 const provData = await api('GET', '/api/dashboard/provider')
3636 if (!provData.provider) {
37 content.innerHTML = `
38 <div class="card" style="text-align:center;padding:3rem;">
39 <h2>No Provider Profile</h2>
40 <p style="color:var(--text-secondary);margin:1rem 0;">You need a provider profile to use the dashboard.</p>
41 <p style="color:var(--text-secondary);">If you just signed in for the first time, refresh the page — your account may have been auto-linked.</p>
42 <button class="btn btn-accent" onclick="location.reload()" style="margin-top:1rem;">Refresh</button>
43 </div>`
37 renderCreateProvider()
4438 return
4539 }
4640 } catch (err) {
……@@ -269,12 +263,12 @@ async function renderClientDetail (clientId) {
269263
270264async function renderPackages () {
271265 try {
272 const providers = await api('GET', '/api/providers')
273 if (providers.length === 0) {
266 const provData = await api('GET', '/api/dashboard/provider')
267 if (!provData.provider) {
274268 content.innerHTML = '<p class="empty">Create a provider profile first.</p>'
275269 return
276270 }
277 const provider = providers[0] // First provider for this user
271 const provider = provData.provider
278272 const packages = await api('GET', `/api/providers/${provider.slug}/packages`)
279273
280274 let html = `
……@@ -314,10 +308,11 @@ async function renderPackages () {
314308
315309async function renderPackageEdit (packageId) {
316310 let pkg = null
317 let providers = []
311 let myProvider = null
318312
319313 try {
320 providers = await api('GET', '/api/providers')
314 const provData = await api('GET', '/api/dashboard/provider')
315 myProvider = provData.provider
321316 if (packageId) {
322317 pkg = await api('GET', `/api/packages/${packageId}`)
323318 }
……@@ -327,7 +322,7 @@ async function renderPackageEdit (packageId) {
327322 }
328323
329324 const isNew = !pkg
330 const providerId = pkg ? pkg.providerId : (providers[0] ? providers[0].id : '')
325 const providerId = pkg ? pkg.providerId : (myProvider ? myProvider.id : '')
331326
332327 content.innerHTML = `
333328 <p><a href="/dashboard/packages">&larr; Back to Packages</a></p>
……@@ -410,12 +405,12 @@ async function renderPackageEdit (packageId) {
410405
411406async function renderAvailability () {
412407 try {
413 const providers = await api('GET', '/api/providers')
414 if (providers.length === 0) {
408 const provData = await api('GET', '/api/dashboard/provider')
409 if (!provData.provider) {
415410 content.innerHTML = '<p class="empty">Create a provider profile first.</p>'
416411 return
417412 }
418 const provider = providers[0]
413 const provider = provData.provider
419414 const avail = await api('GET', `/api/providers/${provider.slug}/availability`)
420415
421416 const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
……@@ -567,69 +562,89 @@ async function renderAnalytics () {
567562
568563async function renderSettings () {
569564 try {
570 const providers = await api('GET', '/api/providers')
571 if (providers.length === 0) {
565 const provData = await api('GET', '/api/dashboard/provider')
566 if (!provData.provider) {
572567 content.innerHTML = '<p class="empty">No provider profile found.</p>'
573568 return
574569 }
575 const p = providers[0]
570 const p = provData.provider
576571 const avail = await api('GET', `/api/providers/${p.slug}/availability`)
577572
573 const pubPage = PATH_PREFIX + '/' + p.slug
574
578575 content.innerHTML = `
579 <div class="settings-section">
580 <h3>Profile</h3>
581 <form id="profile-form">
582 <label>Display Name</label>
583 <input type="text" id="set-name" value="${esc(p.displayName)}">
584 <label>Bio</label>
585 <textarea id="set-bio" rows="4">${esc(p.bio)}</textarea>
586 <label>Specialties (comma-separated)</label>
587 <input type="text" id="set-specialties" value="${(p.specialties || []).join(', ')}">
588 <label>Accent Color</label>
589 <input type="color" id="set-color" value="${p.accentColor || '#c4a265'}" style="width:60px;height:40px;padding:2px;">
590 <div style="margin-top:1rem;">
591 <button type="submit" class="btn btn-accent">Save Profile</button>
576 <div class="settings-grid">
577
578 <div class="settings-section">
579 <h3>Profile</h3>
580 <form id="profile-form">
581 <label>Display Name</label>
582 <input type="text" id="set-name" value="${esc(p.displayName)}">
583 <label>Bio</label>
584 <textarea id="set-bio" rows="3">${esc(p.bio)}</textarea>
585 <label>Specialties (comma-separated)</label>
586 <input type="text" id="set-specialties" value="${(p.specialties || []).join(', ')}">
587 <div class="settings-row">
588 <div>
589 <label>Accent Color</label>
590 <input type="color" id="set-color" value="${p.accentColor || '#c4a265'}" style="width:50px;height:34px;padding:2px;">
591 </div>
592 <div style="flex:2;">
593 <label>Timezone</label>
594 <input type="text" value="${esc(p.timezone || 'America/New_York')}" disabled style="background:#f8f6f2;">
595 </div>
596 </div>
597 <button type="submit" class="btn btn-accent" style="margin-top:0.25rem;">Save Profile</button>
598 </form>
599 </div>
600
601 <div class="settings-section">
602 <h3>Booking Page</h3>
603 <div class="settings-info">
604 <p>Your public URL:</p>
605 <p style="margin:0.5rem 0;"><a href="${pubPage}" target="_blank" style="font-size:1rem;">${pubPage}</a></p>
606 <p>Status: <span class="settings-badge ${p.active ? 'success' : 'neutral'}">${p.active ? 'Published' : 'Unpublished'}</span></p>
592607 </div>
593 </form>
594 </div>
595608
596 <div class="settings-section">
597 <h3>Booking Page</h3>
598 <p>Your URL: <a href="/${p.slug}" target="_blank">/${p.slug}</a></p>
599 <p>Status: ${p.active ? '<span style="color:var(--success)">Published</span>' : '<span style="color:var(--error)">Unpublished</span>'}</p>
600 </div>
609 <h3 style="margin-top:1.25rem;">Cancellation Policy</h3>
610 <form id="policy-form">
611 <div class="settings-row">
612 <div>
613 <label>Window</label>
614 <select id="set-cancelWindow" style="width:auto;">
615 ${[0, 12, 24, 48, 72].map(h => `<option value="${h}" ${avail.cancellation && avail.cancellation.windowHours === h ? 'selected' : ''}>${h}h before</option>`).join('')}
616 </select>
617 </div>
618 <div>
619 <label>Refund</label>
620 <select id="set-refundPolicy" style="width:auto;">
621 <option value="full" ${avail.cancellation && avail.cancellation.refundPolicy === 'full' ? 'selected' : ''}>Full refund</option>
622 <option value="partial" ${avail.cancellation && avail.cancellation.refundPolicy === 'partial' ? 'selected' : ''}>50% refund</option>
623 <option value="none" ${avail.cancellation && avail.cancellation.refundPolicy === 'none' ? 'selected' : ''}>No refund</option>
624 </select>
625 </div>
626 </div>
627 <button type="submit" class="btn btn-accent">Save Policy</button>
628 </form>
629 </div>
601630
602 <div class="settings-section">
603 <h3>Google Calendar</h3>
604 <div id="gcal-section"><p class="empty">Loading...</p></div>
605 </div>
631 <div class="settings-section">
632 <h3>Google Calendar</h3>
633 <div id="gcal-section"><p class="settings-info">Loading...</p></div>
634 </div>
606635
607 <div class="settings-section">
608 <h3>Cancellation Policy</h3>
609 <form id="policy-form">
610 <label>Cancellation window</label>
611 <select id="set-cancelWindow" style="width:auto;">
612 ${[0, 12, 24, 48, 72].map(h => `<option value="${h}" ${avail.cancellation && avail.cancellation.windowHours === h ? 'selected' : ''}>${h} hours</option>`).join('')}
613 </select>
614 <label style="margin-top:0.5rem;">Refund policy</label>
615 <select id="set-refundPolicy" style="width:auto;">
616 <option value="full" ${avail.cancellation && avail.cancellation.refundPolicy === 'full' ? 'selected' : ''}>Full refund</option>
617 <option value="partial" ${avail.cancellation && avail.cancellation.refundPolicy === 'partial' ? 'selected' : ''}>50% refund</option>
618 <option value="none" ${avail.cancellation && avail.cancellation.refundPolicy === 'none' ? 'selected' : ''}>No refund</option>
619 </select>
620 <div style="margin-top:1rem;">
621 <button type="submit" class="btn btn-accent">Save Policy</button>
636 <div class="settings-section">
637 <h3>Identity</h3>
638 <p class="settings-info" style="word-break:break-all;">
639 Public Key:<br>
640 <code style="font-size:0.75rem;">${HyperBookingIdentity.getPublicKey() || 'Not signed in'}</code>
641 </p>
642 <div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
643 <button class="btn btn-secondary" onclick="exportKeypair()">Export Keypair</button>
644 <button class="btn btn-danger" onclick="signOut()">Sign Out</button>
622645 </div>
623 </form>
624 </div>
646 </div>
625647
626 <div class="settings-section">
627 <h3>Identity</h3>
628 <p style="font-size:0.85rem;color:var(--text-secondary);word-break:break-all;">
629 Public Key: ${HyperBookingIdentity.getPublicKey() || 'Not signed in'}
630 </p>
631 <button class="btn btn-secondary" onclick="exportKeypair()">Export Keypair</button>
632 <button class="btn btn-danger" style="margin-left:0.5rem;" onclick="signOut()">Sign Out</button>
633648 </div>
634649 `
635650
……@@ -689,15 +704,57 @@ async function loadGcalSection () {
689704 }
690705
691706 if (status.connected) {
692 // Connected — show simple status + disconnect
707 // Connected — show status, calendar picker, disconnect
693708 gcalEl.innerHTML = `
694 <p><span style="color:var(--success);font-weight:500;">Connected</span>
695 — your Google Calendar events automatically block booking slots.</p>
696 <p style="font-size:0.85rem;color:var(--text-secondary);margin:0.5rem 0;">
709 <div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
710 <span class="settings-badge success">Connected</span>
711 <span class="settings-info" style="margin:0;">Events block booking slots automatically</span>
712 </div>
713 <p style="font-size:0.8rem;color:var(--text-secondary);margin-bottom:0.75rem;">
697714 Only availability (busy/free) is read. Event details are never accessed.
698715 </p>
699 <button class="btn btn-danger" id="gcal-disconnect-btn">Disconnect</button>
716 <div id="gcal-calendars">
717 <p class="settings-info">Loading calendars...</p>
718 </div>
719 <div style="margin-top:0.75rem;">
720 <button class="btn btn-danger" id="gcal-disconnect-btn" style="font-size:0.8rem;padding:0.4rem 0.8rem;">Disconnect</button>
721 </div>
700722 `
723
724 // Load calendar picker
725 try {
726 const calendars = await api('GET', '/api/gcal/calendars')
727 const selectedIds = status.calendarIds || ['primary']
728 const calDiv = document.getElementById('gcal-calendars')
729 if (calDiv && Array.isArray(calendars) && calendars.length > 0) {
730 calDiv.innerHTML = `
731 <p style="font-size:0.8rem;font-weight:500;margin-bottom:0.4rem;color:var(--text);">Monitor for conflicts:</p>
732 <div style="max-height:240px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;padding:0.25rem 0;">
733 ${calendars.map(cal => `
734 <label style="display:flex;align-items:center;gap:0.5rem;padding:0.35rem 0.75rem;cursor:pointer;font-size:0.85rem;border-bottom:1px solid #f0f0f0;">
735 <input type="checkbox" class="gcal-cal-check" value="${cal.id}"
736 ${selectedIds.includes(cal.id) ? 'checked' : ''}
737 style="width:auto;margin:0;flex-shrink:0;">
738 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(cal.summary)}${cal.primary ? ' <em style="color:var(--text-secondary);">(primary)</em>' : ''}</span>
739 </label>
740 `).join('')}
741 </div>
742 <button class="btn btn-accent" id="gcal-save-cals-btn" style="margin-top:0.5rem;font-size:0.8rem;padding:0.4rem 0.8rem;">Save Selection</button>
743 `
744 document.getElementById('gcal-save-cals-btn').addEventListener('click', async () => {
745 const checked = [...document.querySelectorAll('.gcal-cal-check:checked')].map(el => el.value)
746 if (checked.length === 0) { showToast('Select at least one calendar', 'error'); return }
747 try {
748 await api('PUT', '/api/gcal/calendars', { calendarIds: checked })
749 showToast('Calendar selection saved', 'success')
750 } catch (err) { showToast(err.message, 'error') }
751 })
752 }
753 } catch (err) {
754 const calDiv = document.getElementById('gcal-calendars')
755 if (calDiv) calDiv.innerHTML = '<p style="font-size:0.85rem;color:var(--text-secondary);">Could not load calendar list.</p>'
756 }
757
701758 document.getElementById('gcal-disconnect-btn').addEventListener('click', async () => {
702759 if (!confirm('Disconnect Google Calendar?')) return
703760 try {
……@@ -841,19 +898,143 @@ async function renderPortal () {
841898
842899// ---- Admin ----
843900
901// ---- Create Provider ----
902
903function renderCreateProvider () {
904 content.innerHTML = `
905 <div class="card" style="max-width:600px;margin:0 auto;padding:2rem;">
906 <h2 style="margin-bottom:0.5rem;">Create Your Coaching Page</h2>
907 <p style="color:var(--text-secondary);margin-bottom:1.5rem;">
908 Set up your provider profile so clients can discover and book sessions with you.
909 </p>
910
911 <form id="create-provider-form">
912 <label for="cp-name">Display Name *</label>
913 <input type="text" id="cp-name" placeholder="e.g. Fleeky Flanco" required>
914
915 <label for="cp-slug" style="margin-top:1rem;">URL Slug *</label>
916 <input type="text" id="cp-slug" placeholder="e.g. fleeky-flanco" required
917 pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]" title="Lowercase letters, numbers, and hyphens only">
918 <p style="font-size:0.8rem;color:var(--text-secondary);margin:0.25rem 0 0;">
919 Your public page will be at <strong>/${document.getElementById('cp-slug')?.value || 'your-slug'}</strong>
920 </p>
921
922 <label for="cp-bio" style="margin-top:1rem;">Bio</label>
923 <textarea id="cp-bio" rows="4" placeholder="Tell clients about yourself, your approach, and your experience..."></textarea>
924
925 <label for="cp-specialties" style="margin-top:1rem;">Specialties</label>
926 <input type="text" id="cp-specialties" placeholder="e.g. Handstands, Acrobatics, Movement (comma-separated)">
927
928 <label for="cp-timezone" style="margin-top:1rem;">Timezone</label>
929 <select id="cp-timezone">
930 <option value="America/New_York">Eastern (New York)</option>
931 <option value="America/Chicago">Central (Chicago)</option>
932 <option value="America/Denver">Mountain (Denver)</option>
933 <option value="America/Los_Angeles">Pacific (Los Angeles)</option>
934 <option value="UTC">UTC</option>
935 <option value="Europe/London">London</option>
936 <option value="Europe/Paris">Paris</option>
937 <option value="Asia/Tokyo">Tokyo</option>
938 </select>
939
940 <label for="cp-color" style="margin-top:1rem;">Accent Color</label>
941 <input type="color" id="cp-color" value="#c4a265" style="width:60px;height:36px;border:none;cursor:pointer;">
942
943 <div style="margin-top:1.5rem;">
944 <button type="submit" class="btn btn-accent" id="cp-submit">Create My Page</button>
945 </div>
946
947 <p id="cp-error" style="color:var(--error);margin-top:0.75rem;display:none;"></p>
948 </form>
949 </div>
950 `
951
952 // Auto-generate slug from name
953 const nameInput = document.getElementById('cp-name')
954 const slugInput = document.getElementById('cp-slug')
955 nameInput.addEventListener('input', () => {
956 if (!slugInput.dataset.manual) {
957 slugInput.value = nameInput.value
958 .toLowerCase()
959 .replace(/[^a-z0-9]+/g, '-')
960 .replace(/^-|-$/g, '')
961 }
962 })
963 slugInput.addEventListener('input', () => {
964 slugInput.dataset.manual = 'true'
965 })
966
967 document.getElementById('create-provider-form').addEventListener('submit', async (e) => {
968 e.preventDefault()
969 const errEl = document.getElementById('cp-error')
970 errEl.style.display = 'none'
971
972 const name = nameInput.value.trim()
973 const slug = slugInput.value.trim()
974 if (!name || !slug) {
975 errEl.textContent = 'Name and slug are required.'
976 errEl.style.display = ''
977 return
978 }
979
980 const submitBtn = document.getElementById('cp-submit')
981 submitBtn.disabled = true
982 submitBtn.textContent = 'Creating...'
983
984 try {
985 await api('POST', '/api/providers', {
986 displayName: name,
987 slug: slug,
988 bio: document.getElementById('cp-bio').value.trim(),
989 specialties: document.getElementById('cp-specialties').value
990 .split(',').map(s => s.trim()).filter(Boolean),
991 timezone: document.getElementById('cp-timezone').value,
992 accentColor: document.getElementById('cp-color').value
993 })
994
995 showToast('Your coaching page is live!', 'success')
996 // Reload dashboard — now they have a provider
997 setTimeout(() => location.reload(), 500)
998 } catch (err) {
999 errEl.textContent = err.message
1000 errEl.style.display = ''
1001 submitBtn.disabled = false
1002 submitBtn.textContent = 'Create My Page'
1003 }
1004 })
1005}
1006
1007// ---- Admin ----
1008
8441009async function renderAdmin () {
8451010 try {
846 const providers = await api('GET', '/api/providers')
1011 const sys = await api('GET', '/api/admin/system')
8471012 content.innerHTML = `
8481013 <div class="summary-grid">
8491014 <div class="summary-card">
850 <div class="summary-number">${providers.length}</div>
1015 <div class="summary-number">${sys.users}</div>
1016 <div class="summary-label">Users</div>
1017 </div>
1018 <div class="summary-card">
1019 <div class="summary-number">${sys.providers}</div>
8511020 <div class="summary-label">Providers</div>
8521021 </div>
1022 <div class="summary-card">
1023 <div class="summary-number">${sys.bookings}</div>
1024 <div class="summary-label">Bookings</div>
1025 </div>
1026 <div class="summary-card">
1027 <div class="summary-number">${sys.gcalConfigured ? 'Yes' : 'No'}</div>
1028 <div class="summary-label">Google Calendar</div>
1029 </div>
8531030 </div>
854 <h3 style="margin:1.5rem 0 1rem;">Quick Links</h3>
855 <div style="display:flex;gap:0.5rem;">
856 <a href="/admin/providers" class="btn btn-secondary">Manage Providers</a>
1031 <p style="font-size:0.85rem;color:var(--text-secondary);margin-top:0.5rem;">
1032 v${sys.version} · uptime ${Math.floor(sys.uptime / 60)}m
1033 </p>
1034 <h3 style="margin:1.5rem 0 1rem;">Manage</h3>
1035 <div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
1036 <a href="${PATH_PREFIX}/admin/providers" class="btn btn-secondary">Providers</a>
1037 <a href="${PATH_PREFIX}/admin/users" class="btn btn-secondary">Users</a>
8571038 </div>
8581039 `
8591040 } catch (err) {
……@@ -863,22 +1044,54 @@ async function renderAdmin () {
8631044
8641045async function renderAdminProviders () {
8651046 try {
866 const providers = await api('GET', '/api/providers')
1047 const [providers, users] = await Promise.all([
1048 api('GET', '/api/admin/providers'),
1049 api('GET', '/api/admin/users')
1050 ])
8671051
868 let html = '<p><a href="/admin">&larr; Back to Admin</a></p>'
1052 let html = `<p><a href="${PATH_PREFIX}/admin">&larr; Back to Admin</a></p>`
8691053 html += `<h3 style="margin:1rem 0;">Providers (${providers.length})</h3>`
8701054
8711055 for (const p of providers) {
1056 const ownerLabel = p.owner
1057 ? esc(p.owner.username || p.owner.name || p.owner.userId.substring(0, 12) + '...')
1058 : '<em style="color:var(--text-secondary)">unassigned</em>'
1059
8721060 html += `
873 <div class="package-row">
874 <div class="package-row-info">
1061 <div class="package-row" style="flex-wrap:wrap;gap:0.5rem;">
1062 <div class="package-row-info" style="min-width:200px;">
8751063 <div class="package-row-name">${esc(p.displayName)}</div>
876 <div class="package-row-meta">/${esc(p.slug)} · ${p.active ? 'Active' : 'Disabled'}</div>
1064 <div class="package-row-meta">
1065 /${esc(p.slug)} · ${p.active ? '<span style="color:var(--success)">Active</span>' : '<span style="color:var(--error)">Disabled</span>'}
1066 · ${p.bookingCount} bookings
1067 ${p.gcalConnected ? ' · <span style="color:var(--success)">GCal</span>' : ''}
1068 </div>
1069 <div class="package-row-meta" style="margin-top:0.25rem;">
1070 Owner: ${ownerLabel}
1071 </div>
8771072 </div>
878 <div class="package-row-actions">
879 <a href="/${p.slug}" class="btn btn-secondary" target="_blank">View Page</a>
1073 <div class="package-row-actions" style="display:flex;gap:0.25rem;flex-wrap:wrap;">
1074 <a href="${PATH_PREFIX}/${p.slug}" class="btn btn-secondary" target="_blank" style="font-size:0.8rem;">View</a>
1075 <button class="btn btn-secondary" style="font-size:0.8rem;" onclick="adminToggleProvider('${p.id}', ${p.active})">${p.active ? 'Disable' : 'Enable'}</button>
1076 ${p.userId ? `<button class="btn btn-secondary" style="font-size:0.8rem;" onclick="adminUnlinkProvider('${p.id}', '${esc(p.displayName)}')">Unlink Owner</button>` : ''}
1077 ${!p.userId ? `<button class="btn btn-accent" style="font-size:0.8rem;" onclick="adminShowAssign('${p.id}')">Assign</button>` : ''}
8801078 </div>
8811079 </div>`
1080
1081 // Hidden assign form
1082 if (!p.userId) {
1083 html += `
1084 <div id="assign-${p.id}" style="display:none;padding:0.75rem 1rem;background:var(--surface);border-radius:8px;margin:-0.5rem 0 0.5rem;">
1085 <label style="font-size:0.85rem;">Assign to user:</label>
1086 <select id="assign-select-${p.id}" style="margin:0.25rem 0;">
1087 ${users.filter(u => !u.provider).map(u =>
1088 `<option value="${u.userId}">${esc(u.username || u.name || u.userId.substring(0, 16))}</option>`
1089 ).join('')}
1090 </select>
1091 <button class="btn btn-accent" style="font-size:0.8rem;" onclick="adminAssignProvider('${p.id}')">Assign</button>
1092 <button class="btn btn-secondary" style="font-size:0.8rem;" onclick="document.getElementById('assign-${p.id}').style.display='none'">Cancel</button>
1093 </div>`
1094 }
8821095 }
8831096
8841097 content.innerHTML = html
……@@ -888,7 +1101,101 @@ async function renderAdminProviders () {
8881101}
8891102
8901103async function renderAdminUsers () {
891 content.innerHTML = '<p class="empty">User management coming soon.</p>'
1104 try {
1105 const users = await api('GET', '/api/admin/users')
1106
1107 let html = `<p><a href="${PATH_PREFIX}/admin">&larr; Back to Admin</a></p>`
1108 html += `<h3 style="margin:1rem 0;">Users (${users.length})</h3>`
1109
1110 for (const u of users) {
1111 const perms = (u.globalPermissions || []).join(', ')
1112 const provLabel = u.provider
1113 ? `<a href="${PATH_PREFIX}/${u.provider.slug}">${esc(u.provider.displayName)}</a>`
1114 : '<em style="color:var(--text-secondary)">none</em>'
1115 const pkSnippet = u.identities.length > 0
1116 ? u.identities[0].publicKey?.substring(0, 20) + '...'
1117 : 'no identity'
1118
1119 html += `
1120 <div class="package-row" style="flex-wrap:wrap;gap:0.5rem;">
1121 <div class="package-row-info" style="min-width:200px;">
1122 <div class="package-row-name">${esc(u.username || u.name || 'Anonymous')}</div>
1123 <div class="package-row-meta">
1124 ${u.userId.substring(0, 12)}... · ${perms}
1125 </div>
1126 <div class="package-row-meta" style="margin-top:0.25rem;">
1127 Provider: ${provLabel} · Key: <code style="font-size:0.75rem;">${esc(pkSnippet)}</code>
1128 </div>
1129 </div>
1130 <div class="package-row-actions" style="display:flex;gap:0.25rem;flex-wrap:wrap;">
1131 ${!u.globalPermissions?.includes('admin')
1132 ? `<button class="btn btn-secondary" style="font-size:0.8rem;" onclick="adminPromote('${u.userId}')">Make Admin</button>`
1133 : `<span style="font-size:0.8rem;color:var(--success);padding:0.25rem 0.5rem;">Admin</span>`
1134 }
1135 ${u.globalPermissions?.includes('admin') && u.userId !== users[0]?.userId
1136 ? `<button class="btn btn-secondary" style="font-size:0.8rem;" onclick="adminDemote('${u.userId}')">Remove Admin</button>`
1137 : ''
1138 }
1139 </div>
1140 </div>`
1141 }
1142
1143 content.innerHTML = html
1144 } catch (err) {
1145 content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
1146 }
1147}
1148
1149// ---- Admin Actions ----
1150
1151async function adminToggleProvider (providerId, currentlyActive) {
1152 try {
1153 const result = await api('PUT', `/api/admin/providers/${providerId}/toggle`)
1154 showToast(result.active ? 'Provider enabled' : 'Provider disabled', 'success')
1155 renderAdminProviders()
1156 } catch (err) { showToast(err.message, 'error') }
1157}
1158
1159async function adminUnlinkProvider (providerId, name) {
1160 if (!confirm(`Unlink owner from "${name}"? The provider page stays but has no owner until reassigned.`)) return
1161 try {
1162 await api('PUT', `/api/admin/providers/${providerId}/unlink`)
1163 showToast('Owner unlinked', 'success')
1164 renderAdminProviders()
1165 } catch (err) { showToast(err.message, 'error') }
1166}
1167
1168function adminShowAssign (providerId) {
1169 const el = document.getElementById('assign-' + providerId)
1170 if (el) el.style.display = ''
1171}
1172
1173async function adminAssignProvider (providerId) {
1174 const select = document.getElementById('assign-select-' + providerId)
1175 if (!select) return
1176 try {
1177 await api('PUT', `/api/admin/providers/${providerId}/assign`, { userId: select.value })
1178 showToast('Provider assigned', 'success')
1179 renderAdminProviders()
1180 } catch (err) { showToast(err.message, 'error') }
1181}
1182
1183async function adminPromote (userId) {
1184 if (!confirm('Grant admin permissions?')) return
1185 try {
1186 await api('PUT', `/api/admin/users/${userId}/permissions`, { permissions: ['read', 'write', 'admin'] })
1187 showToast('User promoted to admin', 'success')
1188 renderAdminUsers()
1189 } catch (err) { showToast(err.message, 'error') }
1190}
1191
1192async function adminDemote (userId) {
1193 if (!confirm('Remove admin permissions? User will keep read + write.')) return
1194 try {
1195 await api('PUT', `/api/admin/users/${userId}/permissions`, { permissions: ['read', 'write'] })
1196 showToast('Admin removed', 'success')
1197 renderAdminUsers()
1198 } catch (err) { showToast(err.message, 'error') }
8921199}
8931200
8941201// ---- Global Actions ----
web/js/identity.js +13 -1
……@@ -93,13 +93,25 @@ window.HyperBookingIdentity = (function () {
9393 return { publicKey: publicKeyB64, keypair: keypair }
9494 }
9595
96 function getPathPrefix () {
97 var path = window.location.pathname
98 var idx = path.indexOf('/web/')
99 if (idx > 0) return path.substring(0, idx)
100 var dashIdx = path.indexOf('/dashboard')
101 if (dashIdx > 0) return path.substring(0, dashIdx)
102 var match = path.match(/^(\/[^/]+)\//)
103 if (match && match[1] !== '/api' && match[1] !== '/web') return match[1]
104 return ''
105 }
106
96107 async function getChallenge () {
97108 if (cachedChallenge) {
98109 var remaining = cachedChallenge.expiresAt - Date.now()
99110 if (remaining > 60000) return cachedChallenge
100111 }
101112
102 var res = await originalFetch('/api/auth/challenge')
113 var prefix = getPathPrefix()
114 var res = await originalFetch(prefix + '/api/auth/challenge')
103115 if (!res.ok) throw new Error('Failed to get challenge')
104116 cachedChallenge = await res.json()
105117 return cachedChallenge