@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.
- package/README.md +313 -0
- package/index.js +148 -0
- package/migrate/001-drop-channel-invites.js +3 -0
- package/migrate/002-invite-initial-roles.js +5 -0
- package/migrate/003-dm-channels.js +35 -0
- package/migrate/004-notifications.js +15 -0
- package/migrate/005-uploads.js +21 -0
- package/migrate/006-mention-priority.js +3 -0
- package/migrate/007-push-subscriptions.js +14 -0
- package/migrate/008-messages-channel-seq-index.js +3 -0
- package/migrate/009-message-reactions.js +15 -0
- package/migrate/010-edit-messages.js +7 -0
- package/package.json +51 -0
- package/pages/_error.html +12 -0
- package/pages/_layout.html +31 -0
- package/pages/_layout.js +13 -0
- package/pages/admin/_layout.html +52 -0
- package/pages/admin/_layout.js +8 -0
- package/pages/admin/bots/[userId].js +88 -0
- package/pages/admin/bots/[userId].phtml +89 -0
- package/pages/admin/bots/index.js +41 -0
- package/pages/admin/bots/index.phtml +58 -0
- package/pages/admin/index.js +8 -0
- package/pages/admin/invites/index.js +72 -0
- package/pages/admin/invites/index.phtml +88 -0
- package/pages/admin/users/[userId].js +60 -0
- package/pages/admin/users/[userId].phtml +57 -0
- package/pages/admin/users/index.js +20 -0
- package/pages/admin/users/index.phtml +37 -0
- package/pages/api/uploads/index.js +66 -0
- package/pages/api/user/settings.js +26 -0
- package/pages/auth/signout.js +14 -0
- package/pages/channels/[channelId].js +99 -0
- package/pages/channels/[channelId].phtml +173 -0
- package/pages/index.js +33 -0
- package/pages/invite/[token].js +10 -0
- package/pages/login/index.js +57 -0
- package/pages/login/index.phtml +29 -0
- package/pages/public/client/action-sheet.js +77 -0
- package/pages/public/client/app.js +38 -0
- package/pages/public/client/auth-tabs.js +13 -0
- package/pages/public/client/emoji-data.js +197 -0
- package/pages/public/client/islands/call.js +1770 -0
- package/pages/public/client/islands/sidebar.js +1197 -0
- package/pages/public/client/long-press.js +59 -0
- package/pages/public/client/modal.js +50 -0
- package/pages/public/client/router.js +87 -0
- package/pages/public/client/rtc-peer-manager.js +344 -0
- package/pages/public/client/settings-sync.js +76 -0
- package/pages/public/client/shared/messages.js +147 -0
- package/pages/public/client/swipe-nav.js +98 -0
- package/pages/public/client/theme.js +27 -0
- package/pages/public/client/ws.js +71 -0
- package/pages/public/favicon.ico +0 -0
- package/pages/public/favicon.png +0 -0
- package/pages/public/icon.png +0 -0
- package/pages/public/manifest.json +11 -0
- package/pages/public/sw.js +38 -0
- package/pages/public/themes/base.css +1786 -0
- package/pages/public/themes/dark.css +22 -0
- package/pages/public/themes/forest.css +22 -0
- package/pages/public/themes/light.css +23 -0
- package/pages/public/themes/ocean.css +22 -0
- package/pages/public/themes/rose.css +22 -0
- package/pages/registration/index.js +35 -0
- package/pages/registration/index.phtml +38 -0
- package/pages/uploads/[uploadId]/[filename].js +45 -0
- package/src/adapters/InMemoryAuthRepository.js +74 -0
- package/src/adapters/InMemoryChannelRepository.js +138 -0
- package/src/adapters/InMemoryDeliveryRepository.js +52 -0
- package/src/adapters/InMemoryFileStore.js +53 -0
- package/src/adapters/InMemoryHubRepository.js +85 -0
- package/src/adapters/InMemoryMessageRepository.js +35 -0
- package/src/adapters/InMemoryReactionRepository.js +45 -0
- package/src/adapters/InMemorySearchRepository.js +37 -0
- package/src/adapters/InMemorySignalingRepository.js +35 -0
- package/src/adapters/InMemoryUploadRepository.js +36 -0
- package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
- package/src/adapters/LocalFileStore.js +40 -0
- package/src/adapters/SqliteAuthRepository.js +184 -0
- package/src/adapters/SqliteChannelRepository.js +149 -0
- package/src/adapters/SqliteDeliveryRepository.js +53 -0
- package/src/adapters/SqliteHubRepository.js +99 -0
- package/src/adapters/SqliteMessageRepository.js +90 -0
- package/src/adapters/SqlitePushRepository.js +39 -0
- package/src/adapters/SqliteReactionRepository.js +50 -0
- package/src/adapters/SqliteSearchRepository.js +42 -0
- package/src/adapters/SqliteSignalingRepository.js +50 -0
- package/src/adapters/SqliteUploadRepository.js +34 -0
- package/src/adapters/SqliteUserSettingsRepository.js +23 -0
- package/src/adminAuth.js +25 -0
- package/src/config.js +11 -0
- package/src/context.js +77 -0
- package/src/core/dm.js +10 -0
- package/src/core/mentions.js +27 -0
- package/src/core/messages.js +21 -0
- package/src/core/reactions.js +6 -0
- package/src/core/roles.js +5 -0
- package/src/core/uploads.js +107 -0
- package/src/db/initDb.js +225 -0
- package/src/db/openDb.js +18 -0
- package/src/db/runMigrations.js +45 -0
- package/src/db/transaction.js +11 -0
- package/src/ports/IFileStore.js +34 -0
- package/src/services/AuthService.js +208 -0
- package/src/services/BotService.js +148 -0
- package/src/services/ChannelService.js +176 -0
- package/src/services/DeliveryService.js +28 -0
- package/src/services/HubService.js +133 -0
- package/src/services/MessageService.js +122 -0
- package/src/services/NotificationService.js +45 -0
- package/src/services/PresenceService.js +55 -0
- package/src/services/ReactionService.js +57 -0
- package/src/services/SearchService.js +21 -0
- package/src/services/SignalingService.js +177 -0
- package/src/services/UploadService.js +111 -0
- package/src/services/UserSettingsService.js +30 -0
- package/src/services/WebPushService.js +217 -0
- package/src/util/crypto.js +21 -0
- package/src/util/errors.js +14 -0
- package/src/util/ids.js +3 -0
- package/src/util/logger.js +21 -0
- package/src/ws/ChatServer.js +478 -0
- package/src/ws/handlers/authHandlers.js +152 -0
- package/src/ws/handlers/channelHandlers.js +166 -0
- package/src/ws/handlers/hubHandlers.js +82 -0
- package/src/ws/handlers/messageHandlers.js +88 -0
- package/src/ws/handlers/pushHandlers.js +25 -0
- package/src/ws/handlers/reactionHandlers.js +27 -0
- package/src/ws/handlers/rtcHandlers.js +126 -0
- 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>
|
package/pages/_layout.js
ADDED
|
@@ -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,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
|
+
}
|