create-feedbax 0.2.0 → 0.2.1
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/dist/cli.js +2042 -16
- package/dist/index.js +1997 -5
- package/package.json +1 -1
- package/template/.feedbax-template.sha256 +1 -1
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +404 -0
- package/template/README.md +58 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -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/package.json +9 -5
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/cloudflare-workers.d.ts +6 -0
- package/template/src/comment-creation.test.tsx +42 -1
- 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 +108 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +89 -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/profile-menu.tsx +30 -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/connected-identity.test.ts +144 -0
- package/template/src/connected-identity.ts +122 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +241 -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 +1 -0
- package/template/src/feedback-runtime.ts +79 -12
- package/template/src/feedbax/config/index.test.ts +28 -0
- package/template/src/feedbax/config/index.ts +93 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +215 -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/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +101 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +324 -0
- package/template/src/feedbax/feedback/index.ts +55 -1
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +97 -440
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- 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 +24 -5
- package/template/src/portal-feedback-form.tsx +18 -3
- 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 +10 -0
- package/template/src/portal-server-entry.ts +4 -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-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +298 -208
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- 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 +49 -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/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +41 -1
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function PortalFooter() {
|
|
2
|
+
return (
|
|
3
|
+
<footer className="mt-auto flex justify-end border-t px-6 py-4 text-xs text-muted-foreground lg:px-10">
|
|
4
|
+
<a
|
|
5
|
+
href="https://github.com/jessequartey/feedbax"
|
|
6
|
+
target="_blank"
|
|
7
|
+
rel="noopener noreferrer"
|
|
8
|
+
className="transition-colors hover:text-foreground"
|
|
9
|
+
>
|
|
10
|
+
Powered by Feedbax
|
|
11
|
+
</a>
|
|
12
|
+
</footer>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useConnectedIdentity } from "./connected-identity-provider";
|
|
1
2
|
import { useTheme } from "next-themes";
|
|
2
3
|
import { UserCircle } from "lucide-react";
|
|
3
4
|
|
|
@@ -20,8 +21,37 @@ import { useDeviceProfile } from "./device-profile-provider";
|
|
|
20
21
|
export function ProfileMenu() {
|
|
21
22
|
const { profile, openProfileSetup, clearProfile } = useDeviceProfile();
|
|
22
23
|
|
|
24
|
+
const identity = useConnectedIdentity();
|
|
25
|
+
|
|
23
26
|
const initials = profile ? deriveDeviceProfileInitials(profile) : undefined;
|
|
24
27
|
|
|
28
|
+
if (identity.enabled)
|
|
29
|
+
return (
|
|
30
|
+
<div>
|
|
31
|
+
<Button variant="ghost" onClick={openProfileSetup}>
|
|
32
|
+
Contact profile
|
|
33
|
+
</Button>
|
|
34
|
+
{identity.displayName ? (
|
|
35
|
+
<>
|
|
36
|
+
<span>{identity.displayName}</span>
|
|
37
|
+
<Button variant="outline" onClick={() => void identity.signOut()}>
|
|
38
|
+
Sign out
|
|
39
|
+
</Button>
|
|
40
|
+
</>
|
|
41
|
+
) : (
|
|
42
|
+
<Button
|
|
43
|
+
variant="outline"
|
|
44
|
+
disabled={!identity.ready}
|
|
45
|
+
onClick={() => {
|
|
46
|
+
window.location.href = "/internal/identity/start";
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
Sign in
|
|
50
|
+
</Button>
|
|
51
|
+
)}
|
|
52
|
+
{identity.error ? <p role="alert">{identity.error}</p> : null}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
25
55
|
return profile ? (
|
|
26
56
|
<DropdownMenu>
|
|
27
57
|
<DropdownMenuTrigger
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { Command as CommandPrimitive } from "cmdk";
|
|
3
|
-
|
|
4
3
|
import { cn } from "@feedbax/ui/lib/utils";
|
|
4
|
+
|
|
5
5
|
import {
|
|
6
6
|
Dialog,
|
|
7
7
|
DialogContent,
|
|
@@ -9,10 +9,6 @@ import {
|
|
|
9
9
|
DialogHeader,
|
|
10
10
|
DialogTitle,
|
|
11
11
|
} from "@feedbax/ui/components/dialog";
|
|
12
|
-
import {
|
|
13
|
-
InputGroup,
|
|
14
|
-
InputGroupAddon,
|
|
15
|
-
} from "@feedbax/ui/components/input-group";
|
|
16
12
|
import { SearchIcon, CheckIcon } from "lucide-react";
|
|
17
13
|
|
|
18
14
|
function Command({
|
|
@@ -47,17 +43,17 @@ function CommandDialog({
|
|
|
47
43
|
}) {
|
|
48
44
|
return (
|
|
49
45
|
<Dialog {...props}>
|
|
50
|
-
<DialogHeader className="sr-only">
|
|
51
|
-
<DialogTitle>{title}</DialogTitle>
|
|
52
|
-
<DialogDescription>{description}</DialogDescription>
|
|
53
|
-
</DialogHeader>
|
|
54
46
|
<DialogContent
|
|
55
47
|
className={cn(
|
|
56
|
-
"top-
|
|
48
|
+
"top-24 translate-y-0 overflow-hidden rounded-none p-0 sm:top-32",
|
|
57
49
|
className,
|
|
58
50
|
)}
|
|
59
51
|
showCloseButton={showCloseButton}
|
|
60
52
|
>
|
|
53
|
+
<DialogHeader className="sr-only">
|
|
54
|
+
<DialogTitle>{title}</DialogTitle>
|
|
55
|
+
<DialogDescription>{description}</DialogDescription>
|
|
56
|
+
</DialogHeader>
|
|
61
57
|
{children}
|
|
62
58
|
</DialogContent>
|
|
63
59
|
</Dialog>
|
|
@@ -69,20 +65,22 @@ function CommandInput({
|
|
|
69
65
|
...props
|
|
70
66
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
|
71
67
|
return (
|
|
72
|
-
<div
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
68
|
+
<div
|
|
69
|
+
data-slot="command-input-wrapper"
|
|
70
|
+
className="flex h-14 shrink-0 items-center gap-3 border-b px-4"
|
|
71
|
+
>
|
|
72
|
+
<SearchIcon
|
|
73
|
+
className="size-5 shrink-0 text-muted-foreground"
|
|
74
|
+
aria-hidden="true"
|
|
75
|
+
/>
|
|
76
|
+
<CommandPrimitive.Input
|
|
77
|
+
data-slot="command-input"
|
|
78
|
+
className={cn(
|
|
79
|
+
"h-full w-full min-w-0 border-0 bg-transparent py-4 text-base text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm",
|
|
80
|
+
className,
|
|
81
|
+
)}
|
|
82
|
+
{...props}
|
|
83
|
+
/>
|
|
86
84
|
</div>
|
|
87
85
|
);
|
|
88
86
|
}
|
|
@@ -95,7 +93,7 @@ function CommandList({
|
|
|
95
93
|
<CommandPrimitive.List
|
|
96
94
|
data-slot="command-list"
|
|
97
95
|
className={cn(
|
|
98
|
-
"no-scrollbar max-h-
|
|
96
|
+
"no-scrollbar max-h-80 scroll-py-2 overflow-x-hidden overflow-y-auto outline-none",
|
|
99
97
|
className,
|
|
100
98
|
)}
|
|
101
99
|
{...props}
|
|
@@ -110,7 +108,10 @@ function CommandEmpty({
|
|
|
110
108
|
return (
|
|
111
109
|
<CommandPrimitive.Empty
|
|
112
110
|
data-slot="command-empty"
|
|
113
|
-
className={cn(
|
|
111
|
+
className={cn(
|
|
112
|
+
"px-6 py-10 text-center text-sm text-muted-foreground",
|
|
113
|
+
className,
|
|
114
|
+
)}
|
|
114
115
|
{...props}
|
|
115
116
|
/>
|
|
116
117
|
);
|
|
@@ -124,7 +125,7 @@ function CommandGroup({
|
|
|
124
125
|
<CommandPrimitive.Group
|
|
125
126
|
data-slot="command-group"
|
|
126
127
|
className={cn(
|
|
127
|
-
"overflow-hidden text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-
|
|
128
|
+
"overflow-hidden text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-2 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:text-muted-foreground",
|
|
128
129
|
className,
|
|
129
130
|
)}
|
|
130
131
|
{...props}
|
|
@@ -139,7 +140,7 @@ function CommandSeparator({
|
|
|
139
140
|
return (
|
|
140
141
|
<CommandPrimitive.Separator
|
|
141
142
|
data-slot="command-separator"
|
|
142
|
-
className={cn("-
|
|
143
|
+
className={cn("my-1 h-px bg-border", className)}
|
|
143
144
|
{...props}
|
|
144
145
|
/>
|
|
145
146
|
);
|
|
@@ -154,7 +155,7 @@ function CommandItem({
|
|
|
154
155
|
<CommandPrimitive.Item
|
|
155
156
|
data-slot="command-item"
|
|
156
157
|
className={cn(
|
|
157
|
-
"group/command-item relative flex cursor-default items-center gap-2 rounded-none px-
|
|
158
|
+
"group/command-item relative flex cursor-default items-center gap-2 rounded-none px-3 py-3 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-none! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
|
158
159
|
className,
|
|
159
160
|
)}
|
|
160
161
|
{...props}
|
|
@@ -5,8 +5,8 @@ import {
|
|
|
5
5
|
useMessageScrollerScrollable,
|
|
6
6
|
useMessageScrollerVisibility,
|
|
7
7
|
} from "@shadcn/react/message-scroller";
|
|
8
|
-
|
|
9
8
|
import { cn } from "@feedbax/ui/lib/utils";
|
|
9
|
+
|
|
10
10
|
import { Button } from "@feedbax/ui/components/button";
|
|
11
11
|
import { ArrowDownIcon } from "lucide-react";
|
|
12
12
|
|
|
@@ -40,7 +40,7 @@ function MessageScrollerViewport({
|
|
|
40
40
|
<MessageScrollerPrimitive.Viewport
|
|
41
41
|
data-slot="message-scroller-viewport"
|
|
42
42
|
className={cn(
|
|
43
|
-
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent",
|
|
43
|
+
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible",
|
|
44
44
|
className,
|
|
45
45
|
)}
|
|
46
46
|
{...props}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
|
|
2
|
+
import { cn } from "@feedbax/ui/lib/utils";
|
|
3
|
+
|
|
4
|
+
function Switch({
|
|
5
|
+
className,
|
|
6
|
+
size = "default",
|
|
7
|
+
...props
|
|
8
|
+
}: SwitchPrimitive.Root.Props & {
|
|
9
|
+
size?: "sm" | "default";
|
|
10
|
+
}) {
|
|
11
|
+
return (
|
|
12
|
+
<SwitchPrimitive.Root
|
|
13
|
+
data-slot="switch"
|
|
14
|
+
data-size={size}
|
|
15
|
+
className={cn(
|
|
16
|
+
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none group-has-[:focus-visible]/field-label:border-transparent group-has-[:focus-visible]/field-label:ring-0 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
>
|
|
21
|
+
<SwitchPrimitive.Thumb
|
|
22
|
+
data-slot="switch-thumb"
|
|
23
|
+
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
|
24
|
+
/>
|
|
25
|
+
</SwitchPrimitive.Root>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { Switch };
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { createConnectedIdentityHandler } from "./connected-identity";
|
|
3
|
+
|
|
4
|
+
it("starts sign-in with a secure browser challenge and never accepts a return URL", async () => {
|
|
5
|
+
const handler = createConnectedIdentityHandler({
|
|
6
|
+
siteUrl: "https://feedback.example",
|
|
7
|
+
loginUrl: "https://app.example/feedback-login",
|
|
8
|
+
issuer: "https://app.example",
|
|
9
|
+
secret: "identity-test-secret-at-least-32-characters",
|
|
10
|
+
store: undefined!,
|
|
11
|
+
});
|
|
12
|
+
const response = await handler(
|
|
13
|
+
new Request(
|
|
14
|
+
"https://feedback.example/internal/identity/start?returnTo=https://evil.example",
|
|
15
|
+
),
|
|
16
|
+
);
|
|
17
|
+
expect(response.status).toBe(303);
|
|
18
|
+
const target = new URL(response.headers.get("location")!);
|
|
19
|
+
expect(target.origin).toBe("https://app.example");
|
|
20
|
+
expect(target.searchParams.get("state")?.length).toBeGreaterThan(30);
|
|
21
|
+
expect(target.searchParams.get("callback")).toBe(
|
|
22
|
+
"https://feedback.example/internal/identity/callback",
|
|
23
|
+
);
|
|
24
|
+
expect(response.headers.get("set-cookie")).toContain(
|
|
25
|
+
"HttpOnly; Secure; SameSite=None",
|
|
26
|
+
);
|
|
27
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("exchanges a browser-bound handoff once, keeps identity private, and revokes sessions on logout", async () => {
|
|
31
|
+
const { Miniflare } = await import("miniflare");
|
|
32
|
+
const { readFile } = await import("node:fs/promises");
|
|
33
|
+
const { existsSync } = await import("node:fs");
|
|
34
|
+
const { createConnectedStore, signIdentityHandoff } =
|
|
35
|
+
await import("@feedbax/feedback");
|
|
36
|
+
const runtime = new Miniflare({
|
|
37
|
+
modules: true,
|
|
38
|
+
script: "export default {fetch(){return new Response('ok')}}",
|
|
39
|
+
compatibilityDate: "2026-07-30",
|
|
40
|
+
d1Databases: ["DB"],
|
|
41
|
+
});
|
|
42
|
+
try {
|
|
43
|
+
const db = await runtime.getD1Database("DB");
|
|
44
|
+
await db.exec(
|
|
45
|
+
(await readFile(
|
|
46
|
+
existsSync("apps/portal/migrations/0001_connected.sql")
|
|
47
|
+
? "apps/portal/migrations/0001_connected.sql"
|
|
48
|
+
: "migrations/0001_connected.sql",
|
|
49
|
+
"utf8",
|
|
50
|
+
)) +
|
|
51
|
+
"\n" +
|
|
52
|
+
(await readFile(
|
|
53
|
+
existsSync("apps/portal/migrations/0002_participation.sql")
|
|
54
|
+
? "apps/portal/migrations/0002_participation.sql"
|
|
55
|
+
: "migrations/0002_participation.sql",
|
|
56
|
+
"utf8",
|
|
57
|
+
)),
|
|
58
|
+
);
|
|
59
|
+
const secret = "identity-test-secret-at-least-32-characters";
|
|
60
|
+
const handler = createConnectedIdentityHandler({
|
|
61
|
+
siteUrl: "https://feedback.example",
|
|
62
|
+
loginUrl: "https://app.example/login",
|
|
63
|
+
issuer: "https://app.example",
|
|
64
|
+
secret,
|
|
65
|
+
store: createConnectedStore(db),
|
|
66
|
+
now: () => 1000000,
|
|
67
|
+
});
|
|
68
|
+
const started = await handler(
|
|
69
|
+
new Request("https://feedback.example/internal/identity/start"),
|
|
70
|
+
);
|
|
71
|
+
const challenge = started.headers.get("set-cookie")!.split(";")[0]!;
|
|
72
|
+
const state = new URL(started.headers.get("location")!).searchParams.get(
|
|
73
|
+
"state",
|
|
74
|
+
)!;
|
|
75
|
+
const token = signIdentityHandoff(
|
|
76
|
+
{
|
|
77
|
+
subject: "private-subject",
|
|
78
|
+
displayName: "Alice",
|
|
79
|
+
audience: "https://feedback.example",
|
|
80
|
+
issuer: "https://app.example",
|
|
81
|
+
state,
|
|
82
|
+
nonce: "nonce",
|
|
83
|
+
issuedAt: 1000,
|
|
84
|
+
expiresAt: 1100,
|
|
85
|
+
},
|
|
86
|
+
secret,
|
|
87
|
+
);
|
|
88
|
+
const callback = (cookie: string, origin = "https://app.example") =>
|
|
89
|
+
new Request("https://feedback.example/internal/identity/callback", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: {
|
|
92
|
+
Origin: origin,
|
|
93
|
+
Cookie: cookie,
|
|
94
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
95
|
+
},
|
|
96
|
+
body: new URLSearchParams({ token }),
|
|
97
|
+
});
|
|
98
|
+
expect((await handler(callback(""))).status).toBe(401);
|
|
99
|
+
expect(
|
|
100
|
+
(await handler(callback(challenge, "https://evil.example"))).status,
|
|
101
|
+
).toBe(403);
|
|
102
|
+
const signedIn = await handler(callback(challenge));
|
|
103
|
+
expect(signedIn.status).toBe(303);
|
|
104
|
+
expect((await handler(callback(challenge))).status).toBe(401);
|
|
105
|
+
const session = signedIn.headers
|
|
106
|
+
.getSetCookie()
|
|
107
|
+
.find((c) => c.startsWith("__Host-feedbax-session="))!
|
|
108
|
+
.split(";")[0]!;
|
|
109
|
+
const sessionRequest = () =>
|
|
110
|
+
new Request("https://feedback.example/internal/identity/session", {
|
|
111
|
+
headers: { Cookie: session },
|
|
112
|
+
});
|
|
113
|
+
const response = await handler(sessionRequest());
|
|
114
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
115
|
+
expect(await response.json()).toEqual({
|
|
116
|
+
participant: { displayName: "Alice" },
|
|
117
|
+
});
|
|
118
|
+
expect(
|
|
119
|
+
(
|
|
120
|
+
await handler(
|
|
121
|
+
new Request("https://feedback.example/internal/identity/logout", {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { Cookie: session, Origin: "https://evil.example" },
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
126
|
+
).status,
|
|
127
|
+
).toBe(403);
|
|
128
|
+
expect(
|
|
129
|
+
(
|
|
130
|
+
await handler(
|
|
131
|
+
new Request("https://feedback.example/internal/identity/logout", {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { Cookie: session, Origin: "https://feedback.example" },
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
).status,
|
|
137
|
+
).toBe(200);
|
|
138
|
+
expect(await (await handler(sessionRequest())).json()).toEqual({
|
|
139
|
+
participant: null,
|
|
140
|
+
});
|
|
141
|
+
} finally {
|
|
142
|
+
await runtime.dispose();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { verifyIdentityHandoff, type ConnectedStore } from "@feedbax/feedback";
|
|
3
|
+
|
|
4
|
+
const challengeCookie = "__Host-feedbax-handoff";
|
|
5
|
+
const sessionCookie = "__Host-feedbax-session";
|
|
6
|
+
export const sessionHash = (token: string) =>
|
|
7
|
+
createHash("sha256").update(token).digest("hex");
|
|
8
|
+
export function readSessionToken(request: Request) {
|
|
9
|
+
return cookie(request, sessionCookie);
|
|
10
|
+
}
|
|
11
|
+
export function sameOrigin(request: Request, siteUrl: string) {
|
|
12
|
+
return request.headers.get("origin") === new URL(siteUrl).origin;
|
|
13
|
+
}
|
|
14
|
+
export function createConnectedIdentityHandler(options: {
|
|
15
|
+
siteUrl: string;
|
|
16
|
+
loginUrl: string;
|
|
17
|
+
issuer: string;
|
|
18
|
+
secret: string;
|
|
19
|
+
store: ConnectedStore;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
}) {
|
|
22
|
+
const origin = new URL(options.siteUrl).origin;
|
|
23
|
+
return async (request: Request): Promise<Response> => {
|
|
24
|
+
const path = new URL(request.url).pathname;
|
|
25
|
+
const headers = new Headers({
|
|
26
|
+
"Cache-Control": "no-store",
|
|
27
|
+
"Referrer-Policy": "no-referrer",
|
|
28
|
+
});
|
|
29
|
+
const json = (value: unknown, status = 200) =>
|
|
30
|
+
Response.json(value, { status, headers });
|
|
31
|
+
try {
|
|
32
|
+
if (path === "/internal/identity/start" && request.method === "GET") {
|
|
33
|
+
const state = randomBytes(32).toString("base64url");
|
|
34
|
+
const target = new URL(options.loginUrl);
|
|
35
|
+
target.searchParams.set("state", state);
|
|
36
|
+
target.searchParams.set(
|
|
37
|
+
"callback",
|
|
38
|
+
`${origin}/internal/identity/callback`,
|
|
39
|
+
);
|
|
40
|
+
headers.set(
|
|
41
|
+
"Set-Cookie",
|
|
42
|
+
`${challengeCookie}=${state}; Path=/; Max-Age=300; HttpOnly; Secure; SameSite=None`,
|
|
43
|
+
);
|
|
44
|
+
headers.set("Location", target.toString());
|
|
45
|
+
return new Response(null, { status: 303, headers });
|
|
46
|
+
}
|
|
47
|
+
if (path === "/internal/identity/callback" && request.method === "POST") {
|
|
48
|
+
if (request.headers.get("origin") !== new URL(options.loginUrl).origin)
|
|
49
|
+
return json({ error: "Sign-in origin is invalid." }, 403);
|
|
50
|
+
const body = await request.text();
|
|
51
|
+
if (body.length > 12000)
|
|
52
|
+
return json({ error: "Sign-in request is too large." }, 413);
|
|
53
|
+
const form = new URLSearchParams(body);
|
|
54
|
+
const now = options.now?.() ?? Date.now();
|
|
55
|
+
const identity = verifyIdentityHandoff(
|
|
56
|
+
form.get("token") ?? "",
|
|
57
|
+
options.secret,
|
|
58
|
+
{
|
|
59
|
+
audience: origin,
|
|
60
|
+
issuer: options.issuer,
|
|
61
|
+
state: cookie(request, challengeCookie) ?? "",
|
|
62
|
+
now: Math.floor(now / 1000),
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
const token = randomBytes(32).toString("base64url");
|
|
66
|
+
await options.store.acceptIdentity({
|
|
67
|
+
...identity,
|
|
68
|
+
subject: JSON.stringify([identity.issuer, identity.subject]),
|
|
69
|
+
nonce: identity.nonce,
|
|
70
|
+
challenge: identity.state,
|
|
71
|
+
sessionHash: sessionHash(token),
|
|
72
|
+
expiresAt: now + 7 * 86400000,
|
|
73
|
+
});
|
|
74
|
+
headers.append(
|
|
75
|
+
"Set-Cookie",
|
|
76
|
+
`${sessionCookie}=${token}; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Lax`,
|
|
77
|
+
);
|
|
78
|
+
headers.append(
|
|
79
|
+
"Set-Cookie",
|
|
80
|
+
`${challengeCookie}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=None`,
|
|
81
|
+
);
|
|
82
|
+
headers.set("Location", origin);
|
|
83
|
+
return new Response(null, { status: 303, headers });
|
|
84
|
+
}
|
|
85
|
+
if (path === "/internal/identity/session" && request.method === "GET") {
|
|
86
|
+
const token = readSessionToken(request);
|
|
87
|
+
const participant = token
|
|
88
|
+
? await options.store.session(sessionHash(token), options.now?.())
|
|
89
|
+
: undefined;
|
|
90
|
+
return json({
|
|
91
|
+
participant: participant
|
|
92
|
+
? { displayName: participant.displayName }
|
|
93
|
+
: null,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (path === "/internal/identity/logout" && request.method === "POST") {
|
|
97
|
+
if (!sameOrigin(request, origin))
|
|
98
|
+
return json({ error: "Request origin is invalid." }, 403);
|
|
99
|
+
const token = readSessionToken(request);
|
|
100
|
+
if (token) await options.store.logout(sessionHash(token));
|
|
101
|
+
headers.set(
|
|
102
|
+
"Set-Cookie",
|
|
103
|
+
`${sessionCookie}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`,
|
|
104
|
+
);
|
|
105
|
+
return json({ signedOut: true });
|
|
106
|
+
}
|
|
107
|
+
return json({ error: "Not found." }, 404);
|
|
108
|
+
} catch {
|
|
109
|
+
return json(
|
|
110
|
+
{ error: "Sign-in could not be completed. Start sign-in again." },
|
|
111
|
+
401,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function cookie(request: Request, name: string): string | undefined {
|
|
117
|
+
const pairs = (request.headers.get("cookie") ?? "")
|
|
118
|
+
.split(";")
|
|
119
|
+
.map((v) => v.trim())
|
|
120
|
+
.filter((v) => v.startsWith(`${name}=`));
|
|
121
|
+
return pairs.length === 1 ? pairs[0]!.slice(name.length + 1) : undefined;
|
|
122
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
waitFor,
|
|
9
|
+
} from "@testing-library/react";
|
|
10
|
+
import { ConnectedIdentityProvider } from "./components/connected-identity-provider";
|
|
11
|
+
import { VoteToggle } from "./vote-toggle";
|
|
12
|
+
vi.mock("./feedbax", async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import("./feedbax")>();
|
|
14
|
+
return {
|
|
15
|
+
default: {
|
|
16
|
+
...actual.default,
|
|
17
|
+
connected: {
|
|
18
|
+
issuer: "https://app.example",
|
|
19
|
+
loginUrl: "https://app.example/login",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
cleanup();
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
localStorage.clear();
|
|
28
|
+
sessionStorage.clear();
|
|
29
|
+
});
|
|
30
|
+
it("restores a signed-in Participant's Vote from the server instead of device storage", async () => {
|
|
31
|
+
const request = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
|
32
|
+
const url = String(input);
|
|
33
|
+
if (url === "/internal/identity/session")
|
|
34
|
+
return Response.json({ participant: { displayName: "Alice" } });
|
|
35
|
+
if (url.startsWith("/internal/connected/votes?"))
|
|
36
|
+
return Response.json({ voted: true, voteCount: 4 });
|
|
37
|
+
return Response.json({ slug: "post", voteCount: 3, voted: false });
|
|
38
|
+
});
|
|
39
|
+
vi.stubGlobal("fetch", request);
|
|
40
|
+
render(
|
|
41
|
+
<ConnectedIdentityProvider>
|
|
42
|
+
<VoteToggle
|
|
43
|
+
post={{
|
|
44
|
+
slug: "post",
|
|
45
|
+
title: "Post",
|
|
46
|
+
description: "Description",
|
|
47
|
+
type: "Feature Request",
|
|
48
|
+
status: "Planned",
|
|
49
|
+
createdAt: new Date(),
|
|
50
|
+
updatedAt: new Date(),
|
|
51
|
+
voteCount: 4,
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
</ConnectedIdentityProvider>,
|
|
55
|
+
);
|
|
56
|
+
const button = await screen.findByRole("button", {
|
|
57
|
+
name: "Remove Vote, 4 Votes",
|
|
58
|
+
});
|
|
59
|
+
expect(button.getAttribute("aria-pressed")).toBe("true");
|
|
60
|
+
fireEvent.click(button);
|
|
61
|
+
await waitFor(() =>
|
|
62
|
+
expect(
|
|
63
|
+
screen.getByRole("button", { name: "Add Vote, 3 Votes" }),
|
|
64
|
+
).toBeTruthy(),
|
|
65
|
+
);
|
|
66
|
+
expect(localStorage.length).toBe(0);
|
|
67
|
+
});
|