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.
- package/README.md +17 -5
- package/dist/cli.js +2610 -291
- package/dist/index.js +2375 -200
- package/package.json +7 -7
- package/template/.feedbax-template.sha256 +1 -1
- package/template/.gitignore.template +1 -0
- package/template/.npmignore.template +1 -0
- package/template/.nvmrc +1 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +418 -0
- package/template/README.md +97 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -0
- package/template/feedbax-vite.ts +79 -0
- package/template/migrations/0001_connected.sql +6 -0
- package/template/migrations/0002_participation.sql +8 -0
- package/template/migrations/0003_images.sql +3 -0
- package/template/migrations/0004_followed_status.sql +9 -0
- package/template/migrations/0005_github.sql +4 -0
- package/template/migrations/0006_integration.sql +3 -0
- package/template/migrations/0007_ownership.sql +2 -0
- package/template/migrations/0008_integration_recovery.sql +5 -0
- package/template/migrations/0009_observed_history.sql +3 -0
- package/template/package.json +39 -34
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +9 -0
- package/template/src/comment-creation.test.tsx +95 -7
- package/template/src/comment-thread-page.ts +11 -1
- package/template/src/components/command-palette.tsx +59 -30
- package/template/src/components/connected-identity-provider.tsx +135 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +78 -0
- package/template/src/components/header.tsx +2 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +48 -5
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +37 -0
- package/template/src/components/ui/components/command.tsx +30 -29
- package/template/src/components/ui/components/message-scroller.tsx +2 -2
- package/template/src/components/ui/components/scroll-area.tsx +0 -1
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/styles/globals.css +1 -1
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +148 -0
- package/template/src/connected-identity.ts +127 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +244 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/feedback-cache-invalidation.ts +3 -0
- package/template/src/feedback-runtime.ts +123 -13
- package/template/src/feedbax/changelog/index.test.ts +35 -0
- package/template/src/feedbax/changelog/index.ts +5 -1
- package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
- package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
- package/template/src/feedbax/config/index.test.ts +73 -1
- package/template/src/feedbax/config/index.ts +205 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +225 -0
- package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
- package/template/src/feedbax/feedback/email-delivery.ts +66 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
- package/template/src/feedbax/feedback/github-comments.ts +390 -0
- package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
- package/template/src/feedbax/feedback/github-feedback.ts +581 -0
- package/template/src/feedbax/feedback/github-request.ts +120 -0
- package/template/src/feedbax/feedback/github-service-client.ts +56 -0
- package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +114 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +350 -0
- package/template/src/feedbax/feedback/index.ts +226 -16
- package/template/src/feedbax/feedback/integration-auth.ts +138 -0
- package/template/src/feedbax/feedback/notion-comments.ts +2 -35
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
- package/template/src/feedbax/feedback/observed-history.ts +219 -0
- package/template/src/feedbax/feedback/ownership.ts +170 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +103 -440
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- package/template/src/my-feedback-page.test.tsx +152 -0
- package/template/src/my-feedback-page.tsx +398 -0
- package/template/src/my-feedback-runtime.test.ts +93 -0
- package/template/src/my-feedback-runtime.ts +154 -0
- package/template/src/overlay-post-detail.test.tsx +59 -8
- package/template/src/overlay-post-detail.tsx +20 -69
- package/template/src/participation-client.ts +63 -0
- package/template/src/participation-runtime.test.ts +35 -0
- package/template/src/participation-runtime.ts +255 -0
- package/template/src/portal-comments.ts +35 -5
- package/template/src/portal-feedback-form.tsx +29 -6
- package/template/src/portal-feedback-runtime.ts +3 -0
- package/template/src/portal-feedback-server-function.ts +6 -3
- package/template/src/portal-feedback-submission.ts +19 -0
- package/template/src/portal-server-entry.ts +13 -0
- package/template/src/post-creation-flow.tsx +2 -2
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-results.tsx +1 -1
- package/template/src/public-cache-policy.test.ts +11 -4
- package/template/src/public-cache-policy.ts +1 -1
- package/template/src/public-feedback-index.tsx +9 -39
- package/template/src/public-feedback-page.test.tsx +65 -0
- package/template/src/public-feedback-page.ts +12 -1
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +437 -271
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/public-roadmap-route.test.tsx +38 -2
- package/template/src/public-roadmap-view.tsx +56 -23
- package/template/src/public-shell.test.tsx +26 -0
- package/template/src/routeTree.gen.ts +70 -3
- package/template/src/router.tsx +2 -0
- package/template/src/routes/__root.tsx +3 -5
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/routes/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +87 -2
- package/template/src/submission-intent.ts +26 -0
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
- 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
|
+
});
|