Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Database
DATABASE_URL="postgresql://user:password@host/db?sslmode=require"

# Auth
AUTH_URL="http://localhost:3000"
AUTH_SECRET="" # openssl rand -base64 32
AUTH_GITHUB_ID=""
AUTH_GITHUB_SECRET=""

# Email
AUTH_RESEND_KEY=""
EMAIL_FROM="noreply@yourdomain.com"

# App
NEXT_PUBLIC_APP_URL="http://localhost:3000"

# Demo
DEMO_MODE="true"
CRON_SECRET="" # openssl rand -base64 32
11 changes: 11 additions & 0 deletions .env.test.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
NODE_ENV=production
DATABASE_URL="postgresql://test:test@localhost:5432/feedbackflow_test"
AUTH_SECRET="test-secret-test-secret-test-secret-test"
AUTH_GITHUB_ID="test"
AUTH_GITHUB_SECRET="test"
AUTH_RESEND_KEY="test"
EMAIL_FROM="test@test.com"
PORT=3001
AUTH_URL="http://localhost:3001"
NEXT_PUBLIC_APP_URL="http://localhost:3001"
E2E_TEST_MODE="true"
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,33 @@ jobs:
AUTH_RESEND_KEY: fake
EMAIL_FROM: fake@example.com
NEXT_PUBLIC_APP_URL: http://localhost:3000
e2e:
runs-on: ubuntu-latest
env:
DATABASE_URL: ${{ secrets.E2E_DATABASE_URL }}
AUTH_SECRET: test-secret-test-secret-test-secret-test
AUTH_GITHUB_ID: fake
AUTH_GITHUB_SECRET: fake
AUTH_RESEND_KEY: fake
EMAIL_FROM: fake@example.com
NEXT_PUBLIC_APP_URL: http://localhost:3001
E2E_TEST_MODE: "true"
PORT: "3001"

steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx prisma migrate deploy
- run: npm run build
- run: npx playwright test
- if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
# env files
.env*
!.env.example
!.env.test.example

# vercel
.vercel
Expand Down
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,11 @@ Modèle : 1 User → 1 Board (relation 1:1 pour ce MVP). Chaque Post appartient

## Tests

- **Vitest** pour les Server Actions critiques (`createPost`, `toggleVote`, `changeStatus`). Mock Prisma via `vitest-mock-extended`.
- **Playwright** pour 1 parcours e2e : login démo → créer post → voter → changer statut.
- Pas d'objectif de couverture. Cibler la valeur, pas la métrique.
- Tests dans `src/**/__tests__/*.test.ts` ou colocalisés `*.test.ts` à côté du fichier source.
- **Vitest** : Server Actions critiques. Mocks Prisma via `vitest-mock-extended`.
- **Playwright** : 1 happy path + scénarios d'autorisation + scénarios admin.
- Bypass auth en e2e via provider Credentials conditionnel (`E2E_TEST_MODE=true`) et page `/e2e-login`. Le provider et la page sont actifs uniquement quand la variable d'env est `"true"`, double check au runtime.
- Base de test : Postgres en Docker avec `tmpfs` (reset entre runs). `e2e/helpers/db.ts` fournit `cleanDatabase` et `seedDemoBoard` appelés en `beforeEach`.
- `playwright.config.ts` est en `fullyParallel: false` à cause de la base partagée. Si tu paralléllises, isole les bases par worker.

## Sécurité

Expand Down
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ A minimalist open-source alternative to Canny / Frill.

[Live demo](https://feedbackflow.vercel.app) · [Public board](https://feedbackflow.vercel.app/b/demo) · [Roadmap](https://feedbackflow.vercel.app/b/demo/roadmap)

[![CI](https://github.com/<your-handle>/feedbackflow/actions/workflows/ci.yml/badge.svg)](https://github.com/<your-handle>/feedbackflow/actions)
> Demo account: `demo@feedbackflow.app` (magic link, no password)

[![CI](https://github.com/Yentec/FeedbackFlow/actions/workflows/ci.yml/badge.svg)](https://github.com/Yentec/FeedbackFlow/actions)
![License](https://img.shields.io/badge/License-MIT-blue.svg)
![Next.js](https://img.shields.io/badge/Next.js-16-black)
![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue)
Expand Down Expand Up @@ -45,7 +47,7 @@ A minimalist open-source alternative to Canny / Frill.
| Styling | Tailwind v4 + shadcn/ui |
| Email | Resend |
| Deployment | Vercel |
| Testing | Vitest |
| Testing | Vitest + Playwright |
| CI | GitHub Actions |

## Screenshots
Expand Down Expand Up @@ -97,6 +99,7 @@ cp .env.example .env.local

npm run db:migrate
npm run db:seed
# Demo account: demo@feedbackflow.app
npm run dev
```

Expand All @@ -121,6 +124,27 @@ npm run db:seed # seed the demo board
npm run db:studio # browse the DB
```

## Testing

```bash
# Unit tests (Vitest) — Server Actions, validators
npm test

# E2E tests (Playwright) — full user flows
npm run e2e:db:up # start Postgres in Docker
npm run e2e:db:reset # apply migrations
npm run test:e2e # run Chromium against a fresh build
npm run test:e2e:ui # interactive mode
```

The e2e suite covers:

- **Happy path**: sign in → create post → vote → comment
- **Authorization**: guests redirected, private boards return 404
- **Admin actions**: status changes, post deletion, propagation to public board and roadmap

Both suites run on every PR in [CI](.github/workflows/ci.yml).

## Deploy

One-click deploy on Vercel:
Expand Down
23 changes: 23 additions & 0 deletions app/(auth)/e2e-login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { signIn } from "@/auth";

// Test-only bypass page. Security is enforced in the "e2e" Credentials provider's
// authorize(), which returns null unless E2E_TEST_MODE=true.
type Props = { searchParams: Promise<{ email?: string; callbackUrl?: string }> };

export default async function E2ELoginPage({ searchParams }: Props) {
const { email, callbackUrl } = await searchParams;
if (!email) return null;

const doSignIn = async () => {
"use server";
await signIn("e2e", { email, redirectTo: callbackUrl ?? "/dashboard" });
};

return (
<form action={doSignIn}>
<button type="submit" id="e2e-submit">
Continue as {email}
</button>
</form>
);
}
16 changes: 16 additions & 0 deletions app/api/board-visibility/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";

export const dynamic = "force-dynamic";

export async function GET(req: NextRequest) {
const slug = req.nextUrl.searchParams.get("slug");
if (!slug) return NextResponse.json({ isPublic: false });

const board = await db.board.findUnique({
where: { slug },
select: { isPublic: true },
});

return NextResponse.json({ isPublic: board?.isPublic ?? false });
}
2 changes: 2 additions & 0 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { MetadataRoute } from "next";
import { db } from "@/lib/db";

export const dynamic = "force-dynamic";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = process.env["NEXT_PUBLIC_APP_URL"] ?? "http://localhost:3000";

Expand Down
43 changes: 42 additions & 1 deletion auth.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,49 @@
import type { NextAuthConfig } from "next-auth";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";

const providers: NextAuthConfig["providers"] = [GitHub];

if (process.env["E2E_TEST_MODE"] === "true") {
providers.push(
Credentials({
id: "e2e",
name: "E2E Test Login",
credentials: { email: { type: "text" } },
async authorize(credentials) {
if (process.env["E2E_TEST_MODE"] !== "true") return null;
const email = credentials?.email;
if (typeof email !== "string" || !email.includes("@")) return null;

// Lazy import to keep auth.config edge-safe in non-test envs
const { db } = await import("@/lib/db");
const { slugify } = await import("@/lib/slug");

let user = await db.user.findUnique({ where: { email } });
if (!user) {
user = await db.user.create({
data: { email, name: email.split("@")[0], emailVerified: new Date() },
});
const base = slugify(user.name ?? "board");
let slug = base || "board";
let suffix = 0;
while (await db.board.findUnique({ where: { slug } })) {
suffix += 1;
slug = `${base}-${suffix}`;
}
await db.board.create({
data: { slug, name: `${user.name}'s board`, ownerId: user.id },
});
}

return { id: user.id, email: user.email, name: user.name };
},
}),
);
}

export default {
providers: [GitHub],
providers,
pages: {
signIn: "/login",
verifyRequest: "/verify-request",
Expand Down
2 changes: 1 addition & 1 deletion auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
...authConfig.providers,
Resend({ from: process.env["EMAIL_FROM"] }),
Resend({ from: process.env["EMAIL_FROM"] }), // Resend needs PrismaAdapter — kept here only, not in auth.config
Credentials({
id: "demo",
credentials: {},
Expand Down
2 changes: 1 addition & 1 deletion components/posts/vote-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function VoteButton({
onClick={handleClick}
disabled={isPending}
aria-pressed={optimistic.hasVoted}
aria-label={optimistic.hasVoted ? "Remove vote" : "Vote"}
aria-label={size === "md" && optimistic.hasVoted ? "Remove vote" : "Vote"}
className={cn(
"flex flex-col items-center gap-0.5 rounded-md border transition",
size === "sm" ? "px-3 py-2 text-sm" : "px-4 py-3 text-base",
Expand Down
20 changes: 10 additions & 10 deletions components/ui/switch.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,33 @@
"use client"
"use client";

import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import * as React from "react";
import { Switch as SwitchPrimitive } from "radix-ui";

import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";

function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 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",
className
"peer group/switch focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 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 relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 aria-invalid:ring-3 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
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"
className="bg-background dark:data-checked:bg-primary-foreground dark:data-unchecked:bg-foreground pointer-events-none block rounded-full 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)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0"
/>
</SwitchPrimitive.Root>
)
);
}

export { Switch }
export { Switch };
56 changes: 56 additions & 0 deletions e2e/admin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test, expect } from "@playwright/test";
import { cleanDatabase, seedDemoBoard, disconnect } from "./helpers/db";
import { loginAs } from "./helpers/auth";

test.beforeEach(async () => {
await cleanDatabase();
await seedDemoBoard();
});

test.afterAll(async () => {
await disconnect();
});

test("board owner can change post status from the admin list", async ({ page }) => {
await loginAs(page, "demo@feedbackflow.app");
await page.goto("/posts");

await expect(page.getByText("Existing feature request")).toBeVisible();

// Click the status badge to open the dropdown
await page
.getByRole("row", { name: /existing feature request/i })
.getByRole("button")
.first()
.click();

await page.getByRole("menuitem", { name: /^planned$/i }).click();
await expect(page.getByText(/status changed to planned/i)).toBeVisible();

// Verify the public board reflects the change
await page.goto("/b/demo");
await expect(
page.locator("article", { hasText: "Existing feature request" }).getByText("Planned"),
).toBeVisible();

// And the roadmap
await page.goto("/b/demo/roadmap");
await expect(
page.locator("h2", { hasText: "Planned" }).locator("../..").getByText("Existing feature request"),
).toBeVisible();
});

test("board owner can delete a post", async ({ page }) => {
await loginAs(page, "demo@feedbackflow.app");
await page.goto("/posts");

page.on("dialog", (dialog) => dialog.accept());

await page
.getByRole("row", { name: /existing feature request/i })
.getByRole("button", { name: /delete post/i })
.click();

await expect(page.getByText(/post deleted/i)).toBeVisible();
await expect(page.getByText("Existing feature request")).not.toBeVisible();
});
Loading
Loading