@storyshelf/app 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -4
- package/dist/app-types.d.mts +1 -3
- package/dist/asset-manifest.mjs +13 -5
- package/dist/asset-manifest.mjs.map +1 -1
- package/dist/assets/{htmx.min.js → htmx.mjs} +10 -1
- package/dist/assets/htmx.mjs.map +1 -0
- package/dist/assets/mark.mjs +10 -0
- package/dist/assets/mark.mjs.map +1 -0
- package/dist/boot-tasks.mjs +80 -0
- package/dist/boot-tasks.mjs.map +1 -0
- package/dist/build-state-flags.mjs +18 -0
- package/dist/build-state-flags.mjs.map +1 -0
- package/dist/build-state-setup.mjs +75 -0
- package/dist/build-state-setup.mjs.map +1 -0
- package/dist/capture-alerts.mjs +46 -0
- package/dist/capture-alerts.mjs.map +1 -0
- package/dist/capture-process.mjs +59 -0
- package/dist/capture-process.mjs.map +1 -0
- package/dist/capture-setup.mjs +95 -117
- package/dist/capture-setup.mjs.map +1 -1
- package/dist/connections.mjs +34 -0
- package/dist/connections.mjs.map +1 -0
- package/dist/consumer-setup.mjs +58 -0
- package/dist/consumer-setup.mjs.map +1 -0
- package/dist/git-status.mjs +46 -0
- package/dist/git-status.mjs.map +1 -0
- package/dist/hidden-data.mjs +24 -0
- package/dist/hidden-data.mjs.map +1 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +203 -125
- package/dist/index.mjs.map +1 -1
- package/dist/insights/budget.mjs +49 -0
- package/dist/insights/budget.mjs.map +1 -0
- package/dist/insights/deps.mjs +21 -0
- package/dist/insights/deps.mjs.map +1 -0
- package/dist/insights/errors.mjs +22 -0
- package/dist/insights/errors.mjs.map +1 -0
- package/dist/insights/health-panel.mjs +40 -0
- package/dist/insights/health-panel.mjs.map +1 -0
- package/dist/insights/job.mjs +103 -0
- package/dist/insights/job.mjs.map +1 -0
- package/dist/insights/load-health.mjs +59 -0
- package/dist/insights/load-health.mjs.map +1 -0
- package/dist/insights/load-triage.mjs +98 -0
- package/dist/insights/load-triage.mjs.map +1 -0
- package/dist/insights/panel.mjs +28 -0
- package/dist/insights/panel.mjs.map +1 -0
- package/dist/insights/request.mjs +113 -0
- package/dist/insights/request.mjs.map +1 -0
- package/dist/insights/stale.mjs +21 -0
- package/dist/insights/stale.mjs.map +1 -0
- package/dist/insights/usage-report.mjs +60 -0
- package/dist/insights/usage-report.mjs.map +1 -0
- package/dist/insights/view.mjs +48 -0
- package/dist/insights/view.mjs.map +1 -0
- package/dist/job-canceller.mjs +14 -0
- package/dist/job-canceller.mjs.map +1 -0
- package/dist/job-queue.mjs +51 -0
- package/dist/job-queue.mjs.map +1 -0
- package/dist/jobs-setup.mjs +132 -0
- package/dist/jobs-setup.mjs.map +1 -0
- package/dist/lifecycle.mjs +91 -19
- package/dist/lifecycle.mjs.map +1 -1
- package/dist/middleware/auth-gate.mjs +4 -2
- package/dist/middleware/auth-gate.mjs.map +1 -1
- package/dist/middleware/rate-limit.mjs +41 -33
- package/dist/middleware/rate-limit.mjs.map +1 -1
- package/dist/middleware/request-log.mjs +3 -2
- package/dist/middleware/request-log.mjs.map +1 -1
- package/dist/middleware/require-review.mjs +51 -0
- package/dist/middleware/require-review.mjs.map +1 -0
- package/dist/middleware/require-testing.mjs +41 -0
- package/dist/middleware/require-testing.mjs.map +1 -0
- package/dist/middleware/setup-gate.mjs.map +1 -1
- package/dist/middleware/store-scope.mjs +12 -2
- package/dist/middleware/store-scope.mjs.map +1 -1
- package/dist/notifications-pause.mjs +33 -0
- package/dist/notifications-pause.mjs.map +1 -0
- package/dist/notify.d.mts +2 -1
- package/dist/notify.mjs +78 -39
- package/dist/notify.mjs.map +1 -1
- package/dist/pages/a11y-violations.mjs +116 -0
- package/dist/pages/a11y-violations.mjs.map +1 -0
- package/dist/pages/admin-ai.mjs +78 -0
- package/dist/pages/admin-ai.mjs.map +1 -0
- package/dist/pages/admin-system-origin.mjs +123 -0
- package/dist/pages/admin-system-origin.mjs.map +1 -0
- package/dist/pages/admin-system-targets.mjs +53 -0
- package/dist/pages/admin-system-targets.mjs.map +1 -0
- package/dist/pages/admin-system.mjs +63 -13
- package/dist/pages/admin-system.mjs.map +1 -1
- package/dist/pages/build-detail.mjs +104 -76
- package/dist/pages/build-detail.mjs.map +1 -1
- package/dist/pages/build-diff-actions.mjs +87 -33
- package/dist/pages/build-diff-actions.mjs.map +1 -1
- package/dist/pages/build-diff-comments.mjs +16 -14
- package/dist/pages/build-diff-comments.mjs.map +1 -1
- package/dist/pages/build-diff-environment.mjs +82 -0
- package/dist/pages/build-diff-environment.mjs.map +1 -0
- package/dist/pages/build-diff-header.mjs +22 -4
- package/dist/pages/build-diff-header.mjs.map +1 -1
- package/dist/pages/build-diff-insight.mjs +113 -0
- package/dist/pages/build-diff-insight.mjs.map +1 -0
- package/dist/pages/build-diff-model.mjs +55 -10
- package/dist/pages/build-diff-model.mjs.map +1 -1
- package/dist/pages/build-diff-nav.mjs +28 -12
- package/dist/pages/build-diff-nav.mjs.map +1 -1
- package/dist/pages/build-diff-shortcuts.mjs +5 -1
- package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
- package/dist/pages/build-diff-usage.mjs +63 -0
- package/dist/pages/build-diff-usage.mjs.map +1 -0
- package/dist/pages/build-diff-viewer.mjs +62 -5
- package/dist/pages/build-diff-viewer.mjs.map +1 -1
- package/dist/pages/build-diff.mjs +34 -13
- package/dist/pages/build-diff.mjs.map +1 -1
- package/dist/pages/build-tests.mjs +74 -0
- package/dist/pages/build-tests.mjs.map +1 -0
- package/dist/pages/component-detail.mjs +249 -0
- package/dist/pages/component-detail.mjs.map +1 -0
- package/dist/pages/component-stories.mjs +168 -0
- package/dist/pages/component-stories.mjs.map +1 -0
- package/dist/pages/compute-jobs.mjs +13 -26
- package/dist/pages/compute-jobs.mjs.map +1 -1
- package/dist/pages/design-links.mjs +182 -0
- package/dist/pages/design-links.mjs.map +1 -0
- package/dist/pages/environment-change.mjs +53 -0
- package/dist/pages/environment-change.mjs.map +1 -0
- package/dist/pages/health-panel.mjs +126 -0
- package/dist/pages/health-panel.mjs.map +1 -0
- package/dist/pages/library-grid.mjs +105 -0
- package/dist/pages/library-grid.mjs.map +1 -0
- package/dist/pages/library-model.mjs +47 -0
- package/dist/pages/library-model.mjs.map +1 -0
- package/dist/pages/library-snapshots.mjs +130 -0
- package/dist/pages/library-snapshots.mjs.map +1 -0
- package/dist/pages/library.mjs +112 -218
- package/dist/pages/library.mjs.map +1 -1
- package/dist/pages/notifications-paused.mjs +22 -0
- package/dist/pages/notifications-paused.mjs.map +1 -0
- package/dist/pages/profile.mjs +53 -48
- package/dist/pages/profile.mjs.map +1 -1
- package/dist/pages/project-builds-table.mjs +2 -2
- package/dist/pages/project-builds-table.mjs.map +1 -1
- package/dist/pages/project-builds.mjs +51 -21
- package/dist/pages/project-builds.mjs.map +1 -1
- package/dist/pages/project-settings.mjs +51 -12
- package/dist/pages/project-settings.mjs.map +1 -1
- package/dist/pages/projects-card.mjs +14 -1
- package/dist/pages/projects-card.mjs.map +1 -1
- package/dist/pages/projects.mjs +6 -2
- package/dist/pages/projects.mjs.map +1 -1
- package/dist/pages/review-changeset.mjs +116 -0
- package/dist/pages/review-changeset.mjs.map +1 -0
- package/dist/pages/review-comments.mjs +103 -0
- package/dist/pages/review-comments.mjs.map +1 -0
- package/dist/pages/review-verdict.mjs +102 -0
- package/dist/pages/review-verdict.mjs.map +1 -0
- package/dist/pages/settings-ai.mjs +69 -0
- package/dist/pages/settings-ai.mjs.map +1 -0
- package/dist/pages/settings-capture.mjs +124 -0
- package/dist/pages/settings-capture.mjs.map +1 -0
- package/dist/pages/settings-design-credential.mjs +99 -0
- package/dist/pages/settings-design-credential.mjs.map +1 -0
- package/dist/pages/settings-design-links.mjs +179 -0
- package/dist/pages/settings-design-links.mjs.map +1 -0
- package/dist/pages/settings-design.mjs +48 -0
- package/dist/pages/settings-design.mjs.map +1 -0
- package/dist/pages/settings-general.mjs +46 -21
- package/dist/pages/settings-general.mjs.map +1 -1
- package/dist/pages/settings-labels.mjs +43 -2
- package/dist/pages/settings-labels.mjs.map +1 -1
- package/dist/pages/settings-links.mjs +112 -0
- package/dist/pages/settings-links.mjs.map +1 -0
- package/dist/pages/settings-notifications-git.mjs +128 -0
- package/dist/pages/settings-notifications-git.mjs.map +1 -0
- package/dist/pages/settings-notifications.mjs +79 -42
- package/dist/pages/settings-notifications.mjs.map +1 -1
- package/dist/pages/settings-tests.mjs +33 -2
- package/dist/pages/settings-tests.mjs.map +1 -1
- package/dist/pages/target-choices.mjs +39 -0
- package/dist/pages/target-choices.mjs.map +1 -0
- package/dist/publish-setup.mjs +89 -0
- package/dist/publish-setup.mjs.map +1 -0
- package/dist/retention-timer.mjs +2 -1
- package/dist/retention-timer.mjs.map +1 -1
- package/dist/review-events.mjs +48 -0
- package/dist/review-events.mjs.map +1 -0
- package/dist/routers/admin-pages.mjs +45 -3
- package/dist/routers/admin-pages.mjs.map +1 -1
- package/dist/routers/admin-public-origin.mjs +85 -0
- package/dist/routers/admin-public-origin.mjs.map +1 -0
- package/dist/routers/assets.mjs +9 -2
- package/dist/routers/assets.mjs.map +1 -1
- package/dist/routers/auth.mjs +1 -1
- package/dist/routers/baseline-guard.mjs +2 -2
- package/dist/routers/baseline-guard.mjs.map +1 -1
- package/dist/routers/baselines.handlers.mjs +64 -0
- package/dist/routers/baselines.handlers.mjs.map +1 -0
- package/dist/routers/baselines.mjs +125 -0
- package/dist/routers/baselines.mjs.map +1 -0
- package/dist/routers/build-cancel.mjs +68 -0
- package/dist/routers/build-cancel.mjs.map +1 -0
- package/dist/routers/build-changeset.mjs +70 -0
- package/dist/routers/build-changeset.mjs.map +1 -0
- package/dist/routers/build-reviews.handlers.mjs +86 -0
- package/dist/routers/build-reviews.handlers.mjs.map +1 -0
- package/dist/routers/build-reviews.mjs +74 -0
- package/dist/routers/build-reviews.mjs.map +1 -0
- package/dist/routers/builds.handlers.mjs +46 -55
- package/dist/routers/builds.handlers.mjs.map +1 -1
- package/dist/routers/builds.mjs +71 -17
- package/dist/routers/builds.mjs.map +1 -1
- package/dist/routers/capture-config.mjs +35 -0
- package/dist/routers/capture-config.mjs.map +1 -0
- package/dist/routers/comments.mjs +41 -15
- package/dist/routers/comments.mjs.map +1 -1
- package/dist/routers/component-catalog.handlers.mjs +71 -0
- package/dist/routers/component-catalog.handlers.mjs.map +1 -0
- package/dist/routers/component.handlers.mjs +121 -0
- package/dist/routers/component.handlers.mjs.map +1 -0
- package/dist/routers/connections.mjs +118 -0
- package/dist/routers/connections.mjs.map +1 -0
- package/dist/routers/design-connect.handlers.mjs +44 -0
- package/dist/routers/design-connect.handlers.mjs.map +1 -0
- package/dist/routers/design.handlers.mjs +114 -0
- package/dist/routers/design.handlers.mjs.map +1 -0
- package/dist/routers/health-report.mjs +2 -3
- package/dist/routers/health-report.mjs.map +1 -1
- package/dist/routers/health.mjs +4 -3
- package/dist/routers/health.mjs.map +1 -1
- package/dist/routers/helpers.mjs +19 -3
- package/dist/routers/helpers.mjs.map +1 -1
- package/dist/routers/insights-ui.mjs +64 -0
- package/dist/routers/insights-ui.mjs.map +1 -0
- package/dist/routers/insights.handlers.mjs +45 -0
- package/dist/routers/insights.handlers.mjs.map +1 -0
- package/dist/routers/insights.mjs +189 -0
- package/dist/routers/insights.mjs.map +1 -0
- package/dist/routers/links.handlers.mjs +80 -0
- package/dist/routers/links.handlers.mjs.map +1 -0
- package/dist/routers/links.mjs +139 -0
- package/dist/routers/links.mjs.map +1 -0
- package/dist/routers/media.mjs +18 -4
- package/dist/routers/media.mjs.map +1 -1
- package/dist/routers/members.mjs +2 -1
- package/dist/routers/members.mjs.map +1 -1
- package/dist/routers/notification-channels.mjs +17 -23
- package/dist/routers/notification-channels.mjs.map +1 -1
- package/dist/routers/oauth-state.mjs +45 -0
- package/dist/routers/oauth-state.mjs.map +1 -0
- package/dist/routers/preview-base.mjs +38 -0
- package/dist/routers/preview-base.mjs.map +1 -0
- package/dist/routers/preview-token.mjs +51 -0
- package/dist/routers/preview-token.mjs.map +1 -0
- package/dist/routers/profile.mjs +3 -1
- package/dist/routers/profile.mjs.map +1 -1
- package/dist/routers/projects.mjs +17 -1
- package/dist/routers/projects.mjs.map +1 -1
- package/dist/routers/publish-upload.mjs +78 -0
- package/dist/routers/publish-upload.mjs.map +1 -0
- package/dist/routers/published-access.mjs +52 -0
- package/dist/routers/published-access.mjs.map +1 -0
- package/dist/routers/published-files.mjs +52 -0
- package/dist/routers/published-files.mjs.map +1 -0
- package/dist/routers/review-forms.mjs +72 -0
- package/dist/routers/review-forms.mjs.map +1 -0
- package/dist/routers/review-section.handlers.mjs +85 -0
- package/dist/routers/review-section.handlers.mjs.map +1 -0
- package/dist/routers/review-switch-off.mjs +29 -0
- package/dist/routers/review-switch-off.mjs.map +1 -0
- package/dist/routers/schemas.mjs +140 -22
- package/dist/routers/schemas.mjs.map +1 -1
- package/dist/routers/scoped-models.mjs +22 -0
- package/dist/routers/scoped-models.mjs.map +1 -0
- package/dist/routers/settings-ai.mjs +35 -0
- package/dist/routers/settings-ai.mjs.map +1 -0
- package/dist/routers/settings-capture.mjs +77 -0
- package/dist/routers/settings-capture.mjs.map +1 -0
- package/dist/routers/settings-design.mjs +103 -0
- package/dist/routers/settings-design.mjs.map +1 -0
- package/dist/routers/settings-general.mjs +55 -10
- package/dist/routers/settings-general.mjs.map +1 -1
- package/dist/routers/settings-git-channel.mjs +77 -0
- package/dist/routers/settings-git-channel.mjs.map +1 -0
- package/dist/routers/settings-labels.mjs +27 -1
- package/dist/routers/settings-labels.mjs.map +1 -1
- package/dist/routers/settings-links.mjs +63 -0
- package/dist/routers/settings-links.mjs.map +1 -0
- package/dist/routers/settings-members.mjs +4 -7
- package/dist/routers/settings-members.mjs.map +1 -1
- package/dist/routers/settings-notifications.mjs +15 -10
- package/dist/routers/settings-notifications.mjs.map +1 -1
- package/dist/routers/settings-tokens.mjs +2 -5
- package/dist/routers/settings-tokens.mjs.map +1 -1
- package/dist/routers/settings-webhooks.mjs +7 -24
- package/dist/routers/settings-webhooks.mjs.map +1 -1
- package/dist/routers/settings.handlers.mjs +107 -26
- package/dist/routers/settings.handlers.mjs.map +1 -1
- package/dist/routers/settings.mjs +16 -4
- package/dist/routers/settings.mjs.map +1 -1
- package/dist/routers/signed-storybook.mjs +53 -0
- package/dist/routers/signed-storybook.mjs.map +1 -0
- package/dist/routers/snapshot-groups.mjs +63 -0
- package/dist/routers/snapshot-groups.mjs.map +1 -0
- package/dist/routers/snapshots.mjs +103 -5
- package/dist/routers/snapshots.mjs.map +1 -1
- package/dist/routers/storybook.mjs +37 -88
- package/dist/routers/storybook.mjs.map +1 -1
- package/dist/routers/tokens.mjs +2 -5
- package/dist/routers/tokens.mjs.map +1 -1
- package/dist/routers/ui.mjs +34 -5
- package/dist/routers/ui.mjs.map +1 -1
- package/dist/routers/webhooks.mjs +5 -11
- package/dist/routers/webhooks.mjs.map +1 -1
- package/dist/routers/well-known.mjs +11 -4
- package/dist/routers/well-known.mjs.map +1 -1
- package/dist/routes/api-routes.mjs +93 -0
- package/dist/routes/api-routes.mjs.map +1 -0
- package/dist/routes/classification.mjs +38 -0
- package/dist/routes/classification.mjs.map +1 -0
- package/dist/routes/openapi-classes.mjs +22 -0
- package/dist/routes/openapi-classes.mjs.map +1 -0
- package/dist/routes/page-routes.mjs +104 -0
- package/dist/routes/page-routes.mjs.map +1 -0
- package/dist/runtime.mjs +18 -13
- package/dist/runtime.mjs.map +1 -1
- package/dist/serverless.mjs +5 -0
- package/dist/store.mjs +19 -1
- package/dist/store.mjs.map +1 -1
- package/dist/targets.mjs +46 -0
- package/dist/targets.mjs.map +1 -0
- package/dist/ui/buttons.mjs +16 -3
- package/dist/ui/buttons.mjs.map +1 -1
- package/dist/ui/design-reference.mjs +97 -0
- package/dist/ui/design-reference.mjs.map +1 -0
- package/dist/ui/document.mjs +1 -2
- package/dist/ui/document.mjs.map +1 -1
- package/dist/ui/forms.mjs +6 -5
- package/dist/ui/forms.mjs.map +1 -1
- package/dist/ui/icons/paths.mjs +1 -0
- package/dist/ui/icons/paths.mjs.map +1 -1
- package/dist/ui/preview-embed.mjs +82 -0
- package/dist/ui/preview-embed.mjs.map +1 -0
- package/dist/ui/shell/client-script.mjs +85 -0
- package/dist/ui/shell/client-script.mjs.map +1 -1
- package/dist/ui/shell/nav.mjs +12 -8
- package/dist/ui/shell/nav.mjs.map +1 -1
- package/dist/ui/shell/review-script.mjs +8 -0
- package/dist/ui/shell/review-script.mjs.map +1 -1
- package/dist/ui/shell/sidebar.mjs +2 -2
- package/dist/ui/shell/sidebar.mjs.map +1 -1
- package/package.json +11 -9
- package/dist/asset-paths.mjs +0 -23
- package/dist/asset-paths.mjs.map +0 -1
- package/dist/assets/mark.svg +0 -1
- package/dist/pages/settings-status.mjs +0 -137
- package/dist/pages/settings-status.mjs.map +0 -1
- package/dist/routers/settings-status.mjs +0 -77
- package/dist/routers/settings-status.mjs.map +0 -1
- package/dist/routers/status-configs.mjs +0 -108
- package/dist/routers/status-configs.mjs.map +0 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { flash } from "./flash.mjs";
|
|
6
|
+
import { notificationChannelModel } from "./scoped-models.mjs";
|
|
7
|
+
import { asString, renderSettingsPage } from "./settings.handlers.mjs";
|
|
8
|
+
import { NotificationChannelModel } from "@storyshelf/core/models";
|
|
9
|
+
//#region src/routers/settings-git-channel.ts
|
|
10
|
+
/** Create a git (`state`) channel from the settings form: repository, status prefix and token. */
|
|
11
|
+
const CONFIG_FIELDS = [
|
|
12
|
+
"owner",
|
|
13
|
+
"repo",
|
|
14
|
+
"host",
|
|
15
|
+
"prefix"
|
|
16
|
+
];
|
|
17
|
+
/** Only the GitHub provider reads these: how it shows a build and the App that posts check runs. */
|
|
18
|
+
const GITHUB_FIELDS = [
|
|
19
|
+
"mode",
|
|
20
|
+
"appId",
|
|
21
|
+
"installationId"
|
|
22
|
+
];
|
|
23
|
+
/** The provider config from the form; blank optional fields are left out. */
|
|
24
|
+
function gitConfig(provider, form) {
|
|
25
|
+
const config = {};
|
|
26
|
+
const fields = provider === "github" ? [...CONFIG_FIELDS, ...GITHUB_FIELDS] : CONFIG_FIELDS;
|
|
27
|
+
for (const field of fields) {
|
|
28
|
+
const value = asString(form.get(field));
|
|
29
|
+
if (value) config[field] = value;
|
|
30
|
+
}
|
|
31
|
+
return config;
|
|
32
|
+
}
|
|
33
|
+
/** Render the tab again with field errors (HTTP 400). */
|
|
34
|
+
async function formError(c, errors) {
|
|
35
|
+
return c.html(await renderSettingsPage(c, "notifications", { errors }) ?? "", 400);
|
|
36
|
+
}
|
|
37
|
+
/** Map a failed validation to the form fields it concerns. */
|
|
38
|
+
function fieldErrors(secretField, checked) {
|
|
39
|
+
if (checked.field === "secret") return { [secretField]: checked.message };
|
|
40
|
+
if (checked.field === "provider") return { gitProvider: checked.message };
|
|
41
|
+
const errors = {};
|
|
42
|
+
for (const issue of checked.issues ?? []) errors[issue.path] ??= issue.message;
|
|
43
|
+
return Object.keys(errors).length > 0 ? errors : { gitProvider: checked.message };
|
|
44
|
+
}
|
|
45
|
+
/** Validate and store a git channel; the token lands in the encrypted secret column. */
|
|
46
|
+
async function createGitChannel(c, project, provider, form) {
|
|
47
|
+
const config = gitConfig(provider, form);
|
|
48
|
+
const checks = config["mode"] === "checks";
|
|
49
|
+
const token = asString(form.get(checks ? "privateKey" : "token"));
|
|
50
|
+
const problem = gitChannelProblem(project, provider, config, {
|
|
51
|
+
token,
|
|
52
|
+
secretField: checks ? "privateKey" : "token"
|
|
53
|
+
});
|
|
54
|
+
if (problem) return await formError(c, problem);
|
|
55
|
+
await notificationChannelModel().create({
|
|
56
|
+
projectId: project.id,
|
|
57
|
+
provider,
|
|
58
|
+
config,
|
|
59
|
+
secret: token
|
|
60
|
+
});
|
|
61
|
+
flash(c, "Git channel added");
|
|
62
|
+
return c.html(await renderSettingsPage(c, "notifications") ?? "", 201);
|
|
63
|
+
}
|
|
64
|
+
/** The form errors for a git channel input, or null when it is valid. */
|
|
65
|
+
function gitChannelProblem(project, provider, config, secret) {
|
|
66
|
+
const { notifiers } = getStore();
|
|
67
|
+
if (!NotificationChannelModel.isStateKind(notifiers, provider)) return { gitProvider: `Unknown git provider: ${provider}` };
|
|
68
|
+
const checked = NotificationChannelModel.validateInput(notifiers, provider, config, {
|
|
69
|
+
projectId: project.id,
|
|
70
|
+
hasSecret: Boolean(secret.token)
|
|
71
|
+
});
|
|
72
|
+
return checked.ok ? null : fieldErrors(secret.secretField, checked);
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
export { createGitChannel };
|
|
76
|
+
|
|
77
|
+
//# sourceMappingURL=settings-git-channel.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settings-git-channel.mjs","names":[],"sources":["../../src/routers/settings-git-channel.ts"],"sourcesContent":["/** Create a git (`state`) channel from the settings form: repository, status prefix and token. */\nimport { NotificationChannelModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Context } from \"hono\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { notificationChannelModel } from \"./scoped-models.ts\";\nimport { asString, renderSettingsPage } from \"./settings.handlers.ts\";\n\nconst CONFIG_FIELDS = [\"owner\", \"repo\", \"host\", \"prefix\"] as const;\n/** Only the GitHub provider reads these: how it shows a build and the App that posts check runs. */\nconst GITHUB_FIELDS = [\"mode\", \"appId\", \"installationId\"] as const;\n\n/** The provider config from the form; blank optional fields are left out. */\nfunction gitConfig(provider: string, form: FormData): Record<string, string> {\n const config: Record<string, string> = {};\n const fields = provider === \"github\" ? [...CONFIG_FIELDS, ...GITHUB_FIELDS] : CONFIG_FIELDS;\n for (const field of fields) {\n const value = asString(form.get(field));\n if (value) {\n config[field] = value;\n }\n }\n return config;\n}\n\n/** Render the tab again with field errors (HTTP 400). */\nasync function formError(c: Context, errors: Record<string, string>): Promise<Response> {\n return c.html((await renderSettingsPage(c, \"notifications\", { errors })) ?? \"\", 400);\n}\n\n/** Map a failed validation to the form fields it concerns. */\nfunction fieldErrors(\n secretField: \"token\" | \"privateKey\",\n checked: Extract<ReturnType<typeof NotificationChannelModel.validateInput>, { ok: false }>,\n): Record<string, string> {\n if (checked.field === \"secret\") {\n return { [secretField]: checked.message };\n }\n if (checked.field === \"provider\") {\n return { gitProvider: checked.message };\n }\n const errors: Record<string, string> = {};\n for (const issue of checked.issues ?? []) {\n errors[issue.path] ??= issue.message;\n }\n return Object.keys(errors).length > 0 ? errors : { gitProvider: checked.message };\n}\n\n/** Validate and store a git channel; the token lands in the encrypted secret column. */\nexport async function createGitChannel(\n c: Context,\n project: Project,\n provider: string,\n form: FormData,\n): Promise<Response> {\n const config = gitConfig(provider, form);\n // A GitHub App channel keeps its private key where other channels keep a token.\n const checks = config[\"mode\"] === \"checks\";\n const token = asString(form.get(checks ? \"privateKey\" : \"token\"));\n const problem = gitChannelProblem(project, provider, config, {\n token,\n secretField: checks ? \"privateKey\" : \"token\",\n });\n if (problem) {\n return await formError(c, problem);\n }\n await notificationChannelModel().create({\n projectId: project.id,\n provider,\n config,\n secret: token,\n });\n flash(c, \"Git channel added\");\n return c.html((await renderSettingsPage(c, \"notifications\")) ?? \"\", 201);\n}\n\n/** The form errors for a git channel input, or null when it is valid. */\nfunction gitChannelProblem(\n project: Project,\n provider: string,\n config: Record<string, string>,\n secret: { token: string | undefined; secretField: \"token\" | \"privateKey\" },\n): Record<string, string> | null {\n const { notifiers } = getStore();\n if (!NotificationChannelModel.isStateKind(notifiers, provider)) {\n return { gitProvider: `Unknown git provider: ${provider}` };\n }\n const checked = NotificationChannelModel.validateInput(notifiers, provider, config, {\n projectId: project.id,\n hasSecret: Boolean(secret.token),\n });\n return checked.ok ? null : fieldErrors(secret.secretField, checked);\n}\n"],"mappings":";;;;;;;;;;AASA,MAAM,gBAAgB;CAAC;CAAS;CAAQ;CAAQ;AAAQ;;AAExD,MAAM,gBAAgB;CAAC;CAAQ;CAAS;AAAgB;;AAGxD,SAAS,UAAU,UAAkB,MAAwC;CAC3E,MAAM,SAAiC,CAAC;CACxC,MAAM,SAAS,aAAa,WAAW,CAAC,GAAG,eAAe,GAAG,aAAa,IAAI;CAC9E,KAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,QAAQ,SAAS,KAAK,IAAI,KAAK,CAAC;EACtC,IAAI,OACF,OAAO,SAAS;CAEpB;CACA,OAAO;AACT;;AAGA,eAAe,UAAU,GAAY,QAAmD;CACtF,OAAO,EAAE,KAAM,MAAM,mBAAmB,GAAG,iBAAiB,EAAE,OAAO,CAAC,KAAM,IAAI,GAAG;AACrF;;AAGA,SAAS,YACP,aACA,SACwB;CACxB,IAAI,QAAQ,UAAU,UACpB,OAAO,GAAG,cAAc,QAAQ,QAAQ;CAE1C,IAAI,QAAQ,UAAU,YACpB,OAAO,EAAE,aAAa,QAAQ,QAAQ;CAExC,MAAM,SAAiC,CAAC;CACxC,KAAK,MAAM,SAAS,QAAQ,UAAU,CAAC,GACrC,OAAO,MAAM,UAAU,MAAM;CAE/B,OAAO,OAAO,KAAK,MAAM,CAAC,CAAC,SAAS,IAAI,SAAS,EAAE,aAAa,QAAQ,QAAQ;AAClF;;AAGA,eAAsB,iBACpB,GACA,SACA,UACA,MACmB;CACnB,MAAM,SAAS,UAAU,UAAU,IAAI;CAEvC,MAAM,SAAS,OAAO,YAAY;CAClC,MAAM,QAAQ,SAAS,KAAK,IAAI,SAAS,eAAe,OAAO,CAAC;CAChE,MAAM,UAAU,kBAAkB,SAAS,UAAU,QAAQ;EAC3D;EACA,aAAa,SAAS,eAAe;CACvC,CAAC;CACD,IAAI,SACF,OAAO,MAAM,UAAU,GAAG,OAAO;CAEnC,MAAM,yBAAyB,CAAC,CAAC,OAAO;EACtC,WAAW,QAAQ;EACnB;EACA;EACA,QAAQ;CACV,CAAC;CACD,MAAM,GAAG,mBAAmB;CAC5B,OAAO,EAAE,KAAM,MAAM,mBAAmB,GAAG,eAAe,KAAM,IAAI,GAAG;AACzE;;AAGA,SAAS,kBACP,SACA,UACA,QACA,QAC+B;CAC/B,MAAM,EAAE,cAAc,SAAS;CAC/B,IAAI,CAAC,yBAAyB,YAAY,WAAW,QAAQ,GAC3D,OAAO,EAAE,aAAa,yBAAyB,WAAW;CAE5D,MAAM,UAAU,yBAAyB,cAAc,WAAW,UAAU,QAAQ;EAClF,WAAW,QAAQ;EACnB,WAAW,QAAQ,OAAO,KAAK;CACjC,CAAC;CACD,OAAO,QAAQ,KAAK,OAAO,YAAY,OAAO,aAAa,OAAO;AACpE"}
|
|
@@ -9,8 +9,9 @@ import { LabelModel } from "@storyshelf/core/models";
|
|
|
9
9
|
//#region src/routers/settings-labels.ts
|
|
10
10
|
/** Label-type settings (create custom types, delete removable ones). */
|
|
11
11
|
function registerLabelSettings(app) {
|
|
12
|
-
app.get("/projects/:slug/settings/labels", async (c) => c.html(await renderSettingsPage(c, "labels")));
|
|
12
|
+
app.get("/projects/:slug/settings/labels", async (c) => c.html(await renderSettingsPage(c, "labels", { editKey: c.req.query("edit") })));
|
|
13
13
|
app.post("/projects/:slug/settings/labels", handleCreateLabelType);
|
|
14
|
+
app.post("/projects/:slug/settings/labels/:key", handleUpdateLabelType);
|
|
14
15
|
app.post("/projects/:slug/settings/labels/:key/delete", handleDeleteLabelType);
|
|
15
16
|
}
|
|
16
17
|
async function handleCreateLabelType(c) {
|
|
@@ -36,6 +37,31 @@ async function persistLabelType(c, projectId, projectSlug, key, labelName, linkT
|
|
|
36
37
|
return c.html(await renderSettingsPage(c, "labels", { globalError: message }) ?? "", 400);
|
|
37
38
|
}
|
|
38
39
|
}
|
|
40
|
+
async function handleUpdateLabelType(c) {
|
|
41
|
+
const project = await findProject(c.req.param("slug") ?? "");
|
|
42
|
+
const key = c.req.param("key") ?? "";
|
|
43
|
+
const form = await c.req.formData();
|
|
44
|
+
const name = asString(form.get("labelName"));
|
|
45
|
+
if (!name) return c.html(await renderSettingsPage(c, "labels", {
|
|
46
|
+
globalError: "Name is required",
|
|
47
|
+
editKey: key
|
|
48
|
+
}) ?? "", 400);
|
|
49
|
+
const template = asString(form.get("linkTemplate"));
|
|
50
|
+
return await persistLabelUpdate(c, project, key, {
|
|
51
|
+
name,
|
|
52
|
+
linkTemplate: template === void 0 || template === "" ? null : template
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async function persistLabelUpdate(c, project, key, input) {
|
|
56
|
+
try {
|
|
57
|
+
if (!await new LabelModel(getStore().db).updateType(project.id, key, input)) throw new Error("Label type not found");
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const message = error instanceof Error ? error.message : "Failed to update label";
|
|
60
|
+
return c.html(await renderSettingsPage(c, "labels", { globalError: message }) ?? "", 400);
|
|
61
|
+
}
|
|
62
|
+
flash(c, "Label type saved");
|
|
63
|
+
return hxRedirect(c, `/projects/${project.slug}/settings/labels`);
|
|
64
|
+
}
|
|
39
65
|
async function handleDeleteLabelType(c) {
|
|
40
66
|
const project = await findProject(c.req.param("slug") ?? "");
|
|
41
67
|
try {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settings-labels.mjs","names":[],"sources":["../../src/routers/settings-labels.ts"],"sourcesContent":["import { LabelModel } from \"@storyshelf/core/models\";\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/** Label-type settings (create custom types, delete removable ones). */\nexport function registerLabelSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings/labels\", async (c) =>\n c.html(await renderSettingsPage(c, \"labels\")),\n );\n app.post(\"/projects/:slug/settings/labels\", handleCreateLabelType);\n app.post(\"/projects/:slug/settings/labels/:key/delete\", handleDeleteLabelType);\n}\n\nasync function handleCreateLabelType(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const form = await c.req.formData();\n const key = asString(form.get(\"key\"));\n const labelName = asString(form.get(\"labelName\")) ?? asString(form.get(\"name\"));\n const linkTemplate = asString(form.get(\"linkTemplate\"));\n if (!key || !labelName) {\n return c.html(\n (await renderSettingsPage(c, \"labels\", { globalError: \"Key and name are required\" })) ?? \"\",\n 400,\n );\n }\n return await persistLabelType(c, project.id, project.slug, key, labelName, linkTemplate);\n}\n\nasync function persistLabelType(\n c: Context,\n projectId: string,\n projectSlug: string,\n key: string,\n labelName: string,\n linkTemplate: string | undefined,\n): Promise<Response> {\n try {\n await new LabelModel(getStore().db).createType(projectId, {\n key,\n name: labelName,\n linkTemplate,\n });\n flash(c, \"Label type added\");\n return hxRedirect(c, `/projects/${projectSlug}/settings/labels`);\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Failed to create label\";\n return c.html((await renderSettingsPage(c, \"labels\", { globalError: message })) ?? \"\", 400);\n }\n}\n\nasync function handleDeleteLabelType(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n try {\n await new LabelModel(getStore().db).removeType(project.id, c.req.param(\"key\") ?? \"\");\n } catch {\n return c.html(\n (await renderSettingsPage(c, \"labels\", { globalError: \"Cannot delete built-in label\" })) ??\n \"\",\n 400,\n );\n }\n flash(c, \"Label type deleted\");\n return hxRedirect(c, `/projects/${project.slug}/settings/labels`);\n}\n"],"mappings":";;;;;;;;;;AAQA,SAAgB,sBAAsB,KAAwB;CAC5D,IAAI,IAAI,mCAAmC,OAAO,MAChD,EAAE,KAAK,MAAM,mBAAmB,GAAG,
|
|
1
|
+
{"version":3,"file":"settings-labels.mjs","names":[],"sources":["../../src/routers/settings-labels.ts"],"sourcesContent":["import { LabelModel } from \"@storyshelf/core/models\";\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/** Label-type settings (create custom types, delete removable ones). */\nexport function registerLabelSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings/labels\", async (c) =>\n c.html(await renderSettingsPage(c, \"labels\", { editKey: c.req.query(\"edit\") })),\n );\n app.post(\"/projects/:slug/settings/labels\", handleCreateLabelType);\n app.post(\"/projects/:slug/settings/labels/:key\", handleUpdateLabelType);\n app.post(\"/projects/:slug/settings/labels/:key/delete\", handleDeleteLabelType);\n}\n\nasync function handleCreateLabelType(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const form = await c.req.formData();\n const key = asString(form.get(\"key\"));\n const labelName = asString(form.get(\"labelName\")) ?? asString(form.get(\"name\"));\n const linkTemplate = asString(form.get(\"linkTemplate\"));\n if (!key || !labelName) {\n return c.html(\n (await renderSettingsPage(c, \"labels\", { globalError: \"Key and name are required\" })) ?? \"\",\n 400,\n );\n }\n return await persistLabelType(c, project.id, project.slug, key, labelName, linkTemplate);\n}\n\nasync function persistLabelType(\n c: Context,\n projectId: string,\n projectSlug: string,\n key: string,\n labelName: string,\n linkTemplate: string | undefined,\n): Promise<Response> {\n try {\n await new LabelModel(getStore().db).createType(projectId, {\n key,\n name: labelName,\n linkTemplate,\n });\n flash(c, \"Label type added\");\n return hxRedirect(c, `/projects/${projectSlug}/settings/labels`);\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Failed to create label\";\n return c.html((await renderSettingsPage(c, \"labels\", { globalError: message })) ?? \"\", 400);\n }\n}\n\nasync function handleUpdateLabelType(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const key = c.req.param(\"key\") ?? \"\";\n const form = await c.req.formData();\n const name = asString(form.get(\"labelName\"));\n if (!name) {\n return c.html(\n (await renderSettingsPage(c, \"labels\", { globalError: \"Name is required\", editKey: key })) ??\n \"\",\n 400,\n );\n }\n const template = asString(form.get(\"linkTemplate\"));\n const linkTemplate = template === undefined || template === \"\" ? null : template;\n return await persistLabelUpdate(c, project, key, { name, linkTemplate });\n}\n\nasync function persistLabelUpdate(\n c: Context,\n project: { id: string; slug: string },\n key: string,\n input: { name: string; linkTemplate: string | null },\n): Promise<Response> {\n try {\n const updated = await new LabelModel(getStore().db).updateType(project.id, key, input);\n if (!updated) {\n throw new Error(\"Label type not found\");\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Failed to update label\";\n return c.html((await renderSettingsPage(c, \"labels\", { globalError: message })) ?? \"\", 400);\n }\n flash(c, \"Label type saved\");\n return hxRedirect(c, `/projects/${project.slug}/settings/labels`);\n}\n\nasync function handleDeleteLabelType(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n try {\n await new LabelModel(getStore().db).removeType(project.id, c.req.param(\"key\") ?? \"\");\n } catch {\n return c.html(\n (await renderSettingsPage(c, \"labels\", { globalError: \"Cannot delete built-in label\" })) ??\n \"\",\n 400,\n );\n }\n flash(c, \"Label type deleted\");\n return hxRedirect(c, `/projects/${project.slug}/settings/labels`);\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,63 @@
|
|
|
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 { PackageUsageModel, ProjectModel } from "@storyshelf/core/models";
|
|
9
|
+
//#region src/routers/settings-links.ts
|
|
10
|
+
/** Package-link settings (link a consumed package to its upstream project, or unlink it). */
|
|
11
|
+
function registerLinkSettings(app) {
|
|
12
|
+
app.get("/projects/:slug/settings/links", async (c) => c.html(await renderSettingsPage(c, "links")));
|
|
13
|
+
app.post("/projects/:slug/settings/links", handleAddLink);
|
|
14
|
+
app.post("/projects/:slug/settings/links/delete", handleRemoveLink);
|
|
15
|
+
}
|
|
16
|
+
async function rejectLink(c, message) {
|
|
17
|
+
return c.html(await renderSettingsPage(c, "links", { globalError: message }) ?? "", 400);
|
|
18
|
+
}
|
|
19
|
+
/** Replace one package's link, keeping the project's other links. */
|
|
20
|
+
async function saveLink(project, packageName, upstreamId) {
|
|
21
|
+
const model = new PackageUsageModel(getStore().db);
|
|
22
|
+
const kept = (await model.listLinks(project.id)).filter((link) => link.packageName !== packageName);
|
|
23
|
+
await model.replaceLinks(project.id, [...kept.map((link) => ({
|
|
24
|
+
upstreamId: link.upstreamId,
|
|
25
|
+
packageName: link.packageName
|
|
26
|
+
})), {
|
|
27
|
+
upstreamId,
|
|
28
|
+
packageName
|
|
29
|
+
}]);
|
|
30
|
+
}
|
|
31
|
+
/** Read the add-link form fields. */
|
|
32
|
+
async function readLinkForm(c) {
|
|
33
|
+
const form = await c.req.formData();
|
|
34
|
+
return {
|
|
35
|
+
packageName: asString(form.get("package")),
|
|
36
|
+
upstreamSlug: asString(form.get("upstreamSlug"))
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function handleAddLink(c) {
|
|
40
|
+
const project = await findProject(c.req.param("slug") ?? "");
|
|
41
|
+
const { packageName, upstreamSlug } = await readLinkForm(c);
|
|
42
|
+
const upstream = upstreamSlug ? await new ProjectModel(getStore().db).getBySlug(upstreamSlug) : null;
|
|
43
|
+
if (!packageName || !upstream || upstream.id === project.id) return await rejectLink(c, "Enter a package and choose a different, existing upstream project");
|
|
44
|
+
await saveLink(project, packageName, upstream.id);
|
|
45
|
+
flash(c, "Package linked");
|
|
46
|
+
return hxRedirect(c, `/projects/${project.slug}/settings/links`);
|
|
47
|
+
}
|
|
48
|
+
async function handleRemoveLink(c) {
|
|
49
|
+
const project = await findProject(c.req.param("slug") ?? "");
|
|
50
|
+
const packageName = asString((await c.req.formData()).get("package"));
|
|
51
|
+
const model = new PackageUsageModel(getStore().db);
|
|
52
|
+
const kept = (await model.listLinks(project.id)).filter((link) => link.packageName !== packageName);
|
|
53
|
+
await model.replaceLinks(project.id, kept.map((link) => ({
|
|
54
|
+
upstreamId: link.upstreamId,
|
|
55
|
+
packageName: link.packageName
|
|
56
|
+
})));
|
|
57
|
+
flash(c, "Link removed");
|
|
58
|
+
return hxRedirect(c, `/projects/${project.slug}/settings/links`);
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { registerLinkSettings };
|
|
62
|
+
|
|
63
|
+
//# sourceMappingURL=settings-links.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settings-links.mjs","names":[],"sources":["../../src/routers/settings-links.ts"],"sourcesContent":["import { PackageUsageModel, ProjectModel } from \"@storyshelf/core/models\";\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\n/** Package-link settings (link a consumed package to its upstream project, or unlink it). */\nexport function registerLinkSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings/links\", async (c) =>\n c.html(await renderSettingsPage(c, \"links\")),\n );\n app.post(\"/projects/:slug/settings/links\", handleAddLink);\n app.post(\"/projects/:slug/settings/links/delete\", handleRemoveLink);\n}\n\nasync function rejectLink(c: Context, message: string): Promise<Response> {\n return c.html((await renderSettingsPage(c, \"links\", { globalError: message })) ?? \"\", 400);\n}\n\n/** Replace one package's link, keeping the project's other links. */\nasync function saveLink(\n project: { id: string },\n packageName: string,\n upstreamId: string,\n): Promise<void> {\n const model = new PackageUsageModel(getStore().db);\n const kept = (await model.listLinks(project.id)).filter(\n (link) => link.packageName !== packageName,\n );\n await model.replaceLinks(project.id, [\n ...kept.map((link) => ({ upstreamId: link.upstreamId, packageName: link.packageName })),\n { upstreamId, packageName },\n ]);\n}\n\n/** Read the add-link form fields. */\nasync function readLinkForm(c: Context): Promise<{ packageName?: string; upstreamSlug?: string }> {\n const form = await c.req.formData();\n return {\n packageName: asString(form.get(\"package\")),\n upstreamSlug: asString(form.get(\"upstreamSlug\")),\n };\n}\n\nasync function handleAddLink(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const { packageName, upstreamSlug } = await readLinkForm(c);\n const upstream = upstreamSlug\n ? await new ProjectModel(getStore().db).getBySlug(upstreamSlug)\n : null;\n if (!packageName || !upstream || upstream.id === project.id) {\n return await rejectLink(c, \"Enter a package and choose a different, existing upstream project\");\n }\n await saveLink(project, packageName, upstream.id);\n flash(c, \"Package linked\");\n return hxRedirect(c, `/projects/${project.slug}/settings/links`);\n}\n\nasync function handleRemoveLink(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const packageName = asString((await c.req.formData()).get(\"package\"));\n const model = new PackageUsageModel(getStore().db);\n const kept = (await model.listLinks(project.id)).filter(\n (link) => link.packageName !== packageName,\n );\n await model.replaceLinks(\n project.id,\n kept.map((link) => ({ upstreamId: link.upstreamId, packageName: link.packageName })),\n );\n flash(c, \"Link removed\");\n return hxRedirect(c, `/projects/${project.slug}/settings/links`);\n}\n"],"mappings":";;;;;;;;;;AASA,SAAgB,qBAAqB,KAAwB;CAC3D,IAAI,IAAI,kCAAkC,OAAO,MAC/C,EAAE,KAAK,MAAM,mBAAmB,GAAG,OAAO,CAAC,CAC7C;CACA,IAAI,KAAK,kCAAkC,aAAa;CACxD,IAAI,KAAK,yCAAyC,gBAAgB;AACpE;AAEA,eAAe,WAAW,GAAY,SAAoC;CACxE,OAAO,EAAE,KAAM,MAAM,mBAAmB,GAAG,SAAS,EAAE,aAAa,QAAQ,CAAC,KAAM,IAAI,GAAG;AAC3F;;AAGA,eAAe,SACb,SACA,aACA,YACe;CACf,MAAM,QAAQ,IAAI,kBAAkB,SAAS,CAAC,CAAC,EAAE;CACjD,MAAM,QAAQ,MAAM,MAAM,UAAU,QAAQ,EAAE,EAAA,CAAG,QAC9C,SAAS,KAAK,gBAAgB,WACjC;CACA,MAAM,MAAM,aAAa,QAAQ,IAAI,CACnC,GAAG,KAAK,KAAK,UAAU;EAAE,YAAY,KAAK;EAAY,aAAa,KAAK;CAAY,EAAE,GACtF;EAAE;EAAY;CAAY,CAC5B,CAAC;AACH;;AAGA,eAAe,aAAa,GAAsE;CAChG,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;CAClC,OAAO;EACL,aAAa,SAAS,KAAK,IAAI,SAAS,CAAC;EACzC,cAAc,SAAS,KAAK,IAAI,cAAc,CAAC;CACjD;AACF;AAEA,eAAe,cAAc,GAA+B;CAC1D,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;CAC3D,MAAM,EAAE,aAAa,iBAAiB,MAAM,aAAa,CAAC;CAC1D,MAAM,WAAW,eACb,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,UAAU,YAAY,IAC5D;CACJ,IAAI,CAAC,eAAe,CAAC,YAAY,SAAS,OAAO,QAAQ,IACvD,OAAO,MAAM,WAAW,GAAG,mEAAmE;CAEhG,MAAM,SAAS,SAAS,aAAa,SAAS,EAAE;CAChD,MAAM,GAAG,gBAAgB;CACzB,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,gBAAgB;AACjE;AAEA,eAAe,iBAAiB,GAA+B;CAC7D,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;CAC3D,MAAM,cAAc,UAAU,MAAM,EAAE,IAAI,SAAS,EAAA,CAAG,IAAI,SAAS,CAAC;CACpE,MAAM,QAAQ,IAAI,kBAAkB,SAAS,CAAC,CAAC,EAAE;CACjD,MAAM,QAAQ,MAAM,MAAM,UAAU,QAAQ,EAAE,EAAA,CAAG,QAC9C,SAAS,KAAK,gBAAgB,WACjC;CACA,MAAM,MAAM,aACV,QAAQ,IACR,KAAK,KAAK,UAAU;EAAE,YAAY,KAAK;EAAY,aAAa,KAAK;CAAY,EAAE,CACrF;CACA,MAAM,GAAG,cAAc;CACvB,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,gBAAgB;AACjE"}
|
|
@@ -6,6 +6,7 @@ import { hxRedirect } from "./htmx.mjs";
|
|
|
6
6
|
import { flash } from "./flash.mjs";
|
|
7
7
|
import { asString, findProject, renderSettingsPage } from "./settings.handlers.mjs";
|
|
8
8
|
import { MemberModel, ProjectGroupMappingModel } from "@storyshelf/core/models";
|
|
9
|
+
import { isProjectRole } from "@storyshelf/core/types";
|
|
9
10
|
//#region src/routers/settings-members.ts
|
|
10
11
|
/** Project member settings (assign roles, remove members). */
|
|
11
12
|
function registerMemberSettings(app) {
|
|
@@ -15,7 +16,7 @@ function registerMemberSettings(app) {
|
|
|
15
16
|
const form = await c.req.formData();
|
|
16
17
|
const userId = asString(form.get("userId"));
|
|
17
18
|
const role = asString(form.get("role"));
|
|
18
|
-
if (!userId || !role) return c.html(await renderSettingsPage(c, "members", { globalError: "User and role are required" }) ?? "", 400);
|
|
19
|
+
if (!userId || !isProjectRole(role)) return c.html(await renderSettingsPage(c, "members", { globalError: "User and role are required" }) ?? "", 400);
|
|
19
20
|
await new MemberModel(getStore().db).set(project.id, userId, role);
|
|
20
21
|
flash(c, "Member saved");
|
|
21
22
|
return hxRedirect(c, `/projects/${project.slug}/settings/members`);
|
|
@@ -35,12 +36,8 @@ function registerGroupMappingSettings(app) {
|
|
|
35
36
|
const form = await c.req.formData();
|
|
36
37
|
const groupName = asString(form.get("groupName"));
|
|
37
38
|
const role = asString(form.get("role"));
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"developer",
|
|
41
|
-
"approver",
|
|
42
|
-
"admin"
|
|
43
|
-
].includes(role)) return c.html(await renderSettingsPage(c, "members", { globalError: "Group and role are required" }) ?? "", 400);
|
|
39
|
+
const inputError = ProjectGroupMappingModel.inputError(groupName, role);
|
|
40
|
+
if (inputError || !groupName || !isProjectRole(role)) return c.html(await renderSettingsPage(c, "members", { globalError: inputError ?? "Invalid input" }) ?? "", 400);
|
|
44
41
|
await new ProjectGroupMappingModel(getStore().db).create(project.id, groupName, role);
|
|
45
42
|
flash(c, "Group mapping added");
|
|
46
43
|
return hxRedirect(c, `/projects/${project.slug}/settings/members`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settings-members.mjs","names":[],"sources":["../../src/routers/settings-members.ts"],"sourcesContent":["import { MemberModel, ProjectGroupMappingModel } from \"@storyshelf/core/models\";\nimport
|
|
1
|
+
{"version":3,"file":"settings-members.mjs","names":[],"sources":["../../src/routers/settings-members.ts"],"sourcesContent":["import { MemberModel, ProjectGroupMappingModel } from \"@storyshelf/core/models\";\nimport { isProjectRole } from \"@storyshelf/core/types\";\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/** Project member settings (assign roles, remove members). */\nexport function registerMemberSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings/members\", async (c) =>\n c.html(await renderSettingsPage(c, \"members\")),\n );\n\n app.post(\"/projects/:slug/settings/members\", async (c) => {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const form = await c.req.formData();\n const userId = asString(form.get(\"userId\"));\n const role = asString(form.get(\"role\"));\n if (!userId || !isProjectRole(role)) {\n return c.html(\n (await renderSettingsPage(c, \"members\", { globalError: \"User and role are required\" })) ??\n \"\",\n 400,\n );\n }\n await new MemberModel(getStore().db).set(project.id, userId, role);\n flash(c, \"Member saved\");\n return hxRedirect(c, `/projects/${project.slug}/settings/members`);\n });\n\n app.post(\"/projects/:slug/settings/members/:userId/remove\", async (c) => {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n await new MemberModel(getStore().db).remove(project.id, c.req.param(\"userId\") ?? \"\");\n flash(c, \"Member removed\");\n return hxRedirect(c, `/projects/${project.slug}/settings/members`);\n });\n\n registerGroupMappingSettings(app);\n}\n\n/** Identity-provider group mapping settings (exact-match group to role). */\nfunction registerGroupMappingSettings(app: ShelfRouter): void {\n app.post(\"/projects/:slug/settings/members/groups\", async (c) => {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const form = await c.req.formData();\n const groupName = asString(form.get(\"groupName\"));\n const role = asString(form.get(\"role\"));\n const inputError = ProjectGroupMappingModel.inputError(groupName, role);\n if (inputError || !groupName || !isProjectRole(role)) {\n return c.html(\n (await renderSettingsPage(c, \"members\", { globalError: inputError ?? \"Invalid input\" })) ??\n \"\",\n 400,\n );\n }\n await new ProjectGroupMappingModel(getStore().db).create(project.id, groupName, role);\n flash(c, \"Group mapping added\");\n return hxRedirect(c, `/projects/${project.slug}/settings/members`);\n });\n\n app.post(\"/projects/:slug/settings/members/groups/:mappingId/remove\", async (c) => {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n await new ProjectGroupMappingModel(getStore().db).remove(\n project.id,\n c.req.param(\"mappingId\") ?? \"\",\n );\n flash(c, \"Group mapping removed\");\n return hxRedirect(c, `/projects/${project.slug}/settings/members`);\n });\n}\n"],"mappings":";;;;;;;;;;;AAQA,SAAgB,uBAAuB,KAAwB;CAC7D,IAAI,IAAI,oCAAoC,OAAO,MACjD,EAAE,KAAK,MAAM,mBAAmB,GAAG,SAAS,CAAC,CAC/C;CAEA,IAAI,KAAK,oCAAoC,OAAO,MAAM;EACxD,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;EAC3D,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;EAClC,MAAM,SAAS,SAAS,KAAK,IAAI,QAAQ,CAAC;EAC1C,MAAM,OAAO,SAAS,KAAK,IAAI,MAAM,CAAC;EACtC,IAAI,CAAC,UAAU,CAAC,cAAc,IAAI,GAChC,OAAO,EAAE,KACN,MAAM,mBAAmB,GAAG,WAAW,EAAE,aAAa,6BAA6B,CAAC,KACnF,IACF,GACF;EAEF,MAAM,IAAI,YAAY,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,QAAQ,IAAI,QAAQ,IAAI;EACjE,MAAM,GAAG,cAAc;EACvB,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,kBAAkB;CACnE,CAAC;CAED,IAAI,KAAK,mDAAmD,OAAO,MAAM;EACvE,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;EAC3D,MAAM,IAAI,YAAY,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OAAO,QAAQ,IAAI,EAAE,IAAI,MAAM,QAAQ,KAAK,EAAE;EACnF,MAAM,GAAG,gBAAgB;EACzB,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,kBAAkB;CACnE,CAAC;CAED,6BAA6B,GAAG;AAClC;;AAGA,SAAS,6BAA6B,KAAwB;CAC5D,IAAI,KAAK,2CAA2C,OAAO,MAAM;EAC/D,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;EAC3D,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;EAClC,MAAM,YAAY,SAAS,KAAK,IAAI,WAAW,CAAC;EAChD,MAAM,OAAO,SAAS,KAAK,IAAI,MAAM,CAAC;EACtC,MAAM,aAAa,yBAAyB,WAAW,WAAW,IAAI;EACtE,IAAI,cAAc,CAAC,aAAa,CAAC,cAAc,IAAI,GACjD,OAAO,EAAE,KACN,MAAM,mBAAmB,GAAG,WAAW,EAAE,aAAa,cAAc,gBAAgB,CAAC,KACpF,IACF,GACF;EAEF,MAAM,IAAI,yBAAyB,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OAAO,QAAQ,IAAI,WAAW,IAAI;EACpF,MAAM,GAAG,qBAAqB;EAC9B,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,kBAAkB;CACnE,CAAC;CAED,IAAI,KAAK,6DAA6D,OAAO,MAAM;EACjF,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;EAC3D,MAAM,IAAI,yBAAyB,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OAChD,QAAQ,IACR,EAAE,IAAI,MAAM,WAAW,KAAK,EAC9B;EACA,MAAM,GAAG,uBAAuB;EAChC,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,kBAAkB;CACnE,CAAC;AACH"}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import "node:path";
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
|
-
import { credentialKeys } from "../credential-keys.mjs";
|
|
5
4
|
import { getStore } from "../store.mjs";
|
|
6
5
|
import { flash } from "./flash.mjs";
|
|
6
|
+
import { notificationChannelModel } from "./scoped-models.mjs";
|
|
7
7
|
import { asString, findProject, renderSettingsPage } from "./settings.handlers.mjs";
|
|
8
|
+
import { createGitChannel } from "./settings-git-channel.mjs";
|
|
8
9
|
import { NotificationChannelModel, NotificationSubscriptionModel } from "@storyshelf/core/models";
|
|
9
10
|
//#region src/routers/settings-notifications.ts
|
|
10
|
-
/** Notifications settings (channels plus my preferences). */
|
|
11
|
+
/** Notifications settings (message and git channels plus my preferences). */
|
|
11
12
|
function registerNotificationSettings(app) {
|
|
12
13
|
app.get("/projects/:slug/settings/notifications", async (c) => c.html(await renderSettingsPage(c, "notifications")));
|
|
13
14
|
app.post("/projects/:slug/settings/notifications", handleCreateChannel);
|
|
@@ -28,6 +29,11 @@ function parseChecks(form, name) {
|
|
|
28
29
|
async function handleCreateChannel(c) {
|
|
29
30
|
const project = await findProject(c.req.param("slug") ?? "");
|
|
30
31
|
const form = await c.req.formData();
|
|
32
|
+
const gitProvider = asString(form.get("gitProvider"));
|
|
33
|
+
return gitProvider ? await createGitChannel(c, project, gitProvider, form) : await createMessageChannel(c, project, form);
|
|
34
|
+
}
|
|
35
|
+
/** The message-channel form: a provider and a target (email address or webhook URL). */
|
|
36
|
+
async function createMessageChannel(c, project, form) {
|
|
31
37
|
const provider = asString(form.get("provider"));
|
|
32
38
|
const target = asString(form.get("target"));
|
|
33
39
|
if (provider && target) return await createChannelRecord(c, project, provider, target, form);
|
|
@@ -44,11 +50,10 @@ async function targetError(c, message) {
|
|
|
44
50
|
return c.html(await renderSettingsPage(c, "notifications", { errors: { target: message } }) ?? "", 400);
|
|
45
51
|
}
|
|
46
52
|
async function createChannelRecord(c, project, provider, target, form) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
await persistChannel(project, provider, target, parsed.data, form);
|
|
53
|
+
const config = channelConfig(provider, target, form);
|
|
54
|
+
const checked = NotificationChannelModel.validateInput(getStore().notifiers, provider, config);
|
|
55
|
+
if (!checked.ok) return checked.field === "provider" ? await providerError(c, checked.message) : await targetError(c, checked.message);
|
|
56
|
+
await persistChannel(project, provider, target, config, form);
|
|
52
57
|
flash(c, "Notification channel added");
|
|
53
58
|
return c.html(await renderSettingsPage(c, "notifications") ?? "", 201);
|
|
54
59
|
}
|
|
@@ -63,7 +68,7 @@ function channelConfig(provider, target, form) {
|
|
|
63
68
|
}
|
|
64
69
|
/** Persist a validated channel (webhook URLs land in the secret column). */
|
|
65
70
|
async function persistChannel(project, provider, target, config, form) {
|
|
66
|
-
await
|
|
71
|
+
await notificationChannelModel().create({
|
|
67
72
|
projectId: project.id,
|
|
68
73
|
provider,
|
|
69
74
|
config,
|
|
@@ -73,11 +78,11 @@ async function persistChannel(project, provider, target, config, form) {
|
|
|
73
78
|
}
|
|
74
79
|
async function handleDeleteChannel(c) {
|
|
75
80
|
const project = await findProject(c.req.param("slug") ?? "");
|
|
76
|
-
const model =
|
|
81
|
+
const model = notificationChannelModel();
|
|
77
82
|
const channel = await model.get(c.req.param("channelId") ?? "");
|
|
78
83
|
if (!channel || channel.projectId !== project.id) return c.html(await renderSettingsPage(c, "notifications", { globalError: "Channel not found" }) ?? "", 404);
|
|
79
84
|
await model.remove(channel.id);
|
|
80
|
-
flash(c, "
|
|
85
|
+
flash(c, "Channel removed");
|
|
81
86
|
return c.html(await renderSettingsPage(c, "notifications") ?? "");
|
|
82
87
|
}
|
|
83
88
|
async function handleSaveMine(c) {
|
|
@@ -1 +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 {
|
|
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 { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { notificationChannelModel } from \"./scoped-models.ts\";\nimport { createGitChannel } from \"./settings-git-channel.ts\";\nimport { asString, findProject, renderSettingsPage } from \"./settings.handlers.ts\";\n\n/** Notifications settings (message and git 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 gitProvider = asString(form.get(\"gitProvider\"));\n return gitProvider\n ? await createGitChannel(c, project, gitProvider, form)\n : await createMessageChannel(c, project, form);\n}\n\n/** The message-channel form: a provider and a target (email address or webhook URL). */\nasync function createMessageChannel(\n c: Context,\n project: Project,\n form: FormData,\n): Promise<Response> {\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 config = channelConfig(provider, target, form);\n const checked = NotificationChannelModel.validateInput(getStore().notifiers, provider, config);\n if (!checked.ok) {\n return checked.field === \"provider\"\n ? await providerError(c, checked.message)\n : await targetError(c, checked.message);\n }\n await persistChannel(project, provider, target, config, 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 notificationChannelModel().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 = notificationChannelModel();\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, \"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":"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"}
|
|
@@ -6,7 +6,6 @@ import { hxRedirect } from "./htmx.mjs";
|
|
|
6
6
|
import { flash } from "./flash.mjs";
|
|
7
7
|
import { asString, findProject, renderSettingsPage } from "./settings.handlers.mjs";
|
|
8
8
|
import { TokenModel } from "@storyshelf/core/models";
|
|
9
|
-
import { randomToken } from "@storyshelf/core/utils";
|
|
10
9
|
//#region src/routers/settings-tokens.ts
|
|
11
10
|
/** CI token settings (create with one-time display, delete). */
|
|
12
11
|
function registerTokenSettings(app) {
|
|
@@ -19,14 +18,12 @@ async function handleCreateToken(c) {
|
|
|
19
18
|
const form = await c.req.formData();
|
|
20
19
|
const tokenName = asString(form.get("tokenName")) ?? asString(form.get("name"));
|
|
21
20
|
if (!tokenName) return c.html(await renderSettingsPage(c, "tokens", { globalError: "Name is required" }) ?? "", 400);
|
|
22
|
-
const
|
|
23
|
-
await new TokenModel(getStore().db).create(project.id, {
|
|
21
|
+
const { value } = await new TokenModel(getStore().db).issue(project.id, {
|
|
24
22
|
name: tokenName,
|
|
25
|
-
hash: token.hash,
|
|
26
23
|
userId: getStore().user?.id ?? null
|
|
27
24
|
});
|
|
28
25
|
flash(c, "Token created — copy it now, it is shown once");
|
|
29
|
-
return c.html(await renderSettingsPage(c, "tokens", { secret:
|
|
26
|
+
return c.html(await renderSettingsPage(c, "tokens", { secret: value }) ?? "");
|
|
30
27
|
}
|
|
31
28
|
async function handleDeleteToken(c) {
|
|
32
29
|
const project = await findProject(c.req.param("slug") ?? "");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settings-tokens.mjs","names":[],"sources":["../../src/routers/settings-tokens.ts"],"sourcesContent":["import { TokenModel } from \"@storyshelf/core/models\";\nimport
|
|
1
|
+
{"version":3,"file":"settings-tokens.mjs","names":[],"sources":["../../src/routers/settings-tokens.ts"],"sourcesContent":["import { TokenModel } from \"@storyshelf/core/models\";\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 { value } = await new TokenModel(getStore().db).issue(project.id, {\n name: tokenName,\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: 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":";;;;;;;;;;AAQA,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,EAAE,UAAU,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,MAAM,QAAQ,IAAI;EACtE,MAAM;EACN,QAAQ,SAAS,CAAC,CAAC,MAAM,MAAM;CACjC,CAAC;CACD,MAAM,GAAG,+CAA+C;CACxD,OAAO,EAAE,KAAM,MAAM,mBAAmB,GAAG,UAAU,EAAE,QAAQ,MAAM,CAAC,KAAM,EAAE;AAChF;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"}
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import "node:path";
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
|
-
import {
|
|
5
|
-
import { getStore } from "../store.mjs";
|
|
4
|
+
import { notFound } from "./helpers.mjs";
|
|
6
5
|
import { hxRedirect } from "./htmx.mjs";
|
|
7
6
|
import { flash } from "./flash.mjs";
|
|
8
|
-
import {
|
|
7
|
+
import { webhookModel } from "./scoped-models.mjs";
|
|
9
8
|
import { asString, findProject, renderSettingsPage } from "./settings.handlers.mjs";
|
|
10
9
|
import { WebhookModel } from "@storyshelf/core/models";
|
|
11
|
-
import { randomToken } from "@storyshelf/core/utils";
|
|
12
10
|
//#region src/routers/settings-webhooks.ts
|
|
13
11
|
/** Webhook settings (create with one-time secret display, delete). */
|
|
14
12
|
function registerWebhookSettings(app) {
|
|
@@ -16,18 +14,6 @@ function registerWebhookSettings(app) {
|
|
|
16
14
|
app.post("/projects/:slug/settings/webhooks", handleCreateWebhook);
|
|
17
15
|
app.post("/projects/:slug/settings/webhooks/:webhookId/delete", handleDeleteWebhook);
|
|
18
16
|
}
|
|
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
17
|
/** Parse the comma-separated events field. */
|
|
32
18
|
function parseWebhookEvents(form) {
|
|
33
19
|
const eventsRaw = asString(form.get("events"));
|
|
@@ -38,26 +24,23 @@ async function handleCreateWebhook(c) {
|
|
|
38
24
|
const project = await findProject(c.req.param("slug") ?? "");
|
|
39
25
|
const form = await c.req.formData();
|
|
40
26
|
const url = asString(form.get("url"));
|
|
41
|
-
const urlError =
|
|
27
|
+
const urlError = WebhookModel.urlError(url);
|
|
42
28
|
if (urlError || !url) return c.html(await renderSettingsPage(c, "webhooks", { errors: { url: urlError ?? "Invalid URL" } }) ?? "", 400);
|
|
43
29
|
return await createWebhookRecord(c, project, url, parseWebhookEvents(form));
|
|
44
30
|
}
|
|
45
31
|
async function createWebhookRecord(c, project, url, events) {
|
|
46
|
-
const
|
|
47
|
-
const secret = randomToken("whsec_").value;
|
|
48
|
-
await webhookModel.create(project.id, {
|
|
32
|
+
const { secret } = await webhookModel().issue(project.id, {
|
|
49
33
|
url,
|
|
50
|
-
events
|
|
51
|
-
secret
|
|
34
|
+
events
|
|
52
35
|
});
|
|
53
36
|
flash(c, "Webhook created — copy the signing secret now");
|
|
54
37
|
return c.html(await renderSettingsPage(c, "webhooks", { secret }) ?? "", 201);
|
|
55
38
|
}
|
|
56
39
|
async function handleDeleteWebhook(c) {
|
|
57
40
|
const project = await findProject(c.req.param("slug") ?? "");
|
|
58
|
-
const webhook = await
|
|
41
|
+
const webhook = await webhookModel().get(project.id, c.req.param("webhookId") ?? "");
|
|
59
42
|
if (!webhook) notFound("Webhook not found");
|
|
60
|
-
await
|
|
43
|
+
await webhookModel().remove(project.id, webhook.id);
|
|
61
44
|
flash(c, "Webhook deleted");
|
|
62
45
|
return hxRedirect(c, `/projects/${project.slug}/settings/webhooks`);
|
|
63
46
|
}
|
|
@@ -1 +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
|
|
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 type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { flash } from \"./flash.ts\";\nimport { notFound } from \"./helpers.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\nimport { webhookModel } from \"./scoped-models.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/** 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 = WebhookModel.urlError(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 { secret } = await webhookModel().issue(project.id, { url, events });\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 webhookModel().get(project.id, c.req.param(\"webhookId\") ?? \"\");\n if (!webhook) {\n notFound(\"Webhook not found\");\n }\n await webhookModel().remove(project.id, webhook.id);\n flash(c, \"Webhook deleted\");\n return hxRedirect(c, `/projects/${project.slug}/settings/webhooks`);\n}\n"],"mappings":";;;;;;;;;;;AAUA,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,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,aAAa,SAAS,GAAG;CAC1C,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,WAAW,MAAM,aAAa,CAAC,CAAC,MAAM,QAAQ,IAAI;EAAE;EAAK;CAAO,CAAC;CACzE,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,aAAa,CAAC,CAAC,IAAI,QAAQ,IAAI,EAAE,IAAI,MAAM,WAAW,KAAK,EAAE;CACnF,IAAI,CAAC,SACH,SAAS,mBAAmB;CAE9B,MAAM,aAAa,CAAC,CAAC,OAAO,QAAQ,IAAI,QAAQ,EAAE;CAClD,MAAM,GAAG,iBAAiB;CAC1B,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,mBAAmB;AACpE"}
|