@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.
- package/LICENSE +21 -0
- package/README.md +18 -0
- package/dist/app-types.d.mts +54 -0
- package/dist/asset-manifest.mjs +41 -0
- package/dist/asset-manifest.mjs.map +1 -0
- package/dist/asset-paths.mjs +23 -0
- package/dist/asset-paths.mjs.map +1 -0
- package/dist/assets/htmx.min.js +3304 -0
- package/dist/assets/mark.svg +1 -0
- package/dist/capture-setup.mjs +139 -0
- package/dist/capture-setup.mjs.map +1 -0
- package/dist/credential-keys.mjs +20 -0
- package/dist/credential-keys.mjs.map +1 -0
- package/dist/credentials.mjs +56 -0
- package/dist/credentials.mjs.map +1 -0
- package/dist/index.d.mts +18 -0
- package/dist/index.mjs +310 -0
- package/dist/index.mjs.map +1 -0
- package/dist/lifecycle.mjs +132 -0
- package/dist/lifecycle.mjs.map +1 -0
- package/dist/middleware/auth-gate.mjs +36 -0
- package/dist/middleware/auth-gate.mjs.map +1 -0
- package/dist/middleware/csrf.mjs +75 -0
- package/dist/middleware/csrf.mjs.map +1 -0
- package/dist/middleware/rate-limit.mjs +68 -0
- package/dist/middleware/rate-limit.mjs.map +1 -0
- package/dist/middleware/request-log.mjs +30 -0
- package/dist/middleware/request-log.mjs.map +1 -0
- package/dist/middleware/server-timing.mjs +39 -0
- package/dist/middleware/server-timing.mjs.map +1 -0
- package/dist/middleware/setup-gate.mjs +37 -0
- package/dist/middleware/setup-gate.mjs.map +1 -0
- package/dist/middleware/store-scope.mjs +33 -0
- package/dist/middleware/store-scope.mjs.map +1 -0
- package/dist/notify.d.mts +17 -0
- package/dist/notify.mjs +141 -0
- package/dist/notify.mjs.map +1 -0
- package/dist/pages/admin-system.mjs +243 -0
- package/dist/pages/admin-system.mjs.map +1 -0
- package/dist/pages/build-detail.mjs +392 -0
- package/dist/pages/build-detail.mjs.map +1 -0
- package/dist/pages/build-diff-actions.mjs +162 -0
- package/dist/pages/build-diff-actions.mjs.map +1 -0
- package/dist/pages/build-diff-comments.mjs +104 -0
- package/dist/pages/build-diff-comments.mjs.map +1 -0
- package/dist/pages/build-diff-header.mjs +117 -0
- package/dist/pages/build-diff-header.mjs.map +1 -0
- package/dist/pages/build-diff-model.mjs +48 -0
- package/dist/pages/build-diff-model.mjs.map +1 -0
- package/dist/pages/build-diff-nav.mjs +122 -0
- package/dist/pages/build-diff-nav.mjs.map +1 -0
- package/dist/pages/build-diff-shortcuts.mjs +78 -0
- package/dist/pages/build-diff-shortcuts.mjs.map +1 -0
- package/dist/pages/build-diff-stale.mjs +70 -0
- package/dist/pages/build-diff-stale.mjs.map +1 -0
- package/dist/pages/build-diff-viewer.mjs +70 -0
- package/dist/pages/build-diff-viewer.mjs.map +1 -0
- package/dist/pages/build-diff.mjs +110 -0
- package/dist/pages/build-diff.mjs.map +1 -0
- package/dist/pages/compute-jobs.mjs +165 -0
- package/dist/pages/compute-jobs.mjs.map +1 -0
- package/dist/pages/invite.mjs +91 -0
- package/dist/pages/invite.mjs.map +1 -0
- package/dist/pages/label-detail.mjs +154 -0
- package/dist/pages/label-detail.mjs.map +1 -0
- package/dist/pages/library.mjs +343 -0
- package/dist/pages/library.mjs.map +1 -0
- package/dist/pages/login.mjs +117 -0
- package/dist/pages/login.mjs.map +1 -0
- package/dist/pages/passkey-ceremony.mjs +97 -0
- package/dist/pages/passkey-ceremony.mjs.map +1 -0
- package/dist/pages/profile.mjs +364 -0
- package/dist/pages/profile.mjs.map +1 -0
- package/dist/pages/project-builds-table.mjs +151 -0
- package/dist/pages/project-builds-table.mjs.map +1 -0
- package/dist/pages/project-builds.mjs +143 -0
- package/dist/pages/project-builds.mjs.map +1 -0
- package/dist/pages/project-create.mjs +90 -0
- package/dist/pages/project-create.mjs.map +1 -0
- package/dist/pages/project-settings.mjs +95 -0
- package/dist/pages/project-settings.mjs.map +1 -0
- package/dist/pages/projects-card.mjs +194 -0
- package/dist/pages/projects-card.mjs.map +1 -0
- package/dist/pages/projects.mjs +129 -0
- package/dist/pages/projects.mjs.map +1 -0
- package/dist/pages/root.mjs +98 -0
- package/dist/pages/root.mjs.map +1 -0
- package/dist/pages/settings-general.mjs +138 -0
- package/dist/pages/settings-general.mjs.map +1 -0
- package/dist/pages/settings-labels.mjs +107 -0
- package/dist/pages/settings-labels.mjs.map +1 -0
- package/dist/pages/settings-members.mjs +192 -0
- package/dist/pages/settings-members.mjs.map +1 -0
- package/dist/pages/settings-notifications.mjs +240 -0
- package/dist/pages/settings-notifications.mjs.map +1 -0
- package/dist/pages/settings-status.mjs +137 -0
- package/dist/pages/settings-status.mjs.map +1 -0
- package/dist/pages/settings-tests.mjs +111 -0
- package/dist/pages/settings-tests.mjs.map +1 -0
- package/dist/pages/settings-tokens.mjs +90 -0
- package/dist/pages/settings-tokens.mjs.map +1 -0
- package/dist/pages/settings-webhooks.mjs +104 -0
- package/dist/pages/settings-webhooks.mjs.map +1 -0
- package/dist/pages/storybook.mjs +116 -0
- package/dist/pages/storybook.mjs.map +1 -0
- package/dist/retention-timer.mjs +65 -0
- package/dist/retention-timer.mjs.map +1 -0
- package/dist/routers/admin-pages.mjs +48 -0
- package/dist/routers/admin-pages.mjs.map +1 -0
- package/dist/routers/admin.mjs +113 -0
- package/dist/routers/admin.mjs.map +1 -0
- package/dist/routers/assets.mjs +64 -0
- package/dist/routers/assets.mjs.map +1 -0
- package/dist/routers/attempts.mjs +96 -0
- package/dist/routers/attempts.mjs.map +1 -0
- package/dist/routers/auth-sync.mjs +64 -0
- package/dist/routers/auth-sync.mjs.map +1 -0
- package/dist/routers/auth.mjs +236 -0
- package/dist/routers/auth.mjs.map +1 -0
- package/dist/routers/baseline-guard.mjs +83 -0
- package/dist/routers/baseline-guard.mjs.map +1 -0
- package/dist/routers/builds.handlers.mjs +217 -0
- package/dist/routers/builds.handlers.mjs.map +1 -0
- package/dist/routers/builds.mjs +381 -0
- package/dist/routers/builds.mjs.map +1 -0
- package/dist/routers/comments.mjs +109 -0
- package/dist/routers/comments.mjs.map +1 -0
- package/dist/routers/flash.mjs +35 -0
- package/dist/routers/flash.mjs.map +1 -0
- package/dist/routers/health-report.mjs +159 -0
- package/dist/routers/health-report.mjs.map +1 -0
- package/dist/routers/health.mjs +46 -0
- package/dist/routers/health.mjs.map +1 -0
- package/dist/routers/helpers.mjs +110 -0
- package/dist/routers/helpers.mjs.map +1 -0
- package/dist/routers/htmx.mjs +24 -0
- package/dist/routers/htmx.mjs.map +1 -0
- package/dist/routers/labels.mjs +118 -0
- package/dist/routers/labels.mjs.map +1 -0
- package/dist/routers/media.mjs +53 -0
- package/dist/routers/media.mjs.map +1 -0
- package/dist/routers/members.mjs +177 -0
- package/dist/routers/members.mjs.map +1 -0
- package/dist/routers/notification-channels.mjs +190 -0
- package/dist/routers/notification-channels.mjs.map +1 -0
- package/dist/routers/notification-subscriptions.mjs +120 -0
- package/dist/routers/notification-subscriptions.mjs.map +1 -0
- package/dist/routers/profile.mjs +276 -0
- package/dist/routers/profile.mjs.map +1 -0
- package/dist/routers/projects.mjs +133 -0
- package/dist/routers/projects.mjs.map +1 -0
- package/dist/routers/review-next.mjs +47 -0
- package/dist/routers/review-next.mjs.map +1 -0
- package/dist/routers/schemas.mjs +337 -0
- package/dist/routers/schemas.mjs.map +1 -0
- package/dist/routers/settings-general.mjs +104 -0
- package/dist/routers/settings-general.mjs.map +1 -0
- package/dist/routers/settings-labels.mjs +52 -0
- package/dist/routers/settings-labels.mjs.map +1 -0
- package/dist/routers/settings-members.mjs +58 -0
- package/dist/routers/settings-members.mjs.map +1 -0
- package/dist/routers/settings-notifications.mjs +108 -0
- package/dist/routers/settings-notifications.mjs.map +1 -0
- package/dist/routers/settings-status.mjs +77 -0
- package/dist/routers/settings-status.mjs.map +1 -0
- package/dist/routers/settings-tokens.mjs +40 -0
- package/dist/routers/settings-tokens.mjs.map +1 -0
- package/dist/routers/settings-webhooks.mjs +67 -0
- package/dist/routers/settings-webhooks.mjs.map +1 -0
- package/dist/routers/settings.handlers.mjs +149 -0
- package/dist/routers/settings.handlers.mjs.map +1 -0
- package/dist/routers/settings.mjs +25 -0
- package/dist/routers/settings.mjs.map +1 -0
- package/dist/routers/snapshots.mjs +240 -0
- package/dist/routers/snapshots.mjs.map +1 -0
- package/dist/routers/status-configs.mjs +108 -0
- package/dist/routers/status-configs.mjs.map +1 -0
- package/dist/routers/storybook.mjs +171 -0
- package/dist/routers/storybook.mjs.map +1 -0
- package/dist/routers/tokens.mjs +91 -0
- package/dist/routers/tokens.mjs.map +1 -0
- package/dist/routers/ui.mjs +166 -0
- package/dist/routers/ui.mjs.map +1 -0
- package/dist/routers/webhooks.mjs +97 -0
- package/dist/routers/webhooks.mjs.map +1 -0
- package/dist/routers/well-known.mjs +43 -0
- package/dist/routers/well-known.mjs.map +1 -0
- package/dist/runtime.mjs +52 -0
- package/dist/runtime.mjs.map +1 -0
- package/dist/store.mjs +20 -0
- package/dist/store.mjs.map +1 -0
- package/dist/ui/avatar.mjs +76 -0
- package/dist/ui/avatar.mjs.map +1 -0
- package/dist/ui/buttons.mjs +205 -0
- package/dist/ui/buttons.mjs.map +1 -0
- package/dist/ui/code-block.mjs +47 -0
- package/dist/ui/code-block.mjs.map +1 -0
- package/dist/ui/compare-stage.mjs +344 -0
- package/dist/ui/compare-stage.mjs.map +1 -0
- package/dist/ui/csrf-field.mjs +20 -0
- package/dist/ui/csrf-field.mjs.map +1 -0
- package/dist/ui/css.mjs +36 -0
- package/dist/ui/css.mjs.map +1 -0
- package/dist/ui/document.mjs +130 -0
- package/dist/ui/document.mjs.map +1 -0
- package/dist/ui/dropdown.mjs +181 -0
- package/dist/ui/dropdown.mjs.map +1 -0
- package/dist/ui/feedback.mjs +341 -0
- package/dist/ui/feedback.mjs.map +1 -0
- package/dist/ui/filter-input.mjs +58 -0
- package/dist/ui/filter-input.mjs.map +1 -0
- package/dist/ui/forms.mjs +291 -0
- package/dist/ui/forms.mjs.map +1 -0
- package/dist/ui/icons/icon.mjs +53 -0
- package/dist/ui/icons/icon.mjs.map +1 -0
- package/dist/ui/icons/paths.mjs +61 -0
- package/dist/ui/icons/paths.mjs.map +1 -0
- package/dist/ui/icons/sprite.mjs +26 -0
- package/dist/ui/icons/sprite.mjs.map +1 -0
- package/dist/ui/kbd.mjs +33 -0
- package/dist/ui/kbd.mjs.map +1 -0
- package/dist/ui/layout.mjs +173 -0
- package/dist/ui/layout.mjs.map +1 -0
- package/dist/ui/progress.mjs +44 -0
- package/dist/ui/progress.mjs.map +1 -0
- package/dist/ui/segmented.mjs +77 -0
- package/dist/ui/segmented.mjs.map +1 -0
- package/dist/ui/shell/account.mjs +161 -0
- package/dist/ui/shell/account.mjs.map +1 -0
- package/dist/ui/shell/bare.mjs +69 -0
- package/dist/ui/shell/bare.mjs.map +1 -0
- package/dist/ui/shell/boot-script.mjs +46 -0
- package/dist/ui/shell/boot-script.mjs.map +1 -0
- package/dist/ui/shell/client-script.mjs +259 -0
- package/dist/ui/shell/client-script.mjs.map +1 -0
- package/dist/ui/shell/nav.mjs +111 -0
- package/dist/ui/shell/nav.mjs.map +1 -0
- package/dist/ui/shell/review-script.mjs +195 -0
- package/dist/ui/shell/review-script.mjs.map +1 -0
- package/dist/ui/shell/sidebar.mjs +323 -0
- package/dist/ui/shell/sidebar.mjs.map +1 -0
- package/dist/ui/shell/topbar.mjs +268 -0
- package/dist/ui/shell/topbar.mjs.map +1 -0
- package/dist/ui/stacks.mjs +82 -0
- package/dist/ui/stacks.mjs.map +1 -0
- package/dist/ui/styles/base.mjs +41 -0
- package/dist/ui/styles/base.mjs.map +1 -0
- package/dist/ui/styles/behavior.mjs +43 -0
- package/dist/ui/styles/behavior.mjs.map +1 -0
- package/dist/ui/styles/review-layout.mjs +347 -0
- package/dist/ui/styles/review-layout.mjs.map +1 -0
- package/dist/ui/styles/review.mjs +86 -0
- package/dist/ui/styles/review.mjs.map +1 -0
- package/dist/ui/styles/tokens.mjs +132 -0
- package/dist/ui/styles/tokens.mjs.map +1 -0
- package/dist/ui/styles.mjs +19 -0
- package/dist/ui/styles.mjs.map +1 -0
- package/dist/ui/subnav.mjs +91 -0
- package/dist/ui/subnav.mjs.map +1 -0
- package/dist/ui/table.mjs +73 -0
- package/dist/ui/table.mjs.map +1 -0
- package/dist/ui/theme.mjs +64 -0
- package/dist/ui/theme.mjs.map +1 -0
- package/dist/ui/thumbnail.mjs +51 -0
- package/dist/ui/thumbnail.mjs.map +1 -0
- package/dist/ui/time.mjs +50 -0
- package/dist/ui/time.mjs.map +1 -0
- package/package.json +92 -0
|
@@ -0,0 +1,276 @@
|
|
|
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 { forwardToEngine } from "./auth.mjs";
|
|
8
|
+
import { renderProfilePage } from "../pages/profile.mjs";
|
|
9
|
+
import { NotificationSubscriptionModel, ProjectModel, UserModel } from "@storyshelf/core/models";
|
|
10
|
+
import { SESSION_COOKIE } from "@storyshelf/core/types";
|
|
11
|
+
import { MIN_PASSWORD_LENGTH, unsignedToken } from "@storyshelf/auth";
|
|
12
|
+
import { eq, getTableColumns } from "drizzle-orm";
|
|
13
|
+
//#region src/routers/profile.ts
|
|
14
|
+
function field(form, name) {
|
|
15
|
+
const raw = form.get(name);
|
|
16
|
+
return typeof raw === "string" ? raw : "";
|
|
17
|
+
}
|
|
18
|
+
function toDbUser(row) {
|
|
19
|
+
if (!row) return;
|
|
20
|
+
return {
|
|
21
|
+
email: row.email,
|
|
22
|
+
name: row.name,
|
|
23
|
+
displayNameOverride: row.displayNameOverride,
|
|
24
|
+
authProvider: row.authProvider,
|
|
25
|
+
lastLoginAt: row.lastLoginAt,
|
|
26
|
+
createdAt: row.createdAt
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
async function loadProfileData(userId) {
|
|
30
|
+
const { db } = getStore();
|
|
31
|
+
const userRow = await new UserModel(db).get(userId);
|
|
32
|
+
const members = await db.list(db.tables.projectMembers, { where: eq(getTableColumns(db.tables.projectMembers)["userId"], userId) });
|
|
33
|
+
const projects = await db.list(db.tables.projects);
|
|
34
|
+
const byId = new Map(projects.map((project) => [project.id, project]));
|
|
35
|
+
const memberships = members.flatMap((member) => {
|
|
36
|
+
const project = byId.get(member.projectId);
|
|
37
|
+
return project ? [{
|
|
38
|
+
projectName: project.name,
|
|
39
|
+
projectSlug: project.slug,
|
|
40
|
+
role: member.role,
|
|
41
|
+
source: member.source
|
|
42
|
+
}] : [];
|
|
43
|
+
});
|
|
44
|
+
const subs = await new NotificationSubscriptionModel(db).listForUser(userId);
|
|
45
|
+
const subscribedIds = new Set(subs.filter((sub) => sub.enabled).map((sub) => sub.projectId));
|
|
46
|
+
return {
|
|
47
|
+
userRow,
|
|
48
|
+
memberships,
|
|
49
|
+
subscribedSlugs: [...byId.values()].filter((project) => subscribedIds.has(project.id)).map((project) => project.slug)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function readSessionToken(c) {
|
|
53
|
+
const header = c.req.header("cookie") ?? "";
|
|
54
|
+
for (const part of header.split(";")) {
|
|
55
|
+
const eqIndex = part.indexOf("=");
|
|
56
|
+
if (eqIndex !== -1 && part.slice(0, eqIndex).trim() === SESSION_COOKIE) return unsignedToken(part.slice(eqIndex + 1).trim());
|
|
57
|
+
}
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
/** Engine security overview for the profile page. */
|
|
61
|
+
async function loadEngineSecurity(c, auth, user) {
|
|
62
|
+
const [sessions, passkeys, hasPassword] = await Promise.all([
|
|
63
|
+
auth.listSessions(user.id),
|
|
64
|
+
auth.listPasskeys(user.id),
|
|
65
|
+
auth.hasPassword(user.id)
|
|
66
|
+
]);
|
|
67
|
+
const current = readSessionToken(c);
|
|
68
|
+
return {
|
|
69
|
+
sessions: sessions.map((session) => ({
|
|
70
|
+
id: session.id,
|
|
71
|
+
ipAddress: session.ipAddress,
|
|
72
|
+
userAgent: session.userAgent,
|
|
73
|
+
createdAt: session.createdAt,
|
|
74
|
+
expiresAt: session.expiresAt,
|
|
75
|
+
current: session.token === current
|
|
76
|
+
})),
|
|
77
|
+
passkeys,
|
|
78
|
+
passkeysEnabled: auth.passkeysEnabled(),
|
|
79
|
+
hasPassword
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
async function profileView(c, user, auth, extra) {
|
|
83
|
+
const { userRow, memberships, subscribedSlugs } = await loadProfileData(user.id);
|
|
84
|
+
const security = await loadEngineSecurity(c, auth, user);
|
|
85
|
+
const html = await renderProfilePage({
|
|
86
|
+
user,
|
|
87
|
+
dbUser: toDbUser(userRow),
|
|
88
|
+
memberships,
|
|
89
|
+
subscribedSlugs,
|
|
90
|
+
isLocal: extra?.local ?? security.hasPassword,
|
|
91
|
+
security,
|
|
92
|
+
error: extra?.error
|
|
93
|
+
});
|
|
94
|
+
if (extra?.success) flash(c, extra.success);
|
|
95
|
+
c.header("Cache-Control", "no-store");
|
|
96
|
+
return c.html(html, extra?.status ?? 200);
|
|
97
|
+
}
|
|
98
|
+
/** Register the personal profile page and its mutations. */
|
|
99
|
+
function registerProfile(app, auth) {
|
|
100
|
+
app.get("/profile", async (c) => {
|
|
101
|
+
const { user } = getStore();
|
|
102
|
+
if (!user) return c.redirect("/auth/login", 302);
|
|
103
|
+
return await profileView(c, user, auth);
|
|
104
|
+
});
|
|
105
|
+
app.post("/profile", async (c) => {
|
|
106
|
+
const { user } = getStore();
|
|
107
|
+
if (!user) return c.redirect("/auth/login", 302);
|
|
108
|
+
const displayName = field(await c.req.formData(), "displayName").trim();
|
|
109
|
+
if (!displayName) return profileView(c, user, auth, {
|
|
110
|
+
error: "Display name is required",
|
|
111
|
+
status: 400
|
|
112
|
+
});
|
|
113
|
+
await new UserModel(getStore().db).setDisplayNameOverride(user.id, displayName);
|
|
114
|
+
flash(c, "Profile updated");
|
|
115
|
+
return hxRedirect(c, "/profile");
|
|
116
|
+
});
|
|
117
|
+
registerPasswordRoute(app, auth);
|
|
118
|
+
registerSessionRoutes(app, auth);
|
|
119
|
+
registerPasskeyRoutes(app, auth);
|
|
120
|
+
registerNotificationsRoute(app, auth);
|
|
121
|
+
}
|
|
122
|
+
function registerNotificationsRoute(app, auth) {
|
|
123
|
+
app.post("/profile/notifications", async (c) => {
|
|
124
|
+
const { user } = getStore();
|
|
125
|
+
if (!user) return c.redirect("/auth/login", 302);
|
|
126
|
+
return await saveProfileNotifications(c, user, auth);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** Toggle the viewer's own email subscription for one member project. */
|
|
130
|
+
async function saveProfileNotifications(c, user, auth) {
|
|
131
|
+
const form = await c.req.formData();
|
|
132
|
+
const project = await memberProject(field(form, "slug"), user.id);
|
|
133
|
+
if (!project) return profileView(c, user, auth, {
|
|
134
|
+
error: "Unknown project",
|
|
135
|
+
status: 400
|
|
136
|
+
});
|
|
137
|
+
await applyProfileToggle(project.id, user.id, field(form, "enabled") === "1");
|
|
138
|
+
flash(c, "Notification preference saved");
|
|
139
|
+
return hxRedirect(c, "/profile");
|
|
140
|
+
}
|
|
141
|
+
/** Resolve a project the viewer belongs to (null otherwise). */
|
|
142
|
+
async function memberProject(slug, userId) {
|
|
143
|
+
const project = await new ProjectModel(getStore().db).getBySlug(slug);
|
|
144
|
+
if (!project) return null;
|
|
145
|
+
const { memberships } = await loadProfileData(userId);
|
|
146
|
+
return memberships.some((membership) => membership.projectSlug === slug) ? project : null;
|
|
147
|
+
}
|
|
148
|
+
/** Enable (opt-in email) or disable (remove) a subscription. */
|
|
149
|
+
async function applyProfileToggle(projectId, userId, enable) {
|
|
150
|
+
const model = new NotificationSubscriptionModel(getStore().db);
|
|
151
|
+
if (enable) {
|
|
152
|
+
await model.upsert(projectId, userId, { via: ["email"] });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
await model.remove(projectId, userId);
|
|
156
|
+
}
|
|
157
|
+
function registerPasswordRoute(app, auth) {
|
|
158
|
+
app.post("/profile/password", async (c) => {
|
|
159
|
+
const { user } = getStore();
|
|
160
|
+
if (!user) return c.redirect("/auth/login", 302);
|
|
161
|
+
return await handleEnginePasswordChange(c, auth, user);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function registerSessionRoutes(app, auth) {
|
|
165
|
+
app.post("/profile/sessions/revoke", async (c) => {
|
|
166
|
+
const { user } = getStore();
|
|
167
|
+
if (!user) return c.redirect("/auth/login", 302);
|
|
168
|
+
return await handleSessionRevoke(c, auth, user);
|
|
169
|
+
});
|
|
170
|
+
app.post("/profile/sessions/revoke-others", async (c) => {
|
|
171
|
+
const { user } = getStore();
|
|
172
|
+
if (!user) return c.redirect("/auth/login", 302);
|
|
173
|
+
return await handleRevokeOthers(c, auth, user);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function registerPasskeyRoutes(app, auth) {
|
|
177
|
+
app.post("/profile/passkeys/:passkeyId/delete", async (c) => {
|
|
178
|
+
const { user } = getStore();
|
|
179
|
+
if (!user) return c.redirect("/auth/login", 302);
|
|
180
|
+
return await handlePasskeyDelete(c, auth, user);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async function handleSessionRevoke(c, auth, user) {
|
|
184
|
+
const id = await readSessionId(c);
|
|
185
|
+
if (!id) return profileView(c, user, auth, {
|
|
186
|
+
error: "Missing session",
|
|
187
|
+
status: 400
|
|
188
|
+
});
|
|
189
|
+
const token = await resolveSessionToken(auth, user, id);
|
|
190
|
+
if (!token) return profileView(c, user, auth, {
|
|
191
|
+
error: "Could not revoke that session",
|
|
192
|
+
status: 400
|
|
193
|
+
});
|
|
194
|
+
if ((await forwardToEngine(c, auth, "/api/auth/revoke-session", { token })).status !== 200) return profileView(c, user, auth, {
|
|
195
|
+
error: "Could not revoke that session",
|
|
196
|
+
status: 400
|
|
197
|
+
});
|
|
198
|
+
return profileView(c, user, auth, { success: "Session revoked" });
|
|
199
|
+
}
|
|
200
|
+
async function readSessionId(c) {
|
|
201
|
+
return field(await c.req.formData(), "sessionId");
|
|
202
|
+
}
|
|
203
|
+
async function resolveSessionToken(auth, user, id) {
|
|
204
|
+
return (await auth.listSessions(user.id)).find((session) => session.id === id)?.token ?? null;
|
|
205
|
+
}
|
|
206
|
+
async function handleRevokeOthers(c, auth, user) {
|
|
207
|
+
if ((await forwardToEngine(c, auth, "/api/auth/revoke-other-sessions", {})).status !== 200) return profileView(c, user, auth, {
|
|
208
|
+
error: "Could not sign out other devices",
|
|
209
|
+
status: 400
|
|
210
|
+
});
|
|
211
|
+
return profileView(c, user, auth, { success: "Signed out of other devices" });
|
|
212
|
+
}
|
|
213
|
+
async function handlePasskeyDelete(c, auth, user) {
|
|
214
|
+
const id = c.req.param("passkeyId") ?? "";
|
|
215
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/u.test(id)) return profileView(c, user, auth, {
|
|
216
|
+
error: "Unknown passkey",
|
|
217
|
+
status: 400
|
|
218
|
+
});
|
|
219
|
+
if ((await forwardToEngine(c, auth, "/api/auth/passkey/delete-passkey", { id })).status !== 200) return profileView(c, user, auth, {
|
|
220
|
+
error: "Could not remove that passkey",
|
|
221
|
+
status: 400
|
|
222
|
+
});
|
|
223
|
+
return profileView(c, user, auth, { success: "Passkey removed" });
|
|
224
|
+
}
|
|
225
|
+
async function engineErrorMessage(res, fallback) {
|
|
226
|
+
try {
|
|
227
|
+
const body = await res.json();
|
|
228
|
+
return typeof body.message === "string" && body.message ? body.message : fallback;
|
|
229
|
+
} catch {
|
|
230
|
+
return fallback;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Validate the password form and change it through the engine. */
|
|
234
|
+
async function handleEnginePasswordChange(c, auth, user) {
|
|
235
|
+
const { current, next, error } = readPasswordChange(await c.req.formData());
|
|
236
|
+
if (error) return profileView(c, user, auth, {
|
|
237
|
+
error,
|
|
238
|
+
local: true,
|
|
239
|
+
status: 400
|
|
240
|
+
});
|
|
241
|
+
const res = await forwardToEngine(c, auth, "/api/auth/change-password", {
|
|
242
|
+
currentPassword: current,
|
|
243
|
+
newPassword: next,
|
|
244
|
+
revokeOtherSessions: true
|
|
245
|
+
});
|
|
246
|
+
if (res.status !== 200) return profileView(c, user, auth, {
|
|
247
|
+
error: await engineErrorMessage(res, "Password change failed"),
|
|
248
|
+
local: true,
|
|
249
|
+
status: 400
|
|
250
|
+
});
|
|
251
|
+
return profileView(c, user, auth, {
|
|
252
|
+
success: "Password changed",
|
|
253
|
+
local: true
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
function readPasswordChange(form) {
|
|
257
|
+
const next = field(form, "newPassword");
|
|
258
|
+
if (next !== field(form, "confirmPassword")) return {
|
|
259
|
+
current: "",
|
|
260
|
+
next: "",
|
|
261
|
+
error: "Passwords do not match"
|
|
262
|
+
};
|
|
263
|
+
if (next.length < MIN_PASSWORD_LENGTH) return {
|
|
264
|
+
current: "",
|
|
265
|
+
next: "",
|
|
266
|
+
error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters`
|
|
267
|
+
};
|
|
268
|
+
return {
|
|
269
|
+
current: field(form, "currentPassword"),
|
|
270
|
+
next
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
//#endregion
|
|
274
|
+
export { registerProfile };
|
|
275
|
+
|
|
276
|
+
//# sourceMappingURL=profile.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"profile.mjs","names":[],"sources":["../../src/routers/profile.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/auth\";\nimport { MIN_PASSWORD_LENGTH, unsignedToken } from \"@storyshelf/auth\";\nimport { NotificationSubscriptionModel, ProjectModel, UserModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { SESSION_COOKIE, type AuthUser } from \"@storyshelf/core/types\";\nimport { eq, getTableColumns } from \"drizzle-orm\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { renderProfilePage } from \"../pages/profile.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { forwardToEngine } from \"./auth.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** User columns needed to render the profile page. */\ninterface ProfileRow {\n email: string;\n name: string;\n displayNameOverride: string | null;\n authProvider: string;\n lastLoginAt: string | null;\n createdAt: string;\n}\n\n/** Membership entry for the profile page. */\ninterface ProfileMembership {\n projectName: string;\n projectSlug: string;\n role: string;\n source: string;\n}\n\nfunction field(form: FormData, name: string): string {\n const raw = form.get(name);\n return typeof raw === \"string\" ? raw : \"\";\n}\n\nfunction toDbUser(row: ProfileRow | null): ProfilePage[\"dbUser\"] {\n if (!row) {\n return undefined;\n }\n return {\n email: row.email,\n name: row.name,\n displayNameOverride: row.displayNameOverride,\n authProvider: row.authProvider,\n lastLoginAt: row.lastLoginAt,\n createdAt: row.createdAt,\n };\n}\n\ntype ProfilePage = Parameters<typeof renderProfilePage>[0];\n\nasync function loadProfileData(userId: string): Promise<{\n userRow: ProfileRow | null;\n memberships: ProfileMembership[];\n subscribedSlugs: string[];\n}> {\n const { db } = getStore();\n const userRow = (await new UserModel(db).get(userId)) as unknown as ProfileRow | null;\n const members = (await db.list(db.tables.projectMembers, {\n where: eq(getTableColumns(db.tables.projectMembers)[\"userId\"] as never, userId),\n })) as unknown as Array<{ projectId: string; role: string; source: string }>;\n const projects = (await db.list(db.tables.projects)) as unknown as Array<{\n id: string;\n name: string;\n slug: string;\n }>;\n const byId = new Map(projects.map((project) => [project.id, project]));\n const memberships = members.flatMap((member) => {\n const project = byId.get(member.projectId);\n return project\n ? [\n {\n projectName: project.name,\n projectSlug: project.slug,\n role: member.role,\n source: member.source,\n },\n ]\n : [];\n });\n const subs = await new NotificationSubscriptionModel(db).listForUser(userId);\n const subscribedIds = new Set(subs.filter((sub) => sub.enabled).map((sub) => sub.projectId));\n const subscribedSlugs = [...byId.values()]\n .filter((project) => subscribedIds.has(project.id))\n .map((project) => project.slug);\n return { userRow, memberships, subscribedSlugs };\n}\n\nfunction readSessionToken(c: Context): string {\n const header = c.req.header(\"cookie\") ?? \"\";\n for (const part of header.split(\";\")) {\n const eqIndex = part.indexOf(\"=\");\n if (eqIndex !== -1 && part.slice(0, eqIndex).trim() === SESSION_COOKIE) {\n // Engine cookies are signed (`token.signature`); the table holds the raw token.\n return unsignedToken(part.slice(eqIndex + 1).trim());\n }\n }\n return \"\";\n}\n\n/** Engine security overview for the profile page. */\nasync function loadEngineSecurity(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<NonNullable<ProfilePage[\"security\"]>> {\n const [sessions, passkeys, hasPassword] = await Promise.all([\n auth.listSessions(user.id),\n auth.listPasskeys(user.id),\n auth.hasPassword(user.id),\n ]);\n const current = readSessionToken(c);\n return {\n sessions: sessions.map((session) => ({\n id: session.id,\n ipAddress: session.ipAddress,\n userAgent: session.userAgent,\n createdAt: session.createdAt,\n expiresAt: session.expiresAt,\n current: session.token === current,\n })),\n passkeys,\n passkeysEnabled: auth.passkeysEnabled(),\n hasPassword,\n };\n}\n\nasync function profileView(\n c: Context,\n user: AuthUser,\n auth: Auth,\n extra?: { error?: string; success?: string; local?: boolean; status?: 200 | 400 },\n): Promise<Response> {\n const { userRow, memberships, subscribedSlugs } = await loadProfileData(user.id);\n const security = await loadEngineSecurity(c, auth, user);\n const html = await renderProfilePage({\n user,\n dbUser: toDbUser(userRow),\n memberships,\n subscribedSlugs,\n isLocal: extra?.local ?? security.hasPassword,\n security,\n error: extra?.error,\n });\n if (extra?.success) {\n flash(c, extra.success);\n }\n // Device tokens render into the page (per-device revoke forms): never store.\n c.header(\"Cache-Control\", \"no-store\");\n return c.html(html, extra?.status ?? 200);\n}\n\n/** Register the personal profile page and its mutations. */\nexport function registerProfile(app: ShelfRouter, auth: Auth): void {\n app.get(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await profileView(c, user, auth);\n });\n\n app.post(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n const displayName = field(await c.req.formData(), \"displayName\").trim();\n if (!displayName) {\n return profileView(c, user, auth, { error: \"Display name is required\", status: 400 });\n }\n await new UserModel(getStore().db).setDisplayNameOverride(user.id, displayName);\n flash(c, \"Profile updated\");\n return hxRedirect(c, \"/profile\");\n });\n\n registerPasswordRoute(app, auth);\n registerSessionRoutes(app, auth);\n registerPasskeyRoutes(app, auth);\n registerNotificationsRoute(app, auth);\n}\n\nfunction registerNotificationsRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/notifications\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await saveProfileNotifications(c, user, auth);\n });\n}\n\n/** Toggle the viewer's own email subscription for one member project. */\nasync function saveProfileNotifications(c: Context, user: AuthUser, auth: Auth): Promise<Response> {\n const form = await c.req.formData();\n const project = await memberProject(field(form, \"slug\"), user.id);\n if (!project) {\n return profileView(c, user, auth, { error: \"Unknown project\", status: 400 });\n }\n await applyProfileToggle(project.id, user.id, field(form, \"enabled\") === \"1\");\n flash(c, \"Notification preference saved\");\n return hxRedirect(c, \"/profile\");\n}\n\n/** Resolve a project the viewer belongs to (null otherwise). */\nasync function memberProject(slug: string, userId: string): Promise<Project | null> {\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n return null;\n }\n const { memberships } = await loadProfileData(userId);\n const isMember = memberships.some((membership) => membership.projectSlug === slug);\n return isMember ? project : null;\n}\n\n/** Enable (opt-in email) or disable (remove) a subscription. */\nasync function applyProfileToggle(\n projectId: string,\n userId: string,\n enable: boolean,\n): Promise<void> {\n const model = new NotificationSubscriptionModel(getStore().db);\n if (enable) {\n await model.upsert(projectId, userId, { via: [\"email\"] });\n return;\n }\n await model.remove(projectId, userId);\n}\n\nfunction registerPasswordRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/password\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleEnginePasswordChange(c, auth, user);\n });\n}\n\nfunction registerSessionRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/sessions/revoke\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleSessionRevoke(c, auth, user);\n });\n\n app.post(\"/profile/sessions/revoke-others\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleRevokeOthers(c, auth, user);\n });\n}\n\nfunction registerPasskeyRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/passkeys/:passkeyId/delete\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handlePasskeyDelete(c, auth, user);\n });\n}\n\nasync function handleSessionRevoke(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = await readSessionId(c);\n if (!id) {\n return profileView(c, user, auth, { error: \"Missing session\", status: 400 });\n }\n const token = await resolveSessionToken(auth, user, id);\n if (!token) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-session\", { token });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Session revoked\" });\n}\n\nasync function readSessionId(c: Context): Promise<string> {\n return field(await c.req.formData(), \"sessionId\");\n}\n\nasync function resolveSessionToken(auth: Auth, user: AuthUser, id: string): Promise<string | null> {\n const sessions = await auth.listSessions(user.id);\n return sessions.find((session) => session.id === id)?.token ?? null;\n}\n\nasync function handleRevokeOthers(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-other-sessions\", {});\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not sign out other devices\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Signed out of other devices\" });\n}\n\nasync function handlePasskeyDelete(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = c.req.param(\"passkeyId\") ?? \"\";\n if (!/^[A-Za-z0-9_-]{1,64}$/u.test(id)) {\n return profileView(c, user, auth, { error: \"Unknown passkey\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/passkey/delete-passkey\", { id });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not remove that passkey\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Passkey removed\" });\n}\n\nasync function engineErrorMessage(res: Response, fallback: string): Promise<string> {\n try {\n const body = (await res.json()) as { message?: unknown };\n return typeof body.message === \"string\" && body.message ? body.message : fallback;\n } catch {\n return fallback;\n }\n}\n\n/** Validate the password form and change it through the engine. */\nasync function handleEnginePasswordChange(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<Response> {\n const { current, next, error } = readPasswordChange(await c.req.formData());\n if (error) {\n return profileView(c, user, auth, { error, local: true, status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/change-password\", {\n currentPassword: current,\n newPassword: next,\n revokeOtherSessions: true,\n });\n if (res.status !== 200) {\n const message = await engineErrorMessage(res, \"Password change failed\");\n return profileView(c, user, auth, { error: message, local: true, status: 400 });\n }\n return profileView(c, user, auth, { success: \"Password changed\", local: true });\n}\n\nfunction readPasswordChange(form: FormData): { current: string; next: string; error?: string } {\n const next = field(form, \"newPassword\");\n if (next !== field(form, \"confirmPassword\")) {\n return { current: \"\", next: \"\", error: \"Passwords do not match\" };\n }\n if (next.length < MIN_PASSWORD_LENGTH) {\n return {\n current: \"\",\n next: \"\",\n error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters`,\n };\n }\n return { current: field(form, \"currentPassword\"), next };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { forbidden, requireSiteAdmin, resolveAuthorizedProject } from "./helpers.mjs";
|
|
6
|
+
import { badRequest, forbidden as forbidden$1, notFound, projectCreateSchema, projectSchema, projectUpdateSchema, unauthorized } from "./schemas.mjs";
|
|
7
|
+
import { createRoute, z } from "@hono/zod-openapi";
|
|
8
|
+
import { LabelModel, MemberModel, ProjectModel } from "@storyshelf/core/models";
|
|
9
|
+
//#region src/routers/projects.ts
|
|
10
|
+
const VIEW_ROLES = [
|
|
11
|
+
"viewer",
|
|
12
|
+
"developer",
|
|
13
|
+
"approver",
|
|
14
|
+
"admin"
|
|
15
|
+
];
|
|
16
|
+
const ADMIN_ROLES = ["admin"];
|
|
17
|
+
function requireSessionUser() {
|
|
18
|
+
if (!getStore().authEnabled) return;
|
|
19
|
+
if (!getStore().user) forbidden();
|
|
20
|
+
}
|
|
21
|
+
const listProjectsRoute = createRoute({
|
|
22
|
+
method: "get",
|
|
23
|
+
tags: ["Projects"],
|
|
24
|
+
summary: "List projects",
|
|
25
|
+
path: "/api/v1/projects",
|
|
26
|
+
responses: {
|
|
27
|
+
200: {
|
|
28
|
+
content: { "application/json": { schema: projectSchema.array() } },
|
|
29
|
+
description: "List projects"
|
|
30
|
+
},
|
|
31
|
+
...unauthorized
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
const createProjectRoute = createRoute({
|
|
35
|
+
method: "post",
|
|
36
|
+
tags: ["Projects"],
|
|
37
|
+
summary: "Create a project",
|
|
38
|
+
path: "/api/v1/projects",
|
|
39
|
+
request: { body: { content: { "application/json": { schema: projectCreateSchema } } } },
|
|
40
|
+
responses: {
|
|
41
|
+
201: {
|
|
42
|
+
content: { "application/json": { schema: projectSchema } },
|
|
43
|
+
description: "Created project"
|
|
44
|
+
},
|
|
45
|
+
...badRequest,
|
|
46
|
+
...forbidden$1,
|
|
47
|
+
...unauthorized
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
const getProjectRoute = createRoute({
|
|
51
|
+
method: "get",
|
|
52
|
+
tags: ["Projects"],
|
|
53
|
+
summary: "Get a project",
|
|
54
|
+
path: "/api/v1/projects/{slug}",
|
|
55
|
+
request: { params: z.object({ slug: z.string() }) },
|
|
56
|
+
responses: {
|
|
57
|
+
200: {
|
|
58
|
+
content: { "application/json": { schema: projectSchema } },
|
|
59
|
+
description: "Fetch a project"
|
|
60
|
+
},
|
|
61
|
+
...notFound,
|
|
62
|
+
...unauthorized
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
const updateProjectRoute = createRoute({
|
|
66
|
+
method: "patch",
|
|
67
|
+
tags: ["Projects"],
|
|
68
|
+
summary: "Update a project",
|
|
69
|
+
path: "/api/v1/projects/{slug}",
|
|
70
|
+
request: {
|
|
71
|
+
params: z.object({ slug: z.string() }),
|
|
72
|
+
body: { content: { "application/json": { schema: projectUpdateSchema } } }
|
|
73
|
+
},
|
|
74
|
+
responses: {
|
|
75
|
+
200: {
|
|
76
|
+
content: { "application/json": { schema: projectSchema } },
|
|
77
|
+
description: "Updated project"
|
|
78
|
+
},
|
|
79
|
+
...forbidden$1,
|
|
80
|
+
...notFound
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
const deleteProjectRoute = createRoute({
|
|
84
|
+
method: "delete",
|
|
85
|
+
tags: ["Projects"],
|
|
86
|
+
summary: "Delete a project",
|
|
87
|
+
path: "/api/v1/projects/{slug}",
|
|
88
|
+
request: { params: z.object({ slug: z.string() }) },
|
|
89
|
+
responses: {
|
|
90
|
+
204: { description: "Deleted project" },
|
|
91
|
+
...forbidden$1,
|
|
92
|
+
...notFound
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
/** Register the project list, create, fetch, update, and delete endpoints. */
|
|
96
|
+
function registerProjects(app) {
|
|
97
|
+
app.openapi(listProjectsRoute, async (c) => {
|
|
98
|
+
requireSessionUser();
|
|
99
|
+
const projects = new ProjectModel(getStore().db);
|
|
100
|
+
return c.json(await projects.list());
|
|
101
|
+
});
|
|
102
|
+
app.openapi(createProjectRoute, async (c) => {
|
|
103
|
+
requireSiteAdmin(c);
|
|
104
|
+
const body = c.req.valid("json");
|
|
105
|
+
const project = await new ProjectModel(getStore().db).create(body);
|
|
106
|
+
await new LabelModel(getStore().db).seedFor(project.id);
|
|
107
|
+
const creator = getStore().user;
|
|
108
|
+
if (creator) await new MemberModel(getStore().db).set(project.id, creator.id, "admin");
|
|
109
|
+
return c.json(project, 201);
|
|
110
|
+
});
|
|
111
|
+
app.openapi(getProjectRoute, async (c) => {
|
|
112
|
+
const { slug } = c.req.valid("param");
|
|
113
|
+
const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);
|
|
114
|
+
return c.json(project);
|
|
115
|
+
});
|
|
116
|
+
app.openapi(updateProjectRoute, async (c) => {
|
|
117
|
+
const { slug } = c.req.valid("param");
|
|
118
|
+
const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);
|
|
119
|
+
const updated = await new ProjectModel(getStore().db).update(project.id, c.req.valid("json"));
|
|
120
|
+
return c.json(updated);
|
|
121
|
+
});
|
|
122
|
+
app.openapi(deleteProjectRoute, async (c) => {
|
|
123
|
+
requireSiteAdmin(c);
|
|
124
|
+
const { slug } = c.req.valid("param");
|
|
125
|
+
const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);
|
|
126
|
+
await new ProjectModel(getStore().db).remove(project.id);
|
|
127
|
+
return c.body(null, 204);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
export { registerProjects };
|
|
132
|
+
|
|
133
|
+
//# sourceMappingURL=projects.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"projects.mjs","names":["forbiddenResponse"],"sources":["../../src/routers/projects.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { MemberModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { forbidden, requireSiteAdmin, resolveAuthorizedProject } from \"./helpers.ts\";\nimport {\n badRequest,\n forbidden as forbiddenResponse,\n notFound,\n projectCreateSchema,\n projectSchema,\n projectUpdateSchema,\n unauthorized,\n} from \"./schemas.ts\";\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\nfunction requireSessionUser(): void {\n if (!getStore().authEnabled) {\n return;\n }\n if (!getStore().user) {\n forbidden();\n }\n}\n\nconst listProjectsRoute = createRoute({\n method: \"get\",\n tags: [\"Projects\"],\n summary: \"List projects\",\n path: \"/api/v1/projects\",\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema.array() } },\n description: \"List projects\",\n },\n ...unauthorized,\n },\n});\n\nconst createProjectRoute = createRoute({\n method: \"post\",\n tags: [\"Projects\"],\n summary: \"Create a project\",\n path: \"/api/v1/projects\",\n request: { body: { content: { \"application/json\": { schema: projectCreateSchema } } } },\n responses: {\n 201: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Created project\",\n },\n ...badRequest,\n ...forbiddenResponse,\n ...unauthorized,\n },\n});\n\nconst getProjectRoute = createRoute({\n method: \"get\",\n tags: [\"Projects\"],\n summary: \"Get a project\",\n path: \"/api/v1/projects/{slug}\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Fetch a project\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst updateProjectRoute = createRoute({\n method: \"patch\",\n tags: [\"Projects\"],\n summary: \"Update a project\",\n path: \"/api/v1/projects/{slug}\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: projectUpdateSchema } } },\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Updated project\",\n },\n ...forbiddenResponse,\n ...notFound,\n },\n});\n\nconst deleteProjectRoute = createRoute({\n method: \"delete\",\n tags: [\"Projects\"],\n summary: \"Delete a project\",\n path: \"/api/v1/projects/{slug}\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 204: { description: \"Deleted project\" },\n ...forbiddenResponse,\n ...notFound,\n },\n});\n\n/** Register the project list, create, fetch, update, and delete endpoints. */\nexport function registerProjects(app: ShelfRouter): void {\n app.openapi(listProjectsRoute, async (c) => {\n requireSessionUser();\n const projects = new ProjectModel(getStore().db);\n return c.json(await projects.list());\n });\n\n app.openapi(createProjectRoute, async (c) => {\n requireSiteAdmin(c);\n const body = c.req.valid(\"json\");\n const projects = new ProjectModel(getStore().db);\n const project = await projects.create(body);\n await new LabelModel(getStore().db).seedFor(project.id);\n const creator = getStore().user;\n if (creator) {\n await new MemberModel(getStore().db).set(project.id, creator.id, \"admin\");\n }\n return c.json(project, 201);\n });\n\n app.openapi(getProjectRoute, async (c) => {\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n return c.json(project);\n });\n\n app.openapi(updateProjectRoute, async (c) => {\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const updated = await new ProjectModel(getStore().db).update(project.id, c.req.valid(\"json\"));\n return c.json(updated);\n });\n\n app.openapi(deleteProjectRoute, async (c) => {\n requireSiteAdmin(c);\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n await new ProjectModel(getStore().db).remove(project.id);\n return c.body(null, 204);\n });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { hxRefresh, isHxRequest } from "./htmx.mjs";
|
|
6
|
+
import { isOpenSnapshot } from "./baseline-guard.mjs";
|
|
7
|
+
import { SnapshotModel } from "@storyshelf/core/models";
|
|
8
|
+
//#region src/routers/review-next.ts
|
|
9
|
+
/**
|
|
10
|
+
* The next snapshot still waiting for a decision after `currentId`, in list
|
|
11
|
+
* order, wrapping to the start. Null when nothing else is open.
|
|
12
|
+
*/
|
|
13
|
+
function nextOpenSnapshotId(snapshots, currentId) {
|
|
14
|
+
const index = snapshots.findIndex((snap) => snap.id === currentId);
|
|
15
|
+
return [...snapshots.slice(index + 1), ...snapshots.slice(0, Math.max(index, 0))].find((snap) => snap.id !== currentId && isOpenSnapshot(snap))?.id ?? null;
|
|
16
|
+
}
|
|
17
|
+
/** Whether HTMX issued the request from a build's review page (`.../diff`). */
|
|
18
|
+
function fromReviewPage(c) {
|
|
19
|
+
try {
|
|
20
|
+
return new URL(c.req.header("HX-Current-URL") ?? "").pathname.endsWith("/diff");
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Where a decision on `snapshotId` should send the reviewer, or null to just reload. */
|
|
26
|
+
async function advanceTarget(target) {
|
|
27
|
+
const next = nextOpenSnapshotId(await new SnapshotModel(getStore().db).listByBuild(target.buildId), target.snapshotId);
|
|
28
|
+
return next === null ? null : `/projects/${target.slug}/builds/${target.buildId}/diff?snapshot=${next}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* After a single-snapshot decision, send HTMX clients on the review page to
|
|
32
|
+
* the next open snapshot (so a reviewer can clear a build with `a`/`r`).
|
|
33
|
+
* Elsewhere, or when nothing is left, reload the current page.
|
|
34
|
+
*/
|
|
35
|
+
async function hxAdvance(c, target) {
|
|
36
|
+
if (!isHxRequest(c)) return;
|
|
37
|
+
const url = fromReviewPage(c) ? await advanceTarget(target) : null;
|
|
38
|
+
if (url === null) {
|
|
39
|
+
hxRefresh(c);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
c.header("HX-Redirect", url);
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
export { hxAdvance, nextOpenSnapshotId };
|
|
46
|
+
|
|
47
|
+
//# sourceMappingURL=review-next.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"review-next.mjs","names":[],"sources":["../../src/routers/review-next.ts"],"sourcesContent":["import { SnapshotModel } from \"@storyshelf/core/models\";\nimport type { Snapshot } from \"@storyshelf/core/schema\";\nimport type { Context } from \"hono\";\nimport { getStore } from \"../store.ts\";\nimport { isOpenSnapshot } from \"./baseline-guard.ts\";\nimport { hxRefresh, isHxRequest } from \"./htmx.ts\";\n\n/**\n * The next snapshot still waiting for a decision after `currentId`, in list\n * order, wrapping to the start. Null when nothing else is open.\n */\nexport function nextOpenSnapshotId(snapshots: Snapshot[], currentId: string): string | null {\n const index = snapshots.findIndex((snap) => snap.id === currentId);\n const ordered = [...snapshots.slice(index + 1), ...snapshots.slice(0, Math.max(index, 0))];\n return ordered.find((snap) => snap.id !== currentId && isOpenSnapshot(snap))?.id ?? null;\n}\n\n/** Whether HTMX issued the request from a build's review page (`.../diff`). */\nfunction fromReviewPage(c: Context): boolean {\n try {\n return new URL(c.req.header(\"HX-Current-URL\") ?? \"\").pathname.endsWith(\"/diff\");\n } catch {\n return false;\n }\n}\n\n/** Where a decision on `snapshotId` should send the reviewer, or null to just reload. */\nasync function advanceTarget(target: {\n slug: string;\n buildId: string;\n snapshotId: string;\n}): Promise<string | null> {\n const snapshots = await new SnapshotModel(getStore().db).listByBuild(target.buildId);\n const next = nextOpenSnapshotId(snapshots, target.snapshotId);\n return next === null\n ? null\n : `/projects/${target.slug}/builds/${target.buildId}/diff?snapshot=${next}`;\n}\n\n/**\n * After a single-snapshot decision, send HTMX clients on the review page to\n * the next open snapshot (so a reviewer can clear a build with `a`/`r`).\n * Elsewhere, or when nothing is left, reload the current page.\n */\nexport async function hxAdvance(\n c: Context,\n target: { slug: string; buildId: string; snapshotId: string },\n): Promise<void> {\n if (!isHxRequest(c)) {\n return;\n }\n const url = fromReviewPage(c) ? await advanceTarget(target) : null;\n if (url === null) {\n hxRefresh(c);\n return;\n }\n c.header(\"HX-Redirect\", url);\n}\n"],"mappings":";;;;;;;;;;;;AAWA,SAAgB,mBAAmB,WAAuB,WAAkC;CAC1F,MAAM,QAAQ,UAAU,WAAW,SAAS,KAAK,OAAO,SAAS;CAEjE,OAAO,CADU,GAAG,UAAU,MAAM,QAAQ,CAAC,GAAG,GAAG,UAAU,MAAM,GAAG,KAAK,IAAI,OAAO,CAAC,CAAC,CAC3E,CAAC,CAAC,MAAM,SAAS,KAAK,OAAO,aAAa,eAAe,IAAI,CAAC,CAAC,EAAE,MAAM;AACtF;;AAGA,SAAS,eAAe,GAAqB;CAC3C,IAAI;EACF,OAAO,IAAI,IAAI,EAAE,IAAI,OAAO,gBAAgB,KAAK,EAAE,CAAC,CAAC,SAAS,SAAS,OAAO;CAChF,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,eAAe,cAAc,QAIF;CAEzB,MAAM,OAAO,mBAAmB,MADR,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,YAAY,OAAO,OAAO,GACxC,OAAO,UAAU;CAC5D,OAAO,SAAS,OACZ,OACA,aAAa,OAAO,KAAK,UAAU,OAAO,QAAQ,iBAAiB;AACzE;;;;;;AAOA,eAAsB,UACpB,GACA,QACe;CACf,IAAI,CAAC,YAAY,CAAC,GAChB;CAEF,MAAM,MAAM,eAAe,CAAC,IAAI,MAAM,cAAc,MAAM,IAAI;CAC9D,IAAI,QAAQ,MAAM;EAChB,UAAU,CAAC;EACX;CACF;CACA,EAAE,OAAO,eAAe,GAAG;AAC7B"}
|