@devchitchat/chat 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +313 -0
  2. package/index.js +148 -0
  3. package/migrate/001-drop-channel-invites.js +3 -0
  4. package/migrate/002-invite-initial-roles.js +5 -0
  5. package/migrate/003-dm-channels.js +35 -0
  6. package/migrate/004-notifications.js +15 -0
  7. package/migrate/005-uploads.js +21 -0
  8. package/migrate/006-mention-priority.js +3 -0
  9. package/migrate/007-push-subscriptions.js +14 -0
  10. package/migrate/008-messages-channel-seq-index.js +3 -0
  11. package/migrate/009-message-reactions.js +15 -0
  12. package/migrate/010-edit-messages.js +7 -0
  13. package/package.json +51 -0
  14. package/pages/_error.html +12 -0
  15. package/pages/_layout.html +31 -0
  16. package/pages/_layout.js +13 -0
  17. package/pages/admin/_layout.html +52 -0
  18. package/pages/admin/_layout.js +8 -0
  19. package/pages/admin/bots/[userId].js +88 -0
  20. package/pages/admin/bots/[userId].phtml +89 -0
  21. package/pages/admin/bots/index.js +41 -0
  22. package/pages/admin/bots/index.phtml +58 -0
  23. package/pages/admin/index.js +8 -0
  24. package/pages/admin/invites/index.js +72 -0
  25. package/pages/admin/invites/index.phtml +88 -0
  26. package/pages/admin/users/[userId].js +60 -0
  27. package/pages/admin/users/[userId].phtml +57 -0
  28. package/pages/admin/users/index.js +20 -0
  29. package/pages/admin/users/index.phtml +37 -0
  30. package/pages/api/uploads/index.js +66 -0
  31. package/pages/api/user/settings.js +26 -0
  32. package/pages/auth/signout.js +14 -0
  33. package/pages/channels/[channelId].js +99 -0
  34. package/pages/channels/[channelId].phtml +173 -0
  35. package/pages/index.js +33 -0
  36. package/pages/invite/[token].js +10 -0
  37. package/pages/login/index.js +57 -0
  38. package/pages/login/index.phtml +29 -0
  39. package/pages/public/client/action-sheet.js +77 -0
  40. package/pages/public/client/app.js +38 -0
  41. package/pages/public/client/auth-tabs.js +13 -0
  42. package/pages/public/client/emoji-data.js +197 -0
  43. package/pages/public/client/islands/call.js +1770 -0
  44. package/pages/public/client/islands/sidebar.js +1197 -0
  45. package/pages/public/client/long-press.js +59 -0
  46. package/pages/public/client/modal.js +50 -0
  47. package/pages/public/client/router.js +87 -0
  48. package/pages/public/client/rtc-peer-manager.js +344 -0
  49. package/pages/public/client/settings-sync.js +76 -0
  50. package/pages/public/client/shared/messages.js +147 -0
  51. package/pages/public/client/swipe-nav.js +98 -0
  52. package/pages/public/client/theme.js +27 -0
  53. package/pages/public/client/ws.js +71 -0
  54. package/pages/public/favicon.ico +0 -0
  55. package/pages/public/favicon.png +0 -0
  56. package/pages/public/icon.png +0 -0
  57. package/pages/public/manifest.json +11 -0
  58. package/pages/public/sw.js +38 -0
  59. package/pages/public/themes/base.css +1786 -0
  60. package/pages/public/themes/dark.css +22 -0
  61. package/pages/public/themes/forest.css +22 -0
  62. package/pages/public/themes/light.css +23 -0
  63. package/pages/public/themes/ocean.css +22 -0
  64. package/pages/public/themes/rose.css +22 -0
  65. package/pages/registration/index.js +35 -0
  66. package/pages/registration/index.phtml +38 -0
  67. package/pages/uploads/[uploadId]/[filename].js +45 -0
  68. package/src/adapters/InMemoryAuthRepository.js +74 -0
  69. package/src/adapters/InMemoryChannelRepository.js +138 -0
  70. package/src/adapters/InMemoryDeliveryRepository.js +52 -0
  71. package/src/adapters/InMemoryFileStore.js +53 -0
  72. package/src/adapters/InMemoryHubRepository.js +85 -0
  73. package/src/adapters/InMemoryMessageRepository.js +35 -0
  74. package/src/adapters/InMemoryReactionRepository.js +45 -0
  75. package/src/adapters/InMemorySearchRepository.js +37 -0
  76. package/src/adapters/InMemorySignalingRepository.js +35 -0
  77. package/src/adapters/InMemoryUploadRepository.js +36 -0
  78. package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
  79. package/src/adapters/LocalFileStore.js +40 -0
  80. package/src/adapters/SqliteAuthRepository.js +184 -0
  81. package/src/adapters/SqliteChannelRepository.js +149 -0
  82. package/src/adapters/SqliteDeliveryRepository.js +53 -0
  83. package/src/adapters/SqliteHubRepository.js +99 -0
  84. package/src/adapters/SqliteMessageRepository.js +90 -0
  85. package/src/adapters/SqlitePushRepository.js +39 -0
  86. package/src/adapters/SqliteReactionRepository.js +50 -0
  87. package/src/adapters/SqliteSearchRepository.js +42 -0
  88. package/src/adapters/SqliteSignalingRepository.js +50 -0
  89. package/src/adapters/SqliteUploadRepository.js +34 -0
  90. package/src/adapters/SqliteUserSettingsRepository.js +23 -0
  91. package/src/adminAuth.js +25 -0
  92. package/src/config.js +11 -0
  93. package/src/context.js +77 -0
  94. package/src/core/dm.js +10 -0
  95. package/src/core/mentions.js +27 -0
  96. package/src/core/messages.js +21 -0
  97. package/src/core/reactions.js +6 -0
  98. package/src/core/roles.js +5 -0
  99. package/src/core/uploads.js +107 -0
  100. package/src/db/initDb.js +225 -0
  101. package/src/db/openDb.js +18 -0
  102. package/src/db/runMigrations.js +45 -0
  103. package/src/db/transaction.js +11 -0
  104. package/src/ports/IFileStore.js +34 -0
  105. package/src/services/AuthService.js +208 -0
  106. package/src/services/BotService.js +148 -0
  107. package/src/services/ChannelService.js +176 -0
  108. package/src/services/DeliveryService.js +28 -0
  109. package/src/services/HubService.js +133 -0
  110. package/src/services/MessageService.js +122 -0
  111. package/src/services/NotificationService.js +45 -0
  112. package/src/services/PresenceService.js +55 -0
  113. package/src/services/ReactionService.js +57 -0
  114. package/src/services/SearchService.js +21 -0
  115. package/src/services/SignalingService.js +177 -0
  116. package/src/services/UploadService.js +111 -0
  117. package/src/services/UserSettingsService.js +30 -0
  118. package/src/services/WebPushService.js +217 -0
  119. package/src/util/crypto.js +21 -0
  120. package/src/util/errors.js +14 -0
  121. package/src/util/ids.js +3 -0
  122. package/src/util/logger.js +21 -0
  123. package/src/ws/ChatServer.js +478 -0
  124. package/src/ws/handlers/authHandlers.js +152 -0
  125. package/src/ws/handlers/channelHandlers.js +166 -0
  126. package/src/ws/handlers/hubHandlers.js +82 -0
  127. package/src/ws/handlers/messageHandlers.js +88 -0
  128. package/src/ws/handlers/pushHandlers.js +25 -0
  129. package/src/ws/handlers/reactionHandlers.js +27 -0
  130. package/src/ws/handlers/rtcHandlers.js +126 -0
  131. package/styles.css +22 -0
@@ -0,0 +1,31 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8
+ <meta name="apple-mobile-web-app-title" content="devchitchat">
9
+ <link rel="apple-touch-icon" href="{{base}}/icon.png">
10
+ <link rel="manifest" href="{{base}}/manifest.json">
11
+ <title>{{pageTitle}} — devchitchat</title>
12
+ <link rel="stylesheet" href="{{base}}/themes/base.css">
13
+ <link rel="stylesheet" href="{{base}}/themes/dark.css" id="theme-stylesheet">
14
+ <script>window.__BASE_PATH__ = "{{base}}"</script>
15
+ <script type="importmap">
16
+ {
17
+ "imports": {
18
+ "@devchitchat/rdbljs": "{{base}}/vendor/rdbl.js"
19
+ }
20
+ }
21
+ </script>
22
+ </head>
23
+ <body>
24
+ <main class="app-shell">
25
+ {{content}}
26
+ </main>
27
+
28
+ <script type="module" src="{{base}}/client/theme.js"></script>
29
+ <script type="module" src="{{base}}/client/app.js"></script>
30
+ </body>
31
+ </html>
@@ -0,0 +1,13 @@
1
+ import { sessionFromRequest } from '../src/context.js'
2
+ import { BASE_PATH } from '../src/config.js'
3
+
4
+ export async function data(req) {
5
+ const session = sessionFromRequest(req)
6
+ const user = session?.user ?? null
7
+ return {
8
+ user,
9
+ isAdmin: user?.roles?.includes('admin') ?? false,
10
+ pageTitle: 'Chat',
11
+ base: BASE_PATH,
12
+ }
13
+ }
@@ -0,0 +1,52 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8
+ <meta name="apple-mobile-web-app-title" content="devchitchat">
9
+ <link rel="apple-touch-icon" href="{{base}}/icon.png">
10
+ <link rel="manifest" href="{{base}}/manifest.json">
11
+ <title>Admin — devchitchat</title>
12
+ <link rel="stylesheet" href="{{base}}/themes/base.css">
13
+ <link rel="stylesheet" href="{{base}}/themes/dark.css" id="theme-stylesheet">
14
+ <script>window.__BASE_PATH__ = "{{base}}"</script>
15
+ <script type="importmap">
16
+ {
17
+ "imports": {
18
+ "@devchitchat/rdbljs": "{{base}}/vendor/rdbl.js"
19
+ }
20
+ }
21
+ </script>
22
+ </head>
23
+ <body>
24
+ <header class="admin-topbar">
25
+ <a href="{{base}}/" class="admin-topbar-brand">devchitchat</a>
26
+ {{#if user}}
27
+ <span class="admin-topbar-user">{{user.display_name}}</span>
28
+ {{/if}}
29
+ <div class="admin-topbar-actions">
30
+ <label for="theme-picker" class="sr-only">Theme</label>
31
+ <select id="theme-picker" aria-label="Choose theme" autocomplete="off">
32
+ <option value="dark">Dark</option>
33
+ <option value="light">Light</option>
34
+ <option value="ocean">Ocean</option>
35
+ <option value="forest">Forest</option>
36
+ <option value="rose">Rose</option>
37
+ </select>
38
+ {{#if user}}
39
+ <form method="POST" action="{{base}}/auth/signout">
40
+ <button type="submit" class="btn-ghost btn-sm">Sign out</button>
41
+ </form>
42
+ {{/if}}
43
+ </div>
44
+ </header>
45
+
46
+ <main>
47
+ {{content}}
48
+ </main>
49
+
50
+ <script type="module" src="{{base}}/client/theme.js"></script>
51
+ </body>
52
+ </html>
@@ -0,0 +1,8 @@
1
+ import { sessionFromRequest } from '../../src/context.js'
2
+ import { BASE_PATH } from '../../src/config.js'
3
+
4
+ export async function data(req) {
5
+ const session = sessionFromRequest(req)
6
+ const user = session?.user ?? null
7
+ return { user, base: BASE_PATH }
8
+ }
@@ -0,0 +1,88 @@
1
+ import { requireAdminSession } from '../../../src/adminAuth.js'
2
+ import { botService, channelService } from '../../../src/context.js'
3
+ import { randomToken } from '../../../src/util/crypto.js'
4
+ import { p } from '../../../src/config.js'
5
+
6
+ // In-memory flash store: flashId → plaintext token. Consumed once on GET.
7
+ // Lost on restart, which is fine — the token was already shown or is gone.
8
+ const tokenFlashes = new Map()
9
+
10
+ function getBotUserId(req) {
11
+ return new URL(req.url).pathname.split('/').pop()
12
+ }
13
+
14
+ export function GET(req) {
15
+ const session = requireAdminSession(req)
16
+ if (session instanceof Response) return session
17
+
18
+ const botUserId = getBotUserId(req)
19
+ const bot = botService.getBot({ userId: botUserId, requestingUserId: session.user.user_id })
20
+
21
+ const url = new URL(req.url)
22
+ // Consume the flash token once — removes it from the map so it can't be replayed
23
+ const flashId = url.searchParams.get('flash_id') ?? null
24
+ const createdToken = flashId ? (tokenFlashes.get(flashId) ?? null) : null
25
+ if (flashId) tokenFlashes.delete(flashId)
26
+ const flash = url.searchParams.get('flash') ?? null
27
+
28
+ // All channels for channel assignment checkboxes
29
+ const allChannels = channelService.listChannels(session.user.user_id, session.user.roles)
30
+ const botChannelIds = new Set(bot.channels.map(c => c.channel_id))
31
+
32
+ return {
33
+ user: session.user,
34
+ pageTitle: `Admin — Bot: ${bot.handle}`,
35
+ bot,
36
+ createdToken,
37
+ flash,
38
+ tokens: bot.tokens.map(t => ({
39
+ ...t,
40
+ created_at_fmt: new Date(t.created_at).toLocaleString(),
41
+ expires_at_fmt: t.expires_at ? new Date(t.expires_at).toLocaleString() : 'Never',
42
+ last_used_at_fmt: t.last_used_at ? new Date(t.last_used_at).toLocaleString() : 'Never',
43
+ revoked: !!t.revoked_at,
44
+ expired: !t.revoked_at && t.expires_at != null && t.expires_at <= Date.now(),
45
+ })),
46
+ allChannels: allChannels.map(c => ({
47
+ ...c,
48
+ checked: botChannelIds.has(c.channel_id),
49
+ })),
50
+ }
51
+ }
52
+
53
+ export async function POST(req) {
54
+ const session = requireAdminSession(req)
55
+ if (session instanceof Response) return session
56
+
57
+ const botUserId = getBotUserId(req)
58
+ const form = await req.formData()
59
+ const action = form.get('action')
60
+
61
+ if (action === 'create_token') {
62
+ const label = form.get('label')?.trim() || null
63
+ const ttlDays = parseInt(form.get('ttl_days') || '', 10)
64
+ const ttlMs = Number.isFinite(ttlDays) && ttlDays > 0 ? ttlDays * 24 * 60 * 60 * 1000 : null
65
+ const result = botService.createToken({ userId: botUserId, label, ttlMs, requestingUserId: session.user.user_id })
66
+ // Store the token in the server-side flash map — never put it in the URL
67
+ const flashId = randomToken(8)
68
+ tokenFlashes.set(flashId, result.token)
69
+ return Response.redirect(
70
+ new URL(p(`/admin/bots/${botUserId}?flash_id=${encodeURIComponent(flashId)}`), req.url),
71
+ 303
72
+ )
73
+ }
74
+
75
+ if (action === 'revoke_token') {
76
+ const tokenId = form.get('token_id')
77
+ botService.revokeToken({ tokenId, requestingUserId: session.user.user_id })
78
+ return Response.redirect(new URL(p(`/admin/bots/${botUserId}?flash=token_revoked`), req.url), 303)
79
+ }
80
+
81
+ if (action === 'set_channels') {
82
+ const channelIds = form.getAll('channel_ids')
83
+ botService.setBotChannels({ userId: botUserId, channelIds, requestingUserId: session.user.user_id })
84
+ return Response.redirect(new URL(p(`/admin/bots/${botUserId}?flash=channels_updated`), req.url), 303)
85
+ }
86
+
87
+ return new Response('Bad request', { status: 400 })
88
+ }
@@ -0,0 +1,89 @@
1
+ <div class="admin-page">
2
+ <nav class="admin-nav">
3
+ <a href="{{base}}/admin/invites" class="admin-nav-link">Invites</a>
4
+ <a href="{{base}}/admin/users" class="admin-nav-link">Users</a>
5
+ <a href="{{base}}/admin/bots" class="admin-nav-link active">Bots</a>
6
+ </nav>
7
+
8
+ <h1>Bot: <code>{{bot.handle}}</code></h1>
9
+ <p class="admin-hint">{{bot.display_name}}</p>
10
+
11
+ {{#if flash}}
12
+ <div class="admin-notice admin-notice--success">Saved.</div>
13
+ {{/if}}
14
+
15
+ {{#if createdToken}}
16
+ <div class="admin-notice admin-notice--success">
17
+ <strong>Token created.</strong> Copy it now — it will not be shown again:<br>
18
+ <code class="token-display">{{createdToken}}</code>
19
+ </div>
20
+ {{/if}}
21
+
22
+ <section class="admin-section">
23
+ <h2>API tokens</h2>
24
+
25
+ <form method="POST" class="admin-form admin-form--inline">
26
+ <input type="hidden" name="action" value="create_token">
27
+ <input type="text" name="label" placeholder="Token label (optional)">
28
+ <select name="ttl_days">
29
+ <option value="">No expiry</option>
30
+ <option value="30">30 days</option>
31
+ <option value="90">90 days</option>
32
+ <option value="365">1 year</option>
33
+ </select>
34
+ <button type="submit" class="btn">Create token</button>
35
+ </form>
36
+
37
+ {{#if tokens}}
38
+ <table class="data-table">
39
+ <thead>
40
+ <tr>
41
+ <th>Label</th>
42
+ <th>Created</th>
43
+ <th>Expires</th>
44
+ <th>Last used</th>
45
+ <th>Status</th>
46
+ <th></th>
47
+ </tr>
48
+ </thead>
49
+ <tbody>
50
+ {{#each tokens}}
51
+ <tr>
52
+ <td>{{label}}</td>
53
+ <td>{{created_at_fmt}}</td>
54
+ <td>{{expires_at_fmt}}</td>
55
+ <td>{{last_used_at_fmt}}</td>
56
+ <td>{{#if revoked}}<span class="tag tag--warn">Revoked</span>{{/if}}{{#if expired}}<span class="tag tag--warn">Expired</span>{{/if}}</td>
57
+ <td>
58
+ {{#unless revoked}}
59
+ <form method="POST" class="inline">
60
+ <input type="hidden" name="action" value="revoke_token">
61
+ <input type="hidden" name="token_id" value="{{token_id}}">
62
+ <button type="submit" class="btn-ghost btn-danger">Revoke</button>
63
+ </form>
64
+ {{/unless}}
65
+ </td>
66
+ </tr>
67
+ {{/each}}
68
+ </tbody>
69
+ </table>
70
+ {{/if}}
71
+ {{#unless tokens}}
72
+ <p class="admin-empty">No tokens yet.</p>
73
+ {{/unless}}
74
+ </section>
75
+
76
+ <section class="admin-section">
77
+ <h2>Channel access</h2>
78
+ <form method="POST" class="admin-form">
79
+ <input type="hidden" name="action" value="set_channels">
80
+ {{#each allChannels}}
81
+ <label class="checkbox-label">
82
+ <input type="checkbox" name="channel_ids" value="{{channel_id}}" {{#if checked}}checked{{/if}}>
83
+ # {{name}}
84
+ </label>
85
+ {{/each}}
86
+ <button type="submit" class="btn">Save channel access</button>
87
+ </form>
88
+ </section>
89
+ </div>
@@ -0,0 +1,41 @@
1
+ import { requireAdminSession } from '../../../src/adminAuth.js'
2
+ import { botService } from '../../../src/context.js'
3
+ import { p } from '../../../src/config.js'
4
+
5
+ export function GET(req) {
6
+ const session = requireAdminSession(req)
7
+ if (session instanceof Response) return session
8
+
9
+ const bots = botService.listBots({ requestingUserId: session.user.user_id })
10
+
11
+ return {
12
+ user: session.user,
13
+ pageTitle: 'Admin — Bots',
14
+ bots: bots.map(b => ({
15
+ ...b,
16
+ created_at_fmt: new Date(b.created_at).toLocaleString(),
17
+ })),
18
+ }
19
+ }
20
+
21
+ export async function POST(req) {
22
+ const session = requireAdminSession(req)
23
+ if (session instanceof Response) return session
24
+
25
+ const form = await req.formData()
26
+ const handle = form.get('handle')?.trim()
27
+ const displayName = form.get('display_name')?.trim() || handle
28
+ const tokenLabel = form.get('token_label')?.trim() || null
29
+
30
+ const result = botService.createBot({
31
+ handle,
32
+ displayName,
33
+ tokenLabel,
34
+ requestingUserId: session.user.user_id,
35
+ })
36
+
37
+ return Response.redirect(
38
+ new URL(p(`/admin/bots/${result.userId}?created_token=${encodeURIComponent(result.token)}`), req.url),
39
+ 303
40
+ )
41
+ }
@@ -0,0 +1,58 @@
1
+ <div class="admin-page">
2
+ <nav class="admin-nav">
3
+ <a href="{{base}}/admin/invites" class="admin-nav-link">Invites</a>
4
+ <a href="{{base}}/admin/users" class="admin-nav-link">Users</a>
5
+ <a href="{{base}}/admin/bots" class="admin-nav-link active">Bots</a>
6
+ </nav>
7
+
8
+ <h1>Bots</h1>
9
+
10
+ <section class="admin-section">
11
+ <h2>Create bot</h2>
12
+ <form method="POST" action="{{base}}/admin/bots" class="admin-form">
13
+ <div class="form-row">
14
+ <label for="handle">Handle</label>
15
+ <input type="text" id="handle" name="handle" required placeholder="e.g. deploy-bot">
16
+ </div>
17
+ <div class="form-row">
18
+ <label for="display_name">Display name</label>
19
+ <input type="text" id="display_name" name="display_name" placeholder="e.g. Deploy Bot">
20
+ </div>
21
+ <div class="form-row">
22
+ <label for="token_label">Initial token label (optional)</label>
23
+ <input type="text" id="token_label" name="token_label" placeholder="e.g. production">
24
+ </div>
25
+ <button type="submit" class="btn">Create bot</button>
26
+ </form>
27
+ </section>
28
+
29
+ <section class="admin-section">
30
+ <h2>Existing bots</h2>
31
+ {{#if bots}}
32
+ <table class="data-table">
33
+ <thead>
34
+ <tr>
35
+ <th>Handle</th>
36
+ <th>Display name</th>
37
+ <th>Tokens</th>
38
+ <th>Created</th>
39
+ <th></th>
40
+ </tr>
41
+ </thead>
42
+ <tbody>
43
+ {{#each bots}}
44
+ <tr>
45
+ <td><code>{{handle}}</code></td>
46
+ <td>{{display_name}}</td>
47
+ <td>{{tokens.length}}</td>
48
+ <td>{{created_at_fmt}}</td>
49
+ <td><a href="{{base}}/admin/bots/{{user_id}}" class="btn-ghost">Manage</a></td>
50
+ </tr>
51
+ {{/each}}
52
+ </tbody>
53
+ </table>
54
+ {{else}}
55
+ <p class="admin-empty">No bots yet.</p>
56
+ {{/if}}
57
+ </section>
58
+ </div>
@@ -0,0 +1,8 @@
1
+ import { requireAdminSession } from '../../src/adminAuth.js'
2
+ import { p } from '../../src/config.js'
3
+
4
+ export function GET(req) {
5
+ const session = requireAdminSession(req)
6
+ if (session instanceof Response) return session
7
+ return Response.redirect(new URL(p('/admin/invites'), req.url), 302)
8
+ }
@@ -0,0 +1,72 @@
1
+ import { requireAdminSession } from '../../../src/adminAuth.js'
2
+ import { auth } from '../../../src/context.js'
3
+ import { p } from '../../../src/config.js'
4
+
5
+ export function GET(req) {
6
+ const session = requireAdminSession(req)
7
+ if (session instanceof Response) return session
8
+
9
+ const url = new URL(req.url)
10
+ const createdToken = url.searchParams.get('created') ?? null
11
+ const origin = url.origin
12
+
13
+ const invites = auth.listInvites({ requestingUserId: session.user.user_id })
14
+ const now = Date.now()
15
+
16
+ return {
17
+ user: session.user,
18
+ pageTitle: 'Admin — Invites',
19
+ createdToken,
20
+ createdLink: createdToken ? `${origin}${p('/registration')}?invite=${encodeURIComponent(createdToken)}` : null,
21
+ invites: invites.map(inv => ({
22
+ ...inv,
23
+ expired: inv.expires_at <= now,
24
+ exhausted: inv.uses >= inv.max_uses,
25
+ expires_at_fmt: new Date(inv.expires_at).toLocaleString(),
26
+ roles_label: (() => {
27
+ try {
28
+ const r = JSON.parse(inv.initial_roles_json ?? '["user"]')
29
+ return r.includes('guest') ? 'Guest' : 'User'
30
+ } catch { return 'User' }
31
+ })(),
32
+ is_guest: (() => {
33
+ try { return JSON.parse(inv.initial_roles_json ?? '["user"]').includes('guest') } catch { return false }
34
+ })(),
35
+ })),
36
+ }
37
+ }
38
+
39
+ export async function POST(req) {
40
+ const session = requireAdminSession(req)
41
+ if (session instanceof Response) return session
42
+
43
+ const form = await req.formData()
44
+ const action = form.get('action')
45
+
46
+ if (action === 'create') {
47
+ const ttlHours = Number(form.get('ttl_hours') ?? 24)
48
+ const maxUses = Number(form.get('max_uses') ?? 1)
49
+ const note = form.get('note')?.trim() || null
50
+ const roleValue = form.get('roles') ?? 'user'
51
+ const roles = roleValue === 'guest' ? ['guest'] : ['user']
52
+ const invite = auth.createInvite({
53
+ createdByUserId: session.user.user_id,
54
+ ttlMs: ttlHours * 60 * 60 * 1000,
55
+ maxUses,
56
+ note,
57
+ roles,
58
+ })
59
+ return Response.redirect(
60
+ new URL(p(`/admin/invites?created=${encodeURIComponent(invite.inviteToken)}`), req.url),
61
+ 303
62
+ )
63
+ }
64
+
65
+ if (action === 'revoke') {
66
+ const inviteId = form.get('invite_id')
67
+ auth.revokeInvite({ inviteId, requestingUserId: session.user.user_id })
68
+ return Response.redirect(new URL(p('/admin/invites'), req.url), 303)
69
+ }
70
+
71
+ return new Response('Bad request', { status: 400 })
72
+ }
@@ -0,0 +1,88 @@
1
+ <div class="admin-page">
2
+ <nav class="admin-nav">
3
+ <a href="{{base}}/admin/invites" class="admin-nav-link active">Invites</a>
4
+ <a href="{{base}}/admin/users" class="admin-nav-link">Users</a>
5
+ <a href="{{base}}/admin/bots" class="admin-nav-link">Bots</a>
6
+ </nav>
7
+
8
+ <h1>Invite tokens</h1>
9
+
10
+ {{#if createdToken}}
11
+ <div class="admin-notice admin-notice--success">
12
+ <strong>Invite created.</strong> Send this link — it will not be shown again:<br>
13
+ <a href="{{createdLink}}" class="invite-link">{{createdLink}}</a>
14
+ <p class="admin-hint">Or share just the token if you prefer:<br>
15
+ <code class="token-display">{{createdToken}}</code></p>
16
+ </div>
17
+ {{/if}}
18
+
19
+ <section class="admin-section">
20
+ <h2>Create invite</h2>
21
+ <form method="POST" action="{{base}}/admin/invites" class="admin-form">
22
+ <input type="hidden" name="action" value="create">
23
+ <div class="form-row">
24
+ <label for="ttl_hours">Expires in (hours)</label>
25
+ <input type="number" id="ttl_hours" name="ttl_hours" value="24" min="1" max="720">
26
+ </div>
27
+ <div class="form-row">
28
+ <label for="max_uses">Max uses</label>
29
+ <input type="number" id="max_uses" name="max_uses" value="1" min="1" max="100">
30
+ </div>
31
+ <div class="form-row">
32
+ <label for="note">Note (optional)</label>
33
+ <input type="text" id="note" name="note" placeholder="e.g. For Alice">
34
+ </div>
35
+ <div class="form-row">
36
+ <label for="roles">Account type</label>
37
+ <select id="roles" name="roles">
38
+ <option value="user">User — can see public channels</option>
39
+ <option value="guest">Guest — can only see channels they're added to</option>
40
+ </select>
41
+ </div>
42
+ <button type="submit" class="btn">Create invite</button>
43
+ </form>
44
+ </section>
45
+
46
+ <section class="admin-section">
47
+ <h2>Active invites</h2>
48
+ {{#if invites}}
49
+ <table class="data-table">
50
+ <thead>
51
+ <tr>
52
+ <th>Note</th>
53
+ <th>Created by</th>
54
+ <th>Account type</th>
55
+ <th>Expires</th>
56
+ <th>Uses</th>
57
+ <th>Status</th>
58
+ <th></th>
59
+ </tr>
60
+ </thead>
61
+ <tbody>
62
+ {{#each invites}}
63
+ <tr>
64
+ <td>{{note}}</td>
65
+ <td>{{created_by_handle}}</td>
66
+ <td>{{#if is_guest}}<span class="tag tag--muted">Guest</span>{{else}}User{{/if}}</td>
67
+ <td>{{expires_at_fmt}}</td>
68
+ <td>{{uses}} / {{max_uses}}</td>
69
+ <td>
70
+ {{#if expired}}<span class="tag tag--warn">Expired</span>{{/if}}
71
+ {{#if exhausted}}<span class="tag tag--warn">Exhausted</span>{{/if}}
72
+ </td>
73
+ <td>
74
+ <form method="POST" action="{{base}}/admin/invites" class="inline">
75
+ <input type="hidden" name="action" value="revoke">
76
+ <input type="hidden" name="invite_id" value="{{invite_id}}">
77
+ <button type="submit" class="btn-ghost btn-danger">Revoke</button>
78
+ </form>
79
+ </td>
80
+ </tr>
81
+ {{/each}}
82
+ </tbody>
83
+ </table>
84
+ {{else}}
85
+ <p class="admin-empty">No invites yet.</p>
86
+ {{/if}}
87
+ </section>
88
+ </div>
@@ -0,0 +1,60 @@
1
+ import { requireAdminSession } from '../../../src/adminAuth.js'
2
+ import { auth } from '../../../src/context.js'
3
+ import { p } from '../../../src/config.js'
4
+
5
+ function getTargetUserId(req) {
6
+ return new URL(req.url).pathname.split('/').pop()
7
+ }
8
+
9
+ export function GET(req) {
10
+ const session = requireAdminSession(req)
11
+ if (session instanceof Response) return session
12
+
13
+ const targetUserId = getTargetUserId(req)
14
+ const target = auth.getUser(targetUserId)
15
+ if (!target) return new Response('User not found', { status: 404 })
16
+
17
+ const url = new URL(req.url)
18
+ const flash = url.searchParams.get('flash') ?? null
19
+
20
+ return {
21
+ user: session.user,
22
+ pageTitle: `Admin — Edit ${target.handle}`,
23
+ target,
24
+ targetRolesJson: JSON.stringify(target.roles),
25
+ isAdmin: target.roles.includes('admin'),
26
+ isUser: target.roles.includes('user'),
27
+ isGuest: target.roles.includes('guest'),
28
+ isBot: target.roles.includes('bot'),
29
+ flash,
30
+ }
31
+ }
32
+
33
+ export async function POST(req) {
34
+ const session = requireAdminSession(req)
35
+ if (session instanceof Response) return session
36
+
37
+ const targetUserId = getTargetUserId(req)
38
+ const form = await req.formData()
39
+ const action = form.get('action')
40
+
41
+ if (action === 'set_display_name') {
42
+ const displayName = form.get('display_name')
43
+ auth.adminUpdateDisplayName({ targetUserId, displayName, requestingUserId: session.user.user_id })
44
+ return Response.redirect(new URL(p(`/admin/users/${targetUserId}?flash=display_name_updated`), req.url), 303)
45
+ }
46
+
47
+ if (action === 'set_roles') {
48
+ const roles = form.getAll('roles')
49
+ auth.setUserRoles({ targetUserId, roles, requestingUserId: session.user.user_id })
50
+ return Response.redirect(new URL(p(`/admin/users/${targetUserId}?flash=roles_updated`), req.url), 303)
51
+ }
52
+
53
+ if (action === 'set_password') {
54
+ const newPassword = form.get('new_password')
55
+ await auth.adminSetPassword({ targetUserId, newPassword, requestingUserId: session.user.user_id })
56
+ return Response.redirect(new URL(p(`/admin/users/${targetUserId}?flash=password_updated`), req.url), 303)
57
+ }
58
+
59
+ return new Response('Bad request', { status: 400 })
60
+ }