| … | … | @@ -10,7 +10,7 @@ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' |
| 10 | 10 | const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token' |
| 11 | 11 | const GOOGLE_FREEBUSY_URL = 'https://www.googleapis.com/calendar/v3/freeBusy' |
| 12 | 12 | const GOOGLE_CALENDARLIST_URL = 'https://www.googleapis.com/calendar/v3/users/me/calendarList' |
| 13 | const SCOPE = 'https://www.googleapis.com/auth/calendar.readonly' | |
| 13 | const SCOPE = 'https://www.googleapis.com/auth/calendar.freebusy https://www.googleapis.com/auth/calendar.calendarlist.readonly' | |
| 14 | 14 | |
| 15 | 15 | export class GoogleCalendarClient { |
| 16 | 16 | constructor (options = {}) { |
| … | … | @@ -50,9 +50,7 @@ export function registerAuthRoutes (router, server) { |
| 50 | 50 | method: auth.method, |
| 51 | 51 | permissions: auth.permissions, |
| 52 | 52 | 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' | |
| 56 | 54 | }) |
| 57 | 55 | }) |
| 58 | 56 | } |
| … | … | @@ -5,6 +5,12 @@ |
| 5 | 5 | import { json, readJson, notFound } from '../response.js' |
| 6 | 6 | import { requireAuth } from './auth-helpers.js' |
| 7 | 7 | |
| 8 | const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/ | |
| 9 | const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/ | |
| 10 | ||
| 11 | function validTime (s) { return typeof s === 'string' && TIME_RE.test(s) } | |
| 12 | function validDate (s) { return typeof s === 'string' && DATE_RE.test(s) } | |
| 13 | ||
| 8 | 14 | export function registerAvailabilityRoutes (router, server) { |
| 9 | 15 | // Get provider availability (public — needed for slot calculation) |
| 10 | 16 | router.get('/api/providers/:slug/availability', async (req, res) => { |
| … | … | @@ -35,11 +41,31 @@ export function registerAvailabilityRoutes (router, server) { |
| 35 | 41 | const providerEntry = await server.db.get(`provider:${providerId}`) |
| 36 | 42 | if (!providerEntry) return notFound(res, 'Provider not found') |
| 37 | 43 | if (providerEntry.value.userId !== auth.userId && |
| 38 | !auth.permissions.global.includes('admin')) { | |
| 44 | !auth.permissions.admin) { | |
| 39 | 45 | json(res, { error: 'Not your provider' }, 403) |
| 40 | 46 | return |
| 41 | 47 | } |
| 42 | 48 | |
| 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 | ||
| 43 | 69 | const availability = { |
| 44 | 70 | providerId, |
| 45 | 71 | timezone: timezone || providerEntry.value.timezone || 'America/New_York', |
| … | … | @@ -84,8 +110,16 @@ export function registerAvailabilityRoutes (router, server) { |
| 84 | 110 | const tz = url.searchParams.get('tz') || 'UTC' |
| 85 | 111 | |
| 86 | 112 | 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) | |
| 89 | 123 | } |
| 90 | 124 | |
| 91 | 125 | if (weekOf) { |
| … | … | @@ -6,6 +6,9 @@ import { json, readJson, notFound } from '../response.js' |
| 6 | 6 | import { requireAuth, optionalAuth } from './auth-helpers.js' |
| 7 | 7 | import { calculateSlots, getExternalBusy } from './availability.js' |
| 8 | 8 | |
| 9 | const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/ | |
| 10 | const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/ | |
| 11 | ||
| 9 | 12 | export function registerBookingRoutes (router, server) { |
| 10 | 13 | // Create booking (authenticated client) |
| 11 | 14 | router.post('/api/bookings', async (req, res) => { |
| … | … | @@ -15,8 +18,16 @@ export function registerBookingRoutes (router, server) { |
| 15 | 18 | const { providerId, packageId, date, startTime, endTime, displayName, email } = body |
| 16 | 19 | |
| 17 | 20 | 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) | |
| 20 | 31 | } |
| 21 | 32 | |
| 22 | 33 | // Get package |
| … | … | @@ -92,7 +103,7 @@ export function registerBookingRoutes (router, server) { |
| 92 | 103 | const isClient = booking.clientId === auth.userId |
| 93 | 104 | const isProvider = await isProviderOwner(server, booking.providerId, auth.userId) |
| 94 | 105 | |
| 95 | if (!isClient && !isProvider && !auth.permissions.global.includes('admin')) { | |
| 106 | if (!isClient && !isProvider && !auth.permissions.admin) { | |
| 96 | 107 | json(res, { error: 'Not authorized' }, 403) |
| 97 | 108 | return |
| 98 | 109 | } |
| … | … | @@ -112,8 +123,13 @@ export function registerBookingRoutes (router, server) { |
| 112 | 123 | const { date, startTime } = body |
| 113 | 124 | |
| 114 | 125 | 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) | |
| 117 | 133 | } |
| 118 | 134 | |
| 119 | 135 | const entry = await server.db.get(`booking:${req.params.id}`) |
| … | … | @@ -263,11 +279,13 @@ function addMinutesToTime (timeStr, minutes) { |
| 263 | 279 | |
| 264 | 280 | async function getOrCreateClient (server, auth, displayName, email, providerId) { |
| 265 | 281 | const clientKey = `client-by-identity:${auth.identityId}` |
| 266 | const existing = await server.db.get(clientKey) | |
| 267 | 282 | |
| 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) | |
| 268 | 287 | if (existing) return existing.value |
| 269 | 288 | |
| 270 | // Create new client | |
| 271 | 289 | const clientId = auth.userId || server.generateId('cli') |
| 272 | 290 | const client = { |
| 273 | 291 | id: clientId, |
| … | … | @@ -283,7 +301,9 @@ async function getOrCreateClient (server, auth, displayName, email, providerId) |
| 283 | 301 | await batch.put(`clients-by-provider:${providerId}:${clientId}`, clientId) |
| 284 | 302 | await batch.flush() |
| 285 | 303 | |
| 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 | |
| 287 | 307 | } |
| 288 | 308 | |
| 289 | 309 | async function isProviderOwner (server, providerId, userId) { |
| … | … | @@ -13,22 +13,7 @@ export function registerDashboardRoutes (router, server) { |
| 13 | 13 | const auth = await requireAuth(req, server, 'read') |
| 14 | 14 | let providerId = await getProviderIdForUser(server, auth.userId) |
| 15 | 15 | |
| 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 | |
| 32 | 17 | |
| 33 | 18 | if (!providerId) { |
| 34 | 19 | return json(res, { provider: null }) |
| … | … | @@ -259,6 +244,158 @@ export function registerDashboardRoutes (router, server) { |
| 259 | 244 | json(res, userEntry ? userEntry.value : { userId: auth.userId }) |
| 260 | 245 | }) |
| 261 | 246 | |
| 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 | ||
| 262 | 399 | // ---- HTML Pages ---- |
| 263 | 400 | |
| 264 | 401 | // Dashboard home |
| … | … | @@ -325,11 +462,20 @@ export function registerDashboardRoutes (router, server) { |
| 325 | 462 | // ---- Helpers ---- |
| 326 | 463 | |
| 327 | 464 | async 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) | |
| 328 | 470 | for await (const entry of server.db.createReadStream({ |
| 329 | 471 | gte: 'provider:', |
| 330 | 472 | lte: 'provider:~' |
| 331 | 473 | })) { |
| 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 | } | |
| 333 | 479 | } |
| 334 | 480 | return null |
| 335 | 481 | } |
| … | … | @@ -406,7 +552,7 @@ function serveDashboardPage (res, server, view, params = {}) { |
| 406 | 552 | <a href="${prefix}/profile" class="nav-item ${view === 'profile' ? 'active' : ''}"> |
| 407 | 553 | <span class="nav-icon">🔐</span> Profile |
| 408 | 554 | </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"> | |
| 410 | 556 | <span class="nav-icon">🛡</span> Admin |
| 411 | 557 | </a> |
| 412 | 558 | </div> |
| … | … | @@ -16,7 +16,18 @@ export function registerGcalRoutes (router, server) { |
| 16 | 16 | return json(res, { configured: false, connected: false }) |
| 17 | 17 | } |
| 18 | 18 | |
| 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 | ||
| 20 | 31 | const providerId = await getProviderIdForUser(server, auth.userId) |
| 21 | 32 | if (!providerId) return json(res, { configured: true, connected: false }) |
| 22 | 33 | |
| … | … | @@ -52,11 +63,8 @@ export function registerGcalRoutes (router, server) { |
| 52 | 63 | createdAt: Date.now() |
| 53 | 64 | }) |
| 54 | 65 | |
| 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 | |
| 60 | 68 | const url = gcal.getAuthUrl(redirectUri, state) |
| 61 | 69 | json(res, { url }) |
| 62 | 70 | }) |
| … | … | @@ -101,10 +109,8 @@ export function registerGcalRoutes (router, server) { |
| 101 | 109 | // Consume nonce |
| 102 | 110 | await server.db.del(`gcal:state:${nonce}`) |
| 103 | 111 | |
| 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 | |
| 108 | 114 | |
| 109 | 115 | try { |
| 110 | 116 | const tokens = await gcal.exchangeCode(code, redirectUri) |
| … | … | @@ -189,11 +195,17 @@ export function registerGcalRoutes (router, server) { |
| 189 | 195 | } |
| 190 | 196 | |
| 191 | 197 | async function getProviderIdForUser (server, userId) { |
| 198 | const indexed = await server.db.get(`provider-by-user:${userId}`) | |
| 199 | if (indexed) return indexed.value | |
| 200 | ||
| 192 | 201 | for await (const entry of server.db.createReadStream({ |
| 193 | 202 | gte: 'provider:', |
| 194 | 203 | lte: 'provider:~' |
| 195 | 204 | })) { |
| 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 | } | |
| 197 | 209 | } |
| 198 | 210 | return null |
| 199 | 211 | } |
| … | … | @@ -61,7 +61,7 @@ export function registerPackageRoutes (router, server) { |
| 61 | 61 | const providerEntry = await server.db.get(`provider:${providerId}`) |
| 62 | 62 | if (!providerEntry) return notFound(res, 'Provider not found') |
| 63 | 63 | if (providerEntry.value.userId !== auth.userId && |
| 64 | !auth.permissions.global.includes('admin')) { | |
| 64 | !auth.permissions.admin) { | |
| 65 | 65 | json(res, { error: 'Not your provider' }, 403) |
| 66 | 66 | return |
| 67 | 67 | } |
| … | … | @@ -105,7 +105,7 @@ export function registerPackageRoutes (router, server) { |
| 105 | 105 | // Verify ownership |
| 106 | 106 | const providerEntry = await server.db.get(`provider:${pkg.providerId}`) |
| 107 | 107 | if (providerEntry.value.userId !== auth.userId && |
| 108 | !auth.permissions.global.includes('admin')) { | |
| 108 | !auth.permissions.admin) { | |
| 109 | 109 | json(res, { error: 'Not your provider' }, 403) |
| 110 | 110 | return |
| 111 | 111 | } |
| … | … | @@ -32,9 +32,9 @@ export function registerProviderRoutes (router, server) { |
| 32 | 32 | json(res, providers) |
| 33 | 33 | }) |
| 34 | 34 | |
| 35 | // Create provider (admin or first user) | |
| 35 | // Create provider (any authenticated user) | |
| 36 | 36 | router.post('/api/providers', async (req, res) => { |
| 37 | const auth = await requireAuth(req, server, 'admin') | |
| 37 | const auth = await requireAuth(req, server, 'write') | |
| 38 | 38 | const body = await readJson(req) |
| 39 | 39 | |
| 40 | 40 | const { displayName, slug, bio, timezone, specialties, socialLinks } = body |
| … | … | @@ -69,6 +69,7 @@ export function registerProviderRoutes (router, server) { |
| 69 | 69 | const batch = server.db.batch() |
| 70 | 70 | await batch.put(`provider:${provider.id}`, provider) |
| 71 | 71 | await batch.put(`provider-by-slug:${slug}`, provider.id) |
| 72 | await batch.put(`provider-by-user:${auth.userId}`, provider.id) | |
| 72 | 73 | await batch.flush() |
| 73 | 74 | |
| 74 | 75 | json(res, provider, 201) |
| … | … | @@ -88,7 +89,7 @@ export function registerProviderRoutes (router, server) { |
| 88 | 89 | const provider = entry.value |
| 89 | 90 | |
| 90 | 91 | // Check ownership |
| 91 | if (provider.userId !== auth.userId && !auth.permissions.global.includes('admin')) { | |
| 92 | if (provider.userId !== auth.userId && !auth.permissions.admin) { | |
| 92 | 93 | json(res, { error: 'Not your provider' }, 403) |
| 93 | 94 | return |
| 94 | 95 | } |
| … | … | @@ -65,6 +65,10 @@ export class HyperBookingServer { |
| 65 | 65 | }) |
| 66 | 66 | this.gcalConfigured = !!gcalClientId |
| 67 | 67 | |
| 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 | ||
| 68 | 72 | // Router |
| 69 | 73 | this.router = new Router(this) |
| 70 | 74 | |
| … | … | @@ -150,6 +154,7 @@ export class HyperBookingServer { |
| 150 | 154 | if (origin.match(/^https?:\/\/192\.168\./)) return true |
| 151 | 155 | if (origin.match(/^https?:\/\/10\./)) return true |
| 152 | 156 | if (origin.match(/^https?:\/\/172\.(1[6-9]|2\d|3[01])\./)) return true |
| 157 | if (origin === 'https://7rnx.net') return true | |
| 153 | 158 | return false |
| 154 | 159 | } |
| 155 | 160 |
| … | … | @@ -288,16 +288,100 @@ |
| 288 | 288 | } |
| 289 | 289 | .add-window:hover { text-decoration: underline; } |
| 290 | 290 | |
| 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 | ||
| 292 | 303 | .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; | |
| 294 | 309 | } |
| 295 | 310 | |
| 296 | 311 | .settings-section h3 { |
| 297 | font-size: 1.1rem; | |
| 312 | font-size: 1rem; | |
| 313 | font-weight: 600; | |
| 298 | 314 | margin-bottom: 1rem; |
| 299 | 315 | padding-bottom: 0.5rem; |
| 300 | 316 | 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 | } | |
| 301 | 385 | } |
| 302 | 386 | |
| 303 | 387 | /* Progress bar */ |
| … | … | @@ -5,6 +5,20 @@ |
| 5 | 5 | |
| 6 | 6 | /* global HyperBookingIdentity */ |
| 7 | 7 | |
| 8 | // ---- Path Prefix ---- | |
| 9 | // Detect prefix from URL so /api/* calls work behind reverse proxies like 7rnx.net/hyper-booking/ | |
| 10 | const 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 | ||
| 8 | 22 | // ---- Utilities ---- |
| 9 | 23 | |
| 10 | 24 | function showToast (message, type = 'info') { |
| … | … | @@ -22,7 +36,7 @@ async function api (method, path, body) { |
| 22 | 36 | } |
| 23 | 37 | if (body) opts.body = JSON.stringify(body) |
| 24 | 38 | |
| 25 | const res = await fetch(path, opts) | |
| 39 | const res = await fetch(PATH_PREFIX + path, opts) | |
| 26 | 40 | const data = await res.json() |
| 27 | 41 | if (!res.ok) throw new Error(data.error || 'Request failed') |
| 28 | 42 | return data |
| … | … | @@ -34,13 +34,7 @@ async function initDashboard (view, params) { |
| 34 | 34 | try { |
| 35 | 35 | const provData = await api('GET', '/api/dashboard/provider') |
| 36 | 36 | 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() | |
| 44 | 38 | return |
| 45 | 39 | } |
| 46 | 40 | } catch (err) { |
| … | … | @@ -269,12 +263,12 @@ async function renderClientDetail (clientId) { |
| 269 | 263 | |
| 270 | 264 | async function renderPackages () { |
| 271 | 265 | 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) { | |
| 274 | 268 | content.innerHTML = '<p class="empty">Create a provider profile first.</p>' |
| 275 | 269 | return |
| 276 | 270 | } |
| 277 | const provider = providers[0] // First provider for this user | |
| 271 | const provider = provData.provider | |
| 278 | 272 | const packages = await api('GET', `/api/providers/${provider.slug}/packages`) |
| 279 | 273 | |
| 280 | 274 | let html = ` |
| … | … | @@ -314,10 +308,11 @@ async function renderPackages () { |
| 314 | 308 | |
| 315 | 309 | async function renderPackageEdit (packageId) { |
| 316 | 310 | let pkg = null |
| 317 | let providers = [] | |
| 311 | let myProvider = null | |
| 318 | 312 | |
| 319 | 313 | try { |
| 320 | providers = await api('GET', '/api/providers') | |
| 314 | const provData = await api('GET', '/api/dashboard/provider') | |
| 315 | myProvider = provData.provider | |
| 321 | 316 | if (packageId) { |
| 322 | 317 | pkg = await api('GET', `/api/packages/${packageId}`) |
| 323 | 318 | } |
| … | … | @@ -327,7 +322,7 @@ async function renderPackageEdit (packageId) { |
| 327 | 322 | } |
| 328 | 323 | |
| 329 | 324 | const isNew = !pkg |
| 330 | const providerId = pkg ? pkg.providerId : (providers[0] ? providers[0].id : '') | |
| 325 | const providerId = pkg ? pkg.providerId : (myProvider ? myProvider.id : '') | |
| 331 | 326 | |
| 332 | 327 | content.innerHTML = ` |
| 333 | 328 | <p><a href="/dashboard/packages">← Back to Packages</a></p> |
| … | … | @@ -410,12 +405,12 @@ async function renderPackageEdit (packageId) { |
| 410 | 405 | |
| 411 | 406 | async function renderAvailability () { |
| 412 | 407 | 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) { | |
| 415 | 410 | content.innerHTML = '<p class="empty">Create a provider profile first.</p>' |
| 416 | 411 | return |
| 417 | 412 | } |
| 418 | const provider = providers[0] | |
| 413 | const provider = provData.provider | |
| 419 | 414 | const avail = await api('GET', `/api/providers/${provider.slug}/availability`) |
| 420 | 415 | |
| 421 | 416 | const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] |
| … | … | @@ -567,69 +562,89 @@ async function renderAnalytics () { |
| 567 | 562 | |
| 568 | 563 | async function renderSettings () { |
| 569 | 564 | 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) { | |
| 572 | 567 | content.innerHTML = '<p class="empty">No provider profile found.</p>' |
| 573 | 568 | return |
| 574 | 569 | } |
| 575 | const p = providers[0] | |
| 570 | const p = provData.provider | |
| 576 | 571 | const avail = await api('GET', `/api/providers/${p.slug}/availability`) |
| 577 | 572 | |
| 573 | const pubPage = PATH_PREFIX + '/' + p.slug | |
| 574 | ||
| 578 | 575 | 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> | |
| 592 | 607 | </div> |
| 593 | </form> | |
| 594 | </div> | |
| 595 | 608 | |
| 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> | |
| 601 | 630 | |
| 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> | |
| 606 | 635 | |
| 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> | |
| 622 | 645 | </div> |
| 623 | </form> | |
| 624 | </div> | |
| 646 | </div> | |
| 625 | 647 | |
| 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> | |
| 633 | 648 | </div> |
| 634 | 649 | ` |
| 635 | 650 | |
| … | … | @@ -689,15 +704,57 @@ async function loadGcalSection () { |
| 689 | 704 | } |
| 690 | 705 | |
| 691 | 706 | if (status.connected) { |
| 692 | // Connected — show simple status + disconnect | |
| 707 | // Connected — show status, calendar picker, disconnect | |
| 693 | 708 | 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;"> | |
| 697 | 714 | Only availability (busy/free) is read. Event details are never accessed. |
| 698 | 715 | </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> | |
| 700 | 722 | ` |
| 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 | ||
| 701 | 758 | document.getElementById('gcal-disconnect-btn').addEventListener('click', async () => { |
| 702 | 759 | if (!confirm('Disconnect Google Calendar?')) return |
| 703 | 760 | try { |
| … | … | @@ -841,19 +898,143 @@ async function renderPortal () { |
| 841 | 898 | |
| 842 | 899 | // ---- Admin ---- |
| 843 | 900 | |
| 901 | // ---- Create Provider ---- | |
| 902 | ||
| 903 | function 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 | ||
| 844 | 1009 | async function renderAdmin () { |
| 845 | 1010 | try { |
| 846 | const providers = await api('GET', '/api/providers') | |
| 1011 | const sys = await api('GET', '/api/admin/system') | |
| 847 | 1012 | content.innerHTML = ` |
| 848 | 1013 | <div class="summary-grid"> |
| 849 | 1014 | <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> | |
| 851 | 1020 | <div class="summary-label">Providers</div> |
| 852 | 1021 | </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> | |
| 853 | 1030 | </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> | |
| 857 | 1038 | </div> |
| 858 | 1039 | ` |
| 859 | 1040 | } catch (err) { |
| … | … | @@ -863,22 +1044,54 @@ async function renderAdmin () { |
| 863 | 1044 | |
| 864 | 1045 | async function renderAdminProviders () { |
| 865 | 1046 | 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 | ]) | |
| 867 | 1051 | |
| 868 | let html = '<p><a href="/admin">← Back to Admin</a></p>' | |
| 1052 | let html = `<p><a href="${PATH_PREFIX}/admin">← Back to Admin</a></p>` | |
| 869 | 1053 | html += `<h3 style="margin:1rem 0;">Providers (${providers.length})</h3>` |
| 870 | 1054 | |
| 871 | 1055 | 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 | ||
| 872 | 1060 | 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;"> | |
| 875 | 1063 | <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> | |
| 877 | 1072 | </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>` : ''} | |
| 880 | 1078 | </div> |
| 881 | 1079 | </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 | } | |
| 882 | 1095 | } |
| 883 | 1096 | |
| 884 | 1097 | content.innerHTML = html |
| … | … | @@ -888,7 +1101,101 @@ async function renderAdminProviders () { |
| 888 | 1101 | } |
| 889 | 1102 | |
| 890 | 1103 | async 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">← 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 | ||
| 1151 | async 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 | ||
| 1159 | async 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 | ||
| 1168 | function adminShowAssign (providerId) { | |
| 1169 | const el = document.getElementById('assign-' + providerId) | |
| 1170 | if (el) el.style.display = '' | |
| 1171 | } | |
| 1172 | ||
| 1173 | async 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 | ||
| 1183 | async 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 | ||
| 1192 | async 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') } | |
| 892 | 1199 | } |
| 893 | 1200 | |
| 894 | 1201 | // ---- Global Actions ---- |
| … | … | @@ -93,13 +93,25 @@ window.HyperBookingIdentity = (function () { |
| 93 | 93 | return { publicKey: publicKeyB64, keypair: keypair } |
| 94 | 94 | } |
| 95 | 95 | |
| 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 | ||
| 96 | 107 | async function getChallenge () { |
| 97 | 108 | if (cachedChallenge) { |
| 98 | 109 | var remaining = cachedChallenge.expiresAt - Date.now() |
| 99 | 110 | if (remaining > 60000) return cachedChallenge |
| 100 | 111 | } |
| 101 | 112 | |
| 102 | var res = await originalFetch('/api/auth/challenge') | |
| 113 | var prefix = getPathPrefix() | |
| 114 | var res = await originalFetch(prefix + '/api/auth/challenge') | |
| 103 | 115 | if (!res.ok) throw new Error('Failed to get challenge') |
| 104 | 116 | cachedChallenge = await res.json() |
| 105 | 117 | return cachedChallenge |