@storyshelf/app 0.5.3

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 (268) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/dist/app-types.d.mts +54 -0
  4. package/dist/asset-manifest.mjs +41 -0
  5. package/dist/asset-manifest.mjs.map +1 -0
  6. package/dist/asset-paths.mjs +23 -0
  7. package/dist/asset-paths.mjs.map +1 -0
  8. package/dist/assets/htmx.min.js +3304 -0
  9. package/dist/assets/mark.svg +1 -0
  10. package/dist/capture-setup.mjs +139 -0
  11. package/dist/capture-setup.mjs.map +1 -0
  12. package/dist/credential-keys.mjs +20 -0
  13. package/dist/credential-keys.mjs.map +1 -0
  14. package/dist/credentials.mjs +56 -0
  15. package/dist/credentials.mjs.map +1 -0
  16. package/dist/index.d.mts +18 -0
  17. package/dist/index.mjs +310 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/lifecycle.mjs +132 -0
  20. package/dist/lifecycle.mjs.map +1 -0
  21. package/dist/middleware/auth-gate.mjs +36 -0
  22. package/dist/middleware/auth-gate.mjs.map +1 -0
  23. package/dist/middleware/csrf.mjs +75 -0
  24. package/dist/middleware/csrf.mjs.map +1 -0
  25. package/dist/middleware/rate-limit.mjs +68 -0
  26. package/dist/middleware/rate-limit.mjs.map +1 -0
  27. package/dist/middleware/request-log.mjs +30 -0
  28. package/dist/middleware/request-log.mjs.map +1 -0
  29. package/dist/middleware/server-timing.mjs +39 -0
  30. package/dist/middleware/server-timing.mjs.map +1 -0
  31. package/dist/middleware/setup-gate.mjs +37 -0
  32. package/dist/middleware/setup-gate.mjs.map +1 -0
  33. package/dist/middleware/store-scope.mjs +33 -0
  34. package/dist/middleware/store-scope.mjs.map +1 -0
  35. package/dist/notify.d.mts +17 -0
  36. package/dist/notify.mjs +141 -0
  37. package/dist/notify.mjs.map +1 -0
  38. package/dist/pages/admin-system.mjs +243 -0
  39. package/dist/pages/admin-system.mjs.map +1 -0
  40. package/dist/pages/build-detail.mjs +392 -0
  41. package/dist/pages/build-detail.mjs.map +1 -0
  42. package/dist/pages/build-diff-actions.mjs +162 -0
  43. package/dist/pages/build-diff-actions.mjs.map +1 -0
  44. package/dist/pages/build-diff-comments.mjs +104 -0
  45. package/dist/pages/build-diff-comments.mjs.map +1 -0
  46. package/dist/pages/build-diff-header.mjs +117 -0
  47. package/dist/pages/build-diff-header.mjs.map +1 -0
  48. package/dist/pages/build-diff-model.mjs +48 -0
  49. package/dist/pages/build-diff-model.mjs.map +1 -0
  50. package/dist/pages/build-diff-nav.mjs +122 -0
  51. package/dist/pages/build-diff-nav.mjs.map +1 -0
  52. package/dist/pages/build-diff-shortcuts.mjs +78 -0
  53. package/dist/pages/build-diff-shortcuts.mjs.map +1 -0
  54. package/dist/pages/build-diff-stale.mjs +70 -0
  55. package/dist/pages/build-diff-stale.mjs.map +1 -0
  56. package/dist/pages/build-diff-viewer.mjs +70 -0
  57. package/dist/pages/build-diff-viewer.mjs.map +1 -0
  58. package/dist/pages/build-diff.mjs +110 -0
  59. package/dist/pages/build-diff.mjs.map +1 -0
  60. package/dist/pages/compute-jobs.mjs +165 -0
  61. package/dist/pages/compute-jobs.mjs.map +1 -0
  62. package/dist/pages/invite.mjs +91 -0
  63. package/dist/pages/invite.mjs.map +1 -0
  64. package/dist/pages/label-detail.mjs +154 -0
  65. package/dist/pages/label-detail.mjs.map +1 -0
  66. package/dist/pages/library.mjs +343 -0
  67. package/dist/pages/library.mjs.map +1 -0
  68. package/dist/pages/login.mjs +117 -0
  69. package/dist/pages/login.mjs.map +1 -0
  70. package/dist/pages/passkey-ceremony.mjs +97 -0
  71. package/dist/pages/passkey-ceremony.mjs.map +1 -0
  72. package/dist/pages/profile.mjs +364 -0
  73. package/dist/pages/profile.mjs.map +1 -0
  74. package/dist/pages/project-builds-table.mjs +151 -0
  75. package/dist/pages/project-builds-table.mjs.map +1 -0
  76. package/dist/pages/project-builds.mjs +143 -0
  77. package/dist/pages/project-builds.mjs.map +1 -0
  78. package/dist/pages/project-create.mjs +90 -0
  79. package/dist/pages/project-create.mjs.map +1 -0
  80. package/dist/pages/project-settings.mjs +95 -0
  81. package/dist/pages/project-settings.mjs.map +1 -0
  82. package/dist/pages/projects-card.mjs +194 -0
  83. package/dist/pages/projects-card.mjs.map +1 -0
  84. package/dist/pages/projects.mjs +129 -0
  85. package/dist/pages/projects.mjs.map +1 -0
  86. package/dist/pages/root.mjs +98 -0
  87. package/dist/pages/root.mjs.map +1 -0
  88. package/dist/pages/settings-general.mjs +138 -0
  89. package/dist/pages/settings-general.mjs.map +1 -0
  90. package/dist/pages/settings-labels.mjs +107 -0
  91. package/dist/pages/settings-labels.mjs.map +1 -0
  92. package/dist/pages/settings-members.mjs +192 -0
  93. package/dist/pages/settings-members.mjs.map +1 -0
  94. package/dist/pages/settings-notifications.mjs +240 -0
  95. package/dist/pages/settings-notifications.mjs.map +1 -0
  96. package/dist/pages/settings-status.mjs +137 -0
  97. package/dist/pages/settings-status.mjs.map +1 -0
  98. package/dist/pages/settings-tests.mjs +111 -0
  99. package/dist/pages/settings-tests.mjs.map +1 -0
  100. package/dist/pages/settings-tokens.mjs +90 -0
  101. package/dist/pages/settings-tokens.mjs.map +1 -0
  102. package/dist/pages/settings-webhooks.mjs +104 -0
  103. package/dist/pages/settings-webhooks.mjs.map +1 -0
  104. package/dist/pages/storybook.mjs +116 -0
  105. package/dist/pages/storybook.mjs.map +1 -0
  106. package/dist/retention-timer.mjs +65 -0
  107. package/dist/retention-timer.mjs.map +1 -0
  108. package/dist/routers/admin-pages.mjs +48 -0
  109. package/dist/routers/admin-pages.mjs.map +1 -0
  110. package/dist/routers/admin.mjs +113 -0
  111. package/dist/routers/admin.mjs.map +1 -0
  112. package/dist/routers/assets.mjs +64 -0
  113. package/dist/routers/assets.mjs.map +1 -0
  114. package/dist/routers/attempts.mjs +96 -0
  115. package/dist/routers/attempts.mjs.map +1 -0
  116. package/dist/routers/auth-sync.mjs +64 -0
  117. package/dist/routers/auth-sync.mjs.map +1 -0
  118. package/dist/routers/auth.mjs +236 -0
  119. package/dist/routers/auth.mjs.map +1 -0
  120. package/dist/routers/baseline-guard.mjs +83 -0
  121. package/dist/routers/baseline-guard.mjs.map +1 -0
  122. package/dist/routers/builds.handlers.mjs +217 -0
  123. package/dist/routers/builds.handlers.mjs.map +1 -0
  124. package/dist/routers/builds.mjs +381 -0
  125. package/dist/routers/builds.mjs.map +1 -0
  126. package/dist/routers/comments.mjs +109 -0
  127. package/dist/routers/comments.mjs.map +1 -0
  128. package/dist/routers/flash.mjs +35 -0
  129. package/dist/routers/flash.mjs.map +1 -0
  130. package/dist/routers/health-report.mjs +159 -0
  131. package/dist/routers/health-report.mjs.map +1 -0
  132. package/dist/routers/health.mjs +46 -0
  133. package/dist/routers/health.mjs.map +1 -0
  134. package/dist/routers/helpers.mjs +110 -0
  135. package/dist/routers/helpers.mjs.map +1 -0
  136. package/dist/routers/htmx.mjs +24 -0
  137. package/dist/routers/htmx.mjs.map +1 -0
  138. package/dist/routers/labels.mjs +118 -0
  139. package/dist/routers/labels.mjs.map +1 -0
  140. package/dist/routers/media.mjs +53 -0
  141. package/dist/routers/media.mjs.map +1 -0
  142. package/dist/routers/members.mjs +177 -0
  143. package/dist/routers/members.mjs.map +1 -0
  144. package/dist/routers/notification-channels.mjs +190 -0
  145. package/dist/routers/notification-channels.mjs.map +1 -0
  146. package/dist/routers/notification-subscriptions.mjs +120 -0
  147. package/dist/routers/notification-subscriptions.mjs.map +1 -0
  148. package/dist/routers/profile.mjs +276 -0
  149. package/dist/routers/profile.mjs.map +1 -0
  150. package/dist/routers/projects.mjs +133 -0
  151. package/dist/routers/projects.mjs.map +1 -0
  152. package/dist/routers/review-next.mjs +47 -0
  153. package/dist/routers/review-next.mjs.map +1 -0
  154. package/dist/routers/schemas.mjs +337 -0
  155. package/dist/routers/schemas.mjs.map +1 -0
  156. package/dist/routers/settings-general.mjs +104 -0
  157. package/dist/routers/settings-general.mjs.map +1 -0
  158. package/dist/routers/settings-labels.mjs +52 -0
  159. package/dist/routers/settings-labels.mjs.map +1 -0
  160. package/dist/routers/settings-members.mjs +58 -0
  161. package/dist/routers/settings-members.mjs.map +1 -0
  162. package/dist/routers/settings-notifications.mjs +108 -0
  163. package/dist/routers/settings-notifications.mjs.map +1 -0
  164. package/dist/routers/settings-status.mjs +77 -0
  165. package/dist/routers/settings-status.mjs.map +1 -0
  166. package/dist/routers/settings-tokens.mjs +40 -0
  167. package/dist/routers/settings-tokens.mjs.map +1 -0
  168. package/dist/routers/settings-webhooks.mjs +67 -0
  169. package/dist/routers/settings-webhooks.mjs.map +1 -0
  170. package/dist/routers/settings.handlers.mjs +149 -0
  171. package/dist/routers/settings.handlers.mjs.map +1 -0
  172. package/dist/routers/settings.mjs +25 -0
  173. package/dist/routers/settings.mjs.map +1 -0
  174. package/dist/routers/snapshots.mjs +240 -0
  175. package/dist/routers/snapshots.mjs.map +1 -0
  176. package/dist/routers/status-configs.mjs +108 -0
  177. package/dist/routers/status-configs.mjs.map +1 -0
  178. package/dist/routers/storybook.mjs +171 -0
  179. package/dist/routers/storybook.mjs.map +1 -0
  180. package/dist/routers/tokens.mjs +91 -0
  181. package/dist/routers/tokens.mjs.map +1 -0
  182. package/dist/routers/ui.mjs +166 -0
  183. package/dist/routers/ui.mjs.map +1 -0
  184. package/dist/routers/webhooks.mjs +97 -0
  185. package/dist/routers/webhooks.mjs.map +1 -0
  186. package/dist/routers/well-known.mjs +43 -0
  187. package/dist/routers/well-known.mjs.map +1 -0
  188. package/dist/runtime.mjs +52 -0
  189. package/dist/runtime.mjs.map +1 -0
  190. package/dist/store.mjs +20 -0
  191. package/dist/store.mjs.map +1 -0
  192. package/dist/ui/avatar.mjs +76 -0
  193. package/dist/ui/avatar.mjs.map +1 -0
  194. package/dist/ui/buttons.mjs +205 -0
  195. package/dist/ui/buttons.mjs.map +1 -0
  196. package/dist/ui/code-block.mjs +47 -0
  197. package/dist/ui/code-block.mjs.map +1 -0
  198. package/dist/ui/compare-stage.mjs +344 -0
  199. package/dist/ui/compare-stage.mjs.map +1 -0
  200. package/dist/ui/csrf-field.mjs +20 -0
  201. package/dist/ui/csrf-field.mjs.map +1 -0
  202. package/dist/ui/css.mjs +36 -0
  203. package/dist/ui/css.mjs.map +1 -0
  204. package/dist/ui/document.mjs +130 -0
  205. package/dist/ui/document.mjs.map +1 -0
  206. package/dist/ui/dropdown.mjs +181 -0
  207. package/dist/ui/dropdown.mjs.map +1 -0
  208. package/dist/ui/feedback.mjs +341 -0
  209. package/dist/ui/feedback.mjs.map +1 -0
  210. package/dist/ui/filter-input.mjs +58 -0
  211. package/dist/ui/filter-input.mjs.map +1 -0
  212. package/dist/ui/forms.mjs +291 -0
  213. package/dist/ui/forms.mjs.map +1 -0
  214. package/dist/ui/icons/icon.mjs +53 -0
  215. package/dist/ui/icons/icon.mjs.map +1 -0
  216. package/dist/ui/icons/paths.mjs +61 -0
  217. package/dist/ui/icons/paths.mjs.map +1 -0
  218. package/dist/ui/icons/sprite.mjs +26 -0
  219. package/dist/ui/icons/sprite.mjs.map +1 -0
  220. package/dist/ui/kbd.mjs +33 -0
  221. package/dist/ui/kbd.mjs.map +1 -0
  222. package/dist/ui/layout.mjs +173 -0
  223. package/dist/ui/layout.mjs.map +1 -0
  224. package/dist/ui/progress.mjs +44 -0
  225. package/dist/ui/progress.mjs.map +1 -0
  226. package/dist/ui/segmented.mjs +77 -0
  227. package/dist/ui/segmented.mjs.map +1 -0
  228. package/dist/ui/shell/account.mjs +161 -0
  229. package/dist/ui/shell/account.mjs.map +1 -0
  230. package/dist/ui/shell/bare.mjs +69 -0
  231. package/dist/ui/shell/bare.mjs.map +1 -0
  232. package/dist/ui/shell/boot-script.mjs +46 -0
  233. package/dist/ui/shell/boot-script.mjs.map +1 -0
  234. package/dist/ui/shell/client-script.mjs +259 -0
  235. package/dist/ui/shell/client-script.mjs.map +1 -0
  236. package/dist/ui/shell/nav.mjs +111 -0
  237. package/dist/ui/shell/nav.mjs.map +1 -0
  238. package/dist/ui/shell/review-script.mjs +195 -0
  239. package/dist/ui/shell/review-script.mjs.map +1 -0
  240. package/dist/ui/shell/sidebar.mjs +323 -0
  241. package/dist/ui/shell/sidebar.mjs.map +1 -0
  242. package/dist/ui/shell/topbar.mjs +268 -0
  243. package/dist/ui/shell/topbar.mjs.map +1 -0
  244. package/dist/ui/stacks.mjs +82 -0
  245. package/dist/ui/stacks.mjs.map +1 -0
  246. package/dist/ui/styles/base.mjs +41 -0
  247. package/dist/ui/styles/base.mjs.map +1 -0
  248. package/dist/ui/styles/behavior.mjs +43 -0
  249. package/dist/ui/styles/behavior.mjs.map +1 -0
  250. package/dist/ui/styles/review-layout.mjs +347 -0
  251. package/dist/ui/styles/review-layout.mjs.map +1 -0
  252. package/dist/ui/styles/review.mjs +86 -0
  253. package/dist/ui/styles/review.mjs.map +1 -0
  254. package/dist/ui/styles/tokens.mjs +132 -0
  255. package/dist/ui/styles/tokens.mjs.map +1 -0
  256. package/dist/ui/styles.mjs +19 -0
  257. package/dist/ui/styles.mjs.map +1 -0
  258. package/dist/ui/subnav.mjs +91 -0
  259. package/dist/ui/subnav.mjs.map +1 -0
  260. package/dist/ui/table.mjs +73 -0
  261. package/dist/ui/table.mjs.map +1 -0
  262. package/dist/ui/theme.mjs +64 -0
  263. package/dist/ui/theme.mjs.map +1 -0
  264. package/dist/ui/thumbnail.mjs +51 -0
  265. package/dist/ui/thumbnail.mjs.map +1 -0
  266. package/dist/ui/time.mjs +50 -0
  267. package/dist/ui/time.mjs.map +1 -0
  268. package/package.json +92 -0
@@ -0,0 +1,108 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { credentialKeys } from "../credential-keys.mjs";
5
+ import { getStore } from "../store.mjs";
6
+ import { flash } from "./flash.mjs";
7
+ import { asString, findProject, renderSettingsPage } from "./settings.handlers.mjs";
8
+ import { NotificationChannelModel, NotificationSubscriptionModel } from "@storyshelf/core/models";
9
+ //#region src/routers/settings-notifications.ts
10
+ /** Notifications settings (channels plus my preferences). */
11
+ function registerNotificationSettings(app) {
12
+ app.get("/projects/:slug/settings/notifications", async (c) => c.html(await renderSettingsPage(c, "notifications")));
13
+ app.post("/projects/:slug/settings/notifications", handleCreateChannel);
14
+ app.post("/projects/:slug/settings/notifications/:channelId/delete", handleDeleteChannel);
15
+ app.post("/projects/:slug/settings/notifications/me", handleSaveMine);
16
+ }
17
+ /** Parse the comma-separated events field. */
18
+ function parseEvents(form) {
19
+ const raw = asString(form.get("events"));
20
+ if (!raw) return;
21
+ const events = raw.split(",").map((event) => event.trim()).filter((event) => event.length > 0);
22
+ return events.length > 0 ? events : void 0;
23
+ }
24
+ /** Parse repeated checkbox values (topics). */
25
+ function parseChecks(form, name) {
26
+ return form.getAll(name).filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
27
+ }
28
+ async function handleCreateChannel(c) {
29
+ const project = await findProject(c.req.param("slug") ?? "");
30
+ const form = await c.req.formData();
31
+ const provider = asString(form.get("provider"));
32
+ const target = asString(form.get("target"));
33
+ if (provider && target) return await createChannelRecord(c, project, provider, target, form);
34
+ const missing = provider ? "target" : "provider";
35
+ const message = provider ? "Enter a target" : "Choose a provider";
36
+ return c.html(await renderSettingsPage(c, "notifications", { errors: { [missing]: message } }) ?? "", 400);
37
+ }
38
+ /** Render the tab with a provider-field error. */
39
+ async function providerError(c, message) {
40
+ return c.html(await renderSettingsPage(c, "notifications", { errors: { provider: message } }) ?? "", 400);
41
+ }
42
+ /** Render the tab with a target-field error. */
43
+ async function targetError(c, message) {
44
+ return c.html(await renderSettingsPage(c, "notifications", { errors: { target: message } }) ?? "", 400);
45
+ }
46
+ async function createChannelRecord(c, project, provider, target, form) {
47
+ const descriptor = getStore().notifiers.find((candidate) => candidate.metadata.kind === provider);
48
+ if (!descriptor) return await providerError(c, `Unknown provider: ${provider}`);
49
+ const parsed = descriptor.metadata.schema.safeParse(channelConfig(provider, target, form));
50
+ if (!parsed.success) return await targetError(c, parsed.error.message);
51
+ await persistChannel(project, provider, target, parsed.data, form);
52
+ flash(c, "Notification channel added");
53
+ return c.html(await renderSettingsPage(c, "notifications") ?? "", 201);
54
+ }
55
+ /** Build the provider config from the form (address vs secret per kind). */
56
+ function channelConfig(provider, target, form) {
57
+ const style = asString(form.get("style")) ?? "compact";
58
+ if (provider === "email") return {
59
+ to: target,
60
+ style
61
+ };
62
+ return { style };
63
+ }
64
+ /** Persist a validated channel (webhook URLs land in the secret column). */
65
+ async function persistChannel(project, provider, target, config, form) {
66
+ await new NotificationChannelModel(getStore().db, void 0, credentialKeys(getStore().config)).create({
67
+ projectId: project.id,
68
+ provider,
69
+ config,
70
+ secret: provider === "email" ? void 0 : target,
71
+ events: parseEvents(form)
72
+ });
73
+ }
74
+ async function handleDeleteChannel(c) {
75
+ const project = await findProject(c.req.param("slug") ?? "");
76
+ const model = new NotificationChannelModel(getStore().db, void 0, credentialKeys(getStore().config));
77
+ const channel = await model.get(c.req.param("channelId") ?? "");
78
+ if (!channel || channel.projectId !== project.id) return c.html(await renderSettingsPage(c, "notifications", { globalError: "Channel not found" }) ?? "", 404);
79
+ await model.remove(channel.id);
80
+ flash(c, "Notification channel removed");
81
+ return c.html(await renderSettingsPage(c, "notifications") ?? "");
82
+ }
83
+ async function handleSaveMine(c) {
84
+ const project = await findProject(c.req.param("slug") ?? "");
85
+ const user = getStore().user;
86
+ if (user) {
87
+ await saveSubscription(project, user.id, await c.req.formData());
88
+ flash(c, "Notification preferences saved");
89
+ return c.html(await renderSettingsPage(c, "notifications") ?? "");
90
+ }
91
+ return c.html(await renderSettingsPage(c, "notifications", { globalError: "Sign in to save notification preferences" }) ?? "", 401);
92
+ }
93
+ /** Persist the viewer's own subscription (unchecked means opt out). */
94
+ async function saveSubscription(project, userId, form) {
95
+ const model = new NotificationSubscriptionModel(getStore().db);
96
+ if (form.get("enabled") === "on") {
97
+ await model.upsert(project.id, userId, {
98
+ events: parseChecks(form, "events"),
99
+ via: ["email"]
100
+ });
101
+ return;
102
+ }
103
+ await model.remove(project.id, userId);
104
+ }
105
+ //#endregion
106
+ export { registerNotificationSettings };
107
+
108
+ //# sourceMappingURL=settings-notifications.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings-notifications.mjs","names":[],"sources":["../../src/routers/settings-notifications.ts"],"sourcesContent":["import { NotificationChannelModel, NotificationSubscriptionModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { asString, findProject, renderSettingsPage } from \"./settings.handlers.ts\";\n\n/** Notifications settings (channels plus my preferences). */\nexport function registerNotificationSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings/notifications\", async (c) =>\n c.html(await renderSettingsPage(c, \"notifications\")),\n );\n app.post(\"/projects/:slug/settings/notifications\", handleCreateChannel);\n app.post(\"/projects/:slug/settings/notifications/:channelId/delete\", handleDeleteChannel);\n app.post(\"/projects/:slug/settings/notifications/me\", handleSaveMine);\n}\n\n/** Parse the comma-separated events field. */\nfunction parseEvents(form: FormData): string[] | undefined {\n const raw = asString(form.get(\"events\"));\n if (!raw) {\n return undefined;\n }\n const events = raw\n .split(\",\")\n .map((event) => event.trim())\n .filter((event) => event.length > 0);\n return events.length > 0 ? events : undefined;\n}\n\n/** Parse repeated checkbox values (topics). */\nfunction parseChecks(form: FormData, name: string): string[] {\n return form\n .getAll(name)\n .filter((entry): entry is string => typeof entry === \"string\")\n .map((entry) => entry.trim())\n .filter((entry) => entry.length > 0);\n}\n\nasync function handleCreateChannel(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const form = await c.req.formData();\n const provider = asString(form.get(\"provider\"));\n const target = asString(form.get(\"target\"));\n if (provider && target) {\n return await createChannelRecord(c, project, provider, target, form);\n }\n const missing = provider ? \"target\" : \"provider\";\n const message = provider ? \"Enter a target\" : \"Choose a provider\";\n return c.html(\n (await renderSettingsPage(c, \"notifications\", { errors: { [missing]: message } })) ?? \"\",\n 400,\n );\n}\n\n/** Render the tab with a provider-field error. */\nasync function providerError(c: Context, message: string): Promise<Response> {\n return c.html(\n (await renderSettingsPage(c, \"notifications\", { errors: { provider: message } })) ?? \"\",\n 400,\n );\n}\n\n/** Render the tab with a target-field error. */\nasync function targetError(c: Context, message: string): Promise<Response> {\n return c.html(\n (await renderSettingsPage(c, \"notifications\", { errors: { target: message } })) ?? \"\",\n 400,\n );\n}\n\nasync function createChannelRecord(\n c: Context,\n project: Project,\n provider: string,\n target: string,\n form: FormData,\n): Promise<Response> {\n const descriptor = getStore().notifiers.find((candidate) => candidate.metadata.kind === provider);\n if (!descriptor) {\n return await providerError(c, `Unknown provider: ${provider}`);\n }\n const parsed = descriptor.metadata.schema.safeParse(channelConfig(provider, target, form));\n if (!parsed.success) {\n return await targetError(c, parsed.error.message);\n }\n await persistChannel(project, provider, target, parsed.data, form);\n flash(c, \"Notification channel added\");\n return c.html((await renderSettingsPage(c, \"notifications\")) ?? \"\", 201);\n}\n\n/** Build the provider config from the form (address vs secret per kind). */\nfunction channelConfig(provider: string, target: string, form: FormData): Record<string, unknown> {\n const style = asString(form.get(\"style\")) ?? \"compact\";\n if (provider === \"email\") {\n return { to: target, style };\n }\n return { style };\n}\n\n/** Persist a validated channel (webhook URLs land in the secret column). */\nasync function persistChannel(\n project: Project,\n provider: string,\n target: string,\n config: unknown,\n form: FormData,\n): Promise<void> {\n await new NotificationChannelModel(\n getStore().db,\n undefined,\n credentialKeys(getStore().config),\n ).create({\n projectId: project.id,\n provider,\n config: config as Record<string, unknown>,\n secret: provider === \"email\" ? undefined : target,\n events: parseEvents(form),\n });\n}\n\nasync function handleDeleteChannel(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const model = new NotificationChannelModel(\n getStore().db,\n undefined,\n credentialKeys(getStore().config),\n );\n const channel = await model.get(c.req.param(\"channelId\") ?? \"\");\n if (!channel || channel.projectId !== project.id) {\n return c.html(\n (await renderSettingsPage(c, \"notifications\", {\n globalError: \"Channel not found\",\n })) ?? \"\",\n 404,\n );\n }\n await model.remove(channel.id);\n flash(c, \"Notification channel removed\");\n return c.html((await renderSettingsPage(c, \"notifications\")) ?? \"\");\n}\n\nasync function handleSaveMine(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const user = getStore().user;\n if (user) {\n await saveSubscription(project, user.id, await c.req.formData());\n flash(c, \"Notification preferences saved\");\n return c.html((await renderSettingsPage(c, \"notifications\")) ?? \"\");\n }\n return c.html(\n (await renderSettingsPage(c, \"notifications\", {\n globalError: \"Sign in to save notification preferences\",\n })) ?? \"\",\n 401,\n );\n}\n\n/** Persist the viewer's own subscription (unchecked means opt out). */\nasync function saveSubscription(project: Project, userId: string, form: FormData): Promise<void> {\n const model = new NotificationSubscriptionModel(getStore().db);\n if (form.get(\"enabled\") === \"on\") {\n await model.upsert(project.id, userId, { events: parseChecks(form, \"events\"), via: [\"email\"] });\n return;\n }\n await model.remove(project.id, userId);\n}\n"],"mappings":";;;;;;;;;;AAUA,SAAgB,6BAA6B,KAAwB;CACnE,IAAI,IAAI,0CAA0C,OAAO,MACvD,EAAE,KAAK,MAAM,mBAAmB,GAAG,eAAe,CAAC,CACrD;CACA,IAAI,KAAK,0CAA0C,mBAAmB;CACtE,IAAI,KAAK,4DAA4D,mBAAmB;CACxF,IAAI,KAAK,6CAA6C,cAAc;AACtE;;AAGA,SAAS,YAAY,MAAsC;CACzD,MAAM,MAAM,SAAS,KAAK,IAAI,QAAQ,CAAC;CACvC,IAAI,CAAC,KACH;CAEF,MAAM,SAAS,IACZ,MAAM,GAAG,CAAC,CACV,KAAK,UAAU,MAAM,KAAK,CAAC,CAAC,CAC5B,QAAQ,UAAU,MAAM,SAAS,CAAC;CACrC,OAAO,OAAO,SAAS,IAAI,SAAS,KAAA;AACtC;;AAGA,SAAS,YAAY,MAAgB,MAAwB;CAC3D,OAAO,KACJ,OAAO,IAAI,CAAC,CACZ,QAAQ,UAA2B,OAAO,UAAU,QAAQ,CAAC,CAC7D,KAAK,UAAU,MAAM,KAAK,CAAC,CAAC,CAC5B,QAAQ,UAAU,MAAM,SAAS,CAAC;AACvC;AAEA,eAAe,oBAAoB,GAA+B;CAChE,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;CAC3D,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;CAClC,MAAM,WAAW,SAAS,KAAK,IAAI,UAAU,CAAC;CAC9C,MAAM,SAAS,SAAS,KAAK,IAAI,QAAQ,CAAC;CAC1C,IAAI,YAAY,QACd,OAAO,MAAM,oBAAoB,GAAG,SAAS,UAAU,QAAQ,IAAI;CAErE,MAAM,UAAU,WAAW,WAAW;CACtC,MAAM,UAAU,WAAW,mBAAmB;CAC9C,OAAO,EAAE,KACN,MAAM,mBAAmB,GAAG,iBAAiB,EAAE,QAAQ,GAAG,UAAU,QAAQ,EAAE,CAAC,KAAM,IACtF,GACF;AACF;;AAGA,eAAe,cAAc,GAAY,SAAoC;CAC3E,OAAO,EAAE,KACN,MAAM,mBAAmB,GAAG,iBAAiB,EAAE,QAAQ,EAAE,UAAU,QAAQ,EAAE,CAAC,KAAM,IACrF,GACF;AACF;;AAGA,eAAe,YAAY,GAAY,SAAoC;CACzE,OAAO,EAAE,KACN,MAAM,mBAAmB,GAAG,iBAAiB,EAAE,QAAQ,EAAE,QAAQ,QAAQ,EAAE,CAAC,KAAM,IACnF,GACF;AACF;AAEA,eAAe,oBACb,GACA,SACA,UACA,QACA,MACmB;CACnB,MAAM,aAAa,SAAS,CAAC,CAAC,UAAU,MAAM,cAAc,UAAU,SAAS,SAAS,QAAQ;CAChG,IAAI,CAAC,YACH,OAAO,MAAM,cAAc,GAAG,qBAAqB,UAAU;CAE/D,MAAM,SAAS,WAAW,SAAS,OAAO,UAAU,cAAc,UAAU,QAAQ,IAAI,CAAC;CACzF,IAAI,CAAC,OAAO,SACV,OAAO,MAAM,YAAY,GAAG,OAAO,MAAM,OAAO;CAElD,MAAM,eAAe,SAAS,UAAU,QAAQ,OAAO,MAAM,IAAI;CACjE,MAAM,GAAG,4BAA4B;CACrC,OAAO,EAAE,KAAM,MAAM,mBAAmB,GAAG,eAAe,KAAM,IAAI,GAAG;AACzE;;AAGA,SAAS,cAAc,UAAkB,QAAgB,MAAyC;CAChG,MAAM,QAAQ,SAAS,KAAK,IAAI,OAAO,CAAC,KAAK;CAC7C,IAAI,aAAa,SACf,OAAO;EAAE,IAAI;EAAQ;CAAM;CAE7B,OAAO,EAAE,MAAM;AACjB;;AAGA,eAAe,eACb,SACA,UACA,QACA,QACA,MACe;CACf,MAAM,IAAI,yBACR,SAAS,CAAC,CAAC,IACX,KAAA,GACA,eAAe,SAAS,CAAC,CAAC,MAAM,CAClC,CAAC,CAAC,OAAO;EACP,WAAW,QAAQ;EACnB;EACQ;EACR,QAAQ,aAAa,UAAU,KAAA,IAAY;EAC3C,QAAQ,YAAY,IAAI;CAC1B,CAAC;AACH;AAEA,eAAe,oBAAoB,GAA+B;CAChE,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;CAC3D,MAAM,QAAQ,IAAI,yBAChB,SAAS,CAAC,CAAC,IACX,KAAA,GACA,eAAe,SAAS,CAAC,CAAC,MAAM,CAClC;CACA,MAAM,UAAU,MAAM,MAAM,IAAI,EAAE,IAAI,MAAM,WAAW,KAAK,EAAE;CAC9D,IAAI,CAAC,WAAW,QAAQ,cAAc,QAAQ,IAC5C,OAAO,EAAE,KACN,MAAM,mBAAmB,GAAG,iBAAiB,EAC5C,aAAa,oBACf,CAAC,KAAM,IACP,GACF;CAEF,MAAM,MAAM,OAAO,QAAQ,EAAE;CAC7B,MAAM,GAAG,8BAA8B;CACvC,OAAO,EAAE,KAAM,MAAM,mBAAmB,GAAG,eAAe,KAAM,EAAE;AACpE;AAEA,eAAe,eAAe,GAA+B;CAC3D,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;CAC3D,MAAM,OAAO,SAAS,CAAC,CAAC;CACxB,IAAI,MAAM;EACR,MAAM,iBAAiB,SAAS,KAAK,IAAI,MAAM,EAAE,IAAI,SAAS,CAAC;EAC/D,MAAM,GAAG,gCAAgC;EACzC,OAAO,EAAE,KAAM,MAAM,mBAAmB,GAAG,eAAe,KAAM,EAAE;CACpE;CACA,OAAO,EAAE,KACN,MAAM,mBAAmB,GAAG,iBAAiB,EAC5C,aAAa,2CACf,CAAC,KAAM,IACP,GACF;AACF;;AAGA,eAAe,iBAAiB,SAAkB,QAAgB,MAA+B;CAC/F,MAAM,QAAQ,IAAI,8BAA8B,SAAS,CAAC,CAAC,EAAE;CAC7D,IAAI,KAAK,IAAI,SAAS,MAAM,MAAM;EAChC,MAAM,MAAM,OAAO,QAAQ,IAAI,QAAQ;GAAE,QAAQ,YAAY,MAAM,QAAQ;GAAG,KAAK,CAAC,OAAO;EAAE,CAAC;EAC9F;CACF;CACA,MAAM,MAAM,OAAO,QAAQ,IAAI,MAAM;AACvC"}
@@ -0,0 +1,77 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { credentialKeys } from "../credential-keys.mjs";
5
+ import { getStore } from "../store.mjs";
6
+ import { hxRedirect } from "./htmx.mjs";
7
+ import { flash } from "./flash.mjs";
8
+ import { asString, findProject, renderSettingsPage } from "./settings.handlers.mjs";
9
+ import { StatusConfigModel } from "@storyshelf/core/models";
10
+ //#region src/routers/settings-status.ts
11
+ /** Merge-gate status-config settings (provider configs + tokens, delete). */
12
+ function registerStatusSettings(app) {
13
+ app.get("/projects/:slug/settings/status", async (c) => c.html(await renderSettingsPage(c, "status")));
14
+ app.post("/projects/:slug/settings/status", handleCreateStatus);
15
+ app.post("/projects/:slug/settings/status/:id/delete", handleDeleteStatus);
16
+ }
17
+ async function unknownProviderResponse(c) {
18
+ return c.html(await renderSettingsPage(c, "status", { globalError: "Unknown git provider" }) ?? "", 400);
19
+ }
20
+ async function missingTokenResponse(c) {
21
+ return c.html(await renderSettingsPage(c, "status", { errors: { token: "Token is required" } }) ?? "", 400);
22
+ }
23
+ async function readStatusFields(c) {
24
+ const project = await findProject(c.req.param("slug") ?? "");
25
+ const form = await c.req.formData();
26
+ const provider = getStore().gitHosts.find((p) => p.metadata.kind === asString(form.get("provider")));
27
+ if (!provider) return await unknownProviderResponse(c);
28
+ const token = asString(form.get("token"));
29
+ if (!token) return await missingTokenResponse(c);
30
+ return {
31
+ project,
32
+ provider,
33
+ token,
34
+ configRaw: asString(form.get("config")) ?? "{}"
35
+ };
36
+ }
37
+ async function handleCreateStatus(c) {
38
+ const fields = await readStatusFields(c);
39
+ if (fields instanceof Response) return fields;
40
+ return await createStatusFromForm(c, fields.project, fields.provider, fields.token, fields.configRaw);
41
+ }
42
+ async function createStatusFromForm(c, project, provider, token, configRaw) {
43
+ const config = parseJsonConfig(configRaw);
44
+ if (config === void 0) return c.html(await renderSettingsPage(c, "status", { errors: { config: "Config must be valid JSON" } }) ?? "", 400);
45
+ const parsed = provider.metadata.schema.safeParse(config);
46
+ if (!parsed.success) return c.html(await renderSettingsPage(c, "status", { errors: { config: parsed.error.message } }) ?? "", 400);
47
+ return await persistStatusConfig(c, project, provider, token, parsed.data);
48
+ }
49
+ async function persistStatusConfig(c, project, provider, token, config) {
50
+ const { db, config: shelfConfig } = getStore();
51
+ await new StatusConfigModel(db, void 0, credentialKeys(shelfConfig)).create(project.id, {
52
+ provider: provider.metadata.kind,
53
+ config,
54
+ token
55
+ });
56
+ flash(c, "Git status config added");
57
+ return hxRedirect(c, `/projects/${project.slug}/settings/status`);
58
+ }
59
+ /** Parse the provider config JSON, or undefined when invalid. */
60
+ function parseJsonConfig(raw) {
61
+ try {
62
+ return JSON.parse(raw);
63
+ } catch {
64
+ return;
65
+ }
66
+ }
67
+ async function handleDeleteStatus(c) {
68
+ const project = await findProject(c.req.param("slug") ?? "");
69
+ const { db, config: shelfConfig } = getStore();
70
+ await new StatusConfigModel(db, void 0, credentialKeys(shelfConfig)).remove(project.id, c.req.param("id") ?? "");
71
+ flash(c, "Git status config removed");
72
+ return hxRedirect(c, `/projects/${project.slug}/settings/status`);
73
+ }
74
+ //#endregion
75
+ export { registerStatusSettings };
76
+
77
+ //# sourceMappingURL=settings-status.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings-status.mjs","names":[],"sources":["../../src/routers/settings-status.ts"],"sourcesContent":["import type { GitHostProvider } from \"@storyshelf/core/adapter/git-host\";\nimport { StatusConfigModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\nimport { asString, findProject, renderSettingsPage } from \"./settings.handlers.ts\";\n/** Merge-gate status-config settings (provider configs + tokens, delete). */\nexport function registerStatusSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings/status\", async (c) =>\n c.html(await renderSettingsPage(c, \"status\")),\n );\n app.post(\"/projects/:slug/settings/status\", handleCreateStatus);\n app.post(\"/projects/:slug/settings/status/:id/delete\", handleDeleteStatus);\n}\n\nasync function unknownProviderResponse(c: Context): Promise<Response> {\n return c.html(\n (await renderSettingsPage(c, \"status\", { globalError: \"Unknown git provider\" })) ?? \"\",\n 400,\n );\n}\n\nasync function missingTokenResponse(c: Context): Promise<Response> {\n return c.html(\n (await renderSettingsPage(c, \"status\", { errors: { token: \"Token is required\" } })) ?? \"\",\n 400,\n );\n}\n\ninterface StatusFields {\n project: Project;\n provider: GitHostProvider;\n token: string;\n configRaw: string;\n}\n\nasync function readStatusFields(c: Context): Promise<StatusFields | Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const form = await c.req.formData();\n const provider = getStore().gitHosts.find(\n (p) => p.metadata.kind === asString(form.get(\"provider\")),\n );\n if (!provider) {\n return await unknownProviderResponse(c);\n }\n const token = asString(form.get(\"token\"));\n if (!token) {\n return await missingTokenResponse(c);\n }\n return { project, provider, token, configRaw: asString(form.get(\"config\")) ?? \"{}\" };\n}\n\nasync function handleCreateStatus(c: Context): Promise<Response> {\n const fields = await readStatusFields(c);\n if (fields instanceof Response) {\n return fields;\n }\n return await createStatusFromForm(\n c,\n fields.project,\n fields.provider,\n fields.token,\n fields.configRaw,\n );\n}\n\nasync function createStatusFromForm(\n c: Context,\n project: Project,\n provider: GitHostProvider,\n token: string,\n configRaw: string,\n): Promise<Response> {\n const config = parseJsonConfig(configRaw);\n if (config === undefined) {\n return c.html(\n (await renderSettingsPage(c, \"status\", {\n errors: { config: \"Config must be valid JSON\" },\n })) ?? \"\",\n 400,\n );\n }\n const parsed = provider.metadata.schema.safeParse(config);\n if (!parsed.success) {\n return c.html(\n (await renderSettingsPage(c, \"status\", { errors: { config: parsed.error.message } })) ?? \"\",\n 400,\n );\n }\n return await persistStatusConfig(c, project, provider, token, parsed.data);\n}\n\nasync function persistStatusConfig(\n c: Context,\n project: Project,\n provider: GitHostProvider,\n token: string,\n config: unknown,\n): Promise<Response> {\n const { db, config: shelfConfig } = getStore();\n await new StatusConfigModel(db, undefined, credentialKeys(shelfConfig)).create(project.id, {\n provider: provider.metadata.kind,\n config,\n token,\n });\n flash(c, \"Git status config added\");\n return hxRedirect(c, `/projects/${project.slug}/settings/status`);\n}\n\n/** Parse the provider config JSON, or undefined when invalid. */\nfunction parseJsonConfig(raw: string): unknown {\n try {\n return JSON.parse(raw) as unknown;\n } catch {\n return undefined;\n }\n}\n\nasync function handleDeleteStatus(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const { db, config: shelfConfig } = getStore();\n await new StatusConfigModel(db, undefined, credentialKeys(shelfConfig)).remove(\n project.id,\n c.req.param(\"id\") ?? \"\",\n );\n flash(c, \"Git status config removed\");\n return hxRedirect(c, `/projects/${project.slug}/settings/status`);\n}\n"],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { hxRedirect } from "./htmx.mjs";
6
+ import { flash } from "./flash.mjs";
7
+ import { asString, findProject, renderSettingsPage } from "./settings.handlers.mjs";
8
+ import { TokenModel } from "@storyshelf/core/models";
9
+ import { randomToken } from "@storyshelf/core/utils";
10
+ //#region src/routers/settings-tokens.ts
11
+ /** CI token settings (create with one-time display, delete). */
12
+ function registerTokenSettings(app) {
13
+ app.get("/projects/:slug/settings/tokens", async (c) => c.html(await renderSettingsPage(c, "tokens")));
14
+ app.post("/projects/:slug/settings/tokens", handleCreateToken);
15
+ app.post("/projects/:slug/settings/tokens/:tokenId/delete", handleDeleteToken);
16
+ }
17
+ async function handleCreateToken(c) {
18
+ const project = await findProject(c.req.param("slug") ?? "");
19
+ const form = await c.req.formData();
20
+ const tokenName = asString(form.get("tokenName")) ?? asString(form.get("name"));
21
+ if (!tokenName) return c.html(await renderSettingsPage(c, "tokens", { globalError: "Name is required" }) ?? "", 400);
22
+ const token = randomToken("shelf_");
23
+ await new TokenModel(getStore().db).create(project.id, {
24
+ name: tokenName,
25
+ hash: token.hash,
26
+ userId: getStore().user?.id ?? null
27
+ });
28
+ flash(c, "Token created — copy it now, it is shown once");
29
+ return c.html(await renderSettingsPage(c, "tokens", { secret: token.value }) ?? "");
30
+ }
31
+ async function handleDeleteToken(c) {
32
+ const project = await findProject(c.req.param("slug") ?? "");
33
+ await new TokenModel(getStore().db).remove(c.req.param("tokenId") ?? "");
34
+ flash(c, "Token deleted");
35
+ return hxRedirect(c, `/projects/${project.slug}/settings/tokens`);
36
+ }
37
+ //#endregion
38
+ export { registerTokenSettings };
39
+
40
+ //# sourceMappingURL=settings-tokens.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings-tokens.mjs","names":[],"sources":["../../src/routers/settings-tokens.ts"],"sourcesContent":["import { TokenModel } from \"@storyshelf/core/models\";\nimport { randomToken } from \"@storyshelf/core/utils\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\nimport { asString, findProject, renderSettingsPage } from \"./settings.handlers.ts\";\n/** CI token settings (create with one-time display, delete). */\nexport function registerTokenSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings/tokens\", async (c) =>\n c.html(await renderSettingsPage(c, \"tokens\")),\n );\n app.post(\"/projects/:slug/settings/tokens\", handleCreateToken);\n app.post(\"/projects/:slug/settings/tokens/:tokenId/delete\", handleDeleteToken);\n}\n\nasync function handleCreateToken(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const form = await c.req.formData();\n const tokenName = asString(form.get(\"tokenName\")) ?? asString(form.get(\"name\"));\n if (!tokenName) {\n return c.html(\n (await renderSettingsPage(c, \"tokens\", { globalError: \"Name is required\" })) ?? \"\",\n 400,\n );\n }\n const token = randomToken(\"shelf_\");\n await new TokenModel(getStore().db).create(project.id, {\n name: tokenName,\n hash: token.hash,\n userId: getStore().user?.id ?? null,\n });\n flash(c, \"Token created — copy it now, it is shown once\");\n return c.html((await renderSettingsPage(c, \"tokens\", { secret: token.value })) ?? \"\");\n}\n\nasync function handleDeleteToken(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n await new TokenModel(getStore().db).remove(c.req.param(\"tokenId\") ?? \"\");\n flash(c, \"Token deleted\");\n return hxRedirect(c, `/projects/${project.slug}/settings/tokens`);\n}\n"],"mappings":";;;;;;;;;;;AASA,SAAgB,sBAAsB,KAAwB;CAC5D,IAAI,IAAI,mCAAmC,OAAO,MAChD,EAAE,KAAK,MAAM,mBAAmB,GAAG,QAAQ,CAAC,CAC9C;CACA,IAAI,KAAK,mCAAmC,iBAAiB;CAC7D,IAAI,KAAK,mDAAmD,iBAAiB;AAC/E;AAEA,eAAe,kBAAkB,GAA+B;CAC9D,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;CAC3D,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;CAClC,MAAM,YAAY,SAAS,KAAK,IAAI,WAAW,CAAC,KAAK,SAAS,KAAK,IAAI,MAAM,CAAC;CAC9E,IAAI,CAAC,WACH,OAAO,EAAE,KACN,MAAM,mBAAmB,GAAG,UAAU,EAAE,aAAa,mBAAmB,CAAC,KAAM,IAChF,GACF;CAEF,MAAM,QAAQ,YAAY,QAAQ;CAClC,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OAAO,QAAQ,IAAI;EACrD,MAAM;EACN,MAAM,MAAM;EACZ,QAAQ,SAAS,CAAC,CAAC,MAAM,MAAM;CACjC,CAAC;CACD,MAAM,GAAG,+CAA+C;CACxD,OAAO,EAAE,KAAM,MAAM,mBAAmB,GAAG,UAAU,EAAE,QAAQ,MAAM,MAAM,CAAC,KAAM,EAAE;AACtF;AAEA,eAAe,kBAAkB,GAA+B;CAC9D,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;CAC3D,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OAAO,EAAE,IAAI,MAAM,SAAS,KAAK,EAAE;CACvE,MAAM,GAAG,eAAe;CACxB,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,iBAAiB;AAClE"}
@@ -0,0 +1,67 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { credentialKeys } from "../credential-keys.mjs";
5
+ import { getStore } from "../store.mjs";
6
+ import { hxRedirect } from "./htmx.mjs";
7
+ import { flash } from "./flash.mjs";
8
+ import { notFound } from "./helpers.mjs";
9
+ import { asString, findProject, renderSettingsPage } from "./settings.handlers.mjs";
10
+ import { WebhookModel } from "@storyshelf/core/models";
11
+ import { randomToken } from "@storyshelf/core/utils";
12
+ //#region src/routers/settings-webhooks.ts
13
+ /** Webhook settings (create with one-time secret display, delete). */
14
+ function registerWebhookSettings(app) {
15
+ app.get("/projects/:slug/settings/webhooks", async (c) => c.html(await renderSettingsPage(c, "webhooks")));
16
+ app.post("/projects/:slug/settings/webhooks", handleCreateWebhook);
17
+ app.post("/projects/:slug/settings/webhooks/:webhookId/delete", handleDeleteWebhook);
18
+ }
19
+ /** Validate a webhook URL, returning the error message or null when valid. */
20
+ function webhookUrlError(url) {
21
+ if (!url) return "A valid URL is required";
22
+ let protocol;
23
+ try {
24
+ protocol = new URL(url).protocol;
25
+ } catch {
26
+ return "Enter a valid URL";
27
+ }
28
+ if (protocol !== "https:" && protocol !== "http:") return "URL must use http or https";
29
+ return null;
30
+ }
31
+ /** Parse the comma-separated events field. */
32
+ function parseWebhookEvents(form) {
33
+ const eventsRaw = asString(form.get("events"));
34
+ if (!eventsRaw) return;
35
+ return eventsRaw.split(",").map((event) => event.trim()).filter((event) => event.length > 0);
36
+ }
37
+ async function handleCreateWebhook(c) {
38
+ const project = await findProject(c.req.param("slug") ?? "");
39
+ const form = await c.req.formData();
40
+ const url = asString(form.get("url"));
41
+ const urlError = webhookUrlError(url);
42
+ if (urlError || !url) return c.html(await renderSettingsPage(c, "webhooks", { errors: { url: urlError ?? "Invalid URL" } }) ?? "", 400);
43
+ return await createWebhookRecord(c, project, url, parseWebhookEvents(form));
44
+ }
45
+ async function createWebhookRecord(c, project, url, events) {
46
+ const webhookModel = new WebhookModel(getStore().db, void 0, credentialKeys(getStore().config));
47
+ const secret = randomToken("whsec_").value;
48
+ await webhookModel.create(project.id, {
49
+ url,
50
+ events,
51
+ secret
52
+ });
53
+ flash(c, "Webhook created — copy the signing secret now");
54
+ return c.html(await renderSettingsPage(c, "webhooks", { secret }) ?? "", 201);
55
+ }
56
+ async function handleDeleteWebhook(c) {
57
+ const project = await findProject(c.req.param("slug") ?? "");
58
+ const webhook = await new WebhookModel(getStore().db, void 0, credentialKeys(getStore().config)).get(project.id, c.req.param("webhookId") ?? "");
59
+ if (!webhook) notFound("Webhook not found");
60
+ await new WebhookModel(getStore().db, void 0, credentialKeys(getStore().config)).remove(project.id, webhook.id);
61
+ flash(c, "Webhook deleted");
62
+ return hxRedirect(c, `/projects/${project.slug}/settings/webhooks`);
63
+ }
64
+ //#endregion
65
+ export { registerWebhookSettings };
66
+
67
+ //# sourceMappingURL=settings-webhooks.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings-webhooks.mjs","names":[],"sources":["../../src/routers/settings-webhooks.ts"],"sourcesContent":["import { WebhookModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { randomToken } from \"@storyshelf/core/utils\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { notFound } from \"./helpers.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\nimport { asString, findProject, renderSettingsPage } from \"./settings.handlers.ts\";\n/** Webhook settings (create with one-time secret display, delete). */\nexport function registerWebhookSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings/webhooks\", async (c) =>\n c.html(await renderSettingsPage(c, \"webhooks\")),\n );\n app.post(\"/projects/:slug/settings/webhooks\", handleCreateWebhook);\n app.post(\"/projects/:slug/settings/webhooks/:webhookId/delete\", handleDeleteWebhook);\n}\n\n/** Validate a webhook URL, returning the error message or null when valid. */\nfunction webhookUrlError(url: string | undefined): string | null {\n if (!url) {\n return \"A valid URL is required\";\n }\n let protocol: string;\n try {\n protocol = new URL(url).protocol;\n } catch {\n return \"Enter a valid URL\";\n }\n if (protocol !== \"https:\" && protocol !== \"http:\") {\n return \"URL must use http or https\";\n }\n return null;\n}\n\n/** Parse the comma-separated events field. */\nfunction parseWebhookEvents(form: FormData): string[] | undefined {\n const eventsRaw = asString(form.get(\"events\"));\n if (!eventsRaw) {\n return undefined;\n }\n return eventsRaw\n .split(\",\")\n .map((event) => event.trim())\n .filter((event) => event.length > 0);\n}\n\nasync function handleCreateWebhook(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const form = await c.req.formData();\n const url = asString(form.get(\"url\"));\n const urlError = webhookUrlError(url);\n if (urlError || !url) {\n return c.html(\n (await renderSettingsPage(c, \"webhooks\", { errors: { url: urlError ?? \"Invalid URL\" } })) ??\n \"\",\n 400,\n );\n }\n return await createWebhookRecord(c, project, url, parseWebhookEvents(form));\n}\n\nasync function createWebhookRecord(\n c: Context,\n project: Project,\n url: string,\n events: string[] | undefined,\n): Promise<Response> {\n const webhookModel = new WebhookModel(\n getStore().db,\n undefined,\n credentialKeys(getStore().config),\n );\n const secret = randomToken(\"whsec_\").value;\n await webhookModel.create(project.id, { url, events, secret });\n flash(c, \"Webhook created — copy the signing secret now\");\n return c.html((await renderSettingsPage(c, \"webhooks\", { secret })) ?? \"\", 201);\n}\n\nasync function handleDeleteWebhook(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const webhook = await new WebhookModel(\n getStore().db,\n undefined,\n credentialKeys(getStore().config),\n ).get(project.id, c.req.param(\"webhookId\") ?? \"\");\n if (!webhook) {\n notFound(\"Webhook not found\");\n }\n await new WebhookModel(getStore().db, undefined, credentialKeys(getStore().config)).remove(\n project.id,\n webhook.id,\n );\n flash(c, \"Webhook deleted\");\n return hxRedirect(c, `/projects/${project.slug}/settings/webhooks`);\n}\n"],"mappings":";;;;;;;;;;;;;AAYA,SAAgB,wBAAwB,KAAwB;CAC9D,IAAI,IAAI,qCAAqC,OAAO,MAClD,EAAE,KAAK,MAAM,mBAAmB,GAAG,UAAU,CAAC,CAChD;CACA,IAAI,KAAK,qCAAqC,mBAAmB;CACjE,IAAI,KAAK,uDAAuD,mBAAmB;AACrF;;AAGA,SAAS,gBAAgB,KAAwC;CAC/D,IAAI,CAAC,KACH,OAAO;CAET,IAAI;CACJ,IAAI;EACF,WAAW,IAAI,IAAI,GAAG,CAAC,CAAC;CAC1B,QAAQ;EACN,OAAO;CACT;CACA,IAAI,aAAa,YAAY,aAAa,SACxC,OAAO;CAET,OAAO;AACT;;AAGA,SAAS,mBAAmB,MAAsC;CAChE,MAAM,YAAY,SAAS,KAAK,IAAI,QAAQ,CAAC;CAC7C,IAAI,CAAC,WACH;CAEF,OAAO,UACJ,MAAM,GAAG,CAAC,CACV,KAAK,UAAU,MAAM,KAAK,CAAC,CAAC,CAC5B,QAAQ,UAAU,MAAM,SAAS,CAAC;AACvC;AAEA,eAAe,oBAAoB,GAA+B;CAChE,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;CAC3D,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;CAClC,MAAM,MAAM,SAAS,KAAK,IAAI,KAAK,CAAC;CACpC,MAAM,WAAW,gBAAgB,GAAG;CACpC,IAAI,YAAY,CAAC,KACf,OAAO,EAAE,KACN,MAAM,mBAAmB,GAAG,YAAY,EAAE,QAAQ,EAAE,KAAK,YAAY,cAAc,EAAE,CAAC,KACrF,IACF,GACF;CAEF,OAAO,MAAM,oBAAoB,GAAG,SAAS,KAAK,mBAAmB,IAAI,CAAC;AAC5E;AAEA,eAAe,oBACb,GACA,SACA,KACA,QACmB;CACnB,MAAM,eAAe,IAAI,aACvB,SAAS,CAAC,CAAC,IACX,KAAA,GACA,eAAe,SAAS,CAAC,CAAC,MAAM,CAClC;CACA,MAAM,SAAS,YAAY,QAAQ,CAAC,CAAC;CACrC,MAAM,aAAa,OAAO,QAAQ,IAAI;EAAE;EAAK;EAAQ;CAAO,CAAC;CAC7D,MAAM,GAAG,+CAA+C;CACxD,OAAO,EAAE,KAAM,MAAM,mBAAmB,GAAG,YAAY,EAAE,OAAO,CAAC,KAAM,IAAI,GAAG;AAChF;AAEA,eAAe,oBAAoB,GAA+B;CAChE,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;CAC3D,MAAM,UAAU,MAAM,IAAI,aACxB,SAAS,CAAC,CAAC,IACX,KAAA,GACA,eAAe,SAAS,CAAC,CAAC,MAAM,CAClC,CAAC,CAAC,IAAI,QAAQ,IAAI,EAAE,IAAI,MAAM,WAAW,KAAK,EAAE;CAChD,IAAI,CAAC,SACH,SAAS,mBAAmB;CAE9B,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,IAAI,KAAA,GAAW,eAAe,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAClF,QAAQ,IACR,QAAQ,EACV;CACA,MAAM,GAAG,iBAAiB;CAC1B,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,mBAAmB;AACpE"}
@@ -0,0 +1,149 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { credentialKeys } from "../credential-keys.mjs";
5
+ import { getStore } from "../store.mjs";
6
+ import { notFound } from "./helpers.mjs";
7
+ import { renderProjectSettingsPage } from "../pages/project-settings.mjs";
8
+ import { LabelModel, MemberModel, NotificationChannelModel, NotificationSubscriptionModel, ProjectGroupMappingModel, ProjectModel, StatusConfigModel, TokenModel, WebhookModel } from "@storyshelf/core/models";
9
+ //#region src/routers/settings.handlers.ts
10
+ /** Tokens without hashes for the settings UI. */
11
+ async function loadTokenSummaries(db, projectId) {
12
+ return (await new TokenModel(db).list(projectId)).map(({ hash: _hash, ...rest }) => rest);
13
+ }
14
+ /** Webhook summaries for the settings UI. */
15
+ async function loadWebhookSummaries(db, projectId) {
16
+ return (await new WebhookModel(db).list(projectId)).map((webhook) => ({
17
+ id: webhook.id,
18
+ url: webhook.url,
19
+ events: WebhookModel.eventsOf(webhook)
20
+ }));
21
+ }
22
+ /** Status-config summaries for the settings UI. */
23
+ async function loadStatusConfigSummaries(db, secret, projectId) {
24
+ return (await new StatusConfigModel(db, void 0, secret).list(projectId)).map((row) => ({
25
+ id: row.id,
26
+ provider: row.provider,
27
+ config: JSON.parse(row.config),
28
+ hasToken: row.tokenEncrypted.length > 0,
29
+ createdAt: row.createdAt,
30
+ updatedAt: row.updatedAt
31
+ }));
32
+ }
33
+ /** True when the user may administer the project. */
34
+ function isProjectAdmin(authEnabled, user, members) {
35
+ return !authEnabled || user?.role === "admin" || members.some((member) => member.userId === user?.id && member.role === "admin");
36
+ }
37
+ /** Label types + member roster for the settings UI. */
38
+ async function loadProjectRoster(db, projectId) {
39
+ return {
40
+ labelTypes: await new LabelModel(db).listTypes(projectId),
41
+ members: await new MemberModel(db).list(projectId)
42
+ };
43
+ }
44
+ /** Label types + member roster + group mappings for the settings UI. */
45
+ async function loadMembersSection(db, projectId) {
46
+ const { labelTypes, members } = await loadProjectRoster(db, projectId);
47
+ return {
48
+ labelTypes,
49
+ members,
50
+ groupMappings: await loadGroupMappings(db, projectId)
51
+ };
52
+ }
53
+ /** Group mappings for the members settings tab. */
54
+ async function loadGroupMappings(db, projectId) {
55
+ return await new ProjectGroupMappingModel(db).list(projectId);
56
+ }
57
+ /** Channel summaries for the settings UI. */
58
+ async function loadNotificationChannels(db, secret, projectId) {
59
+ return (await new NotificationChannelModel(db, void 0, secret).list(projectId)).map((row) => ({
60
+ id: row.id,
61
+ provider: row.provider,
62
+ config: NotificationChannelModel.configOf(row),
63
+ hasSecret: row.secretEncrypted !== null,
64
+ events: NotificationChannelModel.eventsOf(row),
65
+ enabled: row.enabled
66
+ }));
67
+ }
68
+ /** The viewer's own subscription for the settings UI (null when opted out). */
69
+ async function loadMySubscription(db, projectId, userId) {
70
+ if (!userId) return null;
71
+ const row = await new NotificationSubscriptionModel(db).getFor(projectId, userId);
72
+ if (!row) return null;
73
+ return {
74
+ events: NotificationSubscriptionModel.eventsOf(row),
75
+ via: NotificationSubscriptionModel.viaOf(row),
76
+ enabled: row.enabled
77
+ };
78
+ }
79
+ /** Notification channels, providers, and my subscription for the UI. */
80
+ async function loadNotificationSection(db, secret, projectId, userId, notifiers) {
81
+ const notificationChannels = await loadNotificationChannels(db, secret, projectId);
82
+ const mySubscription = await loadMySubscription(db, projectId, userId);
83
+ return {
84
+ notificationChannels,
85
+ notifyProviders: notifiers.map((candidate) => candidate.metadata.kind),
86
+ mySubscription
87
+ };
88
+ }
89
+ /** Roster, tokens, webhooks, and status configs for the settings UI. */
90
+ async function loadInventorySection(db, secret, projectId) {
91
+ const { labelTypes, members, groupMappings } = await loadMembersSection(db, projectId);
92
+ return {
93
+ labelTypes,
94
+ members,
95
+ groupMappings,
96
+ tokens: await loadTokenSummaries(db, projectId),
97
+ webhooks: await loadWebhookSummaries(db, projectId),
98
+ statusConfigs: await loadStatusConfigSummaries(db, secret, projectId)
99
+ };
100
+ }
101
+ async function loadSettingsData(slug) {
102
+ const project = await new ProjectModel(getStore().db).getBySlug(slug);
103
+ if (!project) return null;
104
+ const { db, config, user, authEnabled, gitHosts, notifiers } = getStore();
105
+ const inventory = await loadInventorySection(db, credentialKeys(config), project.id);
106
+ const notifications = await loadNotificationSection(db, credentialKeys(config), project.id, user?.id, notifiers);
107
+ const isAdmin = isProjectAdmin(authEnabled, user, inventory.members);
108
+ return {
109
+ project,
110
+ ...inventory,
111
+ ...notifications,
112
+ gitHosts,
113
+ isAdmin
114
+ };
115
+ }
116
+ /** Render the project settings page for the given tab, optionally with form state. */
117
+ async function renderSettingsPage(c, tab, formState) {
118
+ const data = await loadSettingsData(c.req.param("slug") ?? "");
119
+ if (!data) notFound("Project not found");
120
+ return await renderProjectSettingsPage({
121
+ project: data.project,
122
+ activeTab: tab,
123
+ labelTypes: data.labelTypes,
124
+ tokens: data.tokens,
125
+ members: data.members,
126
+ groupMappings: data.groupMappings,
127
+ webhooks: data.webhooks,
128
+ notificationChannels: data.notificationChannels,
129
+ notifyProviders: data.notifyProviders,
130
+ mySubscription: data.mySubscription,
131
+ statusConfigs: data.statusConfigs,
132
+ gitHosts: data.gitHosts,
133
+ isAdmin: data.isAdmin
134
+ }, formState);
135
+ }
136
+ /** Find a project by slug or throw 404. */
137
+ async function findProject(slug) {
138
+ const project = await new ProjectModel(getStore().db).getBySlug(slug);
139
+ if (!project) notFound("Project not found");
140
+ return project;
141
+ }
142
+ /** Trim a form value to a string, dropping files and blanks. */
143
+ function asString(value) {
144
+ return typeof value === "string" ? value.trim() : void 0;
145
+ }
146
+ //#endregion
147
+ export { asString, findProject, renderSettingsPage };
148
+
149
+ //# sourceMappingURL=settings.handlers.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings.handlers.mjs","names":[],"sources":["../../src/routers/settings.handlers.ts"],"sourcesContent":["import type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { GitHostProvider } from \"@storyshelf/core/adapter/git-host\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { MemberModel } from \"@storyshelf/core/models\";\nimport { NotificationChannelModel } from \"@storyshelf/core/models\";\nimport { NotificationSubscriptionModel } from \"@storyshelf/core/models\";\nimport { ProjectGroupMappingModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { StatusConfigModel } from \"@storyshelf/core/models\";\nimport { TokenModel } from \"@storyshelf/core/models\";\nimport { WebhookModel } from \"@storyshelf/core/models\";\nimport type { LabelType } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { ProjectGroupMapping } from \"@storyshelf/core/schema\";\nimport type { Token } from \"@storyshelf/core/schema\";\nimport type { AuthUser } from \"@storyshelf/core/types\";\nimport type { SecretInput } from \"@storyshelf/core/utils\";\nimport type { Context } from \"hono\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport {\n renderProjectSettingsPage,\n type SettingsFormState,\n type SettingsTab,\n} from \"../pages/project-settings.tsx\";\nimport type { SettingsMember } from \"../pages/settings-members.tsx\";\nimport type {\n SettingsMySubscription,\n SettingsNotificationChannel,\n} from \"../pages/settings-notifications.tsx\";\nimport type { SettingsStatusConfig } from \"../pages/settings-status.tsx\";\nimport type { SettingsWebhook } from \"../pages/settings-webhooks.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { notFound } from \"./helpers.ts\";\n/** Aggregated data for rendering a settings tab. */\nexport interface SettingsData {\n project: Project;\n labelTypes: LabelType[];\n tokens: Omit<Token, \"hash\">[];\n members: SettingsMember[];\n groupMappings: ProjectGroupMapping[];\n webhooks: SettingsWebhook[];\n notificationChannels: SettingsNotificationChannel[];\n notifyProviders: string[];\n mySubscription: SettingsMySubscription | null;\n statusConfigs: SettingsStatusConfig[];\n gitHosts: GitHostProvider[];\n isAdmin: boolean;\n}\n\n/** Tokens without hashes for the settings UI. */\nasync function loadTokenSummaries(\n db: DatabaseAdapter,\n projectId: string,\n): Promise<Omit<Token, \"hash\">[]> {\n const tokensDb = await new TokenModel(db).list(projectId);\n return tokensDb.map(({ hash: _hash, ...rest }) => rest);\n}\n\n/** Webhook summaries for the settings UI. */\nasync function loadWebhookSummaries(\n db: DatabaseAdapter,\n projectId: string,\n): Promise<SettingsWebhook[]> {\n const webhooksDb = await new WebhookModel(db).list(projectId);\n return webhooksDb.map((webhook) => ({\n id: webhook.id,\n url: webhook.url,\n events: WebhookModel.eventsOf(webhook),\n }));\n}\n\n/** Status-config summaries for the settings UI. */\nasync function loadStatusConfigSummaries(\n db: DatabaseAdapter,\n secret: SecretInput,\n projectId: string,\n): Promise<SettingsStatusConfig[]> {\n const statusConfigsDb = await new StatusConfigModel(db, undefined, secret).list(projectId);\n return statusConfigsDb.map((row) => ({\n id: row.id,\n provider: row.provider,\n config: JSON.parse(row.config) as Record<string, unknown>,\n hasToken: row.tokenEncrypted.length > 0,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n }));\n}\n\n/** True when the user may administer the project. */\nfunction isProjectAdmin(\n authEnabled: boolean,\n user: AuthUser | null,\n members: SettingsMember[],\n): boolean {\n return (\n !authEnabled ||\n user?.role === \"admin\" ||\n members.some((member) => member.userId === user?.id && member.role === \"admin\")\n );\n}\n\n/** Label types + member roster for the settings UI. */\nasync function loadProjectRoster(\n db: DatabaseAdapter,\n projectId: string,\n): Promise<{ labelTypes: LabelType[]; members: SettingsMember[] }> {\n const labelTypes = await new LabelModel(db).listTypes(projectId);\n const members = await new MemberModel(db).list(projectId);\n return { labelTypes, members };\n}\n\n/** Label types + member roster + group mappings for the settings UI. */\nasync function loadMembersSection(\n db: DatabaseAdapter,\n projectId: string,\n): Promise<{\n labelTypes: LabelType[];\n members: SettingsMember[];\n groupMappings: ProjectGroupMapping[];\n}> {\n const { labelTypes, members } = await loadProjectRoster(db, projectId);\n const groupMappings = await loadGroupMappings(db, projectId);\n return { labelTypes, members, groupMappings };\n}\n\n/** Group mappings for the members settings tab. */\nasync function loadGroupMappings(\n db: DatabaseAdapter,\n projectId: string,\n): Promise<ProjectGroupMapping[]> {\n return await new ProjectGroupMappingModel(db).list(projectId);\n}\n\n/** Channel summaries for the settings UI. */\nasync function loadNotificationChannels(\n db: DatabaseAdapter,\n secret: SecretInput,\n projectId: string,\n): Promise<SettingsNotificationChannel[]> {\n const rows = await new NotificationChannelModel(db, undefined, secret).list(projectId);\n return rows.map((row) => ({\n id: row.id,\n provider: row.provider,\n config: NotificationChannelModel.configOf(row),\n hasSecret: row.secretEncrypted !== null,\n events: NotificationChannelModel.eventsOf(row),\n enabled: row.enabled,\n }));\n}\n\n/** The viewer's own subscription for the settings UI (null when opted out). */\nasync function loadMySubscription(\n db: DatabaseAdapter,\n projectId: string,\n userId: string | undefined,\n): Promise<SettingsMySubscription | null> {\n if (!userId) {\n return null;\n }\n const row = await new NotificationSubscriptionModel(db).getFor(projectId, userId);\n if (!row) {\n return null;\n }\n return {\n events: NotificationSubscriptionModel.eventsOf(row),\n via: NotificationSubscriptionModel.viaOf(row),\n enabled: row.enabled,\n };\n}\n\n/** Notification channels, providers, and my subscription for the UI. */\nasync function loadNotificationSection(\n db: DatabaseAdapter,\n secret: SecretInput,\n projectId: string,\n userId: string | undefined,\n notifiers: { metadata: { kind: string } }[],\n): Promise<{\n notificationChannels: SettingsNotificationChannel[];\n notifyProviders: string[];\n mySubscription: SettingsMySubscription | null;\n}> {\n const notificationChannels = await loadNotificationChannels(db, secret, projectId);\n const mySubscription = await loadMySubscription(db, projectId, userId);\n return {\n notificationChannels,\n notifyProviders: notifiers.map((candidate) => candidate.metadata.kind),\n mySubscription,\n };\n}\n\n/** Roster, tokens, webhooks, and status configs for the settings UI. */\nasync function loadInventorySection(\n db: DatabaseAdapter,\n secret: SecretInput,\n projectId: string,\n): Promise<{\n labelTypes: LabelType[];\n members: SettingsMember[];\n groupMappings: ProjectGroupMapping[];\n tokens: Omit<Token, \"hash\">[];\n webhooks: SettingsWebhook[];\n statusConfigs: SettingsStatusConfig[];\n}> {\n const { labelTypes, members, groupMappings } = await loadMembersSection(db, projectId);\n const tokens = await loadTokenSummaries(db, projectId);\n const webhooks = await loadWebhookSummaries(db, projectId);\n const statusConfigs = await loadStatusConfigSummaries(db, secret, projectId);\n return { labelTypes, members, groupMappings, tokens, webhooks, statusConfigs };\n}\n\nasync function loadSettingsData(slug: string): Promise<SettingsData | null> {\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n return null;\n }\n const { db, config, user, authEnabled, gitHosts, notifiers } = getStore();\n const inventory = await loadInventorySection(db, credentialKeys(config), project.id);\n const notifications = await loadNotificationSection(\n db,\n credentialKeys(config),\n project.id,\n user?.id,\n notifiers,\n );\n const isAdmin = isProjectAdmin(authEnabled, user, inventory.members);\n return { project, ...inventory, ...notifications, gitHosts, isAdmin };\n}\n\n/** Render the project settings page for the given tab, optionally with form state. */\nexport async function renderSettingsPage(\n c: Context,\n tab: SettingsTab,\n formState?: SettingsFormState,\n): Promise<string> {\n const slug = c.req.param(\"slug\") ?? \"\";\n const data = await loadSettingsData(slug);\n if (!data) {\n notFound(\"Project not found\");\n }\n return await renderProjectSettingsPage(\n {\n project: data.project,\n activeTab: tab,\n labelTypes: data.labelTypes,\n tokens: data.tokens,\n members: data.members,\n groupMappings: data.groupMappings,\n webhooks: data.webhooks,\n notificationChannels: data.notificationChannels,\n notifyProviders: data.notifyProviders,\n mySubscription: data.mySubscription,\n statusConfigs: data.statusConfigs,\n gitHosts: data.gitHosts,\n isAdmin: data.isAdmin,\n },\n formState,\n );\n}\n\n/** Find a project by slug or throw 404. */\nexport async function findProject(slug: string): Promise<Project> {\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n return project;\n}\n\n/** Trim a form value to a string, dropping files and blanks. */\nexport function asString(value: FormDataEntryValue | null): string | undefined {\n return typeof value === \"string\" ? value.trim() : undefined;\n}\n"],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { registerGeneralSettings } from "./settings-general.mjs";
5
+ import { registerLabelSettings } from "./settings-labels.mjs";
6
+ import { registerMemberSettings } from "./settings-members.mjs";
7
+ import { registerNotificationSettings } from "./settings-notifications.mjs";
8
+ import { registerStatusSettings } from "./settings-status.mjs";
9
+ import { registerTokenSettings } from "./settings-tokens.mjs";
10
+ import { registerWebhookSettings } from "./settings-webhooks.mjs";
11
+ //#region src/routers/settings.ts
12
+ /** Register the server-rendered project settings pages and their form handlers. */
13
+ function registerSettingsPages(app) {
14
+ registerGeneralSettings(app);
15
+ registerLabelSettings(app);
16
+ registerTokenSettings(app);
17
+ registerWebhookSettings(app);
18
+ registerNotificationSettings(app);
19
+ registerMemberSettings(app);
20
+ registerStatusSettings(app);
21
+ }
22
+ //#endregion
23
+ export { registerSettingsPages };
24
+
25
+ //# sourceMappingURL=settings.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings.mjs","names":[],"sources":["../../src/routers/settings.ts"],"sourcesContent":["import type { ShelfRouter } from \"../app-types.ts\";\nimport { registerGeneralSettings } from \"./settings-general.ts\";\nimport { registerLabelSettings } from \"./settings-labels.ts\";\nimport { registerMemberSettings } from \"./settings-members.ts\";\nimport { registerNotificationSettings } from \"./settings-notifications.ts\";\nimport { registerStatusSettings } from \"./settings-status.ts\";\nimport { registerTokenSettings } from \"./settings-tokens.ts\";\nimport { registerWebhookSettings } from \"./settings-webhooks.ts\";\n\n/** Register the server-rendered project settings pages and their form handlers. */\nexport function registerSettingsPages(app: ShelfRouter): void {\n registerGeneralSettings(app);\n registerLabelSettings(app);\n registerTokenSettings(app);\n registerWebhookSettings(app);\n registerNotificationSettings(app);\n registerMemberSettings(app);\n registerStatusSettings(app);\n}\n"],"mappings":";;;;;;;;;;;;AAUA,SAAgB,sBAAsB,KAAwB;CAC5D,wBAAwB,GAAG;CAC3B,sBAAsB,GAAG;CACzB,sBAAsB,GAAG;CACzB,wBAAwB,GAAG;CAC3B,6BAA6B,GAAG;CAChC,uBAAuB,GAAG;CAC1B,uBAAuB,GAAG;AAC5B"}