create-feedbax 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +17 -5
  2. package/dist/cli.js +2610 -291
  3. package/dist/index.js +2375 -200
  4. package/package.json +7 -7
  5. package/template/.feedbax-template.sha256 +1 -1
  6. package/template/.gitignore.template +1 -0
  7. package/template/.npmignore.template +1 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/AGENTS.md +13 -0
  10. package/template/CONNECTED.md +418 -0
  11. package/template/README.md +97 -0
  12. package/template/components.json +1 -1
  13. package/template/eslint.config.mjs +23 -0
  14. package/template/feedbax-vite.ts +79 -0
  15. package/template/migrations/0001_connected.sql +6 -0
  16. package/template/migrations/0002_participation.sql +8 -0
  17. package/template/migrations/0003_images.sql +3 -0
  18. package/template/migrations/0004_followed_status.sql +9 -0
  19. package/template/migrations/0005_github.sql +4 -0
  20. package/template/migrations/0006_integration.sql +3 -0
  21. package/template/migrations/0007_ownership.sql +2 -0
  22. package/template/migrations/0008_integration_recovery.sql +5 -0
  23. package/template/migrations/0009_observed_history.sql +3 -0
  24. package/template/package.json +39 -34
  25. package/template/src/authorized-draft-post.tsx +1 -0
  26. package/template/src/changelog-page.tsx +63 -54
  27. package/template/src/changelog-runtime.ts +2 -1
  28. package/template/src/cloudflare-workers.d.ts +9 -0
  29. package/template/src/comment-creation.test.tsx +95 -7
  30. package/template/src/comment-thread-page.ts +11 -1
  31. package/template/src/components/command-palette.tsx +59 -30
  32. package/template/src/components/connected-identity-provider.tsx +135 -0
  33. package/template/src/components/device-profile-provider.tsx +41 -26
  34. package/template/src/components/follow-post-button.tsx +78 -0
  35. package/template/src/components/header.tsx +2 -0
  36. package/template/src/components/image-attachments.tsx +187 -0
  37. package/template/src/components/loader.tsx +48 -5
  38. package/template/src/components/notification-inbox.tsx +232 -0
  39. package/template/src/components/portal-footer.tsx +14 -0
  40. package/template/src/components/post-history.tsx +101 -0
  41. package/template/src/components/profile-menu.tsx +37 -0
  42. package/template/src/components/ui/components/command.tsx +30 -29
  43. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  44. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  45. package/template/src/components/ui/components/switch.tsx +29 -0
  46. package/template/src/components/ui/styles/globals.css +1 -1
  47. package/template/src/configured-feedback.test.ts +82 -0
  48. package/template/src/configured-feedback.ts +61 -0
  49. package/template/src/connected-identity.test.ts +148 -0
  50. package/template/src/connected-identity.ts +127 -0
  51. package/template/src/connected-participation.test.tsx +67 -0
  52. package/template/src/connected-runtime.test.ts +244 -0
  53. package/template/src/connected-runtime.ts +176 -0
  54. package/template/src/connected-social.test.tsx +158 -0
  55. package/template/src/feedback-cache-invalidation.ts +3 -0
  56. package/template/src/feedback-runtime.ts +123 -13
  57. package/template/src/feedbax/changelog/index.test.ts +35 -0
  58. package/template/src/feedbax/changelog/index.ts +5 -1
  59. package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
  60. package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
  61. package/template/src/feedbax/config/index.test.ts +73 -1
  62. package/template/src/feedbax/config/index.ts +205 -9
  63. package/template/src/feedbax/feedback/comment-storage.ts +38 -0
  64. package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
  65. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  66. package/template/src/feedbax/feedback/connected.ts +225 -0
  67. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  68. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  69. package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
  70. package/template/src/feedbax/feedback/github-comments.ts +390 -0
  71. package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
  72. package/template/src/feedbax/feedback/github-feedback.ts +581 -0
  73. package/template/src/feedbax/feedback/github-request.ts +120 -0
  74. package/template/src/feedbax/feedback/github-service-client.ts +56 -0
  75. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  76. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  77. package/template/src/feedbax/feedback/image-feedback.ts +114 -0
  78. package/template/src/feedbax/feedback/images.test.ts +223 -0
  79. package/template/src/feedbax/feedback/images.ts +350 -0
  80. package/template/src/feedbax/feedback/index.ts +226 -16
  81. package/template/src/feedbax/feedback/integration-auth.ts +138 -0
  82. package/template/src/feedbax/feedback/notion-comments.ts +2 -35
  83. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  84. package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
  85. package/template/src/feedbax/feedback/observed-history.ts +219 -0
  86. package/template/src/feedbax/feedback/ownership.ts +170 -0
  87. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  88. package/template/src/feedbax/feedback/participation.ts +502 -0
  89. package/template/src/history-runtime.test.ts +80 -0
  90. package/template/src/history-runtime.ts +37 -0
  91. package/template/src/images-runtime.test.ts +133 -0
  92. package/template/src/images-runtime.ts +125 -0
  93. package/template/src/index.css +103 -440
  94. package/template/src/integration-runtime.test.ts +365 -0
  95. package/template/src/integration-runtime.ts +289 -0
  96. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  97. package/template/src/my-feedback-page.test.tsx +152 -0
  98. package/template/src/my-feedback-page.tsx +398 -0
  99. package/template/src/my-feedback-runtime.test.ts +93 -0
  100. package/template/src/my-feedback-runtime.ts +154 -0
  101. package/template/src/overlay-post-detail.test.tsx +59 -8
  102. package/template/src/overlay-post-detail.tsx +20 -69
  103. package/template/src/participation-client.ts +63 -0
  104. package/template/src/participation-runtime.test.ts +35 -0
  105. package/template/src/participation-runtime.ts +255 -0
  106. package/template/src/portal-comments.ts +35 -5
  107. package/template/src/portal-feedback-form.tsx +29 -6
  108. package/template/src/portal-feedback-runtime.ts +3 -0
  109. package/template/src/portal-feedback-server-function.ts +6 -3
  110. package/template/src/portal-feedback-submission.ts +19 -0
  111. package/template/src/portal-server-entry.ts +13 -0
  112. package/template/src/post-creation-flow.tsx +2 -2
  113. package/template/src/post-detail-content.test.tsx +233 -0
  114. package/template/src/post-detail-content.tsx +162 -0
  115. package/template/src/post-detail-query.ts +34 -0
  116. package/template/src/post-results.tsx +1 -1
  117. package/template/src/public-cache-policy.test.ts +11 -4
  118. package/template/src/public-cache-policy.ts +1 -1
  119. package/template/src/public-feedback-index.tsx +9 -39
  120. package/template/src/public-feedback-page.test.tsx +65 -0
  121. package/template/src/public-feedback-page.ts +12 -1
  122. package/template/src/public-portal-error.tsx +1 -1
  123. package/template/src/public-post-detail-skeleton.tsx +54 -0
  124. package/template/src/public-post-detail.tsx +437 -271
  125. package/template/src/public-post-route.test.tsx +39 -3
  126. package/template/src/public-post-route.tsx +15 -28
  127. package/template/src/public-release-links.test.ts +120 -0
  128. package/template/src/public-release-links.ts +130 -0
  129. package/template/src/public-roadmap-route.test.tsx +38 -2
  130. package/template/src/public-roadmap-view.tsx +56 -23
  131. package/template/src/public-shell.test.tsx +26 -0
  132. package/template/src/routeTree.gen.ts +70 -3
  133. package/template/src/router.tsx +2 -0
  134. package/template/src/routes/__root.tsx +3 -5
  135. package/template/src/routes/email-preferences.tsx +77 -0
  136. package/template/src/routes/my-feedback.tsx +16 -0
  137. package/template/src/routes/submit.tsx +1 -1
  138. package/template/src/safe-public-failure.ts +3 -1
  139. package/template/src/server.ts +87 -2
  140. package/template/src/submission-intent.ts +26 -0
  141. package/template/src/trusted-feedback-handler.test.ts +1 -1
  142. package/template/src/vote-toggle.tsx +42 -9
  143. package/template/vite.config.ts +2 -0
@@ -0,0 +1,152 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import { ConnectedIdentityProvider } from "./components/connected-identity-provider";
7
+ import { MyFeedbackPage } from "./my-feedback-page";
8
+ vi.mock("./feedbax", async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import("./feedbax")>();
10
+ return {
11
+ default: {
12
+ ...actual.default,
13
+ connected: {
14
+ issuer: "https://app.example",
15
+ loginUrl: "https://app.example/login",
16
+ },
17
+ },
18
+ };
19
+ });
20
+ afterEach(() => {
21
+ cleanup();
22
+ vi.unstubAllGlobals();
23
+ localStorage.clear();
24
+ sessionStorage.clear();
25
+ });
26
+ it("switches private filters with accessible tabs and edits an eligible draft without browser-held authority", async () => {
27
+ const user = userEvent.setup();
28
+ let title = "My pending feedback";
29
+ let edited: unknown;
30
+ const post = () => ({
31
+ id: "post-1",
32
+ slug: "my-pending-feedback",
33
+ title,
34
+ description: "A private pending description",
35
+ type: "Feature Request",
36
+ status: "New",
37
+ published: false,
38
+ editable: true,
39
+ createdAt: "2026-10-15T12:00:00Z",
40
+ updatedAt: "2026-10-15T12:00:00Z",
41
+ });
42
+ vi.stubGlobal(
43
+ "fetch",
44
+ vi.fn<typeof fetch>().mockImplementation(async (input, init) => {
45
+ const path = String(input);
46
+ if (path === "/internal/identity/session")
47
+ return Response.json({
48
+ participant: { displayName: "Alice", sessionKey: "alice-session" },
49
+ });
50
+ if (path.startsWith("/internal/my-feedback?"))
51
+ return Response.json({
52
+ items: path.includes("filter=submitted")
53
+ ? [post()]
54
+ : [
55
+ {
56
+ slug: "supported",
57
+ title: "Supported public feedback",
58
+ published: true,
59
+ editable: false,
60
+ },
61
+ ],
62
+ });
63
+ if (path === "/internal/my-feedback/post") {
64
+ const body = JSON.parse(String(init?.body));
65
+ if (body.action === "edit") {
66
+ edited = body;
67
+ title = body.title;
68
+ }
69
+ return Response.json(post());
70
+ }
71
+ throw new Error("Unexpected browser request");
72
+ }),
73
+ );
74
+ render(
75
+ <QueryClientProvider
76
+ client={
77
+ new QueryClient({ defaultOptions: { queries: { retry: false } } })
78
+ }
79
+ >
80
+ <ConnectedIdentityProvider>
81
+ <MyFeedbackPage />
82
+ </ConnectedIdentityProvider>
83
+ </QueryClientProvider>,
84
+ );
85
+ expect(await screen.findByText("My pending feedback")).toBeTruthy();
86
+ await user.click(screen.getByRole("tab", { name: "Supported" }));
87
+ expect(await screen.findByText("Supported public feedback")).toBeTruthy();
88
+ await user.click(screen.getByRole("tab", { name: "Submitted" }));
89
+ await user.click(await screen.findByRole("button", { name: "Edit draft" }));
90
+ const field = screen.getByLabelText("Title");
91
+ await user.clear(field);
92
+ await user.type(field, "Updated on this device");
93
+ await user.click(screen.getByRole("button", { name: "Save changes" }));
94
+ await waitFor(() =>
95
+ expect(edited).toMatchObject({
96
+ id: "post-1",
97
+ title: "Updated on this device",
98
+ action: "edit",
99
+ }),
100
+ );
101
+ expect(JSON.stringify(edited)).not.toContain("browserCapability");
102
+ expect(JSON.stringify(edited)).not.toContain("subject");
103
+ expect(await screen.findByText("Updated on this device")).toBeTruthy();
104
+ });
105
+
106
+ it("discards private lists and open editors when a refreshed session changes even with the same display name", async () => {
107
+ const user = userEvent.setup();
108
+ let current = "alice";
109
+ vi.stubGlobal(
110
+ "fetch",
111
+ vi.fn<typeof fetch>().mockImplementation(async (input) => {
112
+ if (String(input) === "/internal/identity/session")
113
+ return Response.json({
114
+ participant: { displayName: "Shared name", sessionKey: current },
115
+ });
116
+ const post = {
117
+ id: `${current}-post`,
118
+ slug: `${current}-post`,
119
+ title: `${current} private draft`,
120
+ description: "Private details",
121
+ type: "General Feedback",
122
+ status: "New",
123
+ published: false,
124
+ editable: true,
125
+ };
126
+ if (String(input).startsWith("/internal/my-feedback?"))
127
+ return Response.json({ items: [post] });
128
+ if (String(input) === "/internal/my-feedback/post")
129
+ return Response.json(post);
130
+ throw Error("Unexpected request");
131
+ }),
132
+ );
133
+ render(
134
+ <QueryClientProvider
135
+ client={
136
+ new QueryClient({ defaultOptions: { queries: { retry: false } } })
137
+ }
138
+ >
139
+ <ConnectedIdentityProvider>
140
+ <MyFeedbackPage />
141
+ </ConnectedIdentityProvider>
142
+ </QueryClientProvider>,
143
+ );
144
+ expect(await screen.findByText("alice private draft")).toBeTruthy();
145
+ await user.click(screen.getByRole("button", { name: "Edit draft" }));
146
+ expect(await screen.findByLabelText("Title")).toBeTruthy();
147
+ current = "bob";
148
+ window.dispatchEvent(new Event("focus"));
149
+ expect(await screen.findByText("bob private draft")).toBeTruthy();
150
+ expect(screen.queryByText("alice private draft")).toBeNull();
151
+ expect(screen.queryByRole("dialog")).toBeNull();
152
+ });
@@ -0,0 +1,398 @@
1
+ import feedbax from "./feedbax";
2
+ import { useState, type FormEvent } from "react";
3
+ import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
4
+ import type {
5
+ MyFeedbackFilter,
6
+ MyFeedbackPage as FeedbackPage,
7
+ ParticipantPost,
8
+ } from "@feedbax/feedback";
9
+ import { Button } from "@feedbax/ui/components/button";
10
+ import { Input } from "@feedbax/ui/components/input";
11
+ import { Textarea } from "@feedbax/ui/components/textarea";
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogDescription,
16
+ DialogTitle,
17
+ } from "@feedbax/ui/components/dialog";
18
+ import {
19
+ Tabs,
20
+ TabsContent,
21
+ TabsList,
22
+ TabsTrigger,
23
+ } from "@feedbax/ui/components/tabs";
24
+ import { useConnectedIdentity } from "./components/connected-identity-provider";
25
+ import { useAuthorizedDraftPosts } from "./draft-post-list";
26
+ import { readCapabilities } from "./browser-post-state";
27
+
28
+ async function action<T>(input: Record<string, unknown>): Promise<T> {
29
+ const response = await fetch("/internal/my-feedback/post", {
30
+ method: "POST",
31
+ credentials: "same-origin",
32
+ cache: "no-store",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify(input),
35
+ });
36
+ if (!response.ok)
37
+ throw new Error(
38
+ "This submission could not be changed. Refresh and try again.",
39
+ );
40
+ return response.json();
41
+ }
42
+ export function MyFeedbackPage() {
43
+ const identity = useConnectedIdentity();
44
+ if (!identity.ready)
45
+ return (
46
+ <main className="feedback-index">
47
+ <p role="status">Loading your account…</p>
48
+ </main>
49
+ );
50
+ if (!identity.displayName)
51
+ return (
52
+ <main className="feedback-index">
53
+ <h1 className="text-3xl font-semibold">My feedback</h1>
54
+ <p className="my-4">
55
+ Sign in through the product to find your submissions, supported Posts,
56
+ and Follows across devices.
57
+ </p>
58
+ <Button
59
+ nativeButton={false}
60
+ render={<a href="/internal/identity/start" />}
61
+ >
62
+ Sign in
63
+ </Button>
64
+ </main>
65
+ );
66
+ return <PrivateMyFeedbackPage key={identity.sessionKey} />;
67
+ }
68
+ function PrivateMyFeedbackPage() {
69
+ const identity = useConnectedIdentity();
70
+ const client = useQueryClient();
71
+ const [filter, setFilter] = useState<MyFeedbackFilter>("submitted");
72
+ const [editing, setEditing] = useState<ParticipantPost>();
73
+ const [message, setMessage] = useState<string>();
74
+ const query = useInfiniteQuery({
75
+ queryKey: ["my-feedback", identity.sessionKey, filter],
76
+ enabled: identity.ready && Boolean(identity.displayName),
77
+ gcTime: 0,
78
+ staleTime: 0,
79
+ initialPageParam: undefined as string | undefined,
80
+ queryFn: async ({ pageParam, signal }): Promise<FeedbackPage> => {
81
+ const params = new URLSearchParams({ filter });
82
+ if (pageParam) params.set("cursor", pageParam);
83
+ const response = await fetch(`/internal/my-feedback?${params}`, {
84
+ cache: "no-store",
85
+ credentials: "same-origin",
86
+ signal,
87
+ });
88
+ if (!response.ok) throw new Error("Your feedback could not be loaded.");
89
+ return response.json();
90
+ },
91
+ getNextPageParam: (last) => last.nextCursor,
92
+ });
93
+ const reload = () => client.invalidateQueries({ queryKey: ["my-feedback"] });
94
+ async function edit(id: string) {
95
+ setMessage(undefined);
96
+ try {
97
+ const post = await action<ParticipantPost>({ action: "read", id });
98
+ if (!post.editable) throw new Error();
99
+ setEditing(post);
100
+ } catch {
101
+ setMessage(
102
+ "This draft is no longer available to edit. Refresh the list.",
103
+ );
104
+ }
105
+ }
106
+ const items = query.data?.pages.flatMap((page) => page.items) ?? [];
107
+ return (
108
+ <main className="feedback-index mx-auto w-full max-w-4xl px-4 py-8">
109
+ <header className="mb-7">
110
+ <p className="feedback-eyebrow">Your participation</p>
111
+ <h1 className="text-3xl font-semibold">My feedback</h1>
112
+ <p className="mt-2 text-sm text-muted-foreground">
113
+ Only you can see this page. Pending submissions appear here before the
114
+ Product Team publishes them in the portal.
115
+ </p>
116
+ </header>
117
+ <Tabs
118
+ value={filter}
119
+ onValueChange={(value) => {
120
+ if (
121
+ value === "submitted" ||
122
+ value === "supported" ||
123
+ value === "following"
124
+ )
125
+ setFilter(value);
126
+ }}
127
+ >
128
+ <TabsList aria-label="My feedback views">
129
+ <TabsTrigger value="submitted">Submitted</TabsTrigger>
130
+ <TabsTrigger value="supported">Supported</TabsTrigger>
131
+ <TabsTrigger value="following">Following</TabsTrigger>
132
+ </TabsList>
133
+ <TabsContent value={filter} className="mt-5">
134
+ {query.isPending ? (
135
+ <p role="status">Loading your feedback…</p>
136
+ ) : query.isError ? (
137
+ <div role="alert">
138
+ <p>We couldn’t load your feedback.</p>
139
+ <Button variant="outline" onClick={() => void query.refetch()}>
140
+ Try again
141
+ </Button>
142
+ </div>
143
+ ) : items.length ? (
144
+ <ul className="divide-y divide-border">
145
+ {items.map((post) => (
146
+ <li
147
+ key={post.id ?? post.slug}
148
+ className="flex flex-wrap items-start justify-between gap-4 py-5"
149
+ >
150
+ <div className="min-w-0 flex-1">
151
+ <h2 className="break-words text-base font-medium">
152
+ {post.published ? (
153
+ <a
154
+ href={`/p/${encodeURIComponent(post.slug)}`}
155
+ className="hover:underline"
156
+ >
157
+ {post.title}
158
+ </a>
159
+ ) : (
160
+ post.title
161
+ )}
162
+ </h2>
163
+ <p className="mt-1 text-sm text-muted-foreground">
164
+ {post.withdrawn
165
+ ? "Withdrawn"
166
+ : post.unavailable
167
+ ? "Currently unavailable"
168
+ : post.published
169
+ ? post.status
170
+ : `${post.status ?? "New"} · Pending moderation`}
171
+ </p>
172
+ </div>
173
+ {post.editable && post.id ? (
174
+ <Button
175
+ variant="outline"
176
+ onClick={() => void edit(post.id!)}
177
+ >
178
+ Edit draft
179
+ </Button>
180
+ ) : null}
181
+ </li>
182
+ ))}
183
+ </ul>
184
+ ) : (
185
+ <p className="py-8 text-sm text-muted-foreground">
186
+ {filter === "submitted"
187
+ ? "Your submissions will appear here."
188
+ : filter === "supported"
189
+ ? "Posts you support will appear here."
190
+ : "Posts you follow will appear here."}
191
+ </p>
192
+ )}
193
+ {query.hasNextPage ? (
194
+ <Button
195
+ className="mt-4"
196
+ variant="outline"
197
+ disabled={query.isFetchingNextPage}
198
+ onClick={() => void query.fetchNextPage()}
199
+ >
200
+ {query.isFetchingNextPage ? "Loading…" : "Load more"}
201
+ </Button>
202
+ ) : null}
203
+ </TabsContent>
204
+ </Tabs>
205
+ {filter === "submitted" ? <BrowserDraftClaims onLinked={reload} /> : null}
206
+ {message ? (
207
+ <p role="alert" className="mt-4 text-sm">
208
+ {message}
209
+ </p>
210
+ ) : null}
211
+ <Dialog
212
+ open={Boolean(editing)}
213
+ onOpenChange={(open) => {
214
+ if (!open) setEditing(undefined);
215
+ }}
216
+ >
217
+ <DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
218
+ {editing ? (
219
+ <OwnerDraftEditor
220
+ key={editing.id}
221
+ post={editing}
222
+ onSaved={async () => {
223
+ setEditing(undefined);
224
+ await reload();
225
+ }}
226
+ />
227
+ ) : null}
228
+ </DialogContent>
229
+ </Dialog>
230
+ </main>
231
+ );
232
+ }
233
+ function OwnerDraftEditor({
234
+ post,
235
+ onSaved,
236
+ }: {
237
+ post: ParticipantPost;
238
+ onSaved(): Promise<void>;
239
+ }) {
240
+ const [title, setTitle] = useState(post.title);
241
+ const [description, setDescription] = useState(post.description);
242
+ const [type, setType] = useState(post.type);
243
+ const [pending, setPending] = useState(false);
244
+ const [confirming, setConfirming] = useState(false);
245
+ const [error, setError] = useState<string>();
246
+ async function mutate(withdraw = false) {
247
+ setPending(true);
248
+ setError(undefined);
249
+ try {
250
+ await action({
251
+ action: withdraw ? "withdraw" : "edit",
252
+ id: post.id,
253
+ ...(withdraw ? {} : { title, description, type }),
254
+ });
255
+ await onSaved();
256
+ } catch {
257
+ setError(
258
+ "The draft could not be changed. It may have been published or moved beyond New.",
259
+ );
260
+ } finally {
261
+ setPending(false);
262
+ }
263
+ }
264
+ return (
265
+ <>
266
+ <DialogTitle>Edit your draft</DialogTitle>
267
+ <DialogDescription>
268
+ You can change this submission while it is New and unpublished.
269
+ </DialogDescription>
270
+ <form
271
+ className="grid gap-4"
272
+ onSubmit={(event: FormEvent) => {
273
+ event.preventDefault();
274
+ void mutate();
275
+ }}
276
+ >
277
+ <label className="grid gap-2 text-sm">
278
+ Title
279
+ <Input
280
+ value={title}
281
+ onChange={(event) => setTitle(event.target.value)}
282
+ maxLength={feedbax.submissions.maxTitleLength}
283
+ required
284
+ disabled={pending}
285
+ />
286
+ </label>
287
+ <label className="grid gap-2 text-sm">
288
+ Description
289
+ <Textarea
290
+ value={description}
291
+ onChange={(event) => setDescription(event.target.value)}
292
+ maxLength={feedbax.submissions.maxDescriptionLength}
293
+ required
294
+ disabled={pending}
295
+ rows={6}
296
+ />
297
+ </label>
298
+ <label className="grid gap-2 text-sm">
299
+ Type
300
+ <select
301
+ className="h-10 border border-input bg-background px-3"
302
+ value={type}
303
+ onChange={(event) => setType(event.target.value as typeof type)}
304
+ disabled={pending}
305
+ >
306
+ <option>Feature Request</option>
307
+ <option>Bug Report</option>
308
+ <option>General Feedback</option>
309
+ </select>
310
+ </label>
311
+ {error ? <p role="alert">{error}</p> : null}
312
+ <div className="flex flex-wrap justify-between gap-3">
313
+ <Button
314
+ type="button"
315
+ variant="outline"
316
+ disabled={pending}
317
+ onClick={() => {
318
+ if (confirming) void mutate(true);
319
+ else setConfirming(true);
320
+ }}
321
+ >
322
+ {confirming ? "Confirm withdrawal" : "Withdraw draft"}
323
+ </Button>
324
+ <Button type="submit" disabled={pending}>
325
+ {pending ? "Saving…" : "Save changes"}
326
+ </Button>
327
+ </div>
328
+ {confirming ? (
329
+ <p className="text-sm text-muted-foreground">
330
+ Withdrawing removes this draft from the portal. Confirm only if you
331
+ no longer want to submit it.
332
+ </p>
333
+ ) : null}
334
+ </form>
335
+ </>
336
+ );
337
+ }
338
+ function BrowserDraftClaims({ onLinked }: { onLinked(): Promise<unknown> }) {
339
+ const drafts = useAuthorizedDraftPosts({ sort: "new" });
340
+ const [pending, setPending] = useState<string>();
341
+ const [message, setMessage] = useState<string>();
342
+ async function claim(id: string) {
343
+ setPending(id);
344
+ setMessage(undefined);
345
+ try {
346
+ const saved = readCapabilities(window.localStorage)[id];
347
+ if (!saved) throw new Error();
348
+ await action({
349
+ action: "claim",
350
+ id,
351
+ browserCapability: saved.browserCapability,
352
+ });
353
+ await onLinked();
354
+ setMessage("Draft linked to your product account.");
355
+ } catch {
356
+ setMessage(
357
+ "This draft could not be linked. Its browser access may no longer be valid, or it may belong to another account.",
358
+ );
359
+ } finally {
360
+ setPending(undefined);
361
+ }
362
+ }
363
+ if (!drafts.length) return null;
364
+ return (
365
+ <section
366
+ aria-label="Drafts saved in this browser"
367
+ className="mt-8 border-t border-border pt-6"
368
+ >
369
+ <h2 className="font-medium">Drafts saved in this browser</h2>
370
+ <p className="mt-1 text-sm text-muted-foreground">
371
+ Link a draft using this browser’s existing access so you can find it on
372
+ your other devices.
373
+ </p>
374
+ <ul className="mt-3 space-y-3">
375
+ {drafts.map((draft) => (
376
+ <li
377
+ key={draft.id}
378
+ className="flex flex-wrap items-center justify-between gap-3"
379
+ >
380
+ <span className="text-sm">{draft.title}</span>
381
+ <Button
382
+ variant="outline"
383
+ disabled={Boolean(pending)}
384
+ onClick={() => void claim(draft.id)}
385
+ >
386
+ {pending === draft.id ? "Linking…" : "Link draft"}
387
+ </Button>
388
+ </li>
389
+ ))}
390
+ </ul>
391
+ {message ? (
392
+ <p role="status" className="mt-3 text-sm">
393
+ {message}
394
+ </p>
395
+ ) : null}
396
+ </section>
397
+ );
398
+ }
@@ -0,0 +1,93 @@
1
+ import { afterEach, expect, it } from "vitest";
2
+ import { Miniflare } from "miniflare";
3
+ import { readFile, readdir } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import {
6
+ createConnectedStore,
7
+ createFeedbackModule,
8
+ createPostOwnership,
9
+ } from "@feedbax/feedback";
10
+ import { createMyFeedbackRequestHandler } from "./my-feedback-runtime";
11
+ import { readSessionToken, sessionHash } from "./connected-identity";
12
+ let runtime: Miniflare | undefined;
13
+ afterEach(async () => {
14
+ await runtime?.dispose();
15
+ });
16
+ it("scopes private submitted feedback to the authenticated session and rejects cross-origin owner writes", async () => {
17
+ runtime = new Miniflare({
18
+ modules: true,
19
+ script: "export default {}",
20
+ d1Databases: ["DB"],
21
+ compatibilityDate: "2026-07-30",
22
+ });
23
+ const database = await runtime.getD1Database("DB");
24
+ const directory = resolve(import.meta.dirname, "../migrations");
25
+ for (const file of (await readdir(directory)).sort())
26
+ await database.exec(
27
+ (await readFile(resolve(directory, file), "utf8"))
28
+ .replace(/--[^\n]*/g, "")
29
+ .replace(/\s+/g, " "),
30
+ );
31
+ const store = createConnectedStore(database);
32
+ const alice = { subject: "app:alice", displayName: "Alice" };
33
+ const bob = { subject: "app:bob", displayName: "Bob" };
34
+ for (const who of [alice, bob])
35
+ await store.acceptIdentity({
36
+ ...who,
37
+ nonce: who.subject,
38
+ sessionHash: sessionHash(who.subject),
39
+ expiresAt: Date.now() + 60_000,
40
+ });
41
+ const modules = new Map<string, ReturnType<typeof createFeedbackModule>>();
42
+ const factory = (who: typeof alice) => {
43
+ if (!modules.has(who.subject))
44
+ modules.set(
45
+ who.subject,
46
+ createFeedbackModule({ ownership: createPostOwnership(database, who) }),
47
+ );
48
+ return modules.get(who.subject)!;
49
+ };
50
+ const post = await factory(alice).submitPost({
51
+ title: "Private pending submission",
52
+ description: "Original Participant text",
53
+ type: "Feature Request",
54
+ submitter: { email: "private@example.com" },
55
+ });
56
+ const handler = createMyFeedbackRequestHandler({
57
+ enabled: true,
58
+ siteUrl: "https://portal.example",
59
+ database,
60
+ getParticipant: async (request) => {
61
+ const token = readSessionToken(request);
62
+ return token ? store.session(sessionHash(token)) : undefined;
63
+ },
64
+ getFeedback: factory,
65
+ rateLimit: async () => true,
66
+ });
67
+ const request = (who?: string) =>
68
+ new Request(
69
+ "https://portal.example/internal/my-feedback?filter=submitted&subject=app:alice",
70
+ { headers: who ? { Cookie: `__Host-feedbax-session=${who}` } : {} },
71
+ );
72
+ expect((await handler(request()))?.status).toBe(401);
73
+ expect(await (await handler(request(bob.subject)))?.json()).toEqual({
74
+ items: [],
75
+ });
76
+ const mine = await handler(request(alice.subject));
77
+ expect(mine?.headers.get("Cache-Control")).toBe("no-store");
78
+ const body = await mine?.text();
79
+ expect(body).toContain("Private pending submission");
80
+ expect(body).not.toContain("private@example.com");
81
+ expect(body).not.toContain(alice.subject);
82
+ const crossOrigin = await handler(
83
+ new Request("https://portal.example/internal/my-feedback/post", {
84
+ method: "POST",
85
+ headers: {
86
+ Cookie: `__Host-feedbax-session=${alice.subject}`,
87
+ Origin: "https://attacker.example",
88
+ },
89
+ body: JSON.stringify({ action: "withdraw", id: post.id }),
90
+ }),
91
+ );
92
+ expect(crossOrigin?.status).toBe(403);
93
+ });