@meith/web 0.31.0 → 0.33.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 (174) hide show
  1. package/app/(board)/[slug]/feed.xml/route.ts +5 -3
  2. package/app/(board)/[slug]/new/page.tsx +5 -0
  3. package/app/(board)/[slug]/page.tsx +7 -1
  4. package/app/(board)/discover/[view]/page.tsx +6 -1
  5. package/app/(board)/member/[id]/page.tsx +3 -5
  6. package/app/(board)/member/[id]/reputation/page.tsx +8 -15
  7. package/app/(board)/members/page.tsx +3 -8
  8. package/app/(board)/messages/[id]/page.tsx +3 -7
  9. package/app/(board)/messages/page.tsx +3 -5
  10. package/app/(board)/modcp/ip/page.tsx +3 -5
  11. package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
  12. package/app/(board)/moderation/reports/page.tsx +53 -15
  13. package/app/(board)/moderation/warn/page.tsx +3 -6
  14. package/app/(board)/notifications/page.tsx +5 -10
  15. package/app/(board)/notifications/preferences/page.tsx +8 -0
  16. package/app/(board)/online/page.tsx +12 -6
  17. package/app/(board)/staff/page.tsx +4 -5
  18. package/app/(board)/stats/page.tsx +10 -3
  19. package/app/(board)/subscriptions/page.tsx +5 -10
  20. package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
  21. package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
  22. package/app/(board)/thread/[slug]/page.tsx +49 -11
  23. package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
  24. package/app/(board)/unsubscribe/page.tsx +8 -11
  25. package/app/(board)/usercp/contacts/page.tsx +3 -5
  26. package/app/(board)/usercp/drafts/page.tsx +70 -0
  27. package/app/(board)/usercp/options/page.tsx +8 -1
  28. package/app/(board)/usercp/security/page.tsx +16 -0
  29. package/app/admin/antispam/page.tsx +4 -12
  30. package/app/admin/content/attachments/page.tsx +3 -6
  31. package/app/admin/content/page.tsx +3 -13
  32. package/app/admin/forums/[id]/permissions/page.tsx +9 -12
  33. package/app/admin/groups/page.tsx +7 -9
  34. package/app/admin/layout.tsx +23 -8
  35. package/app/admin/page.tsx +12 -6
  36. package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
  37. package/app/admin/plugins/page.tsx +3 -6
  38. package/app/admin/users/[id]/page.tsx +5 -11
  39. package/app/admin/users/prune/page.tsx +4 -12
  40. package/app/admin/webhooks/page.tsx +144 -0
  41. package/app/api/subscribe/thread/[id]/route.ts +53 -0
  42. package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
  43. package/app/atom.xml/route.ts +2 -2
  44. package/app/auth/sso/[provider]/callback/route.ts +10 -0
  45. package/app/feed.xml/route.ts +2 -2
  46. package/app/offline/route.ts +98 -0
  47. package/next.config.mjs +1 -0
  48. package/package.json +49 -48
  49. package/public/sw.js +42 -2
  50. package/src/components/account/draft-forms.tsx +39 -0
  51. package/src/components/account/feed-token.tsx +95 -0
  52. package/src/components/account/notification-forms.tsx +37 -2
  53. package/src/components/account/relation-forms.tsx +3 -2
  54. package/src/components/account/subscription-forms.tsx +20 -6
  55. package/src/components/account/usercp-forms.tsx +44 -0
  56. package/src/components/admin/admin-forms.tsx +57 -9
  57. package/src/components/admin/api-token-forms.tsx +11 -7
  58. package/src/components/admin/ban-filter-forms.tsx +15 -11
  59. package/src/components/admin/content-forms.tsx +31 -14
  60. package/src/components/admin/forum-forms.tsx +241 -129
  61. package/src/components/admin/forum-tree.tsx +5 -7
  62. package/src/components/admin/navigation-forms.tsx +43 -26
  63. package/src/components/admin/navigation-tree.tsx +8 -13
  64. package/src/components/admin/oklch-picker.tsx +2 -1
  65. package/src/components/admin/theme-changes.tsx +3 -2
  66. package/src/components/admin/theme-forms.tsx +3 -2
  67. package/src/components/admin/webhook-forms.tsx +137 -0
  68. package/src/components/auth/register-form.tsx +4 -7
  69. package/src/components/board/live-region.tsx +3 -1
  70. package/src/components/content/attachment-field.tsx +93 -22
  71. package/src/components/content/combobox-keys.ts +38 -0
  72. package/src/components/content/edit-post-form.tsx +75 -1
  73. package/src/components/content/markdown-editor.tsx +29 -86
  74. package/src/components/content/mention-combobox.tsx +137 -0
  75. package/src/components/content/multiquote-button.tsx +15 -12
  76. package/src/components/content/new-thread-form.tsx +95 -17
  77. package/src/components/content/poll-edit-form.tsx +61 -39
  78. package/src/components/content/poll-vote-form.tsx +91 -0
  79. package/src/components/content/poll.tsx +12 -48
  80. package/src/components/content/progressive-marker.tsx +13 -0
  81. package/src/components/content/recipient-segment.ts +39 -0
  82. package/src/components/content/reply-form.tsx +9 -1
  83. package/src/components/content/thanks-button.tsx +12 -7
  84. package/src/components/messages/message-forms.tsx +53 -43
  85. package/src/components/messages/recipient-field.tsx +88 -0
  86. package/src/components/moderation/inline-moderation-form.tsx +2 -0
  87. package/src/components/moderation/modcp-shell.tsx +17 -3
  88. package/src/components/moderation/queue-form.tsx +3 -6
  89. package/src/components/moderation/report-forms.tsx +24 -5
  90. package/src/components/moderation/thread-tools-form.tsx +2 -0
  91. package/src/components/shell/board-notice.tsx +22 -1
  92. package/src/components/shell/confirm-dialog.tsx +93 -0
  93. package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
  94. package/src/components/shell/notice-toast.tsx +40 -0
  95. package/src/components/shell/notification-menu.tsx +5 -3
  96. package/src/components/shell/page-shell.tsx +3 -0
  97. package/src/components/shell/scheme-toggle.tsx +72 -0
  98. package/src/components/shell/theme-switcher.tsx +16 -36
  99. package/src/components/shell/view-tabs.tsx +1 -1
  100. package/src/server/admin-actions.ts +118 -49
  101. package/src/server/admin-settings-actions.ts +5 -0
  102. package/src/server/admin.ts +70 -2
  103. package/src/server/api/search.ts +8 -2
  104. package/src/server/api-token-actions.ts +4 -0
  105. package/src/server/appearance-actions.ts +21 -0
  106. package/src/server/attachments.ts +35 -0
  107. package/src/server/auth-actions.ts +2 -0
  108. package/src/server/auth-form-state.ts +28 -0
  109. package/src/server/auto-watch.ts +31 -0
  110. package/src/server/ban-filter-admin-actions.ts +5 -0
  111. package/src/server/confirm.ts +19 -0
  112. package/src/server/content-actions.ts +129 -19
  113. package/src/server/content-admin-actions.ts +31 -0
  114. package/src/server/cookies.ts +18 -0
  115. package/src/server/discovery.ts +11 -1
  116. package/src/server/feed-routes.ts +13 -4
  117. package/src/server/feed-token-actions.ts +61 -0
  118. package/src/server/feed-token.ts +78 -0
  119. package/src/server/fixture-post-repo.ts +18 -0
  120. package/src/server/forum-admin-actions.ts +23 -16
  121. package/src/server/forum-admin.ts +10 -1
  122. package/src/server/inline-moderation-actions.ts +6 -0
  123. package/src/server/message-actions.ts +9 -0
  124. package/src/server/navigation-admin-actions.ts +6 -0
  125. package/src/server/plugin-admin.ts +7 -2
  126. package/src/server/plugin-pages.ts +50 -0
  127. package/src/server/plugin-panel.ts +34 -1
  128. package/src/server/plugin-routes.ts +8 -0
  129. package/src/server/plugin-view.tsx +46 -7
  130. package/src/server/poll-actions.ts +13 -1
  131. package/src/server/presence.ts +23 -0
  132. package/src/server/push-actions.ts +9 -0
  133. package/src/server/reply-core.ts +8 -1
  134. package/src/server/report-actions.ts +13 -2
  135. package/src/server/reputation-actions.ts +12 -1
  136. package/src/server/search-page.ts +8 -2
  137. package/src/server/search.ts +18 -5
  138. package/src/server/session-cookies.ts +18 -0
  139. package/src/server/subscription-actions.ts +9 -0
  140. package/src/server/thread-core.ts +10 -2
  141. package/src/server/thread-tool-actions.ts +6 -0
  142. package/src/server/unread-goto.ts +28 -0
  143. package/src/server/upgrade-notice.ts +1 -1
  144. package/src/server/usercp-actions.ts +31 -1
  145. package/src/server/viewer-preferences.ts +5 -0
  146. package/src/server/webhook-actions.ts +98 -0
  147. package/src/server/webhooks-admin.ts +122 -0
  148. package/src/styles/globals.css +1 -1
  149. package/src/theme/contract.fixture.ts +36 -5
  150. package/src/view/account-copy.ts +39 -0
  151. package/src/view/admin-copy.ts +4 -9
  152. package/src/view/admin-nav.ts +6 -0
  153. package/src/view/admin-panel-copy.ts +29 -1
  154. package/src/view/attachments.ts +33 -1
  155. package/src/view/board-index.ts +31 -2
  156. package/src/view/board-title.ts +1 -0
  157. package/src/view/content-copy.ts +22 -12
  158. package/src/view/copy.ts +17 -0
  159. package/src/view/discovery-view.ts +2 -1
  160. package/src/view/drafts.ts +45 -0
  161. package/src/view/editor-toolbar.ts +14 -0
  162. package/src/view/forum-display.ts +39 -7
  163. package/src/view/modcp-nav.ts +32 -0
  164. package/src/view/moderation-copy.ts +20 -0
  165. package/src/view/notifications.ts +1 -0
  166. package/src/view/plugin-panel.ts +13 -0
  167. package/src/view/poll-vote.ts +46 -0
  168. package/src/view/post-link.ts +8 -1
  169. package/src/view/progressive-enhancement.ts +5 -0
  170. package/src/view/shell.ts +2 -1
  171. package/src/view/subscriptions.ts +1 -0
  172. package/src/view/thread-view.ts +41 -2
  173. package/src/view/usercp-nav.ts +6 -0
  174. package/src/view/usercp.ts +14 -0
@@ -1,5 +1,7 @@
1
1
  import type { Metadata } from 'next'
2
2
 
3
+ import { TextLink } from '@meith/ui'
4
+
3
5
  import { CreateGroupForm } from '@/components/admin/group-forms'
4
6
  import { PANEL_CARD, PANEL_LIST, PANEL_ROW, PanelActionLink } from '@/components/shell/panel-list'
5
7
  import { PanelPage } from '@/components/shell/panel-page'
@@ -36,12 +38,7 @@ export default async function AdminGroupsPage() {
36
38
  <>
37
39
  {translator.t('adminGroups.ledeBefore')} <em>{translator.t('adminGroups.default')}</em>
38
40
  {translator.t('adminGroups.ledeBetween')}{' '}
39
- <a
40
- href="/admin/forums"
41
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
42
- >
43
- {translator.t('adminGroups.forumPermissions')}
44
- </a>{' '}
41
+ <TextLink href="/admin/forums">{translator.t('adminGroups.forumPermissions')}</TextLink>{' '}
45
42
  {translator.t('adminGroups.ledeEnd')}
46
43
  </>
47
44
  }
@@ -68,13 +65,14 @@ export default async function AdminGroupsPage() {
68
65
  {group.description === null ? '' : ` · ${group.description}`}
69
66
  </span>
70
67
  </span>
71
- <a
68
+ <TextLink
72
69
  href={`/admin/groups/${group.id}`}
73
70
  aria-label={translator.t('adminGroups.editLabel', { title: group.title })}
74
- className="shrink-0 text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
71
+ size="sm"
72
+ className="shrink-0"
75
73
  >
76
74
  {translator.t('adminGroups.edit')}
77
- </a>
75
+ </TextLink>
78
76
  </li>
79
77
  ))}
80
78
  </ul>
@@ -3,12 +3,17 @@ import { notFound } from 'next/navigation'
3
3
 
4
4
  import { ADMIN_IDLE_MINUTES } from '@meith/admin'
5
5
 
6
- import { AdminSignInForm, AdminSignOutForm } from '@/components/admin/admin-forms'
6
+ import {
7
+ AdminSecondFactorForm,
8
+ AdminSignInForm,
9
+ AdminSignOutForm,
10
+ } from '@/components/admin/admin-forms'
7
11
  import { AdminNav } from '@/components/admin/admin-nav'
8
12
  import { PanelShell } from '@/components/shell/panel-shell'
9
- import { askForPassword, resolveAdmin } from '@/server/admin'
13
+ import { askForPassword, pendingAdminSecondFactor, resolveAdmin } from '@/server/admin'
10
14
  import { getActor } from '@/server/context'
11
15
  import { getTranslator, tr } from '@/server/i18n'
16
+ import { twoFactorState } from '@/server/two-factor'
12
17
  import { adminFormsCopy } from '@/view/admin-panel-copy'
13
18
  import { buildPanelLinks } from '@/view/shell'
14
19
 
@@ -22,18 +27,28 @@ export default async function AdminLayout({ children }: { children: React.ReactN
22
27
  if ('denied' in resolved) {
23
28
  if (!askForPassword(resolved.denied)) notFound()
24
29
 
30
+ const copy = adminFormsCopy(await getTranslator())
31
+ const pending = await pendingAdminSecondFactor()
32
+
25
33
  return (
26
34
  <main
27
35
  id="board-content"
28
36
  tabIndex={-1}
29
37
  className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 py-12"
30
38
  >
31
- <AdminSignInForm
32
- next="/admin"
33
- reason={resolved.denied === 'expired' ? 'expired' : null}
34
- idleMinutes={ADMIN_IDLE_MINUTES}
35
- copy={adminFormsCopy(await getTranslator())}
36
- />
39
+ {pending === null ? (
40
+ <AdminSignInForm
41
+ next="/admin"
42
+ reason={resolved.denied === 'expired' ? 'expired' : null}
43
+ idleMinutes={ADMIN_IDLE_MINUTES}
44
+ copy={copy}
45
+ />
46
+ ) : (
47
+ <AdminSecondFactorForm
48
+ recoveryCodesLeft={(await twoFactorState(pending.userId)).recoveryCodesLeft}
49
+ copy={copy}
50
+ />
51
+ )}
37
52
  <a href="/" className="text-sm text-muted-foreground hover:text-foreground">
38
53
  {await tr('adminPanel.cancel')}
39
54
  </a>
@@ -1,4 +1,5 @@
1
1
  import { ModerationQueue } from '@meith/moderation'
2
+ import { PLUGIN_CARD } from '@meith/plugin-kit'
2
3
  import {
3
4
  Alert,
4
5
  AlertDescription,
@@ -12,6 +13,7 @@ import {
12
13
  Empty,
13
14
  EmptyDescription,
14
15
  EmptyTitle,
16
+ TextLink,
15
17
  } from '@meith/ui'
16
18
 
17
19
  import { PanelSectionGrid, PanelWaitingList } from '@/components/shell/panel-overview'
@@ -20,6 +22,7 @@ import { adminPageContext } from '@/server/admin'
20
22
  import { getContainer } from '@/server/container'
21
23
  import { getActor } from '@/server/context'
22
24
  import { getTranslator, tr } from '@/server/i18n'
25
+ import { boardRegion } from '@/server/plugin-view'
23
26
  import { hasReportScope, resolveReportScope } from '@/server/report-scope'
24
27
  import { readTotals } from '@/server/stats'
25
28
  import { pendingUpgradeNotice } from '@/server/upgrade-notice'
@@ -57,6 +60,8 @@ export default async function AdminHomePage() {
57
60
 
58
61
  const allClear = pending === 0 && openReports === 0 && upgradeNotice === null
59
62
 
63
+ const dashboard = await boardRegion('admin.dashboard', actor, PLUGIN_CARD)
64
+
60
65
  return (
61
66
  <PanelPage
62
67
  title={await tr('page.overview')}
@@ -157,6 +162,12 @@ export default async function AdminHomePage() {
157
162
  </PanelSection>
158
163
  )}
159
164
 
165
+ {dashboard !== null && (
166
+ <PanelSection id="plugins-heading" title={await tr('page.plugins')}>
167
+ <div className="flex flex-col gap-4">{dashboard}</div>
168
+ </PanelSection>
169
+ )}
170
+
160
171
  <PanelSection id="activity-heading" title={await tr('page.latest-activity')}>
161
172
  <Card>
162
173
  {recent.length === 0 ? (
@@ -186,12 +197,7 @@ export default async function AdminHomePage() {
186
197
  ))}
187
198
  </CardRows>
188
199
  <CardFooter>
189
- <a
190
- href="/admin/log"
191
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
192
- >
193
- {await tr('page.whole-log')}
194
- </a>
200
+ <TextLink href="/admin/log">{await tr('page.whole-log')}</TextLink>
195
201
  </CardFooter>
196
202
  </>
197
203
  )}
@@ -1,6 +1,8 @@
1
1
  import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
+ import { TextLink } from '@meith/ui'
5
+
4
6
  import { PluginEnableForm, PluginSettingsForm } from '@/components/admin/plugin-forms'
5
7
  import { PANEL_CARD, PANEL_NOTE } from '@/components/shell/panel-list'
6
8
  import { PanelPage } from '@/components/shell/panel-page'
@@ -219,12 +221,7 @@ export default async function AdminPluginPage({
219
221
  <h2 className="font-heading text-lg font-semibold">{await tr('page.scheduled-tasks')}</h2>
220
222
  <p className="text-sm text-muted-foreground">
221
223
  {t.t('adminPluginDetail.tasksHintBefore')}{' '}
222
- <a
223
- href="/admin/system"
224
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
225
- >
226
- {t.t('page.system-health')}
227
- </a>{' '}
224
+ <TextLink href="/admin/system">{t.t('page.system-health')}</TextLink>{' '}
228
225
  {t.t('adminPluginDetail.tasksHintAfter')}
229
226
  </p>
230
227
  <ul className="flex flex-col divide-y divide-border text-sm">
@@ -232,7 +229,9 @@ export default async function AdminPluginPage({
232
229
  <li key={task.id} className="flex justify-between gap-3 py-2">
233
230
  <code className="text-xs">{task.registeredId}</code>
234
231
  <span className="text-xs text-muted-foreground">
235
- {t.t('adminPluginDetail.every', { seconds: task.intervalSeconds })}
232
+ {task.schedule === null
233
+ ? t.t('adminPluginDetail.every', { seconds: task.intervalSeconds })
234
+ : t.t('adminPluginDetail.schedule', { schedule: task.schedule })}
236
235
  </span>
237
236
  </li>
238
237
  ))}
@@ -1,6 +1,6 @@
1
1
  import type { Metadata } from 'next'
2
2
 
3
- import { cn } from '@meith/ui'
3
+ import { cn, TextLink } from '@meith/ui'
4
4
 
5
5
  import { MarketplaceRefreshForm } from '@/components/admin/marketplace-forms'
6
6
  import { PluginEnableForm, PluginHealthResetForm } from '@/components/admin/plugin-forms'
@@ -128,12 +128,9 @@ export default async function AdminPluginsPage() {
128
128
  </span>
129
129
 
130
130
  <span className="flex shrink-0 items-center gap-3">
131
- <a
132
- href={`/admin/plugins/${plugin.key}`}
133
- className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
134
- >
131
+ <TextLink href={`/admin/plugins/${plugin.key}`} size="sm">
135
132
  {t.t('adminPlugins.details')}
136
- </a>
133
+ </TextLink>
137
134
  {plugin.durableHealth?.disabledAt != null && (
138
135
  <PluginHealthResetForm pluginKey={plugin.key} copy={copy} />
139
136
  )}
@@ -1,6 +1,8 @@
1
1
  import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
+ import { TextLink } from '@meith/ui'
5
+
4
6
  import {
5
7
  BanMemberForm,
6
8
  ClearSecondFactorForm,
@@ -151,12 +153,9 @@ export default async function AdminMemberPage({ params }: { params: Promise<{ id
151
153
  <h2 className="font-heading text-lg font-semibold">{translator.t('adminUsers.merge')}</h2>
152
154
  <p className="text-sm text-muted-foreground">{translator.t('adminUsers.mergeLede')}</p>
153
155
  <p>
154
- <a
155
- href={`/admin/users/${member.id}/merge`}
156
- className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
157
- >
156
+ <TextLink href={`/admin/users/${member.id}/merge`} size="sm">
158
157
  {translator.t('adminUsers.mergeLink', { username: member.username })}
159
- </a>
158
+ </TextLink>
160
159
  </p>
161
160
  </section>
162
161
 
@@ -185,12 +184,7 @@ export default async function AdminMemberPage({ params }: { params: Promise<{ id
185
184
  <ul className="flex flex-col gap-1 text-sm">
186
185
  {view.sharedNetwork.map((row) => (
187
186
  <li key={row.id}>
188
- <a
189
- href={`/admin/users/${row.id}`}
190
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
191
- >
192
- {row.username}
193
- </a>{' '}
187
+ <TextLink href={`/admin/users/${row.id}`}>{row.username}</TextLink>{' '}
194
188
  <span className="text-xs text-muted-foreground">
195
189
  {row.state === 'active'
196
190
  ? ''
@@ -1,5 +1,7 @@
1
1
  import type { Metadata } from 'next'
2
2
 
3
+ import { TextLink } from '@meith/ui'
4
+
3
5
  import { PruneForm, SelectedPruneForm } from '@/components/admin/user-forms'
4
6
  import { PANEL_CARD, PANEL_NOTE } from '@/components/shell/panel-list'
5
7
  import { PanelPage } from '@/components/shell/panel-page'
@@ -72,12 +74,7 @@ export default async function AdminPrunePage({
72
74
  <ul className="flex flex-col gap-1 text-sm text-muted-foreground">
73
75
  {selectedPreview.sample.map((row) => (
74
76
  <li key={row.id}>
75
- <a
76
- href={`/admin/users/${row.id}`}
77
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
78
- >
79
- {row.username}
80
- </a>{' '}
77
+ <TextLink href={`/admin/users/${row.id}`}>{row.username}</TextLink>{' '}
81
78
  {translator.t('adminUsers.pruneMemberMeta', {
82
79
  email: row.email,
83
80
  date: row.createdAt.toISOString().slice(0, 10),
@@ -150,12 +147,7 @@ export default async function AdminPrunePage({
150
147
  <ul className="flex flex-col gap-1 text-sm text-muted-foreground">
151
148
  {preview.sample.map((row) => (
152
149
  <li key={row.id}>
153
- <a
154
- href={`/admin/users/${row.id}`}
155
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
156
- >
157
- {row.username}
158
- </a>{' '}
150
+ <TextLink href={`/admin/users/${row.id}`}>{row.username}</TextLink>{' '}
159
151
  {translator.t('adminUsers.pruneMemberMeta', {
160
152
  email: row.email,
161
153
  date: row.createdAt.toISOString().slice(0, 10),
@@ -0,0 +1,144 @@
1
+ import type { Metadata } from 'next'
2
+
3
+ import {
4
+ CreateWebhookForm,
5
+ DeleteWebhookForm,
6
+ ToggleWebhookForm,
7
+ } from '@/components/admin/webhook-forms'
8
+ import { PANEL_CARD } from '@/components/shell/panel-list'
9
+ import { PanelPage } from '@/components/shell/panel-page'
10
+ import { adminPageContext } from '@/server/admin'
11
+ import { getTranslator, tr } from '@/server/i18n'
12
+ import { buildWebhookView } from '@/server/webhooks-admin'
13
+ import { webhookFormsCopy } from '@/view/admin-panel-copy'
14
+ import { formatTime } from '@/view/time'
15
+
16
+ export async function generateMetadata(): Promise<Metadata> {
17
+ return { title: await tr('page.webhooks') }
18
+ }
19
+
20
+ export default async function AdminWebhooksPage() {
21
+ if ((await adminPageContext()) === null) return null
22
+
23
+ const now = new Date()
24
+ const translator = await getTranslator()
25
+ const copy = webhookFormsCopy(translator)
26
+ const view = await buildWebhookView()
27
+
28
+ if (view === null) {
29
+ return (
30
+ <PanelPage title={await tr('page.webhooks')}>
31
+ <p className="mt-2 text-sm text-muted-foreground">
32
+ {await tr('page.this-board-running-in-memory-sample-c')}
33
+ </p>
34
+ </PanelPage>
35
+ )
36
+ }
37
+
38
+ const statusLabel: Record<string, string> = {
39
+ delivered: translator.t('adminWebhooks.delivered'),
40
+ pending: translator.t('adminWebhooks.pending'),
41
+ dead: translator.t('adminWebhooks.dead'),
42
+ }
43
+
44
+ return (
45
+ <PanelPage
46
+ title={await tr('page.webhooks')}
47
+ lede={translator.t('adminWebhooks.lede')}
48
+ gap="loose"
49
+ >
50
+ <section className={PANEL_CARD}>
51
+ <h2 className="font-heading text-lg font-semibold">{translator.t('adminWebhooks.add')}</h2>
52
+ <CreateWebhookForm topics={view.topics} formats={view.formats} copy={copy} />
53
+ </section>
54
+
55
+ <section className={PANEL_CARD}>
56
+ <h2 className="font-heading text-lg font-semibold">
57
+ {translator.t('adminWebhooks.existing')}
58
+ </h2>
59
+
60
+ {view.subscriptions.length === 0 ? (
61
+ <p className="text-sm text-muted-foreground">{translator.t('adminWebhooks.empty')}</p>
62
+ ) : (
63
+ <ul className="flex flex-col gap-4">
64
+ {view.subscriptions.map((subscription) => (
65
+ <li
66
+ key={subscription.id}
67
+ className="flex flex-col gap-3 rounded-md border border-border p-3"
68
+ >
69
+ <div className="flex flex-wrap items-center justify-between gap-2">
70
+ <code className="overflow-x-auto font-mono text-xs">{subscription.url}</code>
71
+ <span
72
+ className={
73
+ subscription.active ? 'text-moderation-approved' : 'text-muted-foreground'
74
+ }
75
+ >
76
+ {subscription.active
77
+ ? translator.t('adminWebhooks.state.active')
78
+ : translator.t('adminWebhooks.state.paused')}
79
+ </span>
80
+ </div>
81
+
82
+ <div className="flex flex-wrap gap-2">
83
+ {subscription.topics.map((topic) => (
84
+ <code key={topic} className="font-mono text-xs text-muted-foreground">
85
+ {topic}
86
+ </code>
87
+ ))}
88
+ </div>
89
+
90
+ <p className="text-xs text-muted-foreground">
91
+ <code className="font-mono">{subscription.format}</code>
92
+ {' · '}
93
+ {statusLabel.delivered}: {subscription.delivered}
94
+ {' · '}
95
+ {statusLabel.pending}: {subscription.pending}
96
+ {' · '}
97
+ {statusLabel.dead}: {subscription.dead}
98
+ </p>
99
+
100
+ {subscription.recent.length > 0 && (
101
+ <details>
102
+ <summary className="cursor-pointer text-xs font-medium">
103
+ {translator.t('adminWebhooks.recent')}
104
+ </summary>
105
+ <div className="mt-2 overflow-x-auto">
106
+ <table className="w-full border-collapse text-xs">
107
+ <tbody>
108
+ {subscription.recent.map((entry) => (
109
+ <tr key={entry.id} className="border-b border-border align-top">
110
+ <td className="py-1 pr-3 font-mono">{entry.topic}</td>
111
+ <td className="py-1 pr-3">
112
+ {statusLabel[entry.status] ?? entry.status}
113
+ </td>
114
+ <td className="py-1 pr-3">{entry.attempts}</td>
115
+ <td className="py-1 pr-3 font-mono">
116
+ {entry.lastStatusCode ?? entry.lastError ?? ''}
117
+ </td>
118
+ <td className="py-1">
119
+ {formatTime(entry.createdAt, now, translator).label}
120
+ </td>
121
+ </tr>
122
+ ))}
123
+ </tbody>
124
+ </table>
125
+ </div>
126
+ </details>
127
+ )}
128
+
129
+ <div className="flex flex-wrap items-center gap-3">
130
+ <ToggleWebhookForm
131
+ webhookId={subscription.id}
132
+ active={subscription.active}
133
+ copy={copy}
134
+ />
135
+ <DeleteWebhookForm webhookId={subscription.id} copy={copy} />
136
+ </div>
137
+ </li>
138
+ ))}
139
+ </ul>
140
+ )}
141
+ </section>
142
+ </PanelPage>
143
+ )
144
+ }
@@ -0,0 +1,53 @@
1
+ import { ALL_THREAD_AUTHORS, PUBLIC_CONTENT } from '@meith/core'
2
+ import { currentRequestId } from '@meith/core/logger'
3
+ import { canHoldThreads } from '@meith/forums'
4
+
5
+ import { getContainer } from '@/server/container'
6
+ import { getActor } from '@/server/context'
7
+ import { emitEvent } from '@/server/plugin-view'
8
+ import { crossOriginRefusal, isSameOrigin } from '@/server/same-origin'
9
+ import { seeOther } from '@/server/see-other'
10
+
11
+ function idFrom(value: string): number | null {
12
+ if (!/^[1-9]\d*$/.test(value)) return null
13
+ const id = Number(value)
14
+ return Number.isSafeInteger(id) ? id : null
15
+ }
16
+
17
+ export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
18
+ if (!isSameOrigin(request)) return crossOriginRefusal()
19
+
20
+ const threadId = idFrom((await params).id)
21
+ const actor = await getActor()
22
+ const { forums, threads, authorizer, subscriptions } = getContainer()
23
+ if (threadId === null || actor.userId === null || subscriptions === null) return seeOther('/')
24
+
25
+ const thread = await threads.findById(threadId, PUBLIC_CONTENT, ALL_THREAD_AUTHORS)
26
+ if (!thread) return seeOther('/')
27
+ const forum = await forums.findById(thread.forumId)
28
+ if (!forum || !canHoldThreads(forum.type)) return seeOther('/')
29
+ const matrix = await authorizer.forumMatrix(actor, forum.id)
30
+ const target = {
31
+ ...(await authorizer.moderatorTargetIn(actor, forum.id, matrix)),
32
+ threadAuthorId: thread.authorUserId,
33
+ }
34
+ if (!authorizer.can(actor, 'thread.view', target)) return seeOther('/')
35
+
36
+ const subscribed = await subscriptions.subscribe({
37
+ userId: actor.userId,
38
+ target: 'thread',
39
+ targetId: threadId,
40
+ mode: 'instant',
41
+ at: new Date(),
42
+ })
43
+
44
+ if (subscribed) {
45
+ await emitEvent(
46
+ 'subscription.changed',
47
+ { userId: actor.userId, target: 'thread', targetId: threadId, subscribed: true },
48
+ { requestId: currentRequestId() ?? null },
49
+ )
50
+ }
51
+
52
+ return seeOther(`/thread/${threadId}-${thread.slug}`)
53
+ }
@@ -0,0 +1,42 @@
1
+ import { ALL_THREAD_AUTHORS, PUBLIC_CONTENT } from '@meith/core'
2
+ import { currentRequestId } from '@meith/core/logger'
3
+
4
+ import { getContainer } from '@/server/container'
5
+ import { getActor } from '@/server/context'
6
+ import { emitEvent } from '@/server/plugin-view'
7
+ import { crossOriginRefusal, isSameOrigin } from '@/server/same-origin'
8
+ import { seeOther } from '@/server/see-other'
9
+
10
+ function idFrom(value: string): number | null {
11
+ if (!/^[1-9]\d*$/.test(value)) return null
12
+ const id = Number(value)
13
+ return Number.isSafeInteger(id) ? id : null
14
+ }
15
+
16
+ export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
17
+ if (!isSameOrigin(request)) return crossOriginRefusal()
18
+
19
+ const threadId = idFrom((await params).id)
20
+ const actor = await getActor()
21
+ const { threads, subscriptions } = getContainer()
22
+ if (threadId === null || actor.userId === null || subscriptions === null) return seeOther('/')
23
+
24
+ const thread = await threads.findById(threadId, PUBLIC_CONTENT, ALL_THREAD_AUTHORS)
25
+ if (!thread) return seeOther('/')
26
+
27
+ const unsubscribed = await subscriptions.unsubscribe({
28
+ userId: actor.userId,
29
+ target: 'thread',
30
+ targetId: threadId,
31
+ })
32
+
33
+ if (unsubscribed) {
34
+ await emitEvent(
35
+ 'subscription.changed',
36
+ { userId: actor.userId, target: 'thread', targetId: threadId, subscribed: false },
37
+ { requestId: currentRequestId() ?? null },
38
+ )
39
+ }
40
+
41
+ return seeOther(`/thread/${threadId}-${thread.slug}`)
42
+ }
@@ -2,6 +2,6 @@ import { boardFeed } from '@/server/feed-routes'
2
2
 
3
3
  export const dynamic = 'force-dynamic'
4
4
 
5
- export async function GET(): Promise<Response> {
6
- return boardFeed('atom', '/atom.xml')
5
+ export async function GET(request: Request): Promise<Response> {
6
+ return boardFeed('atom', '/atom.xml', request)
7
7
  }
@@ -23,6 +23,7 @@ import { isTopLevelNavigation } from '@/server/same-origin'
23
23
  import {
24
24
  clearHandshakeCookie,
25
25
  readHandshakeCookie,
26
+ setSecondFactorCookie,
26
27
  setSessionCookie,
27
28
  } from '@/server/session-cookies'
28
29
  import { decodeHandshake } from '@/server/sso-handshake'
@@ -42,6 +43,10 @@ function withReason(path: string, reason: string): string {
42
43
  return `${path}${path.includes('?') ? '&' : '?'}sso=${reason}`
43
44
  }
44
45
 
46
+ function verifyPath(next: string): string {
47
+ return next === '/' ? '/login/verify' : `/login/verify?next=${encodeURIComponent(next)}`
48
+ }
49
+
45
50
  export async function GET(
46
51
  request: NextRequest,
47
52
  context: { params: Promise<{ provider: string }> },
@@ -146,6 +151,11 @@ export async function GET(
146
151
  },
147
152
  })
148
153
 
154
+ if (outcome.status === 'second-factor') {
155
+ await setSecondFactorCookie(outcome.token, outcome.expiresAt)
156
+ return to(verifyPath(handshake.next))
157
+ }
158
+
149
159
  if (outcome.status === 'pending') {
150
160
  if (outcome.verificationToken === null) return to(withReason(home, 'approval'))
151
161
 
@@ -2,6 +2,6 @@ import { boardFeed } from '@/server/feed-routes'
2
2
 
3
3
  export const dynamic = 'force-dynamic'
4
4
 
5
- export async function GET(): Promise<Response> {
6
- return boardFeed('rss', '/feed.xml')
5
+ export async function GET(request: Request): Promise<Response> {
6
+ return boardFeed('rss', '/feed.xml', request)
7
7
  }