security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states
This commit is contained in:
@@ -30,7 +30,12 @@ COPY --from=builder /app/packages/web/dist packages/web/dist/
|
||||
COPY --from=builder /app/node_modules node_modules/
|
||||
COPY --from=builder /app/echoboard.plugins.ts ./
|
||||
|
||||
RUN addgroup -g 1001 echoboard && adduser -u 1001 -G echoboard -D echoboard && \
|
||||
mkdir -p /app/packages/api/uploads && chown -R echoboard:echoboard /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
|
||||
USER echoboard
|
||||
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy --schema=packages/api/prisma/schema.prisma && node packages/api/dist/index.js"]
|
||||
|
||||
@@ -1,41 +1,32 @@
|
||||
version: "3.8"
|
||||
name: echoboard
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://echoboard:${DB_PASSWORD}@db:5432/echoboard
|
||||
APP_MASTER_KEY: ${APP_MASTER_KEY}
|
||||
APP_BLIND_INDEX_KEY: ${APP_BLIND_INDEX_KEY}
|
||||
TOKEN_SECRET: ${TOKEN_SECRET}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ALTCHA_HMAC_KEY: ${ALTCHA_HMAC_KEY}
|
||||
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Echoboard}
|
||||
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID}
|
||||
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
VAPID_CONTACT: ${VAPID_CONTACT}
|
||||
NODE_ENV: production
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://echoboard:${POSTGRES_PASSWORD}@db:5432/echoboard
|
||||
NODE_ENV: production
|
||||
ports:
|
||||
- "${PORT:-3000}:${PORT:-3000}"
|
||||
volumes:
|
||||
- ./uploads:/app/packages/api/uploads
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: echoboard
|
||||
POSTGRES_USER: echoboard
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: echoboard
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U echoboard"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
||||
7507
package-lock.json
generated
Normal file
7507
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev": "tsx watch --env-file=../../.env src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
@@ -16,6 +16,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.0",
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.0.0",
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@prisma/client": "^6.0.0",
|
||||
@@ -34,6 +35,8 @@
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/rss": "^0.0.32",
|
||||
"@types/web-push": "^3.6.0",
|
||||
"prisma": "^6.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
|
||||
270
packages/api/prisma/migrations/20260319185954_init/migration.sql
Normal file
270
packages/api/prisma/migrations/20260319185954_init/migration.sql
Normal file
@@ -0,0 +1,270 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AuthMethod" AS ENUM ('COOKIE', 'PASSKEY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PostType" AS ENUM ('FEATURE_REQUEST', 'BUG_REPORT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PostStatus" AS ENUM ('OPEN', 'UNDER_REVIEW', 'PLANNED', 'IN_PROGRESS', 'DONE', 'DECLINED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Board" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"externalUrl" TEXT,
|
||||
"isArchived" BOOLEAN NOT NULL DEFAULT false,
|
||||
"voteBudget" INTEGER NOT NULL DEFAULT 10,
|
||||
"voteBudgetReset" TEXT NOT NULL DEFAULT 'monthly',
|
||||
"lastBudgetReset" TIMESTAMP(3),
|
||||
"allowMultiVote" BOOLEAN NOT NULL DEFAULT false,
|
||||
"rssEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"rssFeedCount" INTEGER NOT NULL DEFAULT 50,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Board_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"authMethod" "AuthMethod" NOT NULL DEFAULT 'COOKIE',
|
||||
"tokenHash" TEXT,
|
||||
"username" TEXT,
|
||||
"usernameIdx" TEXT,
|
||||
"displayName" TEXT,
|
||||
"darkMode" TEXT NOT NULL DEFAULT 'system',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Passkey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"credentialId" TEXT NOT NULL,
|
||||
"credentialIdIdx" TEXT NOT NULL,
|
||||
"credentialPublicKey" BYTEA NOT NULL,
|
||||
"counter" BIGINT NOT NULL,
|
||||
"credentialDeviceType" TEXT NOT NULL,
|
||||
"credentialBackedUp" BOOLEAN NOT NULL,
|
||||
"transports" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Passkey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Post" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "PostType" NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" JSONB NOT NULL,
|
||||
"status" "PostStatus" NOT NULL DEFAULT 'OPEN',
|
||||
"category" TEXT,
|
||||
"voteCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"isPinned" BOOLEAN NOT NULL DEFAULT false,
|
||||
"boardId" TEXT NOT NULL,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "StatusChange" (
|
||||
"id" TEXT NOT NULL,
|
||||
"postId" TEXT NOT NULL,
|
||||
"fromStatus" "PostStatus" NOT NULL,
|
||||
"toStatus" "PostStatus" NOT NULL,
|
||||
"changedBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "StatusChange_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Comment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"postId" TEXT NOT NULL,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Comment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Reaction" (
|
||||
"id" TEXT NOT NULL,
|
||||
"emoji" TEXT NOT NULL,
|
||||
"commentId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Reaction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Vote" (
|
||||
"id" TEXT NOT NULL,
|
||||
"weight" INTEGER NOT NULL DEFAULT 1,
|
||||
"postId" TEXT NOT NULL,
|
||||
"voterId" TEXT NOT NULL,
|
||||
"budgetPeriod" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Vote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AdminUser" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AdminResponse" (
|
||||
"id" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"postId" TEXT NOT NULL,
|
||||
"adminId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AdminResponse_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ActivityEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"boardId" TEXT NOT NULL,
|
||||
"postId" TEXT,
|
||||
"metadata" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ActivityEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PushSubscription" (
|
||||
"id" TEXT NOT NULL,
|
||||
"endpoint" TEXT NOT NULL,
|
||||
"endpointIdx" TEXT NOT NULL,
|
||||
"keysP256dh" TEXT NOT NULL,
|
||||
"keysAuth" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"boardId" TEXT,
|
||||
"postId" TEXT,
|
||||
"failureCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Board_slug_key" ON "Board"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_tokenHash_key" ON "User"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_usernameIdx_key" ON "User"("usernameIdx");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Passkey_credentialIdIdx_key" ON "Passkey"("credentialIdIdx");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Reaction_commentId_userId_emoji_key" ON "Reaction"("commentId", "userId", "emoji");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Vote_postId_voterId_key" ON "Vote"("postId", "voterId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityEvent_boardId_createdAt_idx" ON "ActivityEvent"("boardId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ActivityEvent_createdAt_idx" ON "ActivityEvent"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PushSubscription_endpointIdx_key" ON "PushSubscription"("endpointIdx");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Passkey" ADD CONSTRAINT "Passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Post" ADD CONSTRAINT "Post_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StatusChange" ADD CONSTRAINT "StatusChange_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Vote" ADD CONSTRAINT "Vote_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Vote" ADD CONSTRAINT "Vote_voterId_fkey" FOREIGN KEY ("voterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AdminResponse" ADD CONSTRAINT "AdminResponse_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AdminResponse" ADD CONSTRAINT "AdminResponse_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityEvent" ADD CONSTRAINT "ActivityEvent_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ActivityEvent" ADD CONSTRAINT "ActivityEvent_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "EditHistory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"postId" TEXT,
|
||||
"commentId" TEXT,
|
||||
"editedBy" TEXT NOT NULL,
|
||||
"previousTitle" TEXT,
|
||||
"previousDescription" JSONB,
|
||||
"previousBody" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "EditHistory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EditHistory_postId_createdAt_idx" ON "EditHistory"("postId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EditHistory_commentId_createdAt_idx" ON "EditHistory"("commentId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_editedBy_fkey" FOREIGN KEY ("editedBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Post" ADD COLUMN "isEditLocked" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "EditHistory" ALTER COLUMN "editedBy" DROP NOT NULL;
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "EditHistory" DROP CONSTRAINT "EditHistory_editedBy_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EditHistory" ADD CONSTRAINT "EditHistory_editedBy_fkey" FOREIGN KEY ("editedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "SiteSettings" (
|
||||
"id" TEXT NOT NULL DEFAULT 'default',
|
||||
"appName" TEXT NOT NULL DEFAULT 'Echoboard',
|
||||
"logoUrl" TEXT,
|
||||
"faviconUrl" TEXT,
|
||||
"accentColor" TEXT NOT NULL DEFAULT '#F59E0B',
|
||||
"headerFont" TEXT,
|
||||
"bodyFont" TEXT,
|
||||
"poweredByVisible" BOOLEAN NOT NULL DEFAULT true,
|
||||
"customCss" TEXT,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
INSERT INTO "SiteSettings" ("id", "updatedAt") VALUES ('default', NOW());
|
||||
3
packages/api/prisma/migrations/migration_lock.toml
Normal file
3
packages/api/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -17,32 +17,46 @@ enum PostType {
|
||||
BUG_REPORT
|
||||
}
|
||||
|
||||
enum PostStatus {
|
||||
OPEN
|
||||
UNDER_REVIEW
|
||||
PLANNED
|
||||
IN_PROGRESS
|
||||
DONE
|
||||
DECLINED
|
||||
}
|
||||
|
||||
model Board {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
name String
|
||||
description String?
|
||||
externalUrl String?
|
||||
iconName String?
|
||||
iconColor String?
|
||||
isArchived Boolean @default(false)
|
||||
voteBudget Int @default(10)
|
||||
voteBudgetReset String @default("monthly")
|
||||
lastBudgetReset DateTime?
|
||||
allowMultiVote Boolean @default(false)
|
||||
rssEnabled Boolean @default(true)
|
||||
rssFeedCount Int @default(50)
|
||||
staleDays Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
posts Post[]
|
||||
activityEvents ActivityEvent[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
statusConfig BoardStatus[]
|
||||
templates BoardTemplate[]
|
||||
changelogEntries ChangelogEntry[]
|
||||
}
|
||||
|
||||
model BoardStatus {
|
||||
id String @id @default(cuid())
|
||||
boardId String
|
||||
status String
|
||||
label String
|
||||
color String
|
||||
position Int @default(0)
|
||||
enabled Boolean @default(true)
|
||||
|
||||
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([boardId, status])
|
||||
@@index([boardId, position])
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -52,16 +66,35 @@ model User {
|
||||
username String?
|
||||
usernameIdx String? @unique
|
||||
displayName String?
|
||||
avatarPath String?
|
||||
darkMode String @default("system")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
passkeys Passkey[]
|
||||
posts Post[]
|
||||
comments Comment[]
|
||||
reactions Reaction[]
|
||||
votes Vote[]
|
||||
passkeys Passkey[]
|
||||
posts Post[]
|
||||
comments Comment[]
|
||||
reactions Reaction[]
|
||||
votes Vote[]
|
||||
notifications Notification[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
attachments Attachment[]
|
||||
adminLink AdminUser?
|
||||
edits EditHistory[]
|
||||
recoveryCode RecoveryCode?
|
||||
}
|
||||
|
||||
model RecoveryCode {
|
||||
id String @id @default(cuid())
|
||||
codeHash String
|
||||
phraseIdx String @unique
|
||||
userId String @unique
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model Passkey {
|
||||
@@ -80,18 +113,26 @@ model Passkey {
|
||||
}
|
||||
|
||||
model Post {
|
||||
id String @id @default(cuid())
|
||||
type PostType
|
||||
title String
|
||||
description Json
|
||||
status PostStatus @default(OPEN)
|
||||
category String?
|
||||
voteCount Int @default(0)
|
||||
isPinned Boolean @default(false)
|
||||
boardId String
|
||||
authorId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
type PostType
|
||||
title String
|
||||
description Json
|
||||
status String @default("OPEN")
|
||||
statusReason String?
|
||||
category String?
|
||||
templateId String?
|
||||
voteCount Int @default(0)
|
||||
viewCount Int @default(0)
|
||||
isPinned Boolean @default(false)
|
||||
isEditLocked Boolean @default(false)
|
||||
isThreadLocked Boolean @default(false)
|
||||
isVotingLocked Boolean @default(false)
|
||||
onBehalfOf String?
|
||||
boardId String
|
||||
authorId String
|
||||
lastActivityAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||
@@ -99,15 +140,23 @@ model Post {
|
||||
comments Comment[]
|
||||
votes Vote[]
|
||||
adminResponses AdminResponse[]
|
||||
adminNotes AdminNote[]
|
||||
notifications Notification[]
|
||||
activityEvents ActivityEvent[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
tags PostTag[]
|
||||
attachments Attachment[]
|
||||
editHistory EditHistory[]
|
||||
|
||||
@@index([boardId, status])
|
||||
}
|
||||
|
||||
model StatusChange {
|
||||
id String @id @default(cuid())
|
||||
postId String
|
||||
fromStatus PostStatus
|
||||
toStatus PostStatus
|
||||
fromStatus String
|
||||
toStatus String
|
||||
reason String?
|
||||
changedBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -115,16 +164,28 @@ model StatusChange {
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
body String
|
||||
postId String
|
||||
authorId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
body String
|
||||
postId String
|
||||
authorId String
|
||||
replyToId String?
|
||||
isAdmin Boolean @default(false)
|
||||
isEditLocked Boolean @default(false)
|
||||
adminUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||
reactions Reaction[]
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||
replyTo Comment? @relation("CommentReplies", fields: [replyToId], references: [id], onDelete: SetNull)
|
||||
replies Comment[] @relation("CommentReplies")
|
||||
adminUser AdminUser? @relation(fields: [adminUserId], references: [id], onDelete: SetNull)
|
||||
reactions Reaction[]
|
||||
attachments Attachment[]
|
||||
editHistory EditHistory[]
|
||||
|
||||
@@index([replyToId])
|
||||
@@index([postId, createdAt])
|
||||
}
|
||||
|
||||
model Reaction {
|
||||
@@ -143,6 +204,7 @@ model Reaction {
|
||||
model Vote {
|
||||
id String @id @default(cuid())
|
||||
weight Int @default(1)
|
||||
importance String?
|
||||
postId String
|
||||
voterId String
|
||||
budgetPeriod String
|
||||
@@ -154,13 +216,50 @@ model Vote {
|
||||
@@unique([postId, voterId])
|
||||
}
|
||||
|
||||
model AdminUser {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
createdAt DateTime @default(now())
|
||||
enum TeamRole {
|
||||
SUPER_ADMIN
|
||||
ADMIN
|
||||
MODERATOR
|
||||
}
|
||||
|
||||
responses AdminResponse[]
|
||||
model AdminUser {
|
||||
id String @id @default(cuid())
|
||||
email String? @unique
|
||||
passwordHash String?
|
||||
role TeamRole @default(ADMIN)
|
||||
displayName String?
|
||||
teamTitle String?
|
||||
linkedUserId String? @unique
|
||||
invitedById String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
linkedUser User? @relation(fields: [linkedUserId], references: [id], onDelete: SetNull)
|
||||
invitedBy AdminUser? @relation("TeamInviter", fields: [invitedById], references: [id], onDelete: SetNull)
|
||||
invitees AdminUser[] @relation("TeamInviter")
|
||||
responses AdminResponse[]
|
||||
notes AdminNote[]
|
||||
comments Comment[]
|
||||
createdInvites TeamInvite[] @relation("InviteCreator")
|
||||
claimedInvite TeamInvite? @relation("InviteClaimer")
|
||||
}
|
||||
|
||||
model TeamInvite {
|
||||
id String @id @default(cuid())
|
||||
tokenHash String @unique
|
||||
role TeamRole
|
||||
label String?
|
||||
expiresAt DateTime
|
||||
createdById String
|
||||
claimedById String? @unique
|
||||
claimedAt DateTime?
|
||||
recoveryHash String?
|
||||
recoveryIdx String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
createdBy AdminUser @relation("InviteCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
||||
claimedBy AdminUser? @relation("InviteClaimer", fields: [claimedById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model AdminResponse {
|
||||
@@ -190,15 +289,16 @@ model ActivityEvent {
|
||||
}
|
||||
|
||||
model PushSubscription {
|
||||
id String @id @default(cuid())
|
||||
endpoint String
|
||||
endpointIdx String @unique
|
||||
keysP256dh String
|
||||
keysAuth String
|
||||
userId String
|
||||
boardId String?
|
||||
postId String?
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
endpoint String
|
||||
endpointIdx String @unique
|
||||
keysP256dh String
|
||||
keysAuth String
|
||||
userId String
|
||||
boardId String?
|
||||
postId String?
|
||||
failureCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
@@ -211,3 +311,151 @@ model Category {
|
||||
slug String @unique
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
type String
|
||||
title String
|
||||
body String
|
||||
postId String?
|
||||
userId String
|
||||
read Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
post Post? @relation(fields: [postId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId, read, createdAt])
|
||||
}
|
||||
|
||||
model Webhook {
|
||||
id String @id @default(cuid())
|
||||
url String
|
||||
secret String
|
||||
events String[] @default(["status_changed", "post_created", "comment_added"])
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model PostMerge {
|
||||
id String @id @default(cuid())
|
||||
sourcePostId String
|
||||
targetPostId String
|
||||
mergedBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([sourcePostId])
|
||||
}
|
||||
|
||||
model ChangelogEntry {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
body String
|
||||
boardId String?
|
||||
publishedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([boardId, publishedAt])
|
||||
}
|
||||
|
||||
model AdminNote {
|
||||
id String @id @default(cuid())
|
||||
body String
|
||||
postId String
|
||||
adminId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
admin AdminUser @relation(fields: [adminId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
color String @default("#6366F1")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
posts PostTag[]
|
||||
}
|
||||
|
||||
model PostTag {
|
||||
postId String
|
||||
tagId String
|
||||
|
||||
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([postId, tagId])
|
||||
}
|
||||
|
||||
model Attachment {
|
||||
id String @id @default(cuid())
|
||||
filename String
|
||||
path String
|
||||
size Int
|
||||
mimeType String
|
||||
postId String?
|
||||
commentId String?
|
||||
uploaderId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
uploader User @relation(fields: [uploaderId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model BoardTemplate {
|
||||
id String @id @default(cuid())
|
||||
boardId String
|
||||
name String
|
||||
fields Json
|
||||
isDefault Boolean @default(false)
|
||||
position Int @default(0)
|
||||
|
||||
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([boardId, position])
|
||||
}
|
||||
|
||||
model SiteSettings {
|
||||
id String @id @default("default")
|
||||
appName String @default("Echoboard")
|
||||
logoUrl String?
|
||||
faviconUrl String?
|
||||
accentColor String @default("#F59E0B")
|
||||
headerFont String?
|
||||
bodyFont String?
|
||||
poweredByVisible Boolean @default(true)
|
||||
customCss String?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model BlockedToken {
|
||||
id String @id @default(cuid())
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model EditHistory {
|
||||
id String @id @default(cuid())
|
||||
postId String?
|
||||
commentId String?
|
||||
editedBy String?
|
||||
previousTitle String?
|
||||
previousDescription Json?
|
||||
previousBody String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
editor User? @relation(fields: [editedBy], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([postId, createdAt])
|
||||
@@index([commentId, createdAt])
|
||||
}
|
||||
|
||||
1298
packages/api/prisma/seed.ts
Normal file
1298
packages/api/prisma/seed.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,7 @@ async function main() {
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
const admin = await prisma.adminUser.create({
|
||||
data: { email, passwordHash: hash },
|
||||
data: { email, passwordHash: hash, role: "SUPER_ADMIN" },
|
||||
});
|
||||
|
||||
console.log(`Admin created: ${admin.email} (${admin.id})`);
|
||||
|
||||
@@ -3,10 +3,11 @@ import { z } from "zod";
|
||||
const schema = z.object({
|
||||
DATABASE_URL: z.string(),
|
||||
APP_MASTER_KEY: z.string().regex(/^[0-9a-fA-F]{64}$/, "Must be hex-encoded 256-bit key"),
|
||||
APP_BLIND_INDEX_KEY: z.string().regex(/^[0-9a-fA-F]+$/, "Must be hex-encoded"),
|
||||
TOKEN_SECRET: z.string(),
|
||||
JWT_SECRET: z.string(),
|
||||
ALTCHA_HMAC_KEY: z.string(),
|
||||
APP_MASTER_KEY_PREVIOUS: z.string().regex(/^[0-9a-fA-F]{64}$/).optional(),
|
||||
APP_BLIND_INDEX_KEY: z.string().regex(/^[0-9a-fA-F]{32,}$/, "Must be hex-encoded, at least 128 bits"),
|
||||
TOKEN_SECRET: z.string().min(32),
|
||||
JWT_SECRET: z.string().min(32),
|
||||
ALTCHA_HMAC_KEY: z.string().min(32, "ALTCHA HMAC key must be at least 32 characters"),
|
||||
|
||||
WEBAUTHN_RP_NAME: z.string().default("Echoboard"),
|
||||
WEBAUTHN_RP_ID: z.string(),
|
||||
@@ -38,4 +39,7 @@ if (!parsed.success) {
|
||||
export const config = parsed.data;
|
||||
|
||||
export const masterKey = Buffer.from(config.APP_MASTER_KEY, "hex");
|
||||
export const previousMasterKey = config.APP_MASTER_KEY_PREVIOUS
|
||||
? Buffer.from(config.APP_MASTER_KEY_PREVIOUS, "hex")
|
||||
: null;
|
||||
export const blindIndexKey = Buffer.from(config.APP_BLIND_INDEX_KEY, "hex");
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import cron from "node-cron";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { config } from "../config.js";
|
||||
import { cleanExpiredChallenges } from "../routes/passkey.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import { cleanupExpiredTokens } from "../lib/token-blocklist.js";
|
||||
import { getPluginCronJobs } from "../plugins/loader.js";
|
||||
import { cleanupViews } from "../lib/view-tracker.js";
|
||||
|
||||
export function startCronJobs() {
|
||||
// prune old activity events - daily at 3am
|
||||
@@ -38,23 +41,92 @@ export function startCronJobs() {
|
||||
}
|
||||
});
|
||||
|
||||
// clean webauthn challenges - every 10 minutes
|
||||
cron.schedule("*/10 * * * *", () => {
|
||||
// clean webauthn challenges - every minute
|
||||
cron.schedule("* * * * *", () => {
|
||||
cleanExpiredChallenges();
|
||||
});
|
||||
|
||||
// remove failed push subscriptions - daily at 5am
|
||||
cron.schedule("0 5 * * *", async () => {
|
||||
// subscriptions with no associated user get cleaned by cascade
|
||||
// this handles any other stale ones
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - 30);
|
||||
|
||||
const result = await prisma.pushSubscription.deleteMany({
|
||||
where: { createdAt: { lt: cutoff } },
|
||||
// clean expired recovery codes - daily at 3:30am
|
||||
cron.schedule("30 3 * * *", async () => {
|
||||
const result = await prisma.recoveryCode.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
if (result.count > 0) {
|
||||
console.log(`Cleaned ${result.count} old push subscriptions`);
|
||||
console.log(`Cleaned ${result.count} expired recovery codes`);
|
||||
}
|
||||
});
|
||||
|
||||
// clean expired blocked tokens - every 30 minutes
|
||||
cron.schedule("*/30 * * * *", async () => {
|
||||
const count = await cleanupExpiredTokens();
|
||||
if (count > 0) {
|
||||
console.log(`Cleaned ${count} expired blocked tokens`);
|
||||
}
|
||||
});
|
||||
|
||||
// remove push subscriptions with too many failures - daily at 5am
|
||||
cron.schedule("0 5 * * *", async () => {
|
||||
const result = await prisma.pushSubscription.deleteMany({
|
||||
where: { failureCount: { gte: 3 } },
|
||||
});
|
||||
if (result.count > 0) {
|
||||
console.log(`Cleaned ${result.count} failed push subscriptions`);
|
||||
}
|
||||
});
|
||||
|
||||
// clean orphaned attachments (uploaded but never linked) - hourly
|
||||
cron.schedule("30 * * * *", async () => {
|
||||
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const orphans = await prisma.attachment.findMany({
|
||||
where: {
|
||||
postId: null,
|
||||
commentId: null,
|
||||
createdAt: { lt: cutoff },
|
||||
},
|
||||
select: { id: true, path: true },
|
||||
});
|
||||
|
||||
if (orphans.length === 0) return;
|
||||
|
||||
const ids = orphans.map((a) => a.id);
|
||||
const result = await prisma.attachment.deleteMany({
|
||||
where: { id: { in: ids }, postId: null, commentId: null },
|
||||
});
|
||||
|
||||
if (result.count > 0) {
|
||||
console.log(`Cleaned ${result.count} orphaned attachments`);
|
||||
}
|
||||
|
||||
for (const att of orphans) {
|
||||
try {
|
||||
await unlink(resolve(process.cwd(), "uploads", att.path));
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
// clean expired view-tracker entries - every 5 minutes
|
||||
cron.schedule("*/5 * * * *", () => { cleanupViews(); });
|
||||
|
||||
// register plugin-provided cron jobs (min interval: every minute, reject sub-minute)
|
||||
for (const job of getPluginCronJobs()) {
|
||||
if (!cron.validate(job.schedule)) {
|
||||
console.error(`Plugin cron "${job.name}" has invalid schedule: ${job.schedule}, skipping`);
|
||||
continue;
|
||||
}
|
||||
// reject schedules with 6 fields (seconds) to prevent sub-minute execution
|
||||
const fields = job.schedule.trim().split(/\s+/);
|
||||
if (fields.length > 5) {
|
||||
console.error(`Plugin cron "${job.name}" uses sub-minute schedule, skipping`);
|
||||
continue;
|
||||
}
|
||||
cron.schedule(job.schedule, async () => {
|
||||
try {
|
||||
await job.handler();
|
||||
} catch (err) {
|
||||
console.error(`Plugin cron job "${job.name}" failed:`, err);
|
||||
}
|
||||
});
|
||||
console.log(`Registered plugin cron: ${job.name} (${job.schedule})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import prisma from "./lib/prisma.js";
|
||||
import { createServer } from "./server.js";
|
||||
import { config } from "./config.js";
|
||||
import { startCronJobs } from "./cron/index.js";
|
||||
import { reEncryptIfNeeded } from "./services/key-rotation.js";
|
||||
import { validateManifest } from "./services/manifest-validator.js";
|
||||
|
||||
async function main() {
|
||||
validateManifest();
|
||||
|
||||
// ensure pg_trgm extension exists for similarity search
|
||||
await prisma.$executeRaw`CREATE EXTENSION IF NOT EXISTS pg_trgm`;
|
||||
|
||||
// search indexes for fuzzy + full-text search
|
||||
await Promise.all([
|
||||
prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_post_title_trgm ON "Post" USING gin (title gin_trgm_ops)`),
|
||||
prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_post_title_fts ON "Post" USING gin (to_tsvector('english', title))`),
|
||||
prisma.$executeRawUnsafe(`CREATE INDEX IF NOT EXISTS idx_board_name_trgm ON "Board" USING gin (name gin_trgm_ops)`),
|
||||
]);
|
||||
|
||||
const app = await createServer();
|
||||
|
||||
startCronJobs();
|
||||
reEncryptIfNeeded().catch((err) => console.error("Key rotation error:", err));
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.PORT, host: "0.0.0.0" });
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import prisma from "./prisma.js";
|
||||
|
||||
export function getCurrentPeriod(resetSchedule: string): string {
|
||||
const now = new Date();
|
||||
@@ -9,17 +7,27 @@ export function getCurrentPeriod(resetSchedule: string): string {
|
||||
|
||||
switch (resetSchedule) {
|
||||
case "weekly": {
|
||||
const startOfYear = new Date(year, 0, 1);
|
||||
const days = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000);
|
||||
const week = Math.ceil((days + startOfYear.getDay() + 1) / 7);
|
||||
return `${year}-W${String(week).padStart(2, "0")}`;
|
||||
// ISO 8601 week number
|
||||
const d = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
||||
}
|
||||
case "biweekly": {
|
||||
const d = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
const biweek = Math.ceil(week / 2);
|
||||
return `${d.getUTCFullYear()}-BW${String(biweek).padStart(2, "0")}`;
|
||||
}
|
||||
case "quarterly": {
|
||||
const q = Math.ceil((now.getMonth() + 1) / 3);
|
||||
return `${year}-Q${q}`;
|
||||
}
|
||||
case "yearly":
|
||||
return `${year}`;
|
||||
case "per_release":
|
||||
return "per_release";
|
||||
case "never":
|
||||
return "lifetime";
|
||||
case "monthly":
|
||||
@@ -28,17 +36,25 @@ export function getCurrentPeriod(resetSchedule: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRemainingBudget(userId: string, boardId: string): Promise<number> {
|
||||
const board = await prisma.board.findUnique({ where: { id: boardId } });
|
||||
export async function getRemainingBudget(userId: string, boardId: string, db: any = prisma): Promise<number> {
|
||||
const board = await db.board.findUnique({ where: { id: boardId } });
|
||||
if (!board) return 0;
|
||||
|
||||
if (board.voteBudgetReset === "never" && board.voteBudget === 0) {
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
const period = getCurrentPeriod(board.voteBudgetReset);
|
||||
// per_release uses the timestamp of the last manual reset as the period key
|
||||
let period: string;
|
||||
if (board.voteBudgetReset === "per_release") {
|
||||
period = board.lastBudgetReset
|
||||
? `release-${board.lastBudgetReset.toISOString()}`
|
||||
: "release-initial";
|
||||
} else {
|
||||
period = getCurrentPeriod(board.voteBudgetReset);
|
||||
}
|
||||
|
||||
const used = await prisma.vote.aggregate({
|
||||
const used = await db.vote.aggregate({
|
||||
where: { voterId: userId, post: { boardId }, budgetPeriod: period },
|
||||
_sum: { weight: true },
|
||||
});
|
||||
@@ -53,7 +69,16 @@ export function getNextResetDate(resetSchedule: string): Date {
|
||||
switch (resetSchedule) {
|
||||
case "weekly": {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() + (7 - d.getDay()));
|
||||
const daysUntilMonday = ((8 - d.getDay()) % 7) || 7;
|
||||
d.setDate(d.getDate() + daysUntilMonday);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
case "biweekly": {
|
||||
const d = new Date(now);
|
||||
const dayOfWeek = d.getDay();
|
||||
const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
|
||||
d.setDate(d.getDate() + daysUntilMonday + 7);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
@@ -61,10 +86,11 @@ export function getNextResetDate(resetSchedule: string): Date {
|
||||
const q = Math.ceil((now.getMonth() + 1) / 3);
|
||||
return new Date(now.getFullYear(), q * 3, 1);
|
||||
}
|
||||
case "yearly":
|
||||
return new Date(now.getFullYear() + 1, 0, 1);
|
||||
case "per_release":
|
||||
// manual reset only - no automatic next date
|
||||
return new Date(8640000000000000);
|
||||
case "never":
|
||||
return new Date(8640000000000000); // max date
|
||||
return new Date(8640000000000000);
|
||||
case "monthly":
|
||||
default: {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
|
||||
158
packages/api/src/lib/default-templates.ts
Normal file
158
packages/api/src/lib/default-templates.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
interface TemplateField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "textarea" | "select";
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
interface TemplateDef {
|
||||
name: string;
|
||||
fields: TemplateField[];
|
||||
isDefault: boolean;
|
||||
position: number;
|
||||
}
|
||||
|
||||
const BUG_TEMPLATES: TemplateDef[] = [
|
||||
{
|
||||
name: "Bug Report",
|
||||
isDefault: true,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ key: "steps_to_reproduce", label: "Steps to reproduce", type: "textarea", required: true, placeholder: "1. Go to...\n2. Click on...\n3. See error" },
|
||||
{ key: "expected_behavior", label: "Expected behavior", type: "textarea", required: true, placeholder: "What should have happened?" },
|
||||
{ key: "actual_behavior", label: "Actual behavior", type: "textarea", required: true, placeholder: "What happened instead?" },
|
||||
{ key: "environment", label: "Environment", type: "text", required: false, placeholder: "e.g. Chrome 120, Windows 11" },
|
||||
{ key: "severity", label: "Severity", type: "select", required: true, options: ["Critical", "Major", "Minor", "Cosmetic"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "UI/Visual Bug",
|
||||
isDefault: false,
|
||||
position: 1,
|
||||
fields: [
|
||||
{ key: "what_looks_wrong", label: "What looks wrong", type: "textarea", required: true, placeholder: "Describe the visual issue" },
|
||||
{ key: "where_in_app", label: "Where in the app", type: "text", required: true, placeholder: "Page or screen name" },
|
||||
{ key: "browser_device", label: "Browser and device", type: "text", required: true, placeholder: "e.g. Safari on iPhone 15" },
|
||||
{ key: "screen_size", label: "Screen size", type: "text", required: false, placeholder: "e.g. 1920x1080 or mobile" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Performance Issue",
|
||||
isDefault: false,
|
||||
position: 2,
|
||||
fields: [
|
||||
{ key: "whats_slow", label: "What's slow or laggy", type: "textarea", required: true },
|
||||
{ key: "when_it_happens", label: "When does it happen", type: "textarea", required: true, placeholder: "Always, sometimes, under specific conditions..." },
|
||||
{ key: "how_long", label: "How long does it take", type: "text", required: false, placeholder: "e.g. 10+ seconds to load" },
|
||||
{ key: "device_network", label: "Device and network", type: "text", required: false, placeholder: "e.g. MacBook Pro, WiFi" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Crash/Error Report",
|
||||
isDefault: false,
|
||||
position: 3,
|
||||
fields: [
|
||||
{ key: "what_happened", label: "What happened", type: "textarea", required: true },
|
||||
{ key: "error_message", label: "Error message", type: "textarea", required: false, placeholder: "Copy the error text if visible" },
|
||||
{ key: "steps_before_crash", label: "Steps before the crash", type: "textarea", required: true },
|
||||
{ key: "frequency", label: "How often does it happen", type: "select", required: true, options: ["Every time", "Often", "Sometimes", "Once"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Data Issue",
|
||||
isDefault: false,
|
||||
position: 4,
|
||||
fields: [
|
||||
{ key: "what_data_is_wrong", label: "What data is wrong", type: "textarea", required: true },
|
||||
{ key: "where_you_see_it", label: "Where do you see it", type: "text", required: true, placeholder: "Page, section, or API endpoint" },
|
||||
{ key: "what_it_should_be", label: "What it should be", type: "textarea", required: true },
|
||||
{ key: "impact", label: "Impact", type: "select", required: true, options: ["Blocking", "Major", "Minor"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const FEATURE_TEMPLATES: TemplateDef[] = [
|
||||
{
|
||||
name: "Feature Request",
|
||||
isDefault: false,
|
||||
position: 5,
|
||||
fields: [
|
||||
{ key: "use_case", label: "Use case", type: "textarea", required: true, placeholder: "What problem would this solve?" },
|
||||
{ key: "proposed_solution", label: "Proposed solution", type: "textarea", required: true, placeholder: "How do you imagine this working?" },
|
||||
{ key: "alternatives", label: "Alternatives considered", type: "textarea", required: false },
|
||||
{ key: "who_benefits", label: "Who benefits", type: "text", required: false, placeholder: "e.g. All users, admins, new users" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "UX Improvement",
|
||||
isDefault: false,
|
||||
position: 6,
|
||||
fields: [
|
||||
{ key: "whats_confusing", label: "What's confusing or difficult", type: "textarea", required: true },
|
||||
{ key: "how_should_it_work", label: "How should it work instead", type: "textarea", required: true },
|
||||
{ key: "who_runs_into_this", label: "Who runs into this", type: "text", required: false, placeholder: "e.g. New users, power users" },
|
||||
{ key: "frequency", label: "How often does this come up", type: "select", required: false, options: ["Daily", "Weekly", "Occasionally", "Rarely"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Integration Request",
|
||||
isDefault: false,
|
||||
position: 7,
|
||||
fields: [
|
||||
{ key: "tool_or_service", label: "Tool or service", type: "text", required: true, placeholder: "e.g. Slack, Jira, GitHub" },
|
||||
{ key: "what_it_should_do", label: "What it should do", type: "textarea", required: true, placeholder: "Describe the integration behavior" },
|
||||
{ key: "current_workaround", label: "Current workaround", type: "textarea", required: false },
|
||||
{ key: "priority", label: "Priority", type: "select", required: true, options: ["Must have", "Should have", "Nice to have"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Content Change",
|
||||
isDefault: false,
|
||||
position: 8,
|
||||
fields: [
|
||||
{ key: "where", label: "Where is it", type: "text", required: true, placeholder: "Page, section, or URL" },
|
||||
{ key: "current_text", label: "Current text", type: "textarea", required: true },
|
||||
{ key: "suggested_text", label: "Suggested text", type: "textarea", required: true },
|
||||
{ key: "why_change", label: "Why change it", type: "textarea", required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Workflow Improvement",
|
||||
isDefault: false,
|
||||
position: 9,
|
||||
fields: [
|
||||
{ key: "current_workflow", label: "Current workflow", type: "textarea", required: true, placeholder: "Describe the steps you take today" },
|
||||
{ key: "pain_point", label: "Pain point", type: "textarea", required: true, placeholder: "What's slow, repetitive, or error-prone?" },
|
||||
{ key: "ideal_workflow", label: "Ideal workflow", type: "textarea", required: true, placeholder: "How should it work?" },
|
||||
{ key: "impact", label: "Impact", type: "select", required: false, options: ["Saves significant time", "Reduces errors", "Improves quality", "Minor convenience"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_TEMPLATES = [...BUG_TEMPLATES, ...FEATURE_TEMPLATES];
|
||||
|
||||
export async function seedTemplatesForBoard(prisma: PrismaClient, boardId: string) {
|
||||
const existing = await prisma.boardTemplate.count({ where: { boardId } });
|
||||
if (existing > 0) return;
|
||||
|
||||
await prisma.boardTemplate.createMany({
|
||||
data: DEFAULT_TEMPLATES.map((t) => ({
|
||||
boardId,
|
||||
name: t.name,
|
||||
fields: t.fields as any,
|
||||
isDefault: t.isDefault,
|
||||
position: t.position,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function seedAllBoardTemplates(prisma: PrismaClient) {
|
||||
const boards = await prisma.board.findMany({ select: { id: true } });
|
||||
for (const board of boards) {
|
||||
await seedTemplatesForBoard(prisma, board.id);
|
||||
}
|
||||
}
|
||||
5
packages/api/src/lib/prisma.ts
Normal file
5
packages/api/src/lib/prisma.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
|
||||
export default prisma;
|
||||
30
packages/api/src/lib/token-blocklist.ts
Normal file
30
packages/api/src/lib/token-blocklist.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import crypto from "crypto";
|
||||
import prisma from "./prisma.js";
|
||||
|
||||
function hashForBlock(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export async function blockToken(token: string, ttlMs: number = 25 * 60 * 60 * 1000): Promise<void> {
|
||||
const tokenHash = hashForBlock(token);
|
||||
const expiresAt = new Date(Date.now() + ttlMs);
|
||||
await prisma.blockedToken.upsert({
|
||||
where: { tokenHash },
|
||||
create: { tokenHash, expiresAt },
|
||||
update: { expiresAt },
|
||||
});
|
||||
}
|
||||
|
||||
export async function isTokenBlocked(token: string): Promise<boolean> {
|
||||
const tokenHash = hashForBlock(token);
|
||||
const entry = await prisma.blockedToken.findUnique({ where: { tokenHash } });
|
||||
if (!entry) return false;
|
||||
return entry.expiresAt > new Date();
|
||||
}
|
||||
|
||||
export async function cleanupExpiredTokens(): Promise<number> {
|
||||
const result = await prisma.blockedToken.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
25
packages/api/src/lib/view-tracker.ts
Normal file
25
packages/api/src/lib/view-tracker.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
const MAX_ENTRIES = 50000;
|
||||
const seen = new Map<string, number>();
|
||||
|
||||
export function shouldCount(postId: string, identifier: string): boolean {
|
||||
const key = `${postId}:${identifier}`;
|
||||
const expiry = seen.get(key);
|
||||
if (expiry && expiry > Date.now()) return false;
|
||||
if (seen.size >= MAX_ENTRIES) {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of seen) {
|
||||
if (v < now) seen.delete(k);
|
||||
if (seen.size < MAX_ENTRIES * 0.8) break;
|
||||
}
|
||||
if (seen.size >= MAX_ENTRIES) return false;
|
||||
}
|
||||
seen.set(key, Date.now() + 15 * 60 * 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function cleanupViews(): void {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of seen) {
|
||||
if (v < now) seen.delete(k);
|
||||
}
|
||||
}
|
||||
46
packages/api/src/lib/wordlist.ts
Normal file
46
packages/api/src/lib/wordlist.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
|
||||
// 256 common, unambiguous English words (4-7 letters)
|
||||
// 256^6 = ~2.8 * 10^14 combinations - strong enough with rate limiting + ALTCHA
|
||||
const WORDS = [
|
||||
"acre", "alps", "arch", "army", "atom", "aura", "axis", "balm",
|
||||
"band", "bark", "barn", "base", "bath", "beam", "bell", "belt",
|
||||
"bend", "bird", "bite", "blow", "blur", "boat", "bold", "bolt",
|
||||
"bond", "bone", "book", "bore", "boss", "bowl", "brim", "bulb",
|
||||
"bulk", "burn", "bush", "buzz", "cafe", "cage", "calm", "camp",
|
||||
"cape", "card", "cart", "case", "cast", "cave", "cell", "chat",
|
||||
"chip", "city", "clam", "clan", "claw", "clay", "clip", "club",
|
||||
"clue", "coal", "coat", "code", "coil", "coin", "cold", "cone",
|
||||
"cook", "cool", "cope", "cord", "core", "cork", "corn", "cost",
|
||||
"cove", "crew", "crop", "crow", "cube", "curb", "cure", "curl",
|
||||
"dale", "dare", "dart", "dash", "dawn", "deck", "deer", "deli",
|
||||
"demo", "dent", "desk", "dime", "disc", "dock", "dome", "door",
|
||||
"dose", "dove", "draw", "drum", "dune", "dusk", "dust", "edge",
|
||||
"emit", "epic", "exit", "face", "fact", "fame", "fawn", "felt",
|
||||
"fern", "film", "find", "fire", "firm", "fish", "fist", "flag",
|
||||
"flak", "flaw", "flex", "flip", "flow", "flux", "foam", "foil",
|
||||
"fold", "folk", "font", "ford", "fork", "form", "fort", "frog",
|
||||
"fuel", "fume", "fund", "fury", "fuse", "gale", "game", "gang",
|
||||
"gate", "gaze", "gear", "germ", "gift", "gist", "glen", "glow",
|
||||
"glue", "gold", "golf", "gong", "grab", "gram", "grid", "grin",
|
||||
"grip", "grit", "gulf", "gust", "hail", "half", "hall", "halt",
|
||||
"hare", "harp", "hawk", "haze", "heap", "helm", "herb", "herd",
|
||||
"hero", "hike", "hill", "hint", "hive", "hold", "hole", "hood",
|
||||
"hook", "hope", "horn", "host", "howl", "hull", "hunt", "husk",
|
||||
"iris", "iron", "isle", "jade", "jazz", "jest", "jolt", "jump",
|
||||
"jury", "keen", "kelp", "kite", "knob", "knot", "lace", "lake",
|
||||
"lamb", "lamp", "lane", "lark", "lava", "lawn", "lead", "leaf",
|
||||
"lens", "lien", "lime", "line", "link", "lion", "lock", "loft",
|
||||
"loop", "loom", "lore", "luck", "lure", "lynx", "malt", "mane",
|
||||
"maze", "mesa", "mild", "mill", "mine", "mint", "mist", "moat",
|
||||
"mode", "mold", "monk", "moon", "moor", "moss", "myth", "navy",
|
||||
"nest", "node", "norm", "note", "nova", "oaks", "opal", "orca",
|
||||
];
|
||||
|
||||
export function generateRecoveryPhrase(): string {
|
||||
const words: string[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
words.push(WORDS[randomInt(WORDS.length)]);
|
||||
}
|
||||
return words.join("-");
|
||||
}
|
||||
@@ -1,27 +1,33 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { PrismaClient, User } from "@prisma/client";
|
||||
import type { User } from "@prisma/client";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { hashToken } from "../services/encryption.js";
|
||||
import { config } from "../config.js";
|
||||
import { isTokenBlocked } from "../lib/token-blocklist.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
user?: User;
|
||||
adminId?: string;
|
||||
adminRole?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function authPlugin(app: FastifyInstance) {
|
||||
app.decorateRequest("user", undefined);
|
||||
app.decorateRequest("adminId", undefined);
|
||||
app.decorateRequest("adminRole", undefined);
|
||||
|
||||
app.decorate("requireUser", async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
// try cookie auth first
|
||||
const token = req.cookies?.echoboard_token;
|
||||
if (token) {
|
||||
if (await isTokenBlocked(token)) {
|
||||
reply.status(401).send({ error: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
const hash = hashToken(token);
|
||||
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
|
||||
if (user) {
|
||||
@@ -30,11 +36,15 @@ async function authPlugin(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
// try bearer token (passkey sessions)
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
// try passkey session cookie, then fall back to Authorization header
|
||||
const passkeyToken = req.cookies?.echoboard_passkey
|
||||
|| (req.headers.authorization?.startsWith("Bearer ")
|
||||
? req.headers.authorization.slice(7)
|
||||
: null);
|
||||
|
||||
if (passkeyToken && !(await isTokenBlocked(passkeyToken))) {
|
||||
try {
|
||||
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
|
||||
const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string };
|
||||
if (decoded.type === "passkey") {
|
||||
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
|
||||
if (user) {
|
||||
@@ -53,15 +63,23 @@ async function authPlugin(app: FastifyInstance) {
|
||||
app.decorate("optionalUser", async (req: FastifyRequest) => {
|
||||
const token = req.cookies?.echoboard_token;
|
||||
if (token) {
|
||||
if (await isTokenBlocked(token)) return;
|
||||
const hash = hashToken(token);
|
||||
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
|
||||
if (user) req.user = user;
|
||||
return;
|
||||
if (user) {
|
||||
req.user = user;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
|
||||
const passkeyToken = req.cookies?.echoboard_passkey
|
||||
|| (req.headers.authorization?.startsWith("Bearer ")
|
||||
? req.headers.authorization.slice(7)
|
||||
: null);
|
||||
|
||||
if (passkeyToken && !(await isTokenBlocked(passkeyToken))) {
|
||||
try {
|
||||
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
|
||||
const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string };
|
||||
if (decoded.type === "passkey") {
|
||||
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
|
||||
if (user) req.user = user;
|
||||
@@ -72,22 +90,103 @@ async function authPlugin(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.decorate("requireAdmin", async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
reply.status(401).send({ error: "Admin token required" });
|
||||
return;
|
||||
app.decorate("optionalAdmin", async (req: FastifyRequest) => {
|
||||
const token = req.cookies?.echoboard_admin;
|
||||
if (token && !(await isTokenBlocked(token))) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:admin', issuer: 'echoboard' }) as { sub: string; type: string };
|
||||
if (decoded.type === "admin") {
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: decoded.sub }, select: { id: true, role: true } });
|
||||
if (admin) {
|
||||
req.adminId = decoded.sub;
|
||||
req.adminRole = admin.role;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const decoded = jwt.verify(authHeader.slice(7), config.JWT_SECRET) as { sub: string; type: string };
|
||||
if (decoded.type !== "admin") {
|
||||
reply.status(403).send({ error: "Admin access required" });
|
||||
// fallback: check if authenticated user is a linked team member
|
||||
if (req.user) {
|
||||
const admin = await prisma.adminUser.findUnique({
|
||||
where: { linkedUserId: req.user.id },
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
if (admin) {
|
||||
req.adminId = admin.id;
|
||||
req.adminRole = admin.role;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.decorate("requireAdmin", async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
// try admin JWT cookie first (super admin login flow)
|
||||
const token = req.cookies?.echoboard_admin ?? null;
|
||||
if (token && !(await isTokenBlocked(token))) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:admin', issuer: 'echoboard' }) as { sub: string; type: string };
|
||||
if (decoded.type === "admin") {
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: decoded.sub }, select: { id: true, role: true } });
|
||||
if (admin) {
|
||||
req.adminId = admin.id;
|
||||
req.adminRole = admin.role;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// fallback: check if user is authenticated via cookie and linked to an admin
|
||||
const cookieToken = req.cookies?.echoboard_token;
|
||||
if (cookieToken && !(await isTokenBlocked(cookieToken))) {
|
||||
const hash = hashToken(cookieToken);
|
||||
const user = await prisma.user.findUnique({ where: { tokenHash: hash } });
|
||||
if (user) {
|
||||
const admin = await prisma.adminUser.findUnique({
|
||||
where: { linkedUserId: user.id },
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
if (admin) {
|
||||
req.user = user;
|
||||
req.adminId = admin.id;
|
||||
req.adminRole = admin.role;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// try passkey token
|
||||
const passkeyToken = req.cookies?.echoboard_passkey
|
||||
|| (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
|
||||
if (passkeyToken && !(await isTokenBlocked(passkeyToken))) {
|
||||
try {
|
||||
const decoded = jwt.verify(passkeyToken, config.JWT_SECRET, { algorithms: ['HS256'], audience: 'echoboard:user', issuer: 'echoboard' }) as { sub: string; type: string };
|
||||
if (decoded.type === "passkey") {
|
||||
const user = await prisma.user.findUnique({ where: { id: decoded.sub } });
|
||||
if (user) {
|
||||
const admin = await prisma.adminUser.findUnique({
|
||||
where: { linkedUserId: user.id },
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
if (admin) {
|
||||
req.user = user;
|
||||
req.adminId = admin.id;
|
||||
req.adminRole = admin.role;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
reply.status(401).send({ error: "Unauthorized" });
|
||||
});
|
||||
app.decorate("requireRole", (...roles: string[]) => {
|
||||
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!req.adminId || !req.adminRole || !roles.includes(req.adminRole)) {
|
||||
reply.status(403).send({ error: "Insufficient permissions" });
|
||||
return;
|
||||
}
|
||||
req.adminId = decoded.sub;
|
||||
} catch {
|
||||
reply.status(401).send({ error: "Invalid admin token" });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,7 +194,9 @@ declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
requireUser: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
optionalUser: (req: FastifyRequest) => Promise<void>;
|
||||
optionalAdmin: (req: FastifyRequest) => Promise<void>;
|
||||
requireAdmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
requireRole: (...roles: string[]) => (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,26 +2,51 @@ import { FastifyInstance } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
|
||||
async function securityPlugin(app: FastifyInstance) {
|
||||
app.addHook("onSend", async (_req, reply) => {
|
||||
reply.header("Content-Security-Policy", [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; "));
|
||||
app.addHook("onSend", async (req, reply) => {
|
||||
const isEmbed = req.url.startsWith("/api/v1/embed/") || req.url.startsWith("/embed/");
|
||||
|
||||
if (isEmbed) {
|
||||
// embed routes need to be frameable by third-party sites
|
||||
reply.header("Content-Security-Policy", [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"frame-src 'none'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors *",
|
||||
"base-uri 'self'",
|
||||
"form-action 'none'",
|
||||
].join("; "));
|
||||
reply.header("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
reply.header("Access-Control-Allow-Origin", "*");
|
||||
reply.header("Access-Control-Allow-Credentials", "false");
|
||||
} else {
|
||||
reply.header("Content-Security-Policy", [
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"frame-src 'none'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; "));
|
||||
reply.header("X-Frame-Options", "DENY");
|
||||
reply.header("Cross-Origin-Opener-Policy", "same-origin");
|
||||
reply.header("Cross-Origin-Resource-Policy", "same-origin");
|
||||
}
|
||||
|
||||
reply.header("Referrer-Policy", "no-referrer");
|
||||
reply.header("X-Content-Type-Options", "nosniff");
|
||||
reply.header("X-Frame-Options", "DENY");
|
||||
reply.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
reply.header("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
|
||||
reply.header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()");
|
||||
reply.header("X-DNS-Prefetch-Control", "off");
|
||||
reply.header("Cross-Origin-Opener-Policy", "same-origin");
|
||||
reply.header("Cross-Origin-Resource-Policy", "same-origin");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,46 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { existsSync } from "node:fs";
|
||||
import { PluginManifest, EchoboardPlugin } from "./types.js";
|
||||
|
||||
const loadedPlugins: EchoboardPlugin[] = [];
|
||||
|
||||
export async function loadPlugins(app: FastifyInstance) {
|
||||
const manifestPath = resolve(process.cwd(), "echoboard.plugins.json");
|
||||
// try dynamic import of echoboard.plugins.ts (compiled to .js in production)
|
||||
const tsPath = resolve(process.cwd(), "echoboard.plugins.js");
|
||||
const tsSourcePath = resolve(process.cwd(), "echoboard.plugins.ts");
|
||||
|
||||
// The plugin directory must be write-protected in production to prevent
|
||||
// unauthorized code from being loaded via this path.
|
||||
if (existsSync(tsPath) || existsSync(tsSourcePath)) {
|
||||
try {
|
||||
const modPath = existsSync(tsPath) ? tsPath : tsSourcePath;
|
||||
console.warn(`[plugins] loading plugin file: ${modPath}`);
|
||||
const mod = await import(pathToFileURL(modPath).href);
|
||||
const plugins: EchoboardPlugin[] = mod.plugins ?? mod.default?.plugins ?? [];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
app.log.info(`Loading plugin: ${plugin.name} v${plugin.version}`);
|
||||
await plugin.onRegister(app, {});
|
||||
loadedPlugins.push(plugin);
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
app.log.error(`Failed to load plugins from echoboard.plugins: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// fallback: JSON manifest
|
||||
const jsonPath = resolve(process.cwd(), "echoboard.plugins.json");
|
||||
let manifest: PluginManifest;
|
||||
|
||||
try {
|
||||
const raw = await readFile(manifestPath, "utf-8");
|
||||
const raw = await readFile(jsonPath, "utf-8");
|
||||
manifest = JSON.parse(raw);
|
||||
} catch {
|
||||
app.log.info("No plugin manifest found, skipping plugin loading");
|
||||
app.log.info("No plugin manifest found, running without plugins");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,13 +49,72 @@ export async function loadPlugins(app: FastifyInstance) {
|
||||
for (const entry of manifest.plugins) {
|
||||
if (!entry.enabled) continue;
|
||||
|
||||
// reject package names that look like paths or URLs to prevent arbitrary code loading
|
||||
if (/[\/\\]|^\./.test(entry.name) || /^https?:/.test(entry.name)) {
|
||||
app.log.error(`Skipping plugin "${entry.name}": paths and URLs not allowed in JSON manifest`);
|
||||
continue;
|
||||
}
|
||||
// only allow scoped or plain npm package names
|
||||
if (!/^(@[a-z0-9-]+\/)?[a-z0-9-]+$/.test(entry.name)) {
|
||||
app.log.error(`Skipping plugin "${entry.name}": invalid package name`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = await import(entry.name) as { default: EchoboardPlugin };
|
||||
const plugin = mod.default;
|
||||
app.log.info(`Loading plugin: ${plugin.name} v${plugin.version}`);
|
||||
await plugin.register(app, entry.config ?? {});
|
||||
await plugin.onRegister(app, entry.config ?? {});
|
||||
loadedPlugins.push(plugin);
|
||||
} catch (err) {
|
||||
app.log.error(`Failed to load plugin ${entry.name}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function startupPlugins() {
|
||||
for (const plugin of loadedPlugins) {
|
||||
if (plugin.onStartup) {
|
||||
await plugin.onStartup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function shutdownPlugins() {
|
||||
for (const plugin of loadedPlugins) {
|
||||
if (plugin.onShutdown) {
|
||||
await plugin.onShutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getPluginCronJobs() {
|
||||
return loadedPlugins.flatMap((p) => p.getCronJobs?.() ?? []);
|
||||
}
|
||||
|
||||
export function getPluginAdminRoutes() {
|
||||
return loadedPlugins.flatMap((p) => p.getAdminRoutes?.() ?? []);
|
||||
}
|
||||
|
||||
export function getPluginComponents() {
|
||||
const merged: Record<string, (() => unknown)[]> = {};
|
||||
for (const plugin of loadedPlugins) {
|
||||
const comps = plugin.getFrontendComponents?.();
|
||||
if (!comps) continue;
|
||||
for (const [slot, comp] of Object.entries(comps)) {
|
||||
if (!merged[slot]) merged[slot] = [];
|
||||
merged[slot].push(comp);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function getActivePluginInfo() {
|
||||
return loadedPlugins.map((p) => ({
|
||||
name: p.name,
|
||||
version: p.version,
|
||||
adminRoutes: p.getAdminRoutes?.() ?? [],
|
||||
slots: Object.keys(p.getFrontendComponents?.() ?? {}),
|
||||
hasBoardSource: !!p.getBoardSource,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,58 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
export interface AdminRoute {
|
||||
path: string;
|
||||
label: string;
|
||||
component: string;
|
||||
}
|
||||
|
||||
export interface CronJobDefinition {
|
||||
name: string;
|
||||
schedule: string;
|
||||
handler: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface BoardSource {
|
||||
name: string;
|
||||
fetchBoards: () => Promise<{ slug: string; name: string; description?: string; externalUrl?: string }[]>;
|
||||
}
|
||||
|
||||
export interface ComponentMap {
|
||||
[slotName: string]: () => unknown;
|
||||
}
|
||||
|
||||
export interface PrismaMigration {
|
||||
name: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export interface PrismaModelDefinition {
|
||||
name: string;
|
||||
schema: string;
|
||||
}
|
||||
|
||||
export interface EchoboardPlugin {
|
||||
name: string;
|
||||
version: string;
|
||||
register: (app: FastifyInstance, config: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
// Lifecycle
|
||||
onRegister(app: FastifyInstance, config: Record<string, unknown>): void | Promise<void>;
|
||||
onStartup?(): Promise<void>;
|
||||
onShutdown?(): Promise<void>;
|
||||
|
||||
// Database
|
||||
getMigrations?(): PrismaMigration[];
|
||||
getModels?(): PrismaModelDefinition[];
|
||||
|
||||
// UI
|
||||
getAdminRoutes?(): AdminRoute[];
|
||||
getFrontendComponents?(): ComponentMap;
|
||||
|
||||
// Scheduled tasks
|
||||
getCronJobs?(): CronJobDefinition[];
|
||||
|
||||
// Board creation
|
||||
getBoardSource?(): BoardSource;
|
||||
}
|
||||
|
||||
export interface PluginConfig {
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient, Prisma } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const VALID_EVENT_TYPES = [
|
||||
"post_created", "admin_responded", "comment_created", "comment_edited",
|
||||
"vote_cast", "vote_milestone", "status_changed", "post_deleted",
|
||||
] as const;
|
||||
|
||||
const querySchema = z.object({
|
||||
board: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
page: z.coerce.number().min(1).default(1),
|
||||
limit: z.coerce.number().min(1).max(100).default(30),
|
||||
type: z.enum(VALID_EVENT_TYPES).optional(),
|
||||
page: z.coerce.number().int().min(1).max(500).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(30),
|
||||
});
|
||||
|
||||
export default async function activityRoutes(app: FastifyInstance) {
|
||||
app.get<{ Querystring: Record<string, string> }>(
|
||||
"/activity",
|
||||
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const q = querySchema.parse(req.query);
|
||||
|
||||
|
||||
@@ -1,42 +1,156 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { z } from "zod";
|
||||
import { config } from "../../config.js";
|
||||
import { encrypt, decrypt } from "../../services/encryption.js";
|
||||
import { masterKey } from "../../config.js";
|
||||
import { blockToken } from "../../lib/token-blocklist.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const DUMMY_HASH = bcrypt.hashSync("__timing_safe__", 12);
|
||||
const failedAttempts = new Map<string, { count: number; lastAttempt: number }>();
|
||||
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - 15 * 60 * 1000;
|
||||
for (const [k, v] of failedAttempts) {
|
||||
if (v.lastAttempt < cutoff) failedAttempts.delete(k);
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
const loginBody = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
async function ensureLinkedUser(adminId: string): Promise<string> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const admin = await tx.adminUser.findUnique({ where: { id: adminId } });
|
||||
if (admin?.linkedUserId) return admin.linkedUserId;
|
||||
|
||||
const displayName = encrypt("Admin", masterKey);
|
||||
const user = await tx.user.create({
|
||||
data: { displayName, authMethod: "COOKIE" },
|
||||
});
|
||||
await tx.adminUser.update({
|
||||
where: { id: adminId },
|
||||
data: { linkedUserId: user.id },
|
||||
});
|
||||
return user.id;
|
||||
}, { isolationLevel: "Serializable" });
|
||||
}
|
||||
|
||||
export default async function adminAuthRoutes(app: FastifyInstance) {
|
||||
app.post<{ Body: z.infer<typeof loginBody> }>(
|
||||
"/admin/login",
|
||||
{ config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } },
|
||||
async (req, reply) => {
|
||||
const body = loginBody.parse(req.body);
|
||||
|
||||
const admin = await prisma.adminUser.findUnique({ where: { email: body.email } });
|
||||
if (!admin) {
|
||||
const valid = await bcrypt.compare(body.password, admin?.passwordHash ?? DUMMY_HASH);
|
||||
|
||||
if (!admin || !valid) {
|
||||
const bruteKey = `${req.ip}:${body.email.toLowerCase()}`;
|
||||
if (!failedAttempts.has(bruteKey) && failedAttempts.size > 10000) {
|
||||
const sorted = [...failedAttempts.entries()].sort((a, b) => a[1].lastAttempt - b[1].lastAttempt);
|
||||
for (let i = 0; i < sorted.length / 2; i++) failedAttempts.delete(sorted[i][0]);
|
||||
}
|
||||
const entry = failedAttempts.get(bruteKey) || { count: 0, lastAttempt: 0 };
|
||||
entry.count++;
|
||||
entry.lastAttempt = Date.now();
|
||||
failedAttempts.set(bruteKey, entry);
|
||||
const delay = Math.min(100 * Math.pow(2, entry.count - 1), 10000);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
reply.status(401).send({ error: "Invalid credentials" });
|
||||
return;
|
||||
}
|
||||
failedAttempts.delete(`${req.ip}:${body.email.toLowerCase()}`);
|
||||
|
||||
const valid = await bcrypt.compare(body.password, admin.passwordHash);
|
||||
if (!valid) {
|
||||
reply.status(401).send({ error: "Invalid credentials" });
|
||||
return;
|
||||
// only auto-upgrade CLI-created admins (have email, not invited)
|
||||
if (admin.role !== "SUPER_ADMIN" && admin.email && !admin.invitedById) {
|
||||
await prisma.adminUser.update({ where: { id: admin.id }, data: { role: "SUPER_ADMIN" } });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ sub: admin.id, type: "admin" },
|
||||
const linkedUserId = await ensureLinkedUser(admin.id);
|
||||
|
||||
const adminToken = jwt.sign(
|
||||
{ sub: admin.id, type: "admin", aud: "echoboard:admin", iss: "echoboard" },
|
||||
config.JWT_SECRET,
|
||||
{ expiresIn: "24h" }
|
||||
{ expiresIn: "4h" }
|
||||
);
|
||||
|
||||
reply.send({ token });
|
||||
const userToken = jwt.sign(
|
||||
{ sub: linkedUserId, type: "passkey", aud: "echoboard:user", iss: "echoboard" },
|
||||
config.JWT_SECRET,
|
||||
{ expiresIn: "4h" }
|
||||
);
|
||||
|
||||
reply
|
||||
.setCookie("echoboard_admin", adminToken, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 4,
|
||||
})
|
||||
.setCookie("echoboard_passkey", userToken, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 4,
|
||||
})
|
||||
.send({ ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/admin/me",
|
||||
{ preHandler: [app.optionalUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
if (!req.adminId) {
|
||||
reply.send({ isAdmin: false });
|
||||
return;
|
||||
}
|
||||
const admin = await prisma.adminUser.findUnique({
|
||||
where: { id: req.adminId },
|
||||
select: { role: true, displayName: true, teamTitle: true },
|
||||
});
|
||||
if (!admin) {
|
||||
reply.send({ isAdmin: false });
|
||||
return;
|
||||
}
|
||||
reply.send({
|
||||
isAdmin: true,
|
||||
role: admin.role,
|
||||
displayName: admin.displayName ? decrypt(admin.displayName, masterKey) : null,
|
||||
teamTitle: admin.teamTitle ? decrypt(admin.teamTitle, masterKey) : null,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.post("/admin/exit", { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => {
|
||||
const adminToken = req.cookies?.echoboard_admin;
|
||||
const passkeyToken = req.cookies?.echoboard_passkey;
|
||||
if (adminToken) await blockToken(adminToken);
|
||||
if (passkeyToken) await blockToken(passkeyToken);
|
||||
reply
|
||||
.clearCookie("echoboard_admin", { path: "/" })
|
||||
.clearCookie("echoboard_passkey", { path: "/" })
|
||||
.clearCookie("echoboard_token", { path: "/" })
|
||||
.send({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/admin/logout", { preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => {
|
||||
const adminToken = req.cookies?.echoboard_admin;
|
||||
const passkeyToken = req.cookies?.echoboard_passkey;
|
||||
if (adminToken) await blockToken(adminToken);
|
||||
if (passkeyToken) await blockToken(passkeyToken);
|
||||
reply
|
||||
.clearCookie("echoboard_admin", { path: "/" })
|
||||
.clearCookie("echoboard_passkey", { path: "/" })
|
||||
.clearCookie("echoboard_token", { path: "/" })
|
||||
.send({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,33 +1,47 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { seedTemplatesForBoard } from "../../lib/default-templates.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const safeUrl = z.string().url().refine((u) => /^https?:\/\//i.test(u), { message: "URL must use http or https" });
|
||||
|
||||
const iconName = z.string().max(80).regex(/^Icon[A-Za-z0-9]+$/).optional().nullable();
|
||||
const iconColor = z.string().max(30).regex(/^#[0-9a-fA-F]{3,8}$/).optional().nullable();
|
||||
|
||||
const createBoardBody = z.object({
|
||||
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/),
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
externalUrl: z.string().url().optional(),
|
||||
externalUrl: safeUrl.optional(),
|
||||
iconName,
|
||||
iconColor,
|
||||
voteBudget: z.number().int().min(0).default(10),
|
||||
voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).default("monthly"),
|
||||
voteBudgetReset: z.enum(["weekly", "biweekly", "monthly", "quarterly", "per_release", "never"]).default("monthly"),
|
||||
allowMultiVote: z.boolean().default(false),
|
||||
rssEnabled: z.boolean().default(true),
|
||||
rssFeedCount: z.number().int().min(1).max(200).default(50),
|
||||
staleDays: z.number().int().min(0).max(365).default(0),
|
||||
});
|
||||
|
||||
const updateBoardBody = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
externalUrl: z.string().url().optional().nullable(),
|
||||
externalUrl: safeUrl.optional().nullable(),
|
||||
iconName,
|
||||
iconColor,
|
||||
isArchived: z.boolean().optional(),
|
||||
voteBudget: z.number().int().min(0).optional(),
|
||||
voteBudgetReset: z.enum(["weekly", "monthly", "quarterly", "yearly", "never"]).optional(),
|
||||
voteBudgetReset: z.enum(["weekly", "biweekly", "monthly", "quarterly", "per_release", "never"]).optional(),
|
||||
allowMultiVote: z.boolean().optional(),
|
||||
rssEnabled: z.boolean().optional(),
|
||||
rssFeedCount: z.number().int().min(1).max(200).optional(),
|
||||
staleDays: z.number().int().min(0).max(365).optional(),
|
||||
});
|
||||
|
||||
export default async function adminBoardRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/admin/boards",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (_req, reply) => {
|
||||
const boards = await prisma.board.findMany({
|
||||
orderBy: { createdAt: "asc" },
|
||||
@@ -41,7 +55,7 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post<{ Body: z.infer<typeof createBoardBody> }>(
|
||||
"/admin/boards",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = createBoardBody.parse(req.body);
|
||||
|
||||
@@ -52,13 +66,15 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const board = await prisma.board.create({ data: body });
|
||||
await seedTemplatesForBoard(prisma, board.id);
|
||||
req.log.info({ adminId: req.adminId, boardId: board.id, slug: board.slug }, "board created");
|
||||
reply.status(201).send(board);
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBoardBody> }>(
|
||||
"/admin/boards/:id",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
|
||||
if (!board) {
|
||||
@@ -72,13 +88,14 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
|
||||
data: body,
|
||||
});
|
||||
|
||||
req.log.info({ adminId: req.adminId, boardId: board.id }, "board updated");
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/admin/boards/:id/reset-budget",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
|
||||
if (!board) {
|
||||
@@ -91,21 +108,33 @@ export default async function adminBoardRoutes(app: FastifyInstance) {
|
||||
data: { lastBudgetReset: new Date() },
|
||||
});
|
||||
|
||||
req.log.info({ adminId: req.adminId, boardId: board.id }, "budget reset");
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/boards/:id",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
|
||||
const board = await prisma.board.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { _count: { select: { posts: true } } },
|
||||
});
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (board._count.posts > 0) {
|
||||
reply.status(409).send({
|
||||
error: `Board has ${board._count.posts} posts. Archive it or delete posts first.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.board.delete({ where: { id: board.id } });
|
||||
req.log.info({ adminId: req.adminId, boardId: board.id, boardSlug: board.slug }, "board deleted");
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const createCategoryBody = z.object({
|
||||
name: z.string().min(1).max(50),
|
||||
@@ -12,26 +10,27 @@ const createCategoryBody = z.object({
|
||||
export default async function adminCategoryRoutes(app: FastifyInstance) {
|
||||
app.post<{ Body: z.infer<typeof createCategoryBody> }>(
|
||||
"/admin/categories",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = createCategoryBody.parse(req.body);
|
||||
|
||||
const existing = await prisma.category.findFirst({
|
||||
where: { OR: [{ name: body.name }, { slug: body.slug }] },
|
||||
});
|
||||
if (existing) {
|
||||
reply.status(409).send({ error: "Category already exists" });
|
||||
return;
|
||||
try {
|
||||
const cat = await prisma.category.create({ data: body });
|
||||
req.log.info({ adminId: req.adminId, categoryId: cat.id, name: cat.name }, "category created");
|
||||
reply.status(201).send(cat);
|
||||
} catch (err: any) {
|
||||
if (err.code === "P2002") {
|
||||
reply.status(409).send({ error: "Category already exists" });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const cat = await prisma.category.create({ data: body });
|
||||
reply.status(201).send(cat);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/categories/:id",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const cat = await prisma.category.findUnique({ where: { id: req.params.id } });
|
||||
if (!cat) {
|
||||
@@ -40,6 +39,7 @@ export default async function adminCategoryRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
await prisma.category.delete({ where: { id: cat.id } });
|
||||
req.log.info({ adminId: req.adminId, categoryId: cat.id, name: cat.name }, "category deleted");
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
91
packages/api/src/routes/admin/changelog.ts
Normal file
91
packages/api/src/routes/admin/changelog.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const createBody = z.object({
|
||||
title: z.string().min(1).max(200).trim(),
|
||||
body: z.string().min(1).max(10000).trim(),
|
||||
boardId: z.string().optional().nullable(),
|
||||
publishedAt: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
const updateBody = z.object({
|
||||
title: z.string().min(1).max(200).trim().optional(),
|
||||
body: z.string().min(1).max(10000).trim().optional(),
|
||||
boardId: z.string().optional().nullable(),
|
||||
publishedAt: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export default async function adminChangelogRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/admin/changelog",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (_req, reply) => {
|
||||
const entries = await prisma.changelogEntry.findMany({
|
||||
include: { board: { select: { id: true, slug: true, name: true } } },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
reply.send({ entries });
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: z.infer<typeof createBody> }>(
|
||||
"/admin/changelog",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = createBody.parse(req.body);
|
||||
const entry = await prisma.changelogEntry.create({
|
||||
data: {
|
||||
title: body.title,
|
||||
body: body.body,
|
||||
boardId: body.boardId || null,
|
||||
publishedAt: body.publishedAt ?? new Date(),
|
||||
},
|
||||
});
|
||||
req.log.info({ adminId: req.adminId, entryId: entry.id }, "changelog entry created");
|
||||
reply.status(201).send(entry);
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBody> }>(
|
||||
"/admin/changelog/:id",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const entry = await prisma.changelogEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) {
|
||||
reply.status(404).send({ error: "Entry not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = updateBody.parse(req.body);
|
||||
const updated = await prisma.changelogEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
...(body.title !== undefined && { title: body.title }),
|
||||
...(body.body !== undefined && { body: body.body }),
|
||||
...(body.boardId !== undefined && { boardId: body.boardId || null }),
|
||||
...(body.publishedAt !== undefined && { publishedAt: body.publishedAt }),
|
||||
},
|
||||
});
|
||||
req.log.info({ adminId: req.adminId, entryId: entry.id }, "changelog entry updated");
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/changelog/:id",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const entry = await prisma.changelogEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) {
|
||||
reply.status(404).send({ error: "Entry not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.changelogEntry.delete({ where: { id: entry.id } });
|
||||
req.log.info({ adminId: req.adminId, entryId: entry.id }, "changelog entry deleted");
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
}
|
||||
164
packages/api/src/routes/admin/export.ts
Normal file
164
packages/api/src/routes/admin/export.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { decryptWithFallback } from "../../services/encryption.js";
|
||||
import { masterKey, previousMasterKey } from "../../config.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
function decryptName(encrypted: string | null): string {
|
||||
if (!encrypted) return "";
|
||||
try {
|
||||
return decryptWithFallback(encrypted, masterKey, previousMasterKey);
|
||||
} catch {
|
||||
return "[encrypted]";
|
||||
}
|
||||
}
|
||||
|
||||
function toCsv(headers: string[], rows: string[][]): string {
|
||||
const escape = (v: string) => {
|
||||
// prevent formula injection in spreadsheets
|
||||
let safe = v;
|
||||
if (/^[=+\-@\t\r|]/.test(safe)) {
|
||||
safe = "'" + safe;
|
||||
}
|
||||
if (safe.includes(",") || safe.includes('"') || safe.includes("\n")) {
|
||||
return '"' + safe.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return safe;
|
||||
};
|
||||
const lines = [headers.map(escape).join(",")];
|
||||
for (const row of rows) {
|
||||
lines.push(row.map((c) => escape(String(c ?? ""))).join(","));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function fetchPosts() {
|
||||
const posts = await prisma.post.findMany({
|
||||
include: { board: { select: { name: true } }, author: { select: { displayName: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5000,
|
||||
});
|
||||
return posts.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
type: p.type,
|
||||
status: p.status,
|
||||
voteCount: p.voteCount,
|
||||
board: p.board.name,
|
||||
author: decryptName(p.author.displayName),
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchVotes() {
|
||||
const votes = await prisma.vote.findMany({
|
||||
include: {
|
||||
post: { select: { title: true } },
|
||||
voter: { select: { displayName: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10000,
|
||||
});
|
||||
return votes.map((v) => ({
|
||||
postId: v.postId,
|
||||
postTitle: v.post.title,
|
||||
voter: decryptName(v.voter.displayName),
|
||||
weight: v.weight,
|
||||
importance: v.importance ?? "",
|
||||
createdAt: v.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchComments() {
|
||||
const comments = await prisma.comment.findMany({
|
||||
include: {
|
||||
post: { select: { title: true } },
|
||||
author: { select: { displayName: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10000,
|
||||
});
|
||||
return comments.map((c) => ({
|
||||
postId: c.postId,
|
||||
postTitle: c.post.title,
|
||||
body: c.body,
|
||||
author: decryptName(c.author.displayName),
|
||||
isAdmin: c.isAdmin,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
const users = await prisma.user.findMany({ orderBy: { createdAt: "desc" }, take: 5000 });
|
||||
return users.map((u) => ({
|
||||
id: u.id,
|
||||
authMethod: u.authMethod,
|
||||
displayName: decryptName(u.displayName),
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
const postHeaders = ["id", "title", "type", "status", "voteCount", "board", "author", "createdAt"];
|
||||
const voteHeaders = ["postId", "postTitle", "voter", "weight", "importance", "createdAt"];
|
||||
const commentHeaders = ["postId", "postTitle", "body", "author", "isAdmin", "createdAt"];
|
||||
const userHeaders = ["id", "authMethod", "displayName", "createdAt"];
|
||||
|
||||
function toRows(items: Record<string, unknown>[], headers: string[]): string[][] {
|
||||
return items.map((item) => headers.map((h) => String(item[h] ?? "")));
|
||||
}
|
||||
|
||||
export default async function adminExportRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/admin/export",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 2, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const { format = "json", type = "all" } = req.query as { format?: string; type?: string };
|
||||
|
||||
const validTypes = new Set(["all", "posts", "votes", "comments", "users"]);
|
||||
const validFormats = new Set(["json", "csv"]);
|
||||
if (!validTypes.has(type) || !validFormats.has(format)) {
|
||||
reply.status(400).send({ error: "Invalid export type or format" });
|
||||
return;
|
||||
}
|
||||
|
||||
req.log.info({ adminId: req.adminId, exportType: type, format }, "admin data export");
|
||||
|
||||
|
||||
const data: Record<string, unknown[]> = {};
|
||||
|
||||
if (type === "posts" || type === "all") data.posts = await fetchPosts();
|
||||
if (type === "votes" || type === "all") data.votes = await fetchVotes();
|
||||
if (type === "comments" || type === "all") data.comments = await fetchComments();
|
||||
if (type === "users" || type === "all") data.users = await fetchUsers();
|
||||
|
||||
if (format === "csv") {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (data.posts) {
|
||||
parts.push("# Posts");
|
||||
parts.push(toCsv(postHeaders, toRows(data.posts as Record<string, unknown>[], postHeaders)));
|
||||
}
|
||||
if (data.votes) {
|
||||
parts.push("# Votes");
|
||||
parts.push(toCsv(voteHeaders, toRows(data.votes as Record<string, unknown>[], voteHeaders)));
|
||||
}
|
||||
if (data.comments) {
|
||||
parts.push("# Comments");
|
||||
parts.push(toCsv(commentHeaders, toRows(data.comments as Record<string, unknown>[], commentHeaders)));
|
||||
}
|
||||
if (data.users) {
|
||||
parts.push("# Users");
|
||||
parts.push(toCsv(userHeaders, toRows(data.users as Record<string, unknown>[], userHeaders)));
|
||||
}
|
||||
|
||||
const csv = parts.join("\n\n");
|
||||
reply
|
||||
.header("Content-Type", "text/csv; charset=utf-8")
|
||||
.header("Content-Disposition", `attachment; filename="echoboard-export-${type}.csv"`)
|
||||
.send(csv);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.send(data);
|
||||
}
|
||||
);
|
||||
}
|
||||
100
packages/api/src/routes/admin/notes.ts
Normal file
100
packages/api/src/routes/admin/notes.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
function redactEmail(email: string): string {
|
||||
const [local, domain] = email.split("@");
|
||||
if (!domain) return "***";
|
||||
return local[0] + "***@" + domain;
|
||||
}
|
||||
|
||||
const createNoteBody = z.object({
|
||||
body: z.string().min(1).max(2000).trim(),
|
||||
});
|
||||
|
||||
export default async function adminNoteRoutes(app: FastifyInstance) {
|
||||
// Get notes for a post
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/admin/posts/:id/notes",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const notes = await prisma.adminNote.findMany({
|
||||
where: { postId: post.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: {
|
||||
admin: { select: { email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({
|
||||
notes: notes.map((n) => ({
|
||||
id: n.id,
|
||||
body: n.body,
|
||||
adminEmail: n.admin.email ? redactEmail(n.admin.email) : "Team member",
|
||||
createdAt: n.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Add a note to a post
|
||||
app.post<{ Params: { id: string }; Body: z.infer<typeof createNoteBody> }>(
|
||||
"/admin/posts/:id/notes",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { body } = createNoteBody.parse(req.body);
|
||||
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! }, select: { email: true } });
|
||||
|
||||
const note = await prisma.adminNote.create({
|
||||
data: {
|
||||
body,
|
||||
postId: post.id,
|
||||
adminId: req.adminId!,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(201).send({
|
||||
id: note.id,
|
||||
body: note.body,
|
||||
postId: note.postId,
|
||||
adminEmail: admin?.email ? redactEmail(admin.email) : "Team member",
|
||||
createdAt: note.createdAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Delete a note
|
||||
app.delete<{ Params: { noteId: string } }>(
|
||||
"/admin/notes/:noteId",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const note = await prisma.adminNote.findUnique({ where: { id: req.params.noteId } });
|
||||
if (!note) {
|
||||
reply.status(404).send({ error: "Note not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (note.adminId !== req.adminId) {
|
||||
reply.status(403).send({ error: "You can only delete your own notes" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.adminNote.delete({ where: { id: note.id } });
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,87 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient, PostStatus, Prisma } from "@prisma/client";
|
||||
import { Prisma, PostType } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { notifyPostSubscribers } from "../../services/push.js";
|
||||
import { fireWebhook } from "../../services/webhooks.js";
|
||||
import { decrypt } from "../../services/encryption.js";
|
||||
import { masterKey } from "../../config.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g;
|
||||
|
||||
function decryptName(v: string | null): string | null {
|
||||
if (!v) return null;
|
||||
try { return decrypt(v, masterKey); } catch { return null; }
|
||||
}
|
||||
|
||||
const statusBody = z.object({
|
||||
status: z.nativeEnum(PostStatus),
|
||||
status: z.string().min(1).max(50),
|
||||
reason: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
const respondBody = z.object({
|
||||
body: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
const mergeBody = z.object({
|
||||
targetPostId: z.string().min(1),
|
||||
});
|
||||
|
||||
const rollbackBody = z.object({
|
||||
editHistoryId: z.string().min(1),
|
||||
});
|
||||
|
||||
const bulkIds = z.array(z.string().min(1)).min(1).max(100);
|
||||
|
||||
const bulkStatusBody = z.object({
|
||||
postIds: bulkIds,
|
||||
status: z.string().min(1).max(50),
|
||||
});
|
||||
|
||||
const bulkDeleteBody = z.object({
|
||||
postIds: bulkIds,
|
||||
});
|
||||
|
||||
const bulkTagBody = z.object({
|
||||
postIds: bulkIds,
|
||||
tagId: z.string().min(1),
|
||||
action: z.enum(['add', 'remove']),
|
||||
});
|
||||
|
||||
const allowedDescKeys = new Set(["stepsToReproduce", "expectedBehavior", "actualBehavior", "environment", "useCase", "proposedSolution", "alternativesConsidered", "additionalContext"]);
|
||||
|
||||
const descriptionRecord = z.record(z.string()).refine(
|
||||
(obj) => Object.keys(obj).length <= 10 && Object.keys(obj).every((k) => allowedDescKeys.has(k)),
|
||||
{ message: "Unknown description fields" }
|
||||
);
|
||||
|
||||
const proxyPostBody = z.object({
|
||||
type: z.nativeEnum(PostType),
|
||||
title: z.string().min(5).max(200),
|
||||
description: descriptionRecord,
|
||||
onBehalfOf: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
const adminPostsQuery = z.object({
|
||||
page: z.coerce.number().int().min(1).max(500).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
status: z.string().max(50).optional(),
|
||||
boardId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export default async function adminPostRoutes(app: FastifyInstance) {
|
||||
app.get<{ Querystring: Record<string, string> }>(
|
||||
"/admin/posts",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const page = Math.max(1, parseInt(req.query.page ?? "1", 10));
|
||||
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit ?? "50", 10)));
|
||||
const status = req.query.status as PostStatus | undefined;
|
||||
const boardId = req.query.boardId;
|
||||
const q = adminPostsQuery.safeParse(req.query);
|
||||
if (!q.success) {
|
||||
reply.status(400).send({ error: "Invalid query parameters" });
|
||||
return;
|
||||
}
|
||||
const { page, limit, status, boardId } = q.data;
|
||||
|
||||
const where: Prisma.PostWhereInput = {};
|
||||
if (status) where.status = status;
|
||||
@@ -31,24 +91,46 @@ export default async function adminPostRoutes(app: FastifyInstance) {
|
||||
prisma.post.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * limit,
|
||||
skip: Math.min((page - 1) * limit, 50000),
|
||||
take: limit,
|
||||
include: {
|
||||
board: { select: { slug: true, name: true } },
|
||||
author: { select: { id: true, displayName: true } },
|
||||
_count: { select: { comments: true, votes: true } },
|
||||
author: { select: { id: true, displayName: true, avatarPath: true } },
|
||||
_count: { select: { comments: true, votes: true, adminNotes: true } },
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
}),
|
||||
prisma.post.count({ where }),
|
||||
]);
|
||||
|
||||
reply.send({ posts, total, page, pages: Math.ceil(total / limit) });
|
||||
reply.send({
|
||||
posts: posts.map((p) => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
statusReason: p.statusReason,
|
||||
category: p.category,
|
||||
voteCount: p.voteCount,
|
||||
viewCount: p.viewCount,
|
||||
isPinned: p.isPinned,
|
||||
onBehalfOf: p.onBehalfOf,
|
||||
board: p.board,
|
||||
author: p.author ? { id: p.author.id, displayName: decryptName(p.author.displayName), avatarUrl: p.author.avatarPath ? `/api/v1/avatars/${p.author.id}` : null } : null,
|
||||
tags: p.tags.map((pt) => pt.tag),
|
||||
_count: p._count,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
})),
|
||||
total, page, pages: Math.ceil(total / limit),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string }; Body: z.infer<typeof statusBody> }>(
|
||||
"/admin/posts/:id/status",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
@@ -56,17 +138,37 @@ export default async function adminPostRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = statusBody.parse(req.body);
|
||||
const { status, reason } = statusBody.parse(req.body);
|
||||
const reasonText = reason?.trim() || null;
|
||||
|
||||
// check if the target status exists and is enabled for this board
|
||||
const boardConfig = await prisma.boardStatus.findMany({
|
||||
where: { boardId: post.boardId, enabled: true },
|
||||
});
|
||||
if (boardConfig.length === 0) {
|
||||
reply.status(400).send({ error: "No statuses configured for this board" });
|
||||
return;
|
||||
}
|
||||
const statusEntry = boardConfig.find((c) => c.status === status);
|
||||
if (!statusEntry) {
|
||||
reply.status(400).send({ error: `Status "${status}" is not available for this board` });
|
||||
return;
|
||||
}
|
||||
|
||||
const oldStatus = post.status;
|
||||
|
||||
const [updated] = await Promise.all([
|
||||
prisma.post.update({ where: { id: post.id }, data: { status } }),
|
||||
prisma.post.update({
|
||||
where: { id: post.id },
|
||||
data: { status, statusReason: reasonText, lastActivityAt: new Date() },
|
||||
}),
|
||||
prisma.statusChange.create({
|
||||
data: {
|
||||
postId: post.id,
|
||||
fromStatus: oldStatus,
|
||||
toStatus: status,
|
||||
changedBy: req.adminId!,
|
||||
reason: reasonText,
|
||||
},
|
||||
}),
|
||||
prisma.activityEvent.create({
|
||||
@@ -79,20 +181,55 @@ export default async function adminPostRoutes(app: FastifyInstance) {
|
||||
}),
|
||||
]);
|
||||
|
||||
// notify post author and voters
|
||||
const voters = await prisma.vote.findMany({
|
||||
where: { postId: post.id },
|
||||
select: { voterId: true },
|
||||
});
|
||||
const statusLabel = statusEntry.label || status.replace(/_/g, " ");
|
||||
const notifBody = reasonText
|
||||
? `"${post.title}" moved to ${statusLabel} - Reason: ${reasonText}`
|
||||
: `"${post.title}" moved to ${statusLabel}`;
|
||||
const sentinelId = "deleted-user-sentinel";
|
||||
const voterIds = voters.map((v) => v.voterId).filter((id) => id !== sentinelId);
|
||||
if (voterIds.length > 1000) {
|
||||
req.log.warn({ postId: post.id, totalVoters: voterIds.length }, "notification capped at 1000 voters");
|
||||
}
|
||||
const notifyIds = voterIds.slice(0, 1000);
|
||||
const userIds = new Set([post.authorId, ...notifyIds]);
|
||||
userIds.delete(sentinelId);
|
||||
await prisma.notification.createMany({
|
||||
data: [...userIds].map((userId) => ({
|
||||
type: "status_changed",
|
||||
title: "Status updated",
|
||||
body: notifBody,
|
||||
postId: post.id,
|
||||
userId,
|
||||
})),
|
||||
});
|
||||
|
||||
await notifyPostSubscribers(post.id, {
|
||||
title: "Status updated",
|
||||
body: `"${post.title}" moved to ${status}`,
|
||||
body: notifBody,
|
||||
url: `/post/${post.id}`,
|
||||
tag: `status-${post.id}`,
|
||||
});
|
||||
|
||||
fireWebhook("status_changed", {
|
||||
postId: post.id,
|
||||
title: post.title,
|
||||
boardId: post.boardId,
|
||||
from: oldStatus,
|
||||
to: status,
|
||||
});
|
||||
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string } }>(
|
||||
"/admin/posts/:id/pin",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
@@ -100,65 +237,323 @@ export default async function adminPostRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!post.isPinned) {
|
||||
try {
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const pinnedCount = await tx.post.count({
|
||||
where: { boardId: post.boardId, isPinned: true },
|
||||
});
|
||||
if (pinnedCount >= 3) throw new Error("PIN_LIMIT");
|
||||
return tx.post.update({
|
||||
where: { id: post.id },
|
||||
data: { isPinned: true },
|
||||
});
|
||||
}, { isolationLevel: "Serializable" });
|
||||
reply.send(updated);
|
||||
} catch (err: any) {
|
||||
if (err.message === "PIN_LIMIT") {
|
||||
reply.status(409).send({ error: "Max 3 pinned posts per board" });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
const updated = await prisma.post.update({
|
||||
where: { id: post.id },
|
||||
data: { isPinned: false },
|
||||
});
|
||||
reply.send(updated);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: z.infer<typeof respondBody> }>(
|
||||
"/admin/posts/:id/respond",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
|
||||
if (!admin || !admin.linkedUserId) {
|
||||
reply.status(500).send({ error: "Admin account not linked" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { body } = respondBody.parse(req.body);
|
||||
const cleanBody = body.replace(INVISIBLE_RE, '');
|
||||
|
||||
const comment = await prisma.comment.create({
|
||||
data: {
|
||||
body: cleanBody,
|
||||
postId: post.id,
|
||||
authorId: admin.linkedUserId,
|
||||
isAdmin: true,
|
||||
adminUserId: req.adminId!,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.activityEvent.create({
|
||||
data: {
|
||||
type: "admin_responded",
|
||||
boardId: post.boardId,
|
||||
postId: post.id,
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
|
||||
await notifyPostSubscribers(post.id, {
|
||||
title: "Official response",
|
||||
body: cleanBody.slice(0, 100),
|
||||
url: `/post/${post.id}`,
|
||||
tag: `response-${post.id}`,
|
||||
});
|
||||
|
||||
reply.status(201).send(comment);
|
||||
}
|
||||
);
|
||||
|
||||
// Admin creates a post on behalf of a user
|
||||
app.post<{ Params: { boardId: string }; Body: z.infer<typeof proxyPostBody> }>(
|
||||
"/admin/boards/:boardId/posts",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
|
||||
if (!board || board.isArchived) {
|
||||
reply.status(404).send({ error: "Board not found or archived" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = proxyPostBody.parse(req.body);
|
||||
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
|
||||
if (!admin || !admin.linkedUserId) {
|
||||
reply.status(400).send({ error: "Admin account must be linked to a user to submit posts" });
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanTitle = body.title.replace(INVISIBLE_RE, '');
|
||||
const cleanDesc: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(body.description)) {
|
||||
cleanDesc[k] = v.replace(INVISIBLE_RE, '');
|
||||
}
|
||||
|
||||
const post = await prisma.post.create({
|
||||
data: {
|
||||
type: body.type,
|
||||
title: cleanTitle,
|
||||
description: cleanDesc,
|
||||
boardId: board.id,
|
||||
authorId: admin.linkedUserId,
|
||||
onBehalfOf: body.onBehalfOf,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.activityEvent.create({
|
||||
data: {
|
||||
type: "post_created",
|
||||
boardId: board.id,
|
||||
postId: post.id,
|
||||
metadata: { title: post.title, type: post.type, onBehalfOf: body.onBehalfOf },
|
||||
},
|
||||
});
|
||||
|
||||
fireWebhook("post_created", {
|
||||
postId: post.id,
|
||||
title: post.title,
|
||||
type: post.type,
|
||||
boardId: board.id,
|
||||
boardSlug: board.slug,
|
||||
onBehalfOf: body.onBehalfOf,
|
||||
});
|
||||
|
||||
reply.status(201).send(post);
|
||||
}
|
||||
);
|
||||
|
||||
// Merge source post into target post
|
||||
app.post<{ Params: { id: string }; Body: z.infer<typeof mergeBody> }>(
|
||||
"/admin/posts/:id/merge",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const { targetPostId } = mergeBody.parse(req.body);
|
||||
|
||||
const source = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!source) {
|
||||
reply.status(404).send({ error: "Source post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const target = await prisma.post.findUnique({ where: { id: targetPostId } });
|
||||
if (!target) {
|
||||
reply.status(404).send({ error: "Target post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.id === target.id) {
|
||||
reply.status(400).send({ error: "Cannot merge a post into itself" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.boardId !== target.boardId) {
|
||||
reply.status(400).send({ error: "Cannot merge posts across different boards" });
|
||||
return;
|
||||
}
|
||||
|
||||
// only load IDs and weights to minimize memory
|
||||
const sourceVotes = await prisma.vote.findMany({
|
||||
where: { postId: source.id },
|
||||
select: { id: true, voterId: true, weight: true },
|
||||
});
|
||||
const targetVoterIds = new Set(
|
||||
(await prisma.vote.findMany({ where: { postId: target.id }, select: { voterId: true } }))
|
||||
.map((v) => v.voterId)
|
||||
);
|
||||
|
||||
const votesToMove = sourceVotes.filter((v) => !targetVoterIds.has(v.voterId));
|
||||
|
||||
const targetTagIds = (await prisma.postTag.findMany({
|
||||
where: { postId: target.id },
|
||||
select: { tagId: true },
|
||||
})).map((t) => t.tagId);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const v of votesToMove) {
|
||||
await tx.vote.update({ where: { id: v.id }, data: { postId: target.id } });
|
||||
}
|
||||
await tx.vote.deleteMany({ where: { postId: source.id } });
|
||||
await tx.comment.updateMany({ where: { postId: source.id }, data: { postId: target.id } });
|
||||
await tx.attachment.updateMany({ where: { postId: source.id }, data: { postId: target.id } });
|
||||
await tx.postTag.deleteMany({
|
||||
where: { postId: source.id, tagId: { in: targetTagIds } },
|
||||
});
|
||||
await tx.postTag.updateMany({ where: { postId: source.id }, data: { postId: target.id } });
|
||||
await tx.post.update({
|
||||
where: { id: target.id },
|
||||
data: { voteCount: { increment: votesToMove.reduce((sum, v) => sum + v.weight, 0) } },
|
||||
});
|
||||
await tx.postMerge.create({
|
||||
data: { sourcePostId: source.id, targetPostId: target.id, mergedBy: req.adminId! },
|
||||
});
|
||||
await tx.post.delete({ where: { id: source.id } });
|
||||
}, { isolationLevel: "Serializable" });
|
||||
|
||||
const actualCount = await prisma.vote.aggregate({ where: { postId: target.id }, _sum: { weight: true } });
|
||||
await prisma.post.update({ where: { id: target.id }, data: { voteCount: actualCount._sum.weight ?? 0 } });
|
||||
|
||||
req.log.info({ adminId: req.adminId, sourcePostId: source.id, targetPostId: target.id }, "posts merged");
|
||||
reply.send({ merged: true, targetPostId: target.id });
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string } }>(
|
||||
"/admin/posts/:id/lock-edits",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma.post.update({
|
||||
where: { id: post.id },
|
||||
data: { isPinned: !post.isPinned },
|
||||
data: { isEditLocked: !post.isEditLocked },
|
||||
});
|
||||
reply.send({ isEditLocked: updated.isEditLocked });
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string } }>(
|
||||
"/admin/posts/:id/lock-thread",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = z.object({ lockVoting: z.boolean().optional() }).parse(req.body);
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
const newThreadLocked = !post.isThreadLocked;
|
||||
const data: Record<string, boolean> = { isThreadLocked: newThreadLocked };
|
||||
if (newThreadLocked && body.lockVoting) {
|
||||
data.isVotingLocked = true;
|
||||
}
|
||||
if (!newThreadLocked) {
|
||||
data.isVotingLocked = false;
|
||||
}
|
||||
const updated = await prisma.post.update({ where: { id: post.id }, data });
|
||||
reply.send({ isThreadLocked: updated.isThreadLocked, isVotingLocked: updated.isVotingLocked });
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string } }>(
|
||||
"/admin/comments/:id/lock-edits",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
|
||||
if (!comment) {
|
||||
reply.status(404).send({ error: "Comment not found" });
|
||||
return;
|
||||
}
|
||||
const updated = await prisma.comment.update({
|
||||
where: { id: comment.id },
|
||||
data: { isEditLocked: !comment.isEditLocked },
|
||||
});
|
||||
reply.send({ isEditLocked: updated.isEditLocked });
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: z.infer<typeof rollbackBody> }>(
|
||||
"/admin/posts/:id/rollback",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { editHistoryId } = rollbackBody.parse(req.body);
|
||||
const edit = await prisma.editHistory.findUnique({ where: { id: editHistoryId } });
|
||||
if (!edit || edit.postId !== post.id) {
|
||||
reply.status(404).send({ error: "Edit history entry not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
|
||||
if (!admin?.linkedUserId) {
|
||||
reply.status(400).send({ error: "Admin account must be linked to a user" });
|
||||
return;
|
||||
}
|
||||
|
||||
// save current state before rollback
|
||||
await prisma.editHistory.create({
|
||||
data: {
|
||||
postId: post.id,
|
||||
editedBy: admin.linkedUserId,
|
||||
previousTitle: post.title,
|
||||
previousDescription: post.description as any,
|
||||
},
|
||||
});
|
||||
|
||||
const data: Record<string, any> = {};
|
||||
if (edit.previousTitle !== null) data.title = edit.previousTitle;
|
||||
if (edit.previousDescription !== null) data.description = edit.previousDescription;
|
||||
|
||||
const updated = await prisma.post.update({
|
||||
where: { id: post.id },
|
||||
data,
|
||||
});
|
||||
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: z.infer<typeof respondBody> }>(
|
||||
"/admin/posts/:id/respond",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { body } = respondBody.parse(req.body);
|
||||
|
||||
const response = await prisma.adminResponse.create({
|
||||
data: {
|
||||
body,
|
||||
postId: post.id,
|
||||
adminId: req.adminId!,
|
||||
},
|
||||
include: { admin: { select: { id: true, email: true } } },
|
||||
});
|
||||
|
||||
await notifyPostSubscribers(post.id, {
|
||||
title: "Official response",
|
||||
body: body.slice(0, 100),
|
||||
url: `/post/${post.id}`,
|
||||
tag: `response-${post.id}`,
|
||||
});
|
||||
|
||||
reply.status(201).send(response);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/posts/:id",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
async (req, reply) => {
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.post.delete({ where: { id: post.id } });
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/comments/:id",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
app.post<{ Params: { id: string }; Body: z.infer<typeof rollbackBody> }>(
|
||||
"/admin/comments/:id/rollback",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
|
||||
if (!comment) {
|
||||
@@ -166,8 +561,284 @@ export default async function adminPostRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.comment.delete({ where: { id: comment.id } });
|
||||
const { editHistoryId } = rollbackBody.parse(req.body);
|
||||
const edit = await prisma.editHistory.findUnique({ where: { id: editHistoryId } });
|
||||
if (!edit || edit.commentId !== comment.id) {
|
||||
reply.status(404).send({ error: "Edit history entry not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: req.adminId! } });
|
||||
if (!admin?.linkedUserId) {
|
||||
reply.status(400).send({ error: "Admin account must be linked to a user" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.editHistory.create({
|
||||
data: {
|
||||
commentId: comment.id,
|
||||
editedBy: admin.linkedUserId,
|
||||
previousBody: comment.body,
|
||||
},
|
||||
});
|
||||
|
||||
if (edit.previousBody === null) {
|
||||
reply.status(400).send({ error: "No previous body to restore" });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await prisma.comment.update({
|
||||
where: { id: comment.id },
|
||||
data: { body: edit.previousBody },
|
||||
});
|
||||
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/posts/:id",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const commentIds = (await prisma.comment.findMany({
|
||||
where: { postId: post.id },
|
||||
select: { id: true },
|
||||
})).map((c) => c.id);
|
||||
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ postId: post.id },
|
||||
...(commentIds.length ? [{ commentId: { in: commentIds } }] : []),
|
||||
],
|
||||
},
|
||||
select: { id: true, path: true },
|
||||
});
|
||||
|
||||
if (attachments.length) {
|
||||
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
|
||||
}
|
||||
|
||||
await prisma.postMerge.deleteMany({
|
||||
where: { OR: [{ sourcePostId: post.id }, { targetPostId: post.id }] },
|
||||
});
|
||||
|
||||
await prisma.post.delete({ where: { id: post.id } });
|
||||
|
||||
const uploadDir = resolve(process.cwd(), "uploads");
|
||||
for (const att of attachments) {
|
||||
await unlink(resolve(uploadDir, att.path)).catch(() => {});
|
||||
}
|
||||
|
||||
req.log.info({ adminId: req.adminId, postId: post.id }, "admin post deleted");
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/comments/:id",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
|
||||
if (!comment) {
|
||||
reply.status(404).send({ error: "Comment not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
where: { commentId: comment.id },
|
||||
select: { id: true, path: true },
|
||||
});
|
||||
|
||||
if (attachments.length) {
|
||||
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
|
||||
}
|
||||
|
||||
await prisma.comment.delete({ where: { id: comment.id } });
|
||||
|
||||
const uploadDir = resolve(process.cwd(), "uploads");
|
||||
for (const att of attachments) {
|
||||
await unlink(resolve(uploadDir, att.path)).catch(() => {});
|
||||
}
|
||||
|
||||
req.log.info({ adminId: req.adminId, commentId: comment.id }, "admin comment deleted");
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: z.infer<typeof bulkStatusBody> }>(
|
||||
"/admin/posts/bulk-status",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const parsed = bulkStatusBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.status(400).send({ error: "Invalid request body" });
|
||||
return;
|
||||
}
|
||||
const { postIds, status } = parsed.data;
|
||||
|
||||
const posts = await prisma.post.findMany({
|
||||
where: { id: { in: postIds } },
|
||||
select: { id: true, status: true, boardId: true },
|
||||
});
|
||||
|
||||
if (posts.length === 0) {
|
||||
reply.send({ updated: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// validate target status against each board's config
|
||||
const boardIds = [...new Set(posts.map((p) => p.boardId))];
|
||||
for (const boardId of boardIds) {
|
||||
const boardStatuses = await prisma.boardStatus.findMany({
|
||||
where: { boardId, enabled: true },
|
||||
});
|
||||
if (boardStatuses.length > 0 && !boardStatuses.some((s) => s.status === status)) {
|
||||
reply.status(400).send({ error: `Status "${status}" is not enabled for board ${boardId}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.post.updateMany({
|
||||
where: { id: { in: posts.map((p) => p.id) } },
|
||||
data: { status, lastActivityAt: new Date() },
|
||||
}),
|
||||
...posts.map((p) =>
|
||||
prisma.statusChange.create({
|
||||
data: {
|
||||
postId: p.id,
|
||||
fromStatus: p.status,
|
||||
toStatus: status,
|
||||
changedBy: req.adminId!,
|
||||
},
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
reply.send({ updated: posts.length });
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: z.infer<typeof bulkDeleteBody> }>(
|
||||
"/admin/posts/bulk-delete",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const parsed = bulkDeleteBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.status(400).send({ error: "Invalid request body" });
|
||||
return;
|
||||
}
|
||||
const { postIds } = parsed.data;
|
||||
|
||||
const posts = await prisma.post.findMany({
|
||||
where: { id: { in: postIds } },
|
||||
select: { id: true, boardId: true, title: true },
|
||||
});
|
||||
|
||||
if (posts.length === 0) {
|
||||
reply.send({ deleted: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const validPostIds = posts.map((p) => p.id);
|
||||
const commentIds = (await prisma.comment.findMany({
|
||||
where: { postId: { in: validPostIds } },
|
||||
select: { id: true },
|
||||
})).map((c) => c.id);
|
||||
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ postId: { in: validPostIds } },
|
||||
...(commentIds.length ? [{ commentId: { in: commentIds } }] : []),
|
||||
],
|
||||
},
|
||||
select: { id: true, path: true },
|
||||
});
|
||||
|
||||
if (attachments.length) {
|
||||
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.post.deleteMany({ where: { id: { in: validPostIds } } }),
|
||||
...posts.map((p) =>
|
||||
prisma.activityEvent.create({
|
||||
data: {
|
||||
type: "post_deleted",
|
||||
boardId: p.boardId,
|
||||
postId: p.id,
|
||||
metadata: { title: p.title, deletedBy: req.adminId },
|
||||
},
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
const uploadDir = resolve(process.cwd(), "uploads");
|
||||
for (const att of attachments) {
|
||||
await unlink(resolve(uploadDir, att.path)).catch(() => {});
|
||||
}
|
||||
|
||||
req.log.info({ adminId: req.adminId, count: posts.length }, "bulk posts deleted");
|
||||
reply.send({ deleted: posts.length });
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: z.infer<typeof bulkTagBody> }>(
|
||||
"/admin/posts/bulk-tag",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const parsed = bulkTagBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.status(400).send({ error: "Invalid request body" });
|
||||
return;
|
||||
}
|
||||
const { postIds, tagId, action } = parsed.data;
|
||||
|
||||
const tag = await prisma.tag.findUnique({ where: { id: tagId } });
|
||||
if (!tag) {
|
||||
reply.status(404).send({ error: "Tag not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPosts = await prisma.post.findMany({
|
||||
where: { id: { in: postIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const validIds = existingPosts.map((p) => p.id);
|
||||
|
||||
if (validIds.length === 0) {
|
||||
reply.send({ affected: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'add') {
|
||||
const existing = await prisma.postTag.findMany({
|
||||
where: { tagId, postId: { in: validIds } },
|
||||
select: { postId: true },
|
||||
});
|
||||
const alreadyTagged = new Set(existing.map((e) => e.postId));
|
||||
const toAdd = validIds.filter((id) => !alreadyTagged.has(id));
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
await prisma.postTag.createMany({
|
||||
data: toAdd.map((postId) => ({ postId, tagId })),
|
||||
});
|
||||
}
|
||||
reply.send({ affected: toAdd.length });
|
||||
} else {
|
||||
const result = await prisma.postTag.deleteMany({
|
||||
where: { tagId, postId: { in: validIds } },
|
||||
});
|
||||
reply.send({ affected: result.count });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
59
packages/api/src/routes/admin/settings.ts
Normal file
59
packages/api/src/routes/admin/settings.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
const updateSchema = z.object({
|
||||
appName: z.string().min(1).max(100).optional(),
|
||||
logoUrl: z.string().url().max(500).nullable().optional(),
|
||||
faviconUrl: z.string().url().max(500).nullable().optional(),
|
||||
accentColor: z.string().regex(HEX_COLOR).optional(),
|
||||
headerFont: z.string().max(100).nullable().optional(),
|
||||
bodyFont: z.string().max(100).nullable().optional(),
|
||||
poweredByVisible: z.boolean().optional(),
|
||||
customCss: z.string().max(10000).nullable().optional(),
|
||||
});
|
||||
|
||||
export default async function settingsRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/site-settings",
|
||||
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (_req, reply) => {
|
||||
const settings = await prisma.siteSettings.findUnique({ where: { id: "default" } });
|
||||
reply.header("Cache-Control", "public, max-age=60");
|
||||
reply.send(settings ?? {
|
||||
appName: "Echoboard",
|
||||
accentColor: "#F59E0B",
|
||||
poweredByVisible: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/admin/site-settings",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = updateSchema.parse(req.body);
|
||||
|
||||
if (body.customCss) {
|
||||
// decode CSS escape sequences before checking so \75\72\6c() can't bypass url() detection
|
||||
const decoded = body.customCss
|
||||
.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
|
||||
.replace(/\\(.)/g, '$1');
|
||||
const forbidden = /@import|@font-face|@charset|@namespace|url\s*\(|expression\s*\(|javascript:|behavior\s*:|binding\s*:|-moz-binding\s*:/i;
|
||||
if (forbidden.test(decoded)) {
|
||||
reply.status(400).send({ error: "Custom CSS contains forbidden patterns" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await prisma.siteSettings.upsert({
|
||||
where: { id: "default" },
|
||||
create: { id: "default", ...body },
|
||||
update: body,
|
||||
});
|
||||
reply.send(settings);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,32 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { config } from "../../config.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
export default async function adminStatsRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/admin/stats",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (_req, reply) => {
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
|
||||
const [
|
||||
totalPosts,
|
||||
totalUsers,
|
||||
totalComments,
|
||||
totalVotes,
|
||||
thisWeek,
|
||||
postsByStatus,
|
||||
postsByType,
|
||||
boardStats,
|
||||
topUnresolved,
|
||||
usersByAuth,
|
||||
] = await Promise.all([
|
||||
prisma.post.count(),
|
||||
prisma.user.count(),
|
||||
prisma.comment.count(),
|
||||
prisma.vote.count(),
|
||||
prisma.post.count({ where: { createdAt: { gte: weekAgo } } }),
|
||||
prisma.post.groupBy({ by: ["status"], _count: true }),
|
||||
prisma.post.groupBy({ by: ["type"], _count: true }),
|
||||
prisma.board.findMany({
|
||||
@@ -32,30 +37,51 @@ export default async function adminStatsRoutes(app: FastifyInstance) {
|
||||
_count: { select: { posts: true } },
|
||||
},
|
||||
}),
|
||||
prisma.post.findMany({
|
||||
where: { status: { in: ["OPEN", "UNDER_REVIEW", "PLANNED", "IN_PROGRESS"] } },
|
||||
orderBy: { voteCount: "desc" },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
voteCount: true,
|
||||
board: { select: { slug: true } },
|
||||
},
|
||||
}),
|
||||
prisma.user.groupBy({ by: ["authMethod"], _count: true }),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
totalPosts,
|
||||
thisWeek,
|
||||
byStatus: Object.fromEntries(postsByStatus.map((s) => [s.status, s._count])),
|
||||
byType: Object.fromEntries(postsByType.map((t) => [t.type, t._count])),
|
||||
topUnresolved: topUnresolved.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
voteCount: p.voteCount,
|
||||
boardSlug: p.board.slug,
|
||||
})),
|
||||
totals: {
|
||||
posts: totalPosts,
|
||||
users: totalUsers,
|
||||
comments: totalComments,
|
||||
votes: totalVotes,
|
||||
},
|
||||
postsByStatus: Object.fromEntries(postsByStatus.map((s) => [s.status, s._count])),
|
||||
postsByType: Object.fromEntries(postsByType.map((t) => [t.type, t._count])),
|
||||
boards: boardStats.map((b) => ({
|
||||
id: b.id,
|
||||
slug: b.slug,
|
||||
name: b.name,
|
||||
postCount: b._count.posts,
|
||||
})),
|
||||
authMethodRatio: Object.fromEntries(usersByAuth.map((u) => [u.authMethod, u._count])),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/admin/data-retention",
|
||||
{ preHandler: [app.requireAdmin] },
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (_req, reply) => {
|
||||
const activityCutoff = new Date();
|
||||
activityCutoff.setDate(activityCutoff.getDate() - config.DATA_RETENTION_ACTIVITY_DAYS);
|
||||
|
||||
235
packages/api/src/routes/admin/statuses.ts
Normal file
235
packages/api/src/routes/admin/statuses.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const DEFAULT_STATUSES = [
|
||||
{ status: "OPEN", label: "Open", color: "#F59E0B", position: 0 },
|
||||
{ status: "UNDER_REVIEW", label: "Under Review", color: "#06B6D4", position: 1 },
|
||||
{ status: "PLANNED", label: "Planned", color: "#3B82F6", position: 2 },
|
||||
{ status: "IN_PROGRESS", label: "In Progress", color: "#EAB308", position: 3 },
|
||||
{ status: "DONE", label: "Done", color: "#22C55E", position: 4 },
|
||||
{ status: "DECLINED", label: "Declined", color: "#EF4444", position: 5 },
|
||||
];
|
||||
|
||||
const statusEntry = z.object({
|
||||
status: z.string().min(1).max(50).trim(),
|
||||
label: z.string().min(1).max(40).trim(),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||
position: z.number().int().min(0).max(50),
|
||||
});
|
||||
|
||||
const bulkUpdateBody = z.object({
|
||||
statuses: z.array(statusEntry).min(1).max(50),
|
||||
});
|
||||
|
||||
const movePostsBody = z.object({
|
||||
fromStatus: z.string().min(1).max(50),
|
||||
toStatus: z.string().min(1).max(50),
|
||||
});
|
||||
|
||||
export default async function adminStatusRoutes(app: FastifyInstance) {
|
||||
app.get<{ Params: { boardId: string } }>(
|
||||
"/admin/boards/:boardId/statuses",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await prisma.boardStatus.findMany({
|
||||
where: { boardId: board.id, enabled: true },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
|
||||
if (existing.length === 0) {
|
||||
reply.send({
|
||||
statuses: DEFAULT_STATUSES.map((s) => ({
|
||||
...s,
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reply.send({
|
||||
statuses: existing.map((s) => ({
|
||||
status: s.status,
|
||||
label: s.label,
|
||||
color: s.color,
|
||||
position: s.position,
|
||||
enabled: true,
|
||||
})),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { boardId: string }; Body: z.infer<typeof bulkUpdateBody> }>(
|
||||
"/admin/boards/:boardId/statuses",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { statuses } = bulkUpdateBody.parse(req.body);
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const s of statuses) {
|
||||
if (seen.has(s.status)) {
|
||||
reply.status(400).send({ error: `Duplicate status: ${s.status}` });
|
||||
return;
|
||||
}
|
||||
seen.add(s.status);
|
||||
}
|
||||
|
||||
if (!statuses.some((s) => s.status === "OPEN")) {
|
||||
reply.status(400).send({ error: "OPEN status must be included" });
|
||||
return;
|
||||
}
|
||||
|
||||
// find currently active statuses being removed
|
||||
const sentStatuses = new Set(statuses.map((s) => s.status));
|
||||
const currentActive = await prisma.boardStatus.findMany({
|
||||
where: { boardId: board.id, enabled: true },
|
||||
});
|
||||
const removedStatuses = currentActive
|
||||
.map((s) => s.status)
|
||||
.filter((s) => !sentStatuses.has(s));
|
||||
|
||||
if (removedStatuses.length > 0) {
|
||||
const inUse = await prisma.post.count({
|
||||
where: { boardId: board.id, status: { in: removedStatuses } },
|
||||
});
|
||||
if (inUse > 0) {
|
||||
reply.status(409).send({
|
||||
error: "Cannot remove statuses that have posts. Move them first.",
|
||||
inUseStatuses: removedStatuses,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// upsert active, disable removed
|
||||
const ops = [
|
||||
...statuses.map((s) =>
|
||||
prisma.boardStatus.upsert({
|
||||
where: { boardId_status: { boardId: board.id, status: s.status } },
|
||||
create: {
|
||||
boardId: board.id,
|
||||
status: s.status,
|
||||
label: s.label,
|
||||
color: s.color,
|
||||
position: s.position,
|
||||
enabled: true,
|
||||
},
|
||||
update: {
|
||||
label: s.label,
|
||||
color: s.color,
|
||||
position: s.position,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
),
|
||||
// disable any removed statuses
|
||||
...(removedStatuses.length > 0
|
||||
? [prisma.boardStatus.updateMany({
|
||||
where: { boardId: board.id, status: { in: removedStatuses } },
|
||||
data: { enabled: false },
|
||||
})]
|
||||
: []),
|
||||
];
|
||||
|
||||
await prisma.$transaction(ops);
|
||||
|
||||
const result = await prisma.boardStatus.findMany({
|
||||
where: { boardId: board.id, enabled: true },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
|
||||
reply.send({
|
||||
statuses: result.map((s) => ({
|
||||
status: s.status,
|
||||
label: s.label,
|
||||
color: s.color,
|
||||
position: s.position,
|
||||
enabled: true,
|
||||
})),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Move all posts from one status to another on a board
|
||||
app.post<{ Params: { boardId: string }; Body: z.infer<typeof movePostsBody> }>(
|
||||
"/admin/boards/:boardId/statuses/move",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { fromStatus, toStatus } = movePostsBody.parse(req.body);
|
||||
|
||||
if (fromStatus === toStatus) {
|
||||
reply.status(400).send({ error: "Source and target status must differ" });
|
||||
return;
|
||||
}
|
||||
|
||||
// validate that toStatus is enabled for this board
|
||||
const boardStatuses = await prisma.boardStatus.findMany({
|
||||
where: { boardId: board.id, enabled: true },
|
||||
});
|
||||
if (boardStatuses.length > 0 && !boardStatuses.some((s) => s.status === toStatus)) {
|
||||
reply.status(400).send({ error: `Target status "${toStatus}" is not enabled for this board` });
|
||||
return;
|
||||
}
|
||||
|
||||
// find affected posts first for audit trail
|
||||
const totalAffected = await prisma.post.count({
|
||||
where: { boardId: board.id, status: fromStatus },
|
||||
});
|
||||
if (totalAffected > 500) {
|
||||
reply.status(400).send({ error: `Too many posts (${totalAffected}). Move in smaller batches by filtering first.` });
|
||||
return;
|
||||
}
|
||||
const affected = await prisma.post.findMany({
|
||||
where: { boardId: board.id, status: fromStatus },
|
||||
select: { id: true },
|
||||
take: 500,
|
||||
});
|
||||
|
||||
if (affected.length === 0) {
|
||||
reply.send({ moved: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const affectedIds = affected.map((p) => p.id);
|
||||
await prisma.$transaction([
|
||||
prisma.post.updateMany({
|
||||
where: { id: { in: affectedIds } },
|
||||
data: { status: toStatus },
|
||||
}),
|
||||
...affectedIds.map((postId) =>
|
||||
prisma.statusChange.create({
|
||||
data: {
|
||||
postId,
|
||||
fromStatus,
|
||||
toStatus,
|
||||
changedBy: req.adminId!,
|
||||
reason: "Bulk status move",
|
||||
},
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
req.log.info({ adminId: req.adminId, boardId: board.id, fromStatus, toStatus, count: affectedIds.length }, "bulk status move");
|
||||
reply.send({ moved: affectedIds.length });
|
||||
}
|
||||
);
|
||||
}
|
||||
137
packages/api/src/routes/admin/tags.ts
Normal file
137
packages/api/src/routes/admin/tags.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const createTagBody = z.object({
|
||||
name: z.string().min(1).max(30).trim(),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#6366F1"),
|
||||
});
|
||||
|
||||
const updateTagBody = z.object({
|
||||
name: z.string().min(1).max(30).trim().optional(),
|
||||
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
||||
});
|
||||
|
||||
const tagPostBody = z.object({
|
||||
tagIds: z.array(z.string().min(1)).max(10),
|
||||
});
|
||||
|
||||
export default async function adminTagRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/admin/tags",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (_req, reply) => {
|
||||
const tags = await prisma.tag.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: { _count: { select: { posts: true } } },
|
||||
take: 500,
|
||||
});
|
||||
reply.send({ tags });
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: z.infer<typeof createTagBody> }>(
|
||||
"/admin/tags",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = createTagBody.parse(req.body);
|
||||
|
||||
try {
|
||||
const tag = await prisma.tag.create({ data: body });
|
||||
req.log.info({ adminId: req.adminId, tagId: tag.id, tagName: tag.name }, "tag created");
|
||||
reply.status(201).send(tag);
|
||||
} catch (err: any) {
|
||||
if (err.code === "P2002") {
|
||||
reply.status(409).send({ error: "Tag already exists" });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string }; Body: z.infer<typeof updateTagBody> }>(
|
||||
"/admin/tags/:id",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const tag = await prisma.tag.findUnique({ where: { id: req.params.id } });
|
||||
if (!tag) {
|
||||
reply.status(404).send({ error: "Tag not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = updateTagBody.parse(req.body);
|
||||
|
||||
try {
|
||||
const updated = await prisma.tag.update({
|
||||
where: { id: tag.id },
|
||||
data: {
|
||||
...(body.name !== undefined && { name: body.name }),
|
||||
...(body.color !== undefined && { color: body.color }),
|
||||
},
|
||||
});
|
||||
req.log.info({ adminId: req.adminId, tagId: tag.id }, "tag updated");
|
||||
reply.send(updated);
|
||||
} catch (err: any) {
|
||||
if (err.code === "P2002") {
|
||||
reply.status(409).send({ error: "Tag name already taken" });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/tags/:id",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const tag = await prisma.tag.findUnique({ where: { id: req.params.id } });
|
||||
if (!tag) {
|
||||
reply.status(404).send({ error: "Tag not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.tag.delete({ where: { id: tag.id } });
|
||||
req.log.info({ adminId: req.adminId, tagId: tag.id, tagName: tag.name }, "tag deleted");
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
// Set tags on a post (replaces existing tags)
|
||||
app.put<{ Params: { id: string }; Body: z.infer<typeof tagPostBody> }>(
|
||||
"/admin/posts/:id/tags",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { tagIds } = tagPostBody.parse(req.body);
|
||||
|
||||
// verify all tags exist
|
||||
const tags = await prisma.tag.findMany({ where: { id: { in: tagIds } } });
|
||||
if (tags.length !== tagIds.length) {
|
||||
reply.status(400).send({ error: "One or more tags not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// replace all tags in a transaction
|
||||
await prisma.$transaction([
|
||||
prisma.postTag.deleteMany({ where: { postId: post.id } }),
|
||||
...tagIds.map((tagId) =>
|
||||
prisma.postTag.create({ data: { postId: post.id, tagId } })
|
||||
),
|
||||
]);
|
||||
|
||||
const updated = await prisma.postTag.findMany({
|
||||
where: { postId: post.id },
|
||||
include: { tag: true },
|
||||
});
|
||||
|
||||
reply.send({ tags: updated.map((pt) => pt.tag) });
|
||||
}
|
||||
);
|
||||
}
|
||||
358
packages/api/src/routes/admin/team.ts
Normal file
358
packages/api/src/routes/admin/team.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
import { config, masterKey, blindIndexKey } from "../../config.js";
|
||||
import { encrypt, decrypt, hashToken, blindIndex } from "../../services/encryption.js";
|
||||
import { generateRecoveryPhrase } from "../../lib/wordlist.js";
|
||||
|
||||
function decryptSafe(v: string | null): string | null {
|
||||
if (!v) return null;
|
||||
try { return decrypt(v, masterKey); } catch { return null; }
|
||||
}
|
||||
|
||||
const inviteBody = z.object({
|
||||
role: z.enum(["ADMIN", "MODERATOR"]),
|
||||
expiresInHours: z.number().int().min(1).max(168).default(72),
|
||||
label: z.string().min(1).max(100).optional(),
|
||||
generateRecovery: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const claimBody = z.object({
|
||||
displayName: z.string().min(1).max(100).trim(),
|
||||
teamTitle: z.string().max(100).trim().optional(),
|
||||
});
|
||||
|
||||
const updateProfileBody = z.object({
|
||||
displayName: z.string().min(1).max(100).trim().optional(),
|
||||
teamTitle: z.string().max(100).trim().optional(),
|
||||
});
|
||||
|
||||
export default async function adminTeamRoutes(app: FastifyInstance) {
|
||||
// list team members
|
||||
app.get(
|
||||
"/admin/team",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const where = req.adminRole === "SUPER_ADMIN" ? {} : { invitedById: req.adminId };
|
||||
const members = await prisma.adminUser.findMany({
|
||||
where,
|
||||
include: {
|
||||
linkedUser: { select: { id: true, authMethod: true, avatarPath: true } },
|
||||
invitedBy: { select: { id: true, displayName: true } },
|
||||
_count: { select: { invitees: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
reply.send({
|
||||
members: members.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
displayName: decryptSafe(m.displayName),
|
||||
teamTitle: decryptSafe(m.teamTitle),
|
||||
email: m.email ?? null,
|
||||
hasPasskey: m.linkedUser?.authMethod === "PASSKEY",
|
||||
avatarUrl: m.linkedUser?.avatarPath ? `/api/v1/avatars/${m.linkedUser.id}` : null,
|
||||
invitedBy: m.invitedBy ? { id: m.invitedBy.id, displayName: decryptSafe(m.invitedBy.displayName) } : null,
|
||||
inviteeCount: m._count.invitees,
|
||||
createdAt: m.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// generate invite
|
||||
app.post(
|
||||
"/admin/team/invite",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
|
||||
async (req, reply) => {
|
||||
const body = inviteBody.parse(req.body);
|
||||
|
||||
// admins can only invite moderators
|
||||
if (req.adminRole === "ADMIN" && body.role !== "MODERATOR") {
|
||||
reply.status(403).send({ error: "Admins can only invite moderators" });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const tokenH = hashToken(token);
|
||||
const expiresAt = new Date(Date.now() + body.expiresInHours * 60 * 60 * 1000);
|
||||
|
||||
let recoveryPhrase: string | null = null;
|
||||
let recoveryHash: string | null = null;
|
||||
let recoveryIdx: string | null = null;
|
||||
|
||||
if (body.generateRecovery) {
|
||||
recoveryPhrase = generateRecoveryPhrase();
|
||||
recoveryHash = await bcrypt.hash(recoveryPhrase, 12);
|
||||
recoveryIdx = blindIndex(recoveryPhrase, blindIndexKey);
|
||||
}
|
||||
|
||||
await prisma.teamInvite.create({
|
||||
data: {
|
||||
tokenHash: tokenH,
|
||||
role: body.role,
|
||||
label: body.label ?? null,
|
||||
expiresAt,
|
||||
createdById: req.adminId!,
|
||||
recoveryHash,
|
||||
recoveryIdx,
|
||||
},
|
||||
});
|
||||
|
||||
const inviteUrl = `${config.WEBAUTHN_ORIGIN}/admin/join/${token}`;
|
||||
reply.status(201).send({ inviteUrl, token, recoveryPhrase, expiresAt });
|
||||
}
|
||||
);
|
||||
|
||||
// list pending invites
|
||||
app.get(
|
||||
"/admin/team/invites",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const where: Record<string, unknown> = { claimedAt: null, expiresAt: { gt: new Date() } };
|
||||
if (req.adminRole !== "SUPER_ADMIN") where.createdById = req.adminId;
|
||||
|
||||
const invites = await prisma.teamInvite.findMany({
|
||||
where,
|
||||
include: { createdBy: { select: { id: true, displayName: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
reply.send({
|
||||
invites: invites.map((inv) => ({
|
||||
id: inv.id,
|
||||
role: inv.role,
|
||||
label: inv.label,
|
||||
expiresAt: inv.expiresAt,
|
||||
hasRecovery: !!inv.recoveryHash,
|
||||
createdBy: { id: inv.createdBy.id, displayName: decryptSafe(inv.createdBy.displayName) },
|
||||
createdAt: inv.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// revoke invite
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/team/invites/:id",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const invite = await prisma.teamInvite.findUnique({ where: { id: req.params.id } });
|
||||
if (!invite || invite.claimedAt) {
|
||||
reply.status(404).send({ error: "Invite not found" });
|
||||
return;
|
||||
}
|
||||
if (req.adminRole !== "SUPER_ADMIN" && invite.createdById !== req.adminId) {
|
||||
reply.status(403).send({ error: "Not your invite" });
|
||||
return;
|
||||
}
|
||||
await prisma.teamInvite.delete({ where: { id: invite.id } });
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
// remove team member (super admin only)
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/team/:id",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
if (req.params.id === req.adminId) {
|
||||
reply.status(400).send({ error: "Cannot remove yourself" });
|
||||
return;
|
||||
}
|
||||
const member = await prisma.adminUser.findUnique({ where: { id: req.params.id } });
|
||||
if (!member) {
|
||||
reply.status(404).send({ error: "Team member not found" });
|
||||
return;
|
||||
}
|
||||
if (member.role === "SUPER_ADMIN") {
|
||||
reply.status(403).send({ error: "Cannot remove the super admin" });
|
||||
return;
|
||||
}
|
||||
await prisma.adminUser.delete({ where: { id: member.id } });
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
// update own profile
|
||||
app.put(
|
||||
"/admin/team/me",
|
||||
{ preHandler: [app.requireAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = updateProfileBody.parse(req.body);
|
||||
const data: Record<string, string> = {};
|
||||
if (body.displayName !== undefined) data.displayName = encrypt(body.displayName, masterKey);
|
||||
if (body.teamTitle !== undefined) data.teamTitle = body.teamTitle ? encrypt(body.teamTitle, masterKey) : "";
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
reply.status(400).send({ error: "Nothing to update" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.adminUser.update({ where: { id: req.adminId! }, data });
|
||||
reply.send({ ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
// regenerate recovery phrase for a team member
|
||||
app.post<{ Params: { id: string } }>(
|
||||
"/admin/team/:id/recovery",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 5, timeWindow: "1 hour" } } },
|
||||
async (req, reply) => {
|
||||
const target = await prisma.adminUser.findUnique({
|
||||
where: { id: req.params.id },
|
||||
select: { id: true, role: true, invitedById: true, linkedUserId: true },
|
||||
});
|
||||
if (!target || !target.linkedUserId) {
|
||||
reply.status(404).send({ error: "Team member not found" });
|
||||
return;
|
||||
}
|
||||
if (target.role === "SUPER_ADMIN") {
|
||||
reply.status(403).send({ error: "Cannot regenerate recovery for super admin" });
|
||||
return;
|
||||
}
|
||||
// admins can only regenerate for people they invited
|
||||
if (req.adminRole === "ADMIN" && target.invitedById !== req.adminId) {
|
||||
reply.status(403).send({ error: "You can only regenerate recovery for people you invited" });
|
||||
return;
|
||||
}
|
||||
|
||||
const phrase = generateRecoveryPhrase();
|
||||
const codeHash = await bcrypt.hash(phrase, 12);
|
||||
const phraseIdx = blindIndex(phrase, blindIndexKey);
|
||||
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await prisma.recoveryCode.upsert({
|
||||
where: { userId: target.linkedUserId },
|
||||
create: { codeHash, phraseIdx, userId: target.linkedUserId, expiresAt },
|
||||
update: { codeHash, phraseIdx, expiresAt },
|
||||
});
|
||||
|
||||
reply.send({ phrase, expiresAt });
|
||||
}
|
||||
);
|
||||
|
||||
// validate invite token (public, for the claim page)
|
||||
app.get<{ Params: { token: string } }>(
|
||||
"/admin/join/:token",
|
||||
{ config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const tokenH = hashToken(req.params.token);
|
||||
const invite = await prisma.teamInvite.findUnique({
|
||||
where: { tokenHash: tokenH },
|
||||
include: { createdBy: { select: { displayName: true } } },
|
||||
});
|
||||
if (!invite || invite.claimedAt || invite.expiresAt < new Date()) {
|
||||
reply.status(404).send({ error: "Invalid or expired invite" });
|
||||
return;
|
||||
}
|
||||
reply.send({
|
||||
role: invite.role,
|
||||
label: invite.label,
|
||||
invitedBy: decryptSafe(invite.createdBy.displayName) ?? "Admin",
|
||||
expiresAt: invite.expiresAt,
|
||||
hasRecovery: !!invite.recoveryHash,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// claim invite
|
||||
app.post<{ Params: { token: string } }>(
|
||||
"/admin/join/:token",
|
||||
{ config: { rateLimit: { max: 5, timeWindow: "15 minutes" } } },
|
||||
async (req, reply) => {
|
||||
const body = claimBody.parse(req.body);
|
||||
const tokenH = hashToken(req.params.token);
|
||||
|
||||
const sessionToken = randomBytes(32).toString("hex");
|
||||
const sessionHash = hashToken(sessionToken);
|
||||
const encDisplayName = encrypt(body.displayName, masterKey);
|
||||
const encTeamTitle = body.teamTitle ? encrypt(body.teamTitle, masterKey) : null;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await prisma.$transaction(async (tx) => {
|
||||
const invite = await tx.teamInvite.findUnique({ where: { tokenHash: tokenH } });
|
||||
if (!invite || invite.claimedAt || invite.expiresAt < new Date()) {
|
||||
throw new Error("INVITE_INVALID");
|
||||
}
|
||||
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
displayName: encDisplayName,
|
||||
authMethod: "COOKIE",
|
||||
tokenHash: sessionHash,
|
||||
},
|
||||
});
|
||||
|
||||
const admin = await tx.adminUser.create({
|
||||
data: {
|
||||
role: invite.role,
|
||||
displayName: encDisplayName,
|
||||
teamTitle: encTeamTitle,
|
||||
linkedUserId: user.id,
|
||||
invitedById: invite.createdById,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamInvite.update({
|
||||
where: { id: invite.id },
|
||||
data: { claimedById: admin.id, claimedAt: new Date() },
|
||||
});
|
||||
|
||||
if (invite.recoveryHash && invite.recoveryIdx) {
|
||||
await tx.recoveryCode.create({
|
||||
data: {
|
||||
codeHash: invite.recoveryHash,
|
||||
phraseIdx: invite.recoveryIdx,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { userId: user.id, adminId: admin.id, hasRecovery: !!invite.recoveryHash };
|
||||
}, { isolationLevel: "Serializable" });
|
||||
} catch (err: any) {
|
||||
if (err.message === "INVITE_INVALID") {
|
||||
reply.status(404).send({ error: "Invalid or expired invite" });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const adminJwt = jwt.sign(
|
||||
{ sub: result.adminId, type: "admin", aud: "echoboard:admin", iss: "echoboard" },
|
||||
config.JWT_SECRET,
|
||||
{ expiresIn: "24h" }
|
||||
);
|
||||
const userJwt = jwt.sign(
|
||||
{ sub: result.userId, type: "passkey", aud: "echoboard:user", iss: "echoboard" },
|
||||
config.JWT_SECRET,
|
||||
{ expiresIn: "24h" }
|
||||
);
|
||||
|
||||
reply
|
||||
.setCookie("echoboard_token", sessionToken, {
|
||||
path: "/", httpOnly: true, sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24 * 90,
|
||||
})
|
||||
.setCookie("echoboard_admin", adminJwt, {
|
||||
path: "/", httpOnly: true, sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24,
|
||||
})
|
||||
.setCookie("echoboard_passkey", userJwt, {
|
||||
path: "/", httpOnly: true, sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24,
|
||||
})
|
||||
.send({ ok: true, needsSetup: !result.hasRecovery });
|
||||
}
|
||||
);
|
||||
}
|
||||
125
packages/api/src/routes/admin/templates.ts
Normal file
125
packages/api/src/routes/admin/templates.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const fieldSchema = z.object({
|
||||
key: z.string().min(1).max(50).regex(/^[a-zA-Z_][a-zA-Z0-9_ ]*$/, "Invalid field key"),
|
||||
label: z.string().min(1).max(100),
|
||||
type: z.enum(["text", "textarea", "select"]),
|
||||
required: z.boolean(),
|
||||
placeholder: z.string().max(200).optional(),
|
||||
options: z.array(z.string().max(100)).optional(),
|
||||
});
|
||||
|
||||
const createBody = z.object({
|
||||
name: z.string().min(1).max(100).trim(),
|
||||
fields: z.array(fieldSchema).min(1).max(30),
|
||||
isDefault: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const updateBody = z.object({
|
||||
name: z.string().min(1).max(100).trim().optional(),
|
||||
fields: z.array(fieldSchema).min(1).max(30).optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
position: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export default async function adminTemplateRoutes(app: FastifyInstance) {
|
||||
app.get<{ Params: { boardId: string } }>(
|
||||
"/admin/boards/:boardId/templates",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
|
||||
if (!board) return reply.status(404).send({ error: "Board not found" });
|
||||
|
||||
const templates = await prisma.boardTemplate.findMany({
|
||||
where: { boardId: board.id },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
|
||||
reply.send({ templates });
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { boardId: string }; Body: z.infer<typeof createBody> }>(
|
||||
"/admin/boards/:boardId/templates",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
|
||||
if (!board) return reply.status(404).send({ error: "Board not found" });
|
||||
|
||||
const body = createBody.parse(req.body);
|
||||
|
||||
const maxPos = await prisma.boardTemplate.aggregate({
|
||||
where: { boardId: board.id },
|
||||
_max: { position: true },
|
||||
});
|
||||
const nextPos = (maxPos._max.position ?? -1) + 1;
|
||||
|
||||
// if setting as default, unset others
|
||||
if (body.isDefault) {
|
||||
await prisma.boardTemplate.updateMany({
|
||||
where: { boardId: board.id, isDefault: true },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
|
||||
const template = await prisma.boardTemplate.create({
|
||||
data: {
|
||||
boardId: board.id,
|
||||
name: body.name,
|
||||
fields: body.fields as any,
|
||||
isDefault: body.isDefault,
|
||||
position: nextPos,
|
||||
},
|
||||
});
|
||||
|
||||
req.log.info({ adminId: req.adminId, templateId: template.id, boardId: board.id }, "template created");
|
||||
reply.status(201).send(template);
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBody> }>(
|
||||
"/admin/templates/:id",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const existing = await prisma.boardTemplate.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) return reply.status(404).send({ error: "Template not found" });
|
||||
|
||||
const body = updateBody.parse(req.body);
|
||||
|
||||
if (body.isDefault) {
|
||||
await prisma.boardTemplate.updateMany({
|
||||
where: { boardId: existing.boardId, isDefault: true, id: { not: existing.id } },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.boardTemplate.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(body.name !== undefined && { name: body.name }),
|
||||
...(body.fields !== undefined && { fields: body.fields as any }),
|
||||
...(body.isDefault !== undefined && { isDefault: body.isDefault }),
|
||||
...(body.position !== undefined && { position: body.position }),
|
||||
},
|
||||
});
|
||||
|
||||
req.log.info({ adminId: req.adminId, templateId: existing.id }, "template updated");
|
||||
reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/templates/:id",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const existing = await prisma.boardTemplate.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) return reply.status(404).send({ error: "Template not found" });
|
||||
|
||||
await prisma.boardTemplate.delete({ where: { id: existing.id } });
|
||||
req.log.info({ adminId: req.adminId, templateId: existing.id, boardId: existing.boardId }, "template deleted");
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
}
|
||||
144
packages/api/src/routes/admin/webhooks.ts
Normal file
144
packages/api/src/routes/admin/webhooks.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { encrypt } from "../../services/encryption.js";
|
||||
import { masterKey } from "../../config.js";
|
||||
import { isAllowedUrl, resolvedIpIsAllowed } from "../../services/webhooks.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
const VALID_EVENTS = ["status_changed", "post_created", "comment_added"] as const;
|
||||
|
||||
const httpsUrl = z.string().url().max(500).refine((val) => {
|
||||
try {
|
||||
return new URL(val).protocol === "https:";
|
||||
} catch { return false; }
|
||||
}, { message: "Only HTTPS URLs are allowed" });
|
||||
|
||||
const createBody = z.object({
|
||||
url: httpsUrl,
|
||||
events: z.array(z.enum(VALID_EVENTS)).min(1),
|
||||
});
|
||||
|
||||
const updateBody = z.object({
|
||||
url: httpsUrl.optional(),
|
||||
events: z.array(z.enum(VALID_EVENTS)).min(1).optional(),
|
||||
active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export default async function adminWebhookRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/admin/webhooks",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (_req, reply) => {
|
||||
const webhooks = await prisma.webhook.findMany({ orderBy: { createdAt: "desc" }, take: 100 });
|
||||
reply.send({
|
||||
webhooks: webhooks.map((w) => ({
|
||||
id: w.id,
|
||||
url: w.url,
|
||||
events: w.events,
|
||||
active: w.active,
|
||||
createdAt: w.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: z.infer<typeof createBody> }>(
|
||||
"/admin/webhooks",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = createBody.parse(req.body);
|
||||
|
||||
if (!isAllowedUrl(body.url)) {
|
||||
reply.status(400).send({ error: "URL not allowed" });
|
||||
return;
|
||||
}
|
||||
const createHostname = new URL(body.url).hostname;
|
||||
if (!await resolvedIpIsAllowed(createHostname)) {
|
||||
reply.status(400).send({ error: "URL resolves to a disallowed address" });
|
||||
return;
|
||||
}
|
||||
|
||||
const plainSecret = randomBytes(32).toString("hex");
|
||||
const encryptedSecret = encrypt(plainSecret, masterKey);
|
||||
|
||||
const webhook = await prisma.webhook.create({
|
||||
data: {
|
||||
url: body.url,
|
||||
secret: encryptedSecret,
|
||||
events: body.events,
|
||||
},
|
||||
});
|
||||
|
||||
req.log.info({ adminId: req.adminId, webhookId: webhook.id }, "webhook created");
|
||||
// return plaintext secret only on creation - admin needs it to verify signatures
|
||||
reply.status(201).send({
|
||||
id: webhook.id,
|
||||
url: webhook.url,
|
||||
events: webhook.events,
|
||||
active: webhook.active,
|
||||
secret: plainSecret,
|
||||
createdAt: webhook.createdAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string }; Body: z.infer<typeof updateBody> }>(
|
||||
"/admin/webhooks/:id",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const wh = await prisma.webhook.findUnique({ where: { id: req.params.id } });
|
||||
if (!wh) {
|
||||
reply.status(404).send({ error: "Webhook not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = updateBody.parse(req.body);
|
||||
|
||||
if (body.url) {
|
||||
if (!isAllowedUrl(body.url)) {
|
||||
reply.status(400).send({ error: "URL not allowed" });
|
||||
return;
|
||||
}
|
||||
const updateHostname = new URL(body.url).hostname;
|
||||
if (!await resolvedIpIsAllowed(updateHostname)) {
|
||||
reply.status(400).send({ error: "URL resolves to a disallowed address" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.webhook.update({
|
||||
where: { id: wh.id },
|
||||
data: {
|
||||
...(body.url !== undefined && { url: body.url }),
|
||||
...(body.events !== undefined && { events: body.events }),
|
||||
...(body.active !== undefined && { active: body.active }),
|
||||
},
|
||||
});
|
||||
req.log.info({ adminId: req.adminId, webhookId: wh.id }, "webhook updated");
|
||||
reply.send({
|
||||
id: updated.id,
|
||||
url: updated.url,
|
||||
events: updated.events,
|
||||
active: updated.active,
|
||||
createdAt: updated.createdAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/admin/webhooks/:id",
|
||||
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const wh = await prisma.webhook.findUnique({ where: { id: req.params.id } });
|
||||
if (!wh) {
|
||||
reply.status(404).send({ error: "Webhook not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.webhook.delete({ where: { id: wh.id } });
|
||||
req.log.info({ adminId: req.adminId, webhookId: wh.id }, "webhook deleted");
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
}
|
||||
170
packages/api/src/routes/attachments.ts
Normal file
170
packages/api/src/routes/attachments.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { existsSync, mkdirSync, createReadStream } from "node:fs";
|
||||
import { unlink, writeFile, realpath } from "node:fs/promises";
|
||||
import { resolve, extname, sep } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const UPLOAD_DIR = resolve(process.cwd(), "uploads");
|
||||
const MAX_SIZE = 5 * 1024 * 1024;
|
||||
const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||
const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
|
||||
|
||||
const MAGIC_BYTES: Record<string, number[][]> = {
|
||||
"image/jpeg": [[0xFF, 0xD8, 0xFF]],
|
||||
"image/png": [[0x89, 0x50, 0x4E, 0x47]],
|
||||
"image/gif": [[0x47, 0x49, 0x46, 0x38, 0x37, 0x61], [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]],
|
||||
"image/webp": [[0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50]],
|
||||
};
|
||||
|
||||
function validateMagicBytes(buf: Buffer, mime: string): boolean {
|
||||
const sigs = MAGIC_BYTES[mime];
|
||||
if (!sigs) return false;
|
||||
return sigs.some((sig) => sig.every((byte, i) => byte === -1 || buf[i] === byte));
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[\/\\:*?"<>|\x00-\x1F]/g, "_").slice(0, 200);
|
||||
}
|
||||
|
||||
function uid() {
|
||||
return randomBytes(16).toString("hex");
|
||||
}
|
||||
|
||||
export default async function attachmentRoutes(app: FastifyInstance) {
|
||||
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
app.post(
|
||||
"/attachments",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
|
||||
async (req, reply) => {
|
||||
const data = await req.file();
|
||||
if (!data) {
|
||||
reply.status(400).send({ error: "No file uploaded" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = extname(data.filename).toLowerCase();
|
||||
if (!ALLOWED_EXT.has(ext) || !ALLOWED_MIME.has(data.mimetype)) {
|
||||
reply.status(400).send({ error: "Only jpg, png, gif, webp images are allowed" });
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of data.file) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_SIZE) {
|
||||
reply.status(400).send({ error: "File too large (max 5MB)" });
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
if (!validateMagicBytes(buffer, data.mimetype)) {
|
||||
reply.status(400).send({ error: "File content does not match declared type" });
|
||||
return;
|
||||
}
|
||||
|
||||
const safeName = sanitizeFilename(data.filename);
|
||||
const storedName = `${uid()}${ext}`;
|
||||
const filePath = resolve(UPLOAD_DIR, storedName);
|
||||
|
||||
if (!filePath.startsWith(UPLOAD_DIR)) {
|
||||
reply.status(400).send({ error: "Invalid file path" });
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
let attachment;
|
||||
try {
|
||||
attachment = await prisma.attachment.create({
|
||||
data: {
|
||||
filename: safeName,
|
||||
path: storedName,
|
||||
size,
|
||||
mimeType: data.mimetype,
|
||||
uploaderId: req.user!.id,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
await unlink(filePath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
|
||||
reply.status(201).send(attachment);
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/attachments/:id",
|
||||
{ config: { rateLimit: { max: 200, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const attachment = await prisma.attachment.findUnique({ where: { id: req.params.id } });
|
||||
if (!attachment) {
|
||||
reply.status(404).send({ error: "Attachment not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolve(UPLOAD_DIR, attachment.path);
|
||||
let realFile: string;
|
||||
try {
|
||||
realFile = await realpath(filePath);
|
||||
} catch {
|
||||
reply.status(404).send({ error: "File missing" });
|
||||
return;
|
||||
}
|
||||
const realUpload = await realpath(UPLOAD_DIR);
|
||||
if (!realFile.startsWith(realUpload + sep)) {
|
||||
reply.status(404).send({ error: "File missing" });
|
||||
return;
|
||||
}
|
||||
|
||||
const safeName = sanitizeFilename(attachment.filename).replace(/"/g, "");
|
||||
reply
|
||||
.header("Content-Type", attachment.mimeType)
|
||||
.header("Content-Disposition", `inline; filename="${safeName}"`)
|
||||
.header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.send(createReadStream(filePath));
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/attachments/:id",
|
||||
{ preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const attachment = await prisma.attachment.findUnique({ where: { id: req.params.id } });
|
||||
if (!attachment) {
|
||||
reply.status(404).send({ error: "Attachment not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment.uploaderId !== req.user!.id && !req.adminId) {
|
||||
reply.status(403).send({ error: "Not your attachment" });
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolve(UPLOAD_DIR, attachment.path);
|
||||
let realFile: string;
|
||||
try {
|
||||
realFile = await realpath(filePath);
|
||||
} catch {
|
||||
await prisma.attachment.delete({ where: { id: attachment.id } });
|
||||
reply.status(204).send();
|
||||
return;
|
||||
}
|
||||
const realUpload = await realpath(UPLOAD_DIR);
|
||||
if (!realFile.startsWith(realUpload + sep)) {
|
||||
reply.status(400).send({ error: "Invalid file path" });
|
||||
return;
|
||||
}
|
||||
await unlink(realFile).catch(() => {});
|
||||
await prisma.attachment.delete({ where: { id: attachment.id } });
|
||||
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
}
|
||||
175
packages/api/src/routes/avatars.ts
Normal file
175
packages/api/src/routes/avatars.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { existsSync, mkdirSync, createReadStream } from "node:fs";
|
||||
import { unlink, writeFile, realpath } from "node:fs/promises";
|
||||
import { resolve, extname, sep } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import prisma from "../lib/prisma.js";
|
||||
|
||||
const UPLOAD_DIR = resolve(process.cwd(), "uploads", "avatars");
|
||||
const MAX_SIZE = 2 * 1024 * 1024;
|
||||
const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]);
|
||||
|
||||
const MAGIC_BYTES: Record<string, number[][]> = {
|
||||
"image/jpeg": [[0xFF, 0xD8, 0xFF]],
|
||||
"image/png": [[0x89, 0x50, 0x4E, 0x47]],
|
||||
"image/webp": [[0x52, 0x49, 0x46, 0x46, -1, -1, -1, -1, 0x57, 0x45, 0x42, 0x50]],
|
||||
};
|
||||
|
||||
function validateMagicBytes(buf: Buffer, mime: string): boolean {
|
||||
const sigs = MAGIC_BYTES[mime];
|
||||
if (!sigs) return false;
|
||||
return sigs.some((sig) => sig.every((byte, i) => byte === -1 || buf[i] === byte));
|
||||
}
|
||||
|
||||
export default async function avatarRoutes(app: FastifyInstance) {
|
||||
if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
app.post(
|
||||
"/me/avatar",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
|
||||
async (req, reply) => {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
|
||||
if (!user) {
|
||||
reply.status(403).send({ error: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
// allow passkey users and admin-linked users
|
||||
if (user.authMethod !== "PASSKEY") {
|
||||
const isTeamMember = await prisma.adminUser.findUnique({ where: { linkedUserId: user.id }, select: { id: true } });
|
||||
if (!isTeamMember) {
|
||||
reply.status(403).send({ error: "Save your identity with a passkey to upload avatars" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data = await req.file();
|
||||
if (!data) {
|
||||
reply.status(400).send({ error: "No file uploaded" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = extname(data.filename).toLowerCase();
|
||||
if (!ALLOWED_EXT.has(ext) || !ALLOWED_MIME.has(data.mimetype)) {
|
||||
reply.status(400).send({ error: "Only jpg, png, webp images are allowed" });
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of data.file) {
|
||||
size += chunk.length;
|
||||
if (size > MAX_SIZE) {
|
||||
reply.status(400).send({ error: "File too large (max 2MB)" });
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
if (!validateMagicBytes(buffer, data.mimetype)) {
|
||||
reply.status(400).send({ error: "File content does not match declared type" });
|
||||
return;
|
||||
}
|
||||
|
||||
// delete old avatar if exists (with traversal check)
|
||||
if (user.avatarPath) {
|
||||
const oldPath = resolve(UPLOAD_DIR, user.avatarPath);
|
||||
try {
|
||||
const realOld = await realpath(oldPath);
|
||||
const realUpload = await realpath(UPLOAD_DIR);
|
||||
if (realOld.startsWith(realUpload + sep)) {
|
||||
await unlink(realOld).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const storedName = `${randomBytes(16).toString("hex")}${ext}`;
|
||||
const filePath = resolve(UPLOAD_DIR, storedName);
|
||||
|
||||
if (!filePath.startsWith(UPLOAD_DIR)) {
|
||||
reply.status(400).send({ error: "Invalid file path" });
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { avatarPath: storedName },
|
||||
});
|
||||
|
||||
reply.send({ avatarUrl: `/api/v1/avatars/${user.id}` });
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/me/avatar",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
|
||||
if (!user || !user.avatarPath) {
|
||||
reply.status(204).send();
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolve(UPLOAD_DIR, user.avatarPath);
|
||||
try {
|
||||
const realFile = await realpath(filePath);
|
||||
const realUpload = await realpath(UPLOAD_DIR);
|
||||
if (realFile.startsWith(realUpload + sep)) {
|
||||
await unlink(realFile).catch(() => {});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { avatarPath: null },
|
||||
});
|
||||
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { userId: string } }>(
|
||||
"/avatars/:userId",
|
||||
{ config: { rateLimit: { max: 200, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.params.userId },
|
||||
select: { avatarPath: true },
|
||||
});
|
||||
|
||||
if (!user?.avatarPath) {
|
||||
reply.status(404).send({ error: "No avatar" });
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = resolve(UPLOAD_DIR, user.avatarPath);
|
||||
let realFile: string;
|
||||
try {
|
||||
realFile = await realpath(filePath);
|
||||
} catch {
|
||||
reply.status(404).send({ error: "File missing" });
|
||||
return;
|
||||
}
|
||||
const realUpload = await realpath(UPLOAD_DIR);
|
||||
if (!realFile.startsWith(realUpload + sep)) {
|
||||
reply.status(404).send({ error: "File missing" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = extname(user.avatarPath).toLowerCase();
|
||||
const mimeMap: Record<string, string> = {
|
||||
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".png": "image/png", ".webp": "image/webp",
|
||||
};
|
||||
|
||||
reply
|
||||
.header("Content-Type", mimeMap[ext] || "application/octet-stream")
|
||||
.header("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400")
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.send(createReadStream(filePath));
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,103 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { encrypt, blindIndex } from "../services/encryption.js";
|
||||
import { masterKey, blindIndexKey } from "../config.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const PUSH_DOMAINS = [
|
||||
"fcm.googleapis.com", "updates.push.services.mozilla.com",
|
||||
"notify.windows.com", "push.apple.com", "web.push.apple.com",
|
||||
];
|
||||
|
||||
function isValidPushEndpoint(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
return PUSH_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith("." + d));
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
const subscribeBody = z.object({
|
||||
endpoint: z.string().url().refine(isValidPushEndpoint, "Must be a known push service endpoint"),
|
||||
keys: z.object({
|
||||
p256dh: z.string().min(1).max(200),
|
||||
auth: z.string().min(1).max(200),
|
||||
}),
|
||||
});
|
||||
|
||||
const DEFAULT_STATUS_CONFIG: { status: string; label: string; color: string; position: number; enabled: boolean }[] = [
|
||||
{ status: "OPEN", label: "Open", color: "#F59E0B", position: 0, enabled: true },
|
||||
{ status: "UNDER_REVIEW", label: "Under Review", color: "#06B6D4", position: 1, enabled: true },
|
||||
{ status: "PLANNED", label: "Planned", color: "#3B82F6", position: 2, enabled: true },
|
||||
{ status: "IN_PROGRESS", label: "In Progress", color: "#EAB308", position: 3, enabled: true },
|
||||
{ status: "DONE", label: "Done", color: "#22C55E", position: 4, enabled: true },
|
||||
{ status: "DECLINED", label: "Declined", color: "#EF4444", position: 5, enabled: true },
|
||||
];
|
||||
|
||||
async function getStatusConfig(boardId: string) {
|
||||
const config = await prisma.boardStatus.findMany({
|
||||
where: { boardId },
|
||||
orderBy: { position: "asc" },
|
||||
});
|
||||
if (config.length === 0) return DEFAULT_STATUS_CONFIG;
|
||||
return config.map((s) => ({
|
||||
status: s.status,
|
||||
label: s.label,
|
||||
color: s.color,
|
||||
position: s.position,
|
||||
enabled: s.enabled,
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function boardRoutes(app: FastifyInstance) {
|
||||
app.get("/boards", async (_req, reply) => {
|
||||
app.get("/boards", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (_req, reply) => {
|
||||
const boards = await prisma.board.findMany({
|
||||
where: { isArchived: false },
|
||||
include: {
|
||||
_count: { select: { posts: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
// use aggregation queries instead of loading all posts into memory
|
||||
const boardIds = boards.map((b) => b.id);
|
||||
const [openCounts, lastActivities] = await Promise.all([
|
||||
prisma.post.groupBy({
|
||||
by: ["boardId"],
|
||||
where: { boardId: { in: boardIds }, status: { in: ["OPEN", "UNDER_REVIEW"] } },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.post.groupBy({
|
||||
by: ["boardId"],
|
||||
where: { boardId: { in: boardIds } },
|
||||
_max: { updatedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const openMap = new Map(openCounts.map((r) => [r.boardId, r._count]));
|
||||
const activityMap = new Map(lastActivities.map((r) => [r.boardId, r._max.updatedAt]));
|
||||
|
||||
const result = boards.map((b) => ({
|
||||
id: b.id,
|
||||
slug: b.slug,
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
externalUrl: b.externalUrl,
|
||||
iconName: b.iconName,
|
||||
iconColor: b.iconColor,
|
||||
voteBudget: b.voteBudget,
|
||||
voteBudgetReset: b.voteBudgetReset,
|
||||
allowMultiVote: b.allowMultiVote,
|
||||
postCount: b._count.posts,
|
||||
openCount: openMap.get(b.id) ?? 0,
|
||||
lastActivity: activityMap.get(b.id)?.toISOString() ?? null,
|
||||
archived: b.isArchived,
|
||||
createdAt: b.createdAt,
|
||||
}));
|
||||
|
||||
reply.send(result);
|
||||
});
|
||||
|
||||
app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug", async (req, reply) => {
|
||||
app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({
|
||||
where: { slug: req.params.boardSlug },
|
||||
include: {
|
||||
@@ -46,19 +114,99 @@ export default async function boardRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statuses = await getStatusConfig(board.id);
|
||||
|
||||
reply.send({
|
||||
id: board.id,
|
||||
slug: board.slug,
|
||||
name: board.name,
|
||||
description: board.description,
|
||||
externalUrl: board.externalUrl,
|
||||
iconName: board.iconName,
|
||||
iconColor: board.iconColor,
|
||||
isArchived: board.isArchived,
|
||||
voteBudget: board.voteBudget,
|
||||
voteBudgetReset: board.voteBudgetReset,
|
||||
allowMultiVote: board.allowMultiVote,
|
||||
postCount: board._count.posts,
|
||||
statuses: statuses.filter((s) => s.enabled),
|
||||
createdAt: board.createdAt,
|
||||
updatedAt: board.updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
app.get<{ Params: { boardSlug: string } }>("/boards/:boardSlug/statuses", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({
|
||||
where: { slug: req.params.boardSlug },
|
||||
});
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const statuses = await getStatusConfig(board.id);
|
||||
reply.send({ statuses: statuses.filter((s) => s.enabled) });
|
||||
});
|
||||
|
||||
// Check board subscription status
|
||||
app.get<{ Params: { slug: string } }>(
|
||||
"/boards/:slug/subscription",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.slug } });
|
||||
if (!board) return reply.status(404).send({ error: "Board not found" });
|
||||
const sub = await prisma.pushSubscription.findFirst({
|
||||
where: { userId: req.user!.id, boardId: board.id, postId: null },
|
||||
});
|
||||
reply.send({ subscribed: !!sub });
|
||||
}
|
||||
);
|
||||
|
||||
// Subscribe to board
|
||||
app.post<{ Params: { slug: string } }>(
|
||||
"/boards/:slug/subscribe",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.slug } });
|
||||
if (!board) return reply.status(404).send({ error: "Board not found" });
|
||||
|
||||
const body = subscribeBody.parse(req.body);
|
||||
const endpointEnc = encrypt(body.endpoint, masterKey);
|
||||
const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
|
||||
const keysP256dh = encrypt(body.keys.p256dh, masterKey);
|
||||
const keysAuth = encrypt(body.keys.auth, masterKey);
|
||||
|
||||
await prisma.pushSubscription.upsert({
|
||||
where: { endpointIdx },
|
||||
create: {
|
||||
endpoint: endpointEnc,
|
||||
endpointIdx,
|
||||
keysP256dh,
|
||||
keysAuth,
|
||||
userId: req.user!.id,
|
||||
boardId: board.id,
|
||||
postId: null,
|
||||
},
|
||||
update: { keysP256dh, keysAuth, boardId: board.id, postId: null },
|
||||
});
|
||||
|
||||
reply.send({ subscribed: true });
|
||||
}
|
||||
);
|
||||
|
||||
// Unsubscribe from board
|
||||
app.delete<{ Params: { slug: string } }>(
|
||||
"/boards/:slug/subscribe",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.slug } });
|
||||
if (!board) return reply.status(404).send({ error: "Board not found" });
|
||||
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: { userId: req.user!.id, boardId: board.id, postId: null },
|
||||
});
|
||||
|
||||
reply.send({ subscribed: false });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
26
packages/api/src/routes/changelog.ts
Normal file
26
packages/api/src/routes/changelog.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
|
||||
export default async function changelogRoutes(app: FastifyInstance) {
|
||||
const handler = async (req: any, reply: any) => {
|
||||
const boardSlug = req.params?.boardSlug || (req.query as any)?.board || null;
|
||||
|
||||
const where: any = { publishedAt: { lte: new Date() } };
|
||||
if (boardSlug) {
|
||||
where.board = { slug: boardSlug };
|
||||
}
|
||||
|
||||
const entries = await prisma.changelogEntry.findMany({
|
||||
where,
|
||||
include: { board: { select: { slug: true, name: true } } },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
reply.send({ entries });
|
||||
};
|
||||
|
||||
const opts = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } };
|
||||
|
||||
app.get("/changelog", opts, handler);
|
||||
app.get("/b/:boardSlug/changelog", opts, handler);
|
||||
}
|
||||
@@ -1,28 +1,54 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { verifyChallenge } from "../services/altcha.js";
|
||||
import { decrypt, blindIndex } from "../services/encryption.js";
|
||||
import { masterKey, blindIndexKey } from "../config.js";
|
||||
import { notifyPostSubscribers, notifyUserReply } from "../services/push.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g;
|
||||
|
||||
function cleanAuthor(author: { id: string; displayName: string | null; avatarPath?: string | null } | null) {
|
||||
if (!author) return null;
|
||||
let name: string | null = null;
|
||||
if (author.displayName) { try { name = decrypt(author.displayName, masterKey); } catch {} }
|
||||
return {
|
||||
id: author.id,
|
||||
displayName: name,
|
||||
avatarUrl: author.avatarPath ? `/api/v1/avatars/${author.id}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
const createCommentSchema = z.object({
|
||||
body: z.string().min(1).max(5000),
|
||||
altcha: z.string(),
|
||||
body: z.string().min(1).max(2000),
|
||||
altcha: z.string().optional(),
|
||||
replyToId: z.string().max(30).optional(),
|
||||
attachmentIds: z.array(z.string()).max(10).optional(),
|
||||
});
|
||||
|
||||
const updateCommentSchema = z.object({
|
||||
body: z.string().min(1).max(5000),
|
||||
body: z.string().min(1).max(2000),
|
||||
});
|
||||
|
||||
export default async function commentRoutes(app: FastifyInstance) {
|
||||
app.get<{ Params: { boardSlug: string; id: string }; Querystring: { page?: string } }>(
|
||||
"/boards/:boardSlug/posts/:id/comments",
|
||||
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const page = Math.max(1, parseInt(req.query.page ?? "1", 10));
|
||||
const parsed = parseInt(req.query.page ?? "1", 10);
|
||||
const page = Math.max(1, Math.min(500, Number.isNaN(parsed) ? 1 : parsed));
|
||||
const limit = 50;
|
||||
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
if (!post || post.boardId !== board.id) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
@@ -31,29 +57,43 @@ export default async function commentRoutes(app: FastifyInstance) {
|
||||
prisma.comment.findMany({
|
||||
where: { postId: post.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
skip: (page - 1) * limit,
|
||||
skip: Math.min((page - 1) * limit, 50000),
|
||||
take: limit,
|
||||
include: {
|
||||
author: { select: { id: true, displayName: true } },
|
||||
author: { select: { id: true, displayName: true, avatarPath: true } },
|
||||
reactions: {
|
||||
select: { emoji: true, userId: true },
|
||||
},
|
||||
replyTo: {
|
||||
select: {
|
||||
id: true,
|
||||
body: true,
|
||||
isAdmin: true,
|
||||
author: { select: { id: true, displayName: true, avatarPath: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.comment.count({ where: { postId: post.id } }),
|
||||
]);
|
||||
|
||||
const grouped = comments.map((c) => {
|
||||
const reactionMap: Record<string, { count: number; userIds: string[] }> = {};
|
||||
const reactionMap: Record<string, { count: number }> = {};
|
||||
for (const r of c.reactions) {
|
||||
if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0, userIds: [] };
|
||||
if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0 };
|
||||
reactionMap[r.emoji].count++;
|
||||
reactionMap[r.emoji].userIds.push(r.userId);
|
||||
}
|
||||
return {
|
||||
id: c.id,
|
||||
body: c.body,
|
||||
author: c.author,
|
||||
isAdmin: c.isAdmin,
|
||||
author: cleanAuthor(c.author),
|
||||
replyTo: c.replyTo ? {
|
||||
id: c.replyTo.id,
|
||||
body: c.replyTo.body.slice(0, 200),
|
||||
isAdmin: c.replyTo.isAdmin,
|
||||
authorName: cleanAuthor(c.replyTo.author)?.displayName ?? `Anonymous #${(c.replyTo.author?.id ?? "0000").slice(-4)}`,
|
||||
} : null,
|
||||
reactions: reactionMap,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
@@ -66,84 +106,316 @@ export default async function commentRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof createCommentSchema> }>(
|
||||
"/boards/:boardSlug/posts/:id/comments",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 20, timeWindow: "1 hour" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
if (board.isArchived) {
|
||||
reply.status(403).send({ error: "Board is archived" });
|
||||
return;
|
||||
}
|
||||
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
if (!post || post.boardId !== board.id) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
if (post.isThreadLocked) {
|
||||
reply.status(403).send({ error: "Thread is locked" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = createCommentSchema.parse(req.body);
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
|
||||
// admins skip ALTCHA, regular users must provide it
|
||||
if (!req.adminId) {
|
||||
if (!body.altcha) {
|
||||
reply.status(400).send({ error: "Challenge response required" });
|
||||
return;
|
||||
}
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// validate replyToId belongs to the same post
|
||||
if (body.replyToId) {
|
||||
const target = await prisma.comment.findUnique({
|
||||
where: { id: body.replyToId },
|
||||
select: { postId: true },
|
||||
});
|
||||
if (!target || target.postId !== post.id) {
|
||||
reply.status(400).send({ error: "Invalid reply target" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const cleanBody = body.body.replace(INVISIBLE_RE, '');
|
||||
const isAdmin = !!req.adminId;
|
||||
const comment = await prisma.comment.create({
|
||||
data: {
|
||||
body: body.body,
|
||||
body: cleanBody,
|
||||
postId: post.id,
|
||||
authorId: req.user!.id,
|
||||
replyToId: body.replyToId ?? null,
|
||||
isAdmin,
|
||||
adminUserId: req.adminId ?? null,
|
||||
},
|
||||
include: {
|
||||
author: { select: { id: true, displayName: true } },
|
||||
author: { select: { id: true, displayName: true, avatarPath: true } },
|
||||
replyTo: {
|
||||
select: {
|
||||
id: true,
|
||||
body: true,
|
||||
isAdmin: true,
|
||||
authorId: true,
|
||||
author: { select: { id: true, displayName: true, avatarPath: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.activityEvent.create({
|
||||
data: {
|
||||
type: "comment_created",
|
||||
boardId: post.boardId,
|
||||
postId: post.id,
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
if (body.attachmentIds?.length) {
|
||||
await prisma.attachment.updateMany({
|
||||
where: {
|
||||
id: { in: body.attachmentIds },
|
||||
uploaderId: req.user!.id,
|
||||
postId: null,
|
||||
commentId: null,
|
||||
},
|
||||
data: { commentId: comment.id },
|
||||
});
|
||||
}
|
||||
|
||||
reply.status(201).send(comment);
|
||||
await Promise.all([
|
||||
prisma.activityEvent.create({
|
||||
data: {
|
||||
type: isAdmin ? "admin_responded" : "comment_created",
|
||||
boardId: post.boardId,
|
||||
postId: post.id,
|
||||
metadata: {},
|
||||
},
|
||||
}),
|
||||
prisma.post.update({
|
||||
where: { id: post.id },
|
||||
data: { lastActivityAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
|
||||
// in-app notification: notify post author about new comment (unless they wrote it)
|
||||
if (post.authorId !== req.user!.id) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
type: isAdmin ? "admin_response" : "comment",
|
||||
title: isAdmin ? "Official response" : "New comment",
|
||||
body: `${isAdmin ? "Admin commented on" : "Someone commented on"} "${post.title}"`,
|
||||
postId: post.id,
|
||||
userId: post.authorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// in-app notification: notify the replied-to user
|
||||
if (comment.replyTo && comment.replyTo.authorId !== req.user!.id) {
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
type: "reply",
|
||||
title: isAdmin ? "Admin replied to you" : "New reply",
|
||||
body: `${isAdmin ? "Admin" : "Someone"} replied to your comment on "${post.title}"`,
|
||||
postId: post.id,
|
||||
userId: comment.replyTo.authorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// push notify post subscribers for admin comments
|
||||
if (isAdmin) {
|
||||
await notifyPostSubscribers(post.id, {
|
||||
title: "Official response",
|
||||
body: body.body.slice(0, 100),
|
||||
url: `/post/${post.id}`,
|
||||
tag: `response-${post.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
// push notify the quoted user if this is a reply
|
||||
if (comment.replyTo && comment.replyTo.authorId !== req.user!.id) {
|
||||
await notifyUserReply(comment.replyTo.authorId, {
|
||||
title: isAdmin ? "Admin replied to your comment" : "Someone replied to your comment",
|
||||
body: body.body.slice(0, 100),
|
||||
url: `/post/${post.id}`,
|
||||
tag: `reply-${comment.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
// @mention notifications
|
||||
const mentionRe = /@([a-zA-Z0-9_]{3,30})\b/g;
|
||||
const mentions = [...new Set(Array.from(cleanBody.matchAll(mentionRe), (m) => m[1]))];
|
||||
if (mentions.length > 0) {
|
||||
const mentioned: string[] = [];
|
||||
for (const name of mentions.slice(0, 10)) {
|
||||
const idx = blindIndex(name, blindIndexKey);
|
||||
const found = await prisma.user.findUnique({ where: { usernameIdx: idx }, select: { id: true } });
|
||||
if (found && found.id !== req.user!.id) mentioned.push(found.id);
|
||||
}
|
||||
if (mentioned.length > 0) {
|
||||
await prisma.notification.createMany({
|
||||
data: mentioned.map((userId) => ({
|
||||
type: "mention",
|
||||
title: "You were mentioned",
|
||||
body: `Mentioned in a comment on "${post.title}"`,
|
||||
postId: post.id,
|
||||
userId,
|
||||
})),
|
||||
});
|
||||
for (const uid of mentioned) {
|
||||
notifyUserReply(uid, {
|
||||
title: "You were mentioned",
|
||||
body: cleanBody.slice(0, 100),
|
||||
url: `/post/${post.id}`,
|
||||
tag: `mention-${comment.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reply.status(201).send({
|
||||
id: comment.id,
|
||||
body: comment.body,
|
||||
isAdmin: comment.isAdmin,
|
||||
author: cleanAuthor(comment.author),
|
||||
replyTo: comment.replyTo ? {
|
||||
id: comment.replyTo.id,
|
||||
body: comment.replyTo.body.slice(0, 200),
|
||||
isAdmin: comment.replyTo.isAdmin,
|
||||
authorName: cleanAuthor(comment.replyTo.author)?.displayName ?? `Anonymous #${(comment.replyTo.author?.id ?? "0000").slice(-4)}`,
|
||||
} : null,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { id: string }; Body: z.infer<typeof updateCommentSchema> }>(
|
||||
"/comments/:id",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
|
||||
if (!comment) {
|
||||
reply.status(404).send({ error: "Comment not found" });
|
||||
return;
|
||||
}
|
||||
if (comment.authorId !== req.user!.id) {
|
||||
// admins can edit their own admin comments, users can edit their own comments
|
||||
const isOwnComment = comment.authorId === req.user!.id;
|
||||
const isOwnAdminComment = req.adminId && comment.isAdmin && comment.adminUserId === req.adminId;
|
||||
if (!isOwnComment && !isOwnAdminComment) {
|
||||
reply.status(403).send({ error: "Not your comment" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (comment.isEditLocked) {
|
||||
reply.status(403).send({ error: "Editing is locked on this comment" });
|
||||
return;
|
||||
}
|
||||
const parentPost = await prisma.post.findUnique({
|
||||
where: { id: comment.postId },
|
||||
select: { isThreadLocked: true },
|
||||
});
|
||||
if (parentPost?.isThreadLocked) {
|
||||
reply.status(403).send({ error: "Thread is locked" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = updateCommentSchema.parse(req.body);
|
||||
|
||||
const cleanBody = body.body.replace(INVISIBLE_RE, '');
|
||||
if (cleanBody !== comment.body) {
|
||||
await prisma.editHistory.create({
|
||||
data: {
|
||||
commentId: comment.id,
|
||||
editedBy: req.user!.id,
|
||||
previousBody: comment.body,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.comment.update({
|
||||
where: { id: comment.id },
|
||||
data: { body: body.body },
|
||||
data: { body: cleanBody },
|
||||
});
|
||||
|
||||
reply.send(updated);
|
||||
reply.send({
|
||||
id: updated.id, body: updated.body, isAdmin: updated.isAdmin,
|
||||
createdAt: updated.createdAt, updatedAt: updated.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/comments/:id/edits",
|
||||
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
|
||||
if (!comment) {
|
||||
reply.status(404).send({ error: "Comment not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const edits = await prisma.editHistory.findMany({
|
||||
where: { commentId: comment.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
include: {
|
||||
editor: { select: { id: true, displayName: true, avatarPath: true } },
|
||||
},
|
||||
});
|
||||
|
||||
reply.send(edits.map((e) => ({
|
||||
id: e.id,
|
||||
previousBody: e.previousBody,
|
||||
editedBy: cleanAuthor(e.editor),
|
||||
createdAt: e.createdAt,
|
||||
})));
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/comments/:id",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
|
||||
if (!comment) {
|
||||
reply.status(404).send({ error: "Comment not found" });
|
||||
return;
|
||||
}
|
||||
if (comment.authorId !== req.user!.id) {
|
||||
const isOwnComment = comment.authorId === req.user!.id;
|
||||
const isOwnAdminComment = req.adminId && comment.isAdmin && comment.adminUserId === req.adminId;
|
||||
if (!isOwnComment && !isOwnAdminComment) {
|
||||
reply.status(403).send({ error: "Not your comment" });
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
where: { commentId: comment.id },
|
||||
select: { id: true, path: true },
|
||||
});
|
||||
|
||||
if (attachments.length) {
|
||||
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
|
||||
}
|
||||
|
||||
await prisma.comment.delete({ where: { id: comment.id } });
|
||||
|
||||
const uploadDir = resolve(process.cwd(), "uploads");
|
||||
for (const att of attachments) {
|
||||
await unlink(resolve(uploadDir, att.path)).catch(() => {});
|
||||
}
|
||||
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
74
packages/api/src/routes/embed.ts
Normal file
74
packages/api/src/routes/embed.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { z } from "zod";
|
||||
|
||||
const VALID_STATUSES = ["OPEN", "UNDER_REVIEW", "PLANNED", "IN_PROGRESS", "DONE", "DECLINED"] as const;
|
||||
|
||||
const embedQuery = z.object({
|
||||
sort: z.enum(["newest", "top"]).default("top"),
|
||||
status: z.enum(VALID_STATUSES).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(30).default(10),
|
||||
});
|
||||
|
||||
export default async function embedRoutes(app: FastifyInstance) {
|
||||
app.get<{ Params: { boardSlug: string }; Querystring: Record<string, string> }>(
|
||||
"/embed/:boardSlug/posts",
|
||||
{ config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({
|
||||
where: { slug: req.params.boardSlug },
|
||||
});
|
||||
if (!board || board.isArchived) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const q = embedQuery.safeParse(req.query);
|
||||
if (!q.success) {
|
||||
reply.status(400).send({ error: "Invalid parameters" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { sort, status, limit } = q.data;
|
||||
const where: Record<string, unknown> = { boardId: board.id };
|
||||
if (status) where.status = status;
|
||||
|
||||
const orderBy = sort === "top"
|
||||
? { voteCount: "desc" as const }
|
||||
: { createdAt: "desc" as const };
|
||||
|
||||
const posts = await prisma.post.findMany({
|
||||
where,
|
||||
orderBy: [{ isPinned: "desc" }, orderBy],
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
status: true,
|
||||
voteCount: true,
|
||||
isPinned: true,
|
||||
createdAt: true,
|
||||
_count: { select: { comments: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// cache for 60s
|
||||
reply.header("Cache-Control", "public, max-age=60");
|
||||
|
||||
reply.send({
|
||||
board: { name: board.name, slug: board.slug },
|
||||
posts: posts.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
type: p.type,
|
||||
status: p.status,
|
||||
voteCount: p.voteCount,
|
||||
isPinned: p.isPinned,
|
||||
commentCount: p._count.comments,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,105 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import RSS from "rss";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { decrypt } from "../services/encryption.js";
|
||||
import { masterKey, config } from "../config.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
function decryptName(v: string | null): string | null {
|
||||
if (!v) return null;
|
||||
try { return decrypt(v, masterKey); } catch { return null; }
|
||||
}
|
||||
|
||||
function stripHtml(s: string): string {
|
||||
return s.replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] || c));
|
||||
}
|
||||
|
||||
function excerpt(description: Record<string, unknown>, maxLen = 300): string {
|
||||
const text = Object.values(description)
|
||||
.filter((v) => typeof v === "string")
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const trimmed = text.length > maxLen ? text.slice(0, maxLen) + "..." : text;
|
||||
return stripHtml(trimmed);
|
||||
}
|
||||
|
||||
interface FeedItem {
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
url: string;
|
||||
date: Date;
|
||||
guid: string;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
async function buildBoardFeedItems(boardId: string, boardSlug: string, baseUrl: string, itemCount = 50): Promise<FeedItem[]> {
|
||||
const [posts, statusChanges, adminComments] = await Promise.all([
|
||||
prisma.post.findMany({
|
||||
where: { boardId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: itemCount,
|
||||
include: { author: { select: { id: true, displayName: true, avatarPath: true } } },
|
||||
}),
|
||||
prisma.statusChange.findMany({
|
||||
where: { post: { boardId } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: Math.ceil(itemCount / 2),
|
||||
include: { post: { select: { id: true, title: true } } },
|
||||
}),
|
||||
prisma.comment.findMany({
|
||||
where: { post: { boardId }, isAdmin: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: Math.ceil(itemCount / 2),
|
||||
include: { post: { select: { id: true, title: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const items: FeedItem[] = [];
|
||||
|
||||
for (const post of posts) {
|
||||
const typeLabel = post.type === "BUG_REPORT" ? "Bug Report" : "Feature Request";
|
||||
items.push({
|
||||
title: `[${typeLabel}] ${post.title}`,
|
||||
description: `${excerpt(post.description as Record<string, unknown>)} (${post.voteCount} votes)`,
|
||||
author: stripHtml(decryptName(post.author?.displayName ?? null) ?? `Anonymous #${(post.author?.id ?? "0000").slice(-4)}`),
|
||||
url: `${baseUrl}/b/${boardSlug}/post/${post.id}`,
|
||||
date: post.createdAt,
|
||||
guid: post.id,
|
||||
categories: [typeLabel, ...(post.category ? [post.category] : [])],
|
||||
});
|
||||
}
|
||||
|
||||
for (const sc of statusChanges) {
|
||||
items.push({
|
||||
title: `Status: ${sc.post.title} - ${sc.fromStatus} to ${sc.toStatus}`,
|
||||
description: `"${sc.post.title}" moved from ${sc.fromStatus} to ${sc.toStatus}`,
|
||||
author: "Admin",
|
||||
url: `${baseUrl}/b/${boardSlug}/post/${sc.postId}`,
|
||||
date: sc.createdAt,
|
||||
guid: `${sc.postId}-status-${sc.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
for (const ac of adminComments) {
|
||||
items.push({
|
||||
title: `Official response: ${ac.post.title}`,
|
||||
description: stripHtml(ac.body.length > 300 ? ac.body.slice(0, 300) + "..." : ac.body),
|
||||
author: "Admin",
|
||||
url: `${baseUrl}/b/${boardSlug}/post/${ac.postId}`,
|
||||
date: ac.createdAt,
|
||||
guid: `${ac.postId}-response-${ac.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
return items.slice(0, itemCount);
|
||||
}
|
||||
|
||||
export default async function feedRoutes(app: FastifyInstance) {
|
||||
app.get<{ Params: { boardSlug: string } }>(
|
||||
"/boards/:boardSlug/feed.rss",
|
||||
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
@@ -14,54 +107,59 @@ export default async function feedRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const posts = await prisma.post.findMany({
|
||||
where: { boardId: board.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
if (!board.rssEnabled) {
|
||||
reply.status(404).send({ error: "RSS feed disabled for this board" });
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = config.WEBAUTHN_ORIGIN;
|
||||
const items = await buildBoardFeedItems(board.id, board.slug, baseUrl, board.rssFeedCount);
|
||||
|
||||
const feed = new RSS({
|
||||
title: `${board.name} - Echoboard`,
|
||||
description: board.description ?? "",
|
||||
feed_url: `${req.protocol}://${req.hostname}/api/v1/boards/${board.slug}/feed.rss`,
|
||||
site_url: `${req.protocol}://${req.hostname}`,
|
||||
feed_url: `${baseUrl}/api/v1/boards/${board.slug}/feed.rss`,
|
||||
site_url: baseUrl,
|
||||
});
|
||||
|
||||
for (const post of posts) {
|
||||
feed.item({
|
||||
title: post.title,
|
||||
description: `[${post.type}] ${post.status} - ${post.voteCount} votes`,
|
||||
url: `${req.protocol}://${req.hostname}/board/${board.slug}/post/${post.id}`,
|
||||
date: post.createdAt,
|
||||
categories: post.category ? [post.category] : [],
|
||||
});
|
||||
for (const item of items) {
|
||||
feed.item(item);
|
||||
}
|
||||
|
||||
reply.header("Content-Type", "application/rss+xml").send(feed.xml({ indent: true }));
|
||||
}
|
||||
);
|
||||
|
||||
app.get("/feed.rss", async (req, reply) => {
|
||||
const posts = await prisma.post.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
include: { board: { select: { slug: true, name: true } } },
|
||||
app.get("/feed.rss", { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, async (req, reply) => {
|
||||
const boards = await prisma.board.findMany({
|
||||
where: { isArchived: false, rssEnabled: true },
|
||||
select: { id: true, slug: true, name: true, rssFeedCount: true },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
const baseUrl = config.WEBAUTHN_ORIGIN;
|
||||
const allItems: FeedItem[] = [];
|
||||
|
||||
const boardResults = await Promise.all(
|
||||
boards.map((board) =>
|
||||
buildBoardFeedItems(board.id, board.slug, baseUrl, Math.min(board.rssFeedCount, 50))
|
||||
.then((items) => items.map((item) => ({ ...item, title: `[${board.name}] ${item.title}` })))
|
||||
)
|
||||
);
|
||||
for (const items of boardResults) {
|
||||
allItems.push(...items);
|
||||
}
|
||||
|
||||
allItems.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
|
||||
const feed = new RSS({
|
||||
title: "Echoboard - All Feedback",
|
||||
feed_url: `${req.protocol}://${req.hostname}/api/v1/feed.rss`,
|
||||
site_url: `${req.protocol}://${req.hostname}`,
|
||||
feed_url: `${baseUrl}/api/v1/feed.rss`,
|
||||
site_url: baseUrl,
|
||||
});
|
||||
|
||||
for (const post of posts) {
|
||||
feed.item({
|
||||
title: `[${post.board.name}] ${post.title}`,
|
||||
description: `[${post.type}] ${post.status} - ${post.voteCount} votes`,
|
||||
url: `${req.protocol}://${req.hostname}/board/${post.board.slug}/post/${post.id}`,
|
||||
date: post.createdAt,
|
||||
categories: post.category ? [post.category] : [],
|
||||
});
|
||||
for (const item of allItems.slice(0, 50)) {
|
||||
feed.item(item);
|
||||
}
|
||||
|
||||
reply.header("Content-Type", "application/rss+xml").send(feed.xml({ indent: true }));
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
import { hashToken, encrypt, decrypt } from "../services/encryption.js";
|
||||
import { masterKey } from "../config.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { hashToken, encrypt, decrypt, blindIndex } from "../services/encryption.js";
|
||||
import { masterKey, blindIndexKey } from "../config.js";
|
||||
import { verifyChallenge } from "../services/altcha.js";
|
||||
import { blockToken } from "../lib/token-blocklist.js";
|
||||
|
||||
const updateMeSchema = z.object({
|
||||
displayName: z.string().max(50).optional().nullable(),
|
||||
darkMode: z.enum(["system", "light", "dark"]).optional(),
|
||||
altcha: z.string().optional(),
|
||||
});
|
||||
|
||||
export default async function identityRoutes(app: FastifyInstance) {
|
||||
app.post("/identity", async (_req, reply) => {
|
||||
app.post("/identity", { config: { rateLimit: { max: 5, timeWindow: "1 hour" } } }, async (_req, reply) => {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const hash = hashToken(token);
|
||||
|
||||
@@ -27,7 +30,7 @@ export default async function identityRoutes(app: FastifyInstance) {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
maxAge: 60 * 60 * 24 * 90,
|
||||
})
|
||||
.status(201)
|
||||
.send({
|
||||
@@ -37,15 +40,57 @@ export default async function identityRoutes(app: FastifyInstance) {
|
||||
});
|
||||
});
|
||||
|
||||
app.put<{ Body: z.infer<typeof updateMeSchema> }>(
|
||||
app.get(
|
||||
"/me",
|
||||
{ preHandler: [app.requireUser] },
|
||||
async (req, reply) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
include: { recoveryCode: { select: { expiresAt: true } } },
|
||||
});
|
||||
if (!user) {
|
||||
reply.status(404).send({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
reply.send({
|
||||
id: user.id,
|
||||
displayName: user.displayName ? decrypt(user.displayName, masterKey) : null,
|
||||
username: user.username ? decrypt(user.username, masterKey) : null,
|
||||
isPasskeyUser: user.authMethod === "PASSKEY",
|
||||
avatarUrl: user.avatarPath ? `/api/v1/avatars/${user.id}` : null,
|
||||
darkMode: user.darkMode,
|
||||
hasRecoveryCode: !!user.recoveryCode && user.recoveryCode.expiresAt > new Date(),
|
||||
recoveryCodeExpiresAt: user.recoveryCode?.expiresAt ?? null,
|
||||
createdAt: user.createdAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Body: z.infer<typeof updateMeSchema> }>(
|
||||
"/me",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = updateMeSchema.parse(req.body);
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
if (body.displayName !== undefined && body.displayName !== null) {
|
||||
if (!body.altcha) {
|
||||
reply.status(400).send({ error: "Verification required for display name changes" });
|
||||
return;
|
||||
}
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (body.displayName !== undefined) {
|
||||
data.displayName = body.displayName ? encrypt(body.displayName, masterKey) : null;
|
||||
const cleanName = body.displayName
|
||||
? body.displayName.replace(/[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g, '')
|
||||
: null;
|
||||
data.displayName = cleanName ? encrypt(cleanName, masterKey) : null;
|
||||
}
|
||||
if (body.darkMode !== undefined) {
|
||||
data.darkMode = body.darkMode;
|
||||
@@ -59,62 +104,276 @@ export default async function identityRoutes(app: FastifyInstance) {
|
||||
reply.send({
|
||||
id: updated.id,
|
||||
displayName: updated.displayName ? decrypt(updated.displayName, masterKey) : null,
|
||||
username: updated.username ? decrypt(updated.username, masterKey) : null,
|
||||
isPasskeyUser: updated.authMethod === "PASSKEY",
|
||||
avatarUrl: updated.avatarPath ? `/api/v1/avatars/${updated.id}` : null,
|
||||
darkMode: updated.darkMode,
|
||||
authMethod: updated.authMethod,
|
||||
createdAt: updated.createdAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/me/posts",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const posts = await prisma.post.findMany({
|
||||
where: { authorId: req.user!.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
board: { select: { slug: true, name: true } },
|
||||
_count: { select: { comments: true } },
|
||||
},
|
||||
});
|
||||
const page = Math.max(1, Math.min(500, parseInt((req.query as any).page ?? '1', 10) || 1));
|
||||
|
||||
reply.send(posts.map((p) => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
title: p.title,
|
||||
status: p.status,
|
||||
voteCount: p.voteCount,
|
||||
commentCount: p._count.comments,
|
||||
board: p.board,
|
||||
createdAt: p.createdAt,
|
||||
})));
|
||||
const [posts, total] = await Promise.all([
|
||||
prisma.post.findMany({
|
||||
where: { authorId: req.user!.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
skip: (page - 1) * 50,
|
||||
include: {
|
||||
board: { select: { slug: true, name: true } },
|
||||
_count: { select: { comments: true } },
|
||||
},
|
||||
}),
|
||||
prisma.post.count({ where: { authorId: req.user!.id } }),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
posts: posts.map((p) => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
title: p.title,
|
||||
status: p.status,
|
||||
voteCount: p.voteCount,
|
||||
commentCount: p._count.comments,
|
||||
board: p.board,
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pages: Math.ceil(total / 50),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/me",
|
||||
{ preHandler: [app.requireUser] },
|
||||
app.get(
|
||||
"/me/profile",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
await prisma.user.delete({ where: { id: req.user!.id } });
|
||||
const userId = req.user!.id;
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) { reply.status(404).send({ error: "User not found" }); return; }
|
||||
|
||||
const [postCount, commentCount, voteCount, votesReceived] = await Promise.all([
|
||||
prisma.post.count({ where: { authorId: userId } }),
|
||||
prisma.comment.count({ where: { authorId: userId } }),
|
||||
prisma.vote.count({ where: { voterId: userId } }),
|
||||
prisma.vote.aggregate({
|
||||
_sum: { weight: true },
|
||||
where: { post: { authorId: userId } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const votedPosts = await prisma.vote.findMany({
|
||||
where: { voterId: userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
include: {
|
||||
post: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
voteCount: true,
|
||||
board: { select: { slug: true, name: true } },
|
||||
_count: { select: { comments: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({
|
||||
id: user.id,
|
||||
displayName: user.displayName ? decrypt(user.displayName, masterKey) : null,
|
||||
username: user.username ? decrypt(user.username, masterKey) : null,
|
||||
isPasskeyUser: user.authMethod === "PASSKEY",
|
||||
avatarUrl: user.avatarPath ? `/api/v1/avatars/${user.id}` : null,
|
||||
createdAt: user.createdAt,
|
||||
stats: {
|
||||
posts: postCount,
|
||||
comments: commentCount,
|
||||
votesGiven: voteCount,
|
||||
votesReceived: votesReceived._sum.weight ?? 0,
|
||||
},
|
||||
votedPosts: votedPosts
|
||||
.filter((v) => v.post)
|
||||
.map((v) => ({
|
||||
id: v.post.id,
|
||||
title: v.post.title,
|
||||
status: v.post.status,
|
||||
voteCount: v.post.voteCount,
|
||||
commentCount: v.post._count.comments,
|
||||
board: v.post.board,
|
||||
votedAt: v.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Body: { altcha: string } }>(
|
||||
"/me",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 3, timeWindow: "1 hour" } } },
|
||||
async (req, reply) => {
|
||||
const { altcha } = (req.body as any) || {};
|
||||
if (!altcha) {
|
||||
reply.status(400).send({ error: "Verification required to delete account" });
|
||||
return;
|
||||
}
|
||||
const challengeValid = await verifyChallenge(altcha);
|
||||
if (!challengeValid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.user!.id;
|
||||
|
||||
// collect user's avatar path before deletion
|
||||
const userForAvatar = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { avatarPath: true },
|
||||
});
|
||||
|
||||
// collect attachment paths before deletion
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
where: { uploaderId: userId },
|
||||
select: { path: true },
|
||||
});
|
||||
|
||||
// ensure a sentinel "deleted user" exists for orphaned content
|
||||
const sentinelId = "deleted-user-sentinel";
|
||||
await prisma.user.upsert({
|
||||
where: { id: sentinelId },
|
||||
create: { id: sentinelId, authMethod: "COOKIE", darkMode: "system" },
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. remove votes and recalculate voteCount on affected posts
|
||||
const votes = await tx.vote.findMany({
|
||||
where: { voterId: userId },
|
||||
select: { postId: true, weight: true },
|
||||
});
|
||||
await tx.vote.deleteMany({ where: { voterId: userId } });
|
||||
for (const vote of votes) {
|
||||
await tx.post.update({
|
||||
where: { id: vote.postId },
|
||||
data: { voteCount: { decrement: vote.weight } },
|
||||
});
|
||||
}
|
||||
|
||||
// 2. remove reactions
|
||||
await tx.reaction.deleteMany({ where: { userId } });
|
||||
|
||||
// 3. delete push subscriptions
|
||||
await tx.pushSubscription.deleteMany({ where: { userId } });
|
||||
|
||||
// 4. anonymize comments - body becomes "[deleted]", author set to sentinel
|
||||
await tx.comment.updateMany({
|
||||
where: { authorId: userId },
|
||||
data: { body: "[deleted]", authorId: sentinelId },
|
||||
});
|
||||
|
||||
// 5. anonymize posts - title/description replaced, author set to sentinel
|
||||
const userPosts = await tx.post.findMany({
|
||||
where: { authorId: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
for (const post of userPosts) {
|
||||
await tx.post.update({
|
||||
where: { id: post.id },
|
||||
data: {
|
||||
title: "[deleted by author]",
|
||||
description: {},
|
||||
authorId: sentinelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 6. wipe passkeys and recovery codes
|
||||
await tx.passkey.deleteMany({ where: { userId } });
|
||||
await tx.recoveryCode.deleteMany({ where: { userId } });
|
||||
|
||||
// 7. remove attachment records
|
||||
await tx.attachment.deleteMany({ where: { uploaderId: userId } });
|
||||
|
||||
// 8. purge user record
|
||||
await tx.user.delete({ where: { id: userId } });
|
||||
});
|
||||
|
||||
// clean up files on disk after successful transaction
|
||||
const uploadDir = resolve(process.cwd(), "uploads");
|
||||
for (const att of attachments) {
|
||||
await unlink(resolve(uploadDir, att.path)).catch(() => {});
|
||||
}
|
||||
if (userForAvatar?.avatarPath) {
|
||||
await unlink(resolve(uploadDir, "avatars", userForAvatar.avatarPath)).catch(() => {});
|
||||
}
|
||||
|
||||
const passkeyToken = req.cookies?.echoboard_passkey;
|
||||
if (passkeyToken) await blockToken(passkeyToken);
|
||||
|
||||
reply
|
||||
.clearCookie("echoboard_token", { path: "/" })
|
||||
.clearCookie("echoboard_passkey", { path: "/" })
|
||||
.clearCookie("echoboard_admin", { path: "/" })
|
||||
.send({ ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Querystring: { q?: string } }>(
|
||||
"/users/search",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const q = (req.query.q ?? "").trim().toLowerCase();
|
||||
if (q.length < 2 || q.length > 30) {
|
||||
reply.send({ users: [] });
|
||||
return;
|
||||
}
|
||||
const users = await prisma.user.findMany({
|
||||
where: { usernameIdx: { not: null } },
|
||||
select: { id: true, username: true, avatarPath: true },
|
||||
take: 200,
|
||||
});
|
||||
const matches = users
|
||||
.map((u) => {
|
||||
let name: string | null = null;
|
||||
if (u.username) {
|
||||
try { name = decrypt(u.username, masterKey); } catch {}
|
||||
}
|
||||
return {
|
||||
id: u.id,
|
||||
username: name,
|
||||
avatarUrl: u.avatarPath ? `/api/v1/avatars/${u.id}` : null,
|
||||
};
|
||||
})
|
||||
.filter((u) => u.username && u.username.toLowerCase().startsWith(q))
|
||||
.slice(0, 10);
|
||||
reply.send({ users: matches });
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/me/export",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 5, timeWindow: "1 hour" } } },
|
||||
async (req, reply) => {
|
||||
const userId = req.user!.id;
|
||||
|
||||
const [user, posts, comments, votes, reactions] = await Promise.all([
|
||||
const [user, posts, comments, votes, reactions, pushSubs, notifications] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId } }),
|
||||
prisma.post.findMany({ where: { authorId: userId } }),
|
||||
prisma.comment.findMany({ where: { authorId: userId } }),
|
||||
prisma.vote.findMany({ where: { voterId: userId } }),
|
||||
prisma.reaction.findMany({ where: { userId } }),
|
||||
prisma.pushSubscription.findMany({
|
||||
where: { userId },
|
||||
select: { boardId: true, postId: true, createdAt: true },
|
||||
}),
|
||||
prisma.notification.findMany({ where: { userId } }),
|
||||
]);
|
||||
|
||||
const decryptedUser = user ? {
|
||||
@@ -128,10 +387,31 @@ export default async function identityRoutes(app: FastifyInstance) {
|
||||
|
||||
reply.send({
|
||||
user: decryptedUser,
|
||||
posts,
|
||||
comments,
|
||||
posts: posts.map((p) => ({
|
||||
id: p.id, type: p.type, title: p.title, status: p.status,
|
||||
voteCount: p.voteCount, category: p.category,
|
||||
boardId: p.boardId, createdAt: p.createdAt,
|
||||
})),
|
||||
comments: comments.map((c) => ({
|
||||
id: c.id, body: c.body, postId: c.postId, isAdmin: c.isAdmin, createdAt: c.createdAt,
|
||||
})),
|
||||
votes: votes.map((v) => ({ postId: v.postId, weight: v.weight, createdAt: v.createdAt })),
|
||||
reactions: reactions.map((r) => ({ commentId: r.commentId, emoji: r.emoji, createdAt: r.createdAt })),
|
||||
notifications: notifications.map((n) => ({
|
||||
id: n.id, type: n.type, title: n.title, body: n.body,
|
||||
postId: n.postId, read: n.read, createdAt: n.createdAt,
|
||||
})),
|
||||
pushSubscriptions: pushSubs,
|
||||
dataManifest: {
|
||||
trackedModels: ["User", "Passkey", "PushSubscription", "Notification", "RecoveryCode"],
|
||||
fields: {
|
||||
User: ["id", "authMethod", "displayName", "username", "avatarPath", "darkMode", "createdAt"],
|
||||
Passkey: ["id", "credentialId", "credentialDeviceType", "transports", "createdAt"],
|
||||
PushSubscription: ["id", "boardId", "postId", "createdAt"],
|
||||
Notification: ["id", "type", "title", "body", "postId", "read", "createdAt"],
|
||||
RecoveryCode: ["id", "expiresAt", "createdAt"],
|
||||
},
|
||||
},
|
||||
exportedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
43
packages/api/src/routes/notifications.ts
Normal file
43
packages/api/src/routes/notifications.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
|
||||
export default async function notificationRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/notifications",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: { userId: req.user!.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
include: {
|
||||
post: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
board: { select: { slug: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const unread = await prisma.notification.count({
|
||||
where: { userId: req.user!.id, read: false },
|
||||
});
|
||||
|
||||
reply.send({ notifications, unread });
|
||||
}
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/notifications/read",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
await prisma.notification.updateMany({
|
||||
where: { userId: req.user!.id, read: false },
|
||||
data: { read: true },
|
||||
});
|
||||
reply.send({ ok: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
@@ -9,28 +8,45 @@ import {
|
||||
import type {
|
||||
RegistrationResponseJSON,
|
||||
AuthenticationResponseJSON,
|
||||
} from "@simplewebauthn/server";
|
||||
} from "@simplewebauthn/types";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { config, masterKey, blindIndexKey } from "../config.js";
|
||||
import { encrypt, decrypt, blindIndex } from "../services/encryption.js";
|
||||
import { blockToken } from "../lib/token-blocklist.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
// challenge store keyed by purpose:userId for proper isolation
|
||||
const challenges = new Map<string, { challenge: string; expires: number; username?: string }>();
|
||||
|
||||
const challenges = new Map<string, { challenge: string; expires: number }>();
|
||||
const MAX_CHALLENGES = 10000;
|
||||
|
||||
function storeChallenge(userId: string, challenge: string) {
|
||||
challenges.set(userId, { challenge, expires: Date.now() + 5 * 60 * 1000 });
|
||||
function storeChallenge(key: string, challenge: string, username?: string) {
|
||||
if (challenges.size >= MAX_CHALLENGES) {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of challenges) {
|
||||
if (v.expires < now) challenges.delete(k);
|
||||
}
|
||||
if (challenges.size >= MAX_CHALLENGES) {
|
||||
const iter = challenges.keys();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const k = iter.next();
|
||||
if (k.done) break;
|
||||
challenges.delete(k.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
challenges.set(key, { challenge, expires: Date.now() + 60 * 1000, username });
|
||||
}
|
||||
|
||||
function getChallenge(userId: string): string | null {
|
||||
const entry = challenges.get(userId);
|
||||
function getChallenge(key: string): { challenge: string; username?: string } | null {
|
||||
const entry = challenges.get(key);
|
||||
if (!entry || entry.expires < Date.now()) {
|
||||
challenges.delete(userId);
|
||||
challenges.delete(key);
|
||||
return null;
|
||||
}
|
||||
challenges.delete(userId);
|
||||
return entry.challenge;
|
||||
challenges.delete(key);
|
||||
return { challenge: entry.challenge, username: entry.username };
|
||||
}
|
||||
|
||||
export function cleanExpiredChallenges() {
|
||||
@@ -45,9 +61,11 @@ const registerBody = z.object({
|
||||
});
|
||||
|
||||
export default async function passkeyRoutes(app: FastifyInstance) {
|
||||
const passkeyRateLimit = { rateLimit: { max: 5, timeWindow: "1 minute" } };
|
||||
|
||||
app.post<{ Body: z.infer<typeof registerBody> }>(
|
||||
"/auth/passkey/register/options",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser], config: passkeyRateLimit },
|
||||
async (req, reply) => {
|
||||
const { username } = registerBody.parse(req.body);
|
||||
const user = req.user!;
|
||||
@@ -76,23 +94,30 @@ export default async function passkeyRoutes(app: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
|
||||
storeChallenge(user.id, options.challenge);
|
||||
storeChallenge("register:" + user.id, options.challenge, username);
|
||||
reply.send(options);
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: { response: RegistrationResponseJSON; username: string } }>(
|
||||
"/auth/passkey/register/verify",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser], config: passkeyRateLimit },
|
||||
async (req, reply) => {
|
||||
const user = req.user!;
|
||||
const { response, username } = req.body;
|
||||
|
||||
const expectedChallenge = getChallenge(user.id);
|
||||
if (!expectedChallenge) {
|
||||
const stored = getChallenge("register:" + user.id);
|
||||
if (!stored) {
|
||||
reply.status(400).send({ error: "Challenge expired" });
|
||||
return;
|
||||
}
|
||||
const expectedChallenge = stored.challenge;
|
||||
|
||||
const validName = registerBody.safeParse({ username });
|
||||
if (!validName.success || username !== stored.username) {
|
||||
reply.status(400).send({ error: "Invalid username" });
|
||||
return;
|
||||
}
|
||||
|
||||
let verification;
|
||||
try {
|
||||
@@ -102,8 +127,8 @@ export default async function passkeyRoutes(app: FastifyInstance) {
|
||||
expectedOrigin: config.WEBAUTHN_ORIGIN,
|
||||
expectedRPID: config.WEBAUTHN_RP_ID,
|
||||
});
|
||||
} catch (err: any) {
|
||||
reply.status(400).send({ error: err.message });
|
||||
} catch {
|
||||
reply.status(400).send({ error: "Registration verification failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,11 +141,13 @@ export default async function passkeyRoutes(app: FastifyInstance) {
|
||||
|
||||
const credIdStr = Buffer.from(credential.id).toString("base64url");
|
||||
|
||||
const pubKeyEncrypted = encrypt(Buffer.from(credential.publicKey).toString("base64"), masterKey);
|
||||
|
||||
await prisma.passkey.create({
|
||||
data: {
|
||||
credentialId: encrypt(credIdStr, masterKey),
|
||||
credentialIdIdx: blindIndex(credIdStr, blindIndexKey),
|
||||
credentialPublicKey: Buffer.from(credential.publicKey),
|
||||
credentialPublicKey: Buffer.from(pubKeyEncrypted),
|
||||
counter: BigInt(credential.counter),
|
||||
credentialDeviceType,
|
||||
credentialBackedUp,
|
||||
@@ -145,6 +172,7 @@ export default async function passkeyRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post(
|
||||
"/auth/passkey/login/options",
|
||||
{ config: passkeyRateLimit },
|
||||
async (_req, reply) => {
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: config.WEBAUTHN_RP_ID,
|
||||
@@ -158,6 +186,7 @@ export default async function passkeyRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post<{ Body: { response: AuthenticationResponseJSON } }>(
|
||||
"/auth/passkey/login/verify",
|
||||
{ config: passkeyRateLimit },
|
||||
async (req, reply) => {
|
||||
const { response } = req.body;
|
||||
|
||||
@@ -166,24 +195,25 @@ export default async function passkeyRoutes(app: FastifyInstance) {
|
||||
|
||||
const passkey = await prisma.passkey.findUnique({ where: { credentialIdIdx: credIdx } });
|
||||
if (!passkey) {
|
||||
reply.status(400).send({ error: "Passkey not found" });
|
||||
reply.status(400).send({ error: "Authentication failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedChallenge = getChallenge("login:" + response.response.clientDataJSON);
|
||||
// we stored with the challenge value, try to find it
|
||||
let challenge: string | null = null;
|
||||
for (const [key, val] of challenges) {
|
||||
if (key.startsWith("login:") && val.expires > Date.now()) {
|
||||
challenge = val.challenge;
|
||||
challenges.delete(key);
|
||||
break;
|
||||
}
|
||||
let clientData: { challenge?: string };
|
||||
try {
|
||||
clientData = JSON.parse(
|
||||
Buffer.from(response.response.clientDataJSON, "base64url").toString()
|
||||
);
|
||||
} catch {
|
||||
reply.status(400).send({ error: "Invalid client data" });
|
||||
return;
|
||||
}
|
||||
if (!challenge) {
|
||||
const stored = getChallenge("login:" + clientData.challenge);
|
||||
if (!stored) {
|
||||
reply.status(400).send({ error: "Challenge expired" });
|
||||
return;
|
||||
}
|
||||
const challenge = stored.challenge;
|
||||
|
||||
let verification;
|
||||
try {
|
||||
@@ -194,15 +224,15 @@ export default async function passkeyRoutes(app: FastifyInstance) {
|
||||
expectedRPID: config.WEBAUTHN_RP_ID,
|
||||
credential: {
|
||||
id: decrypt(passkey.credentialId, masterKey),
|
||||
publicKey: new Uint8Array(passkey.credentialPublicKey),
|
||||
publicKey: new Uint8Array(Buffer.from(decrypt(passkey.credentialPublicKey.toString(), masterKey), "base64")),
|
||||
counter: Number(passkey.counter),
|
||||
transports: passkey.transports
|
||||
? JSON.parse(decrypt(passkey.transports, masterKey))
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
reply.status(400).send({ error: err.message });
|
||||
} catch {
|
||||
reply.status(400).send({ error: "Authentication failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -211,36 +241,113 @@ export default async function passkeyRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.passkey.update({
|
||||
where: { id: passkey.id },
|
||||
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
|
||||
});
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const current = await tx.passkey.findUnique({
|
||||
where: { id: passkey.id },
|
||||
select: { counter: true },
|
||||
});
|
||||
if (current && current.counter >= BigInt(verification.authenticationInfo.newCounter)) {
|
||||
throw new Error("counter_rollback");
|
||||
}
|
||||
await tx.passkey.update({
|
||||
where: { id: passkey.id },
|
||||
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
|
||||
});
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.message === "counter_rollback") {
|
||||
req.log.warn({ passkeyId: passkey.id, userId: passkey.userId }, "passkey counter rollback detected - possible credential cloning, disabling passkey");
|
||||
await prisma.passkey.delete({ where: { id: passkey.id } }).catch(() => {});
|
||||
}
|
||||
reply.status(400).send({ error: "Authentication failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ sub: passkey.userId, type: "passkey" },
|
||||
{ sub: passkey.userId, type: "passkey", aud: "echoboard:user", iss: "echoboard" },
|
||||
config.JWT_SECRET,
|
||||
{ expiresIn: "30d" }
|
||||
{ expiresIn: "24h" }
|
||||
);
|
||||
|
||||
reply.send({ verified: true, token });
|
||||
reply
|
||||
.setCookie("echoboard_passkey", token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24,
|
||||
})
|
||||
.send({ verified: true });
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/me/passkeys",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const passkeys = await prisma.passkey.findMany({
|
||||
where: { userId: req.user!.id },
|
||||
select: {
|
||||
id: true,
|
||||
credentialDeviceType: true,
|
||||
credentialBackedUp: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
reply.send(passkeys);
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/me/passkeys/:id",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const passkey = await prisma.passkey.findUnique({
|
||||
where: { id: req.params.id },
|
||||
});
|
||||
if (!passkey || passkey.userId !== req.user!.id) {
|
||||
reply.status(404).send({ error: "Passkey not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await prisma.passkey.count({ where: { userId: req.user!.id } });
|
||||
if (count <= 1) {
|
||||
reply.status(400).send({ error: "Cannot remove your only passkey" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.passkey.delete({ where: { id: passkey.id } });
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/auth/passkey/logout",
|
||||
{ preHandler: [app.requireUser] },
|
||||
async (_req, reply) => {
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const passkeyToken = req.cookies?.echoboard_passkey;
|
||||
if (passkeyToken) await blockToken(passkeyToken);
|
||||
|
||||
reply
|
||||
.clearCookie("echoboard_token", { path: "/" })
|
||||
.clearCookie("echoboard_passkey", { path: "/" })
|
||||
.clearCookie("echoboard_admin", { path: "/" })
|
||||
.send({ ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { name: string } }>(
|
||||
"/auth/passkey/check-username/:name",
|
||||
{ config: { rateLimit: { max: 5, timeWindow: "5 minutes" } } },
|
||||
async (req, reply) => {
|
||||
const start = Date.now();
|
||||
const hash = blindIndex(req.params.name, blindIndexKey);
|
||||
const existing = await prisma.user.findUnique({ where: { usernameIdx: hash } });
|
||||
// constant-time response to prevent timing-based enumeration
|
||||
const elapsed = Date.now() - start;
|
||||
if (elapsed < 100) await new Promise((r) => setTimeout(r, 100 - elapsed));
|
||||
reply.send({ available: !existing });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,38 +1,163 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient, PostType, PostStatus, Prisma } from "@prisma/client";
|
||||
import { PostType, Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { verifyChallenge } from "../services/altcha.js";
|
||||
import { fireWebhook } from "../services/webhooks.js";
|
||||
import { decrypt } from "../services/encryption.js";
|
||||
import { masterKey } from "../config.js";
|
||||
import { shouldCount } from "../lib/view-tracker.js";
|
||||
import { notifyBoardSubscribers } from "../services/push.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const INVISIBLE_RE = /[\u200B\u200C\u200D\uFEFF\u200E\u200F\u202A-\u202E\u2066-\u2069\u00AD\u2060\u180E]/g;
|
||||
|
||||
function decryptName(encrypted: string | null): string | null {
|
||||
if (!encrypted) return null;
|
||||
try { return decrypt(encrypted, masterKey); } catch { return null; }
|
||||
}
|
||||
|
||||
function cleanAuthor(author: { id: string; displayName: string | null; avatarPath?: string | null } | null) {
|
||||
if (!author) return null;
|
||||
return {
|
||||
id: author.id,
|
||||
displayName: decryptName(author.displayName),
|
||||
avatarUrl: author.avatarPath ? `/api/v1/avatars/${author.id}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
const bugReportSchema = z.object({
|
||||
stepsToReproduce: z.string().min(1),
|
||||
expectedBehavior: z.string().min(1),
|
||||
actualBehavior: z.string().min(1),
|
||||
environment: z.string().optional(),
|
||||
additionalContext: z.string().optional(),
|
||||
});
|
||||
|
||||
const featureRequestSchema = z.object({
|
||||
useCase: z.string().min(1),
|
||||
proposedSolution: z.string().optional(),
|
||||
alternativesConsidered: z.string().optional(),
|
||||
additionalContext: z.string().optional(),
|
||||
});
|
||||
|
||||
const allowedDescKeys = new Set(["stepsToReproduce", "expectedBehavior", "actualBehavior", "environment", "useCase", "proposedSolution", "alternativesConsidered", "additionalContext"]);
|
||||
|
||||
const descriptionRecord = z.record(z.string().max(5000)).refine(
|
||||
(obj) => Object.keys(obj).length <= 10 && Object.keys(obj).every((k) => allowedDescKeys.has(k)),
|
||||
{ message: "Unknown description fields" }
|
||||
);
|
||||
|
||||
const templateDescRecord = z.record(z.string().max(5000)).refine(
|
||||
(obj) => Object.keys(obj).length <= 30,
|
||||
{ message: "Too many description fields" }
|
||||
);
|
||||
|
||||
const createPostSchema = z.object({
|
||||
type: z.nativeEnum(PostType),
|
||||
title: z.string().min(3).max(200),
|
||||
description: z.any(),
|
||||
title: z.string().min(5).max(200),
|
||||
description: z.record(z.string().max(5000)),
|
||||
category: z.string().optional(),
|
||||
templateId: z.string().optional(),
|
||||
attachmentIds: z.array(z.string()).max(10).optional(),
|
||||
altcha: z.string(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.templateId) {
|
||||
// template posts use flexible description keys
|
||||
const tResult = templateDescRecord.safeParse(data.description);
|
||||
if (!tResult.success) {
|
||||
for (const issue of tResult.error.issues) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: issue.message,
|
||||
path: ["description", ...issue.path],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// standard posts use strict schema
|
||||
const dr = descriptionRecord.safeParse(data.description);
|
||||
if (!dr.success) {
|
||||
for (const issue of dr.error.issues) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: issue.message,
|
||||
path: ["description", ...issue.path],
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = data.type === PostType.BUG_REPORT
|
||||
? bugReportSchema.safeParse(data.description)
|
||||
: featureRequestSchema.safeParse(data.description);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: issue.message,
|
||||
path: ["description", ...issue.path],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const raw = JSON.stringify(data.description);
|
||||
if (raw.length < 20) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Description too short (at least 20 characters required)",
|
||||
path: ["description"],
|
||||
});
|
||||
}
|
||||
if (raw.length > 5000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Description too long (max 5000 characters total)",
|
||||
path: ["description"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const updatePostSchema = z.object({
|
||||
title: z.string().min(3).max(200).optional(),
|
||||
description: z.any().optional(),
|
||||
title: z.string().min(5).max(200).optional(),
|
||||
description: z.record(z.string().max(5000)).optional(),
|
||||
category: z.string().optional().nullable(),
|
||||
altcha: z.string().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.description) {
|
||||
const raw = JSON.stringify(data.description);
|
||||
if (raw.length < 20) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Description too short (at least 20 characters required)",
|
||||
path: ["description"],
|
||||
});
|
||||
}
|
||||
if (raw.length > 5000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Description too long (max 5000 characters total)",
|
||||
path: ["description"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const VALID_STATUSES = ["OPEN", "UNDER_REVIEW", "PLANNED", "IN_PROGRESS", "DONE", "DECLINED"] as const;
|
||||
|
||||
const querySchema = z.object({
|
||||
type: z.nativeEnum(PostType).optional(),
|
||||
category: z.string().optional(),
|
||||
status: z.nativeEnum(PostStatus).optional(),
|
||||
sort: z.enum(["newest", "oldest", "top", "trending"]).default("newest"),
|
||||
search: z.string().optional(),
|
||||
page: z.coerce.number().min(1).default(1),
|
||||
limit: z.coerce.number().min(1).max(100).default(20),
|
||||
category: z.string().max(50).optional(),
|
||||
status: z.enum(VALID_STATUSES).optional(),
|
||||
sort: z.enum(["newest", "oldest", "top", "updated"]).default("newest"),
|
||||
search: z.string().max(200).optional(),
|
||||
page: z.coerce.number().int().min(1).max(500).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export default async function postRoutes(app: FastifyInstance) {
|
||||
app.get<{ Params: { boardSlug: string }; Querystring: Record<string, string> }>(
|
||||
"/boards/:boardSlug/posts",
|
||||
{ preHandler: [app.optionalUser] },
|
||||
{ preHandler: [app.optionalUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
@@ -46,13 +171,29 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
if (q.type) where.type = q.type;
|
||||
if (q.category) where.category = q.category;
|
||||
if (q.status) where.status = q.status;
|
||||
if (q.search) where.title = { contains: q.search, mode: "insensitive" };
|
||||
if (q.search) {
|
||||
const likeTerm = `%${q.search}%`;
|
||||
const matchIds = await prisma.$queryRaw<{ id: string }[]>`
|
||||
SELECT id FROM "Post"
|
||||
WHERE "boardId" = ${board.id}
|
||||
AND (
|
||||
word_similarity(${q.search}, title) > 0.15
|
||||
OR to_tsvector('english', title) @@ plainto_tsquery('english', ${q.search})
|
||||
OR description::text ILIKE ${likeTerm}
|
||||
)
|
||||
`;
|
||||
if (matchIds.length === 0) {
|
||||
reply.send({ posts: [], total: 0, page: q.page, pages: 0, staleDays: board.staleDays });
|
||||
return;
|
||||
}
|
||||
where.id = { in: matchIds.map((m) => m.id) };
|
||||
}
|
||||
|
||||
let orderBy: Prisma.PostOrderByWithRelationInput;
|
||||
switch (q.sort) {
|
||||
case "oldest": orderBy = { createdAt: "asc" }; break;
|
||||
case "top": orderBy = { voteCount: "desc" }; break;
|
||||
case "trending": orderBy = { voteCount: "desc" }; break;
|
||||
case "updated": orderBy = { updatedAt: "desc" }; break;
|
||||
default: orderBy = { createdAt: "desc" };
|
||||
}
|
||||
|
||||
@@ -64,70 +205,266 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
take: q.limit,
|
||||
include: {
|
||||
_count: { select: { comments: true } },
|
||||
author: { select: { id: true, displayName: true } },
|
||||
author: { select: { id: true, displayName: true, avatarPath: true } },
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
}),
|
||||
prisma.post.count({ where }),
|
||||
]);
|
||||
|
||||
const userVotes = new Map<string, number>();
|
||||
if (req.user) {
|
||||
const votes = await prisma.vote.findMany({
|
||||
where: { voterId: req.user.id, postId: { in: posts.map((p) => p.id) } },
|
||||
select: { postId: true, weight: true },
|
||||
});
|
||||
for (const v of votes) userVotes.set(v.postId, v.weight);
|
||||
}
|
||||
|
||||
const staleCutoff = board.staleDays > 0
|
||||
? new Date(Date.now() - board.staleDays * 86400000)
|
||||
: null;
|
||||
|
||||
reply.send({
|
||||
posts: posts.map((p) => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
status: p.status,
|
||||
statusReason: p.statusReason,
|
||||
category: p.category,
|
||||
voteCount: p.voteCount,
|
||||
viewCount: p.viewCount,
|
||||
isPinned: p.isPinned,
|
||||
isStale: staleCutoff ? p.lastActivityAt < staleCutoff : false,
|
||||
commentCount: p._count.comments,
|
||||
author: p.author,
|
||||
author: cleanAuthor(p.author),
|
||||
tags: p.tags.map((pt) => pt.tag),
|
||||
onBehalfOf: p.onBehalfOf,
|
||||
voted: userVotes.has(p.id),
|
||||
voteWeight: userVotes.get(p.id) ?? 0,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
})),
|
||||
total,
|
||||
page: q.page,
|
||||
pages: Math.ceil(total / q.limit),
|
||||
staleDays: board.staleDays,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { boardSlug: string; id: string } }>(
|
||||
"/boards/:boardSlug/posts/:id",
|
||||
{ preHandler: [app.optionalUser] },
|
||||
{ preHandler: [app.optionalUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
author: { select: { id: true, displayName: true } },
|
||||
_count: { select: { comments: true, votes: true } },
|
||||
adminResponses: {
|
||||
include: { admin: { select: { id: true, email: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
author: { select: { id: true, displayName: true, avatarPath: true } },
|
||||
_count: { select: { comments: true, votes: true, editHistory: true } },
|
||||
statusChanges: { orderBy: { createdAt: "asc" } },
|
||||
tags: { include: { tag: true } },
|
||||
attachments: { select: { id: true, filename: true, mimeType: true, size: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
if (!post || post.boardId !== board.id) {
|
||||
// check if this post was merged into another
|
||||
const merge = await prisma.postMerge.findFirst({
|
||||
where: { sourcePostId: req.params.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (merge) {
|
||||
const targetPost = await prisma.post.findUnique({ where: { id: merge.targetPostId }, select: { boardId: true } });
|
||||
if (!targetPost || targetPost.boardId !== board.id) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
reply.send({ merged: true, targetPostId: merge.targetPostId });
|
||||
return;
|
||||
}
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const viewKey = req.user?.id ?? req.ip;
|
||||
if (shouldCount(post.id, viewKey)) {
|
||||
prisma.post.update({ where: { id: post.id }, data: { viewCount: { increment: 1 } } }).catch(() => {});
|
||||
}
|
||||
|
||||
let voted = false;
|
||||
let voteWeight = 0;
|
||||
let userImportance: string | null = null;
|
||||
if (req.user) {
|
||||
const existing = await prisma.vote.findUnique({
|
||||
where: { postId_voterId: { postId: post.id, voterId: req.user.id } },
|
||||
});
|
||||
voted = !!existing;
|
||||
voteWeight = existing?.weight ?? 0;
|
||||
userImportance = existing?.importance ?? null;
|
||||
}
|
||||
|
||||
reply.send({ ...post, voted });
|
||||
const importanceVotes = await prisma.vote.groupBy({
|
||||
by: ["importance"],
|
||||
where: { postId: post.id, importance: { not: null } },
|
||||
_count: { importance: true },
|
||||
});
|
||||
|
||||
const importanceCounts: Record<string, number> = {
|
||||
critical: 0, important: 0, nice_to_have: 0, minor: 0,
|
||||
};
|
||||
for (const row of importanceVotes) {
|
||||
if (row.importance && row.importance in importanceCounts) {
|
||||
importanceCounts[row.importance] = row._count.importance;
|
||||
}
|
||||
}
|
||||
|
||||
reply.send({
|
||||
id: post.id,
|
||||
type: post.type,
|
||||
title: post.title,
|
||||
description: post.description,
|
||||
status: post.status,
|
||||
statusReason: post.statusReason,
|
||||
category: post.category,
|
||||
voteCount: post.voteCount,
|
||||
viewCount: post.viewCount,
|
||||
isPinned: post.isPinned,
|
||||
isEditLocked: post.isEditLocked,
|
||||
isThreadLocked: post.isThreadLocked,
|
||||
isVotingLocked: post.isVotingLocked,
|
||||
onBehalfOf: post.onBehalfOf,
|
||||
commentCount: post._count.comments,
|
||||
voteTotal: post._count.votes,
|
||||
author: cleanAuthor(post.author),
|
||||
tags: post.tags.map((pt) => pt.tag),
|
||||
attachments: post.attachments,
|
||||
statusChanges: post.statusChanges.map((sc) => ({
|
||||
id: sc.id,
|
||||
fromStatus: sc.fromStatus,
|
||||
toStatus: sc.toStatus,
|
||||
reason: sc.reason,
|
||||
createdAt: sc.createdAt,
|
||||
})),
|
||||
voted,
|
||||
voteWeight,
|
||||
userImportance,
|
||||
importanceCounts,
|
||||
editCount: post._count.editHistory,
|
||||
createdAt: post.createdAt,
|
||||
updatedAt: post.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { boardSlug: string; id: string } }>(
|
||||
"/boards/:boardSlug/posts/:id/timeline",
|
||||
{ preHandler: [app.optionalUser, app.optionalAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post || post.boardId !== board.id) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const [statusChanges, comments] = await Promise.all([
|
||||
prisma.statusChange.findMany({
|
||||
where: { postId: post.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 500,
|
||||
}),
|
||||
prisma.comment.findMany({
|
||||
where: { postId: post.id },
|
||||
include: {
|
||||
author: { select: { id: true, displayName: true, avatarPath: true } },
|
||||
adminUser: { select: { displayName: true, teamTitle: true } },
|
||||
reactions: { select: { emoji: true, userId: true } },
|
||||
attachments: { select: { id: true, filename: true, mimeType: true, size: true } },
|
||||
_count: { select: { editHistory: true } },
|
||||
replyTo: {
|
||||
select: {
|
||||
id: true,
|
||||
body: true,
|
||||
isAdmin: true,
|
||||
adminUserId: true,
|
||||
author: { select: { id: true, displayName: true, avatarPath: true } },
|
||||
adminUser: { select: { displayName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 500,
|
||||
}),
|
||||
]);
|
||||
|
||||
const entries = [
|
||||
...statusChanges.map((sc) => ({
|
||||
id: sc.id,
|
||||
type: "status_change" as const,
|
||||
authorName: "System",
|
||||
content: "",
|
||||
oldStatus: sc.fromStatus,
|
||||
newStatus: sc.toStatus,
|
||||
reason: sc.reason ?? null,
|
||||
createdAt: sc.createdAt,
|
||||
isAdmin: true,
|
||||
})),
|
||||
...comments.map((c) => {
|
||||
const emojiMap: Record<string, { count: number; userIds: string[] }> = {};
|
||||
for (const r of c.reactions) {
|
||||
if (!emojiMap[r.emoji]) emojiMap[r.emoji] = { count: 0, userIds: [] };
|
||||
emojiMap[r.emoji].count++;
|
||||
emojiMap[r.emoji].userIds.push(r.userId);
|
||||
}
|
||||
return {
|
||||
id: c.id,
|
||||
type: "comment" as const,
|
||||
authorId: c.author?.id ?? null,
|
||||
authorName: c.isAdmin ? (decryptName(c.adminUser?.displayName ?? null) ?? "Admin") : (decryptName(c.author?.displayName ?? null) ?? `Anonymous #${(c.author?.id ?? "0000").slice(-4)}`),
|
||||
authorTitle: c.isAdmin && c.adminUser?.teamTitle ? decryptName(c.adminUser.teamTitle) : null,
|
||||
authorAvatarUrl: c.author?.avatarPath ? `/api/v1/avatars/${c.author.id}` : null,
|
||||
content: c.body,
|
||||
createdAt: c.createdAt,
|
||||
isAdmin: c.isAdmin,
|
||||
replyTo: c.replyTo ? {
|
||||
id: c.replyTo.id,
|
||||
body: c.replyTo.body.slice(0, 200),
|
||||
isAdmin: c.replyTo.isAdmin,
|
||||
authorName: c.replyTo.isAdmin ? (decryptName(c.replyTo.adminUser?.displayName ?? null) ?? "Admin") : (decryptName(c.replyTo.author?.displayName ?? null) ?? `Anonymous #${(c.replyTo.author?.id ?? "0000").slice(-4)}`),
|
||||
} : null,
|
||||
reactions: Object.entries(emojiMap).map(([emoji, data]) => ({
|
||||
emoji,
|
||||
count: data.count,
|
||||
hasReacted: req.user ? data.userIds.includes(req.user.id) : false,
|
||||
})),
|
||||
attachments: c.attachments,
|
||||
editCount: c._count.editHistory,
|
||||
isEditLocked: c.isEditLocked,
|
||||
};
|
||||
}),
|
||||
].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
|
||||
reply.send({ entries, isCurrentAdmin: !!req.adminId });
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { boardSlug: string }; Body: z.infer<typeof createPostSchema> }>(
|
||||
"/boards/:boardSlug/posts",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 hour" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board || board.isArchived) {
|
||||
@@ -143,17 +480,59 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanTitle = body.title.replace(INVISIBLE_RE, '');
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const normalizedTitle = cleanTitle.trim().replace(/\s+/g, ' ').replace(/[.!?,;:]+$/, '').toLowerCase();
|
||||
const recentPosts = await prisma.post.findMany({
|
||||
where: {
|
||||
boardId: board.id,
|
||||
authorId: req.user!.id,
|
||||
createdAt: { gte: dayAgo },
|
||||
},
|
||||
select: { title: true },
|
||||
take: 100,
|
||||
});
|
||||
const isDuplicate = recentPosts.some(p => {
|
||||
const norm = p.title.replace(INVISIBLE_RE, '').trim().replace(/\s+/g, ' ').replace(/[.!?,;:]+$/, '').toLowerCase();
|
||||
return norm === normalizedTitle;
|
||||
});
|
||||
if (isDuplicate) {
|
||||
reply.status(409).send({ error: "You already posted something similar within the last 24 hours" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.templateId) {
|
||||
const tmpl = await prisma.boardTemplate.findUnique({ where: { id: body.templateId } });
|
||||
if (!tmpl || tmpl.boardId !== board.id) {
|
||||
reply.status(400).send({ error: "Invalid template" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const post = await prisma.post.create({
|
||||
data: {
|
||||
type: body.type,
|
||||
title: body.title,
|
||||
title: cleanTitle,
|
||||
description: body.description,
|
||||
category: body.category,
|
||||
templateId: body.templateId,
|
||||
boardId: board.id,
|
||||
authorId: req.user!.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (body.attachmentIds?.length) {
|
||||
await prisma.attachment.updateMany({
|
||||
where: {
|
||||
id: { in: body.attachmentIds },
|
||||
uploaderId: req.user!.id,
|
||||
postId: null,
|
||||
commentId: null,
|
||||
},
|
||||
data: { postId: post.id },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.activityEvent.create({
|
||||
data: {
|
||||
type: "post_created",
|
||||
@@ -163,16 +542,40 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(201).send(post);
|
||||
fireWebhook("post_created", {
|
||||
postId: post.id,
|
||||
title: post.title,
|
||||
type: post.type,
|
||||
boardId: board.id,
|
||||
boardSlug: board.slug,
|
||||
});
|
||||
|
||||
notifyBoardSubscribers(board.id, {
|
||||
title: `New post in ${board.name}`,
|
||||
body: cleanTitle.slice(0, 100),
|
||||
url: `/b/${board.slug}/post/${post.id}`,
|
||||
tag: `board-${board.id}-new`,
|
||||
});
|
||||
|
||||
reply.status(201).send({
|
||||
id: post.id, type: post.type, title: post.title, description: post.description,
|
||||
status: post.status, category: post.category, createdAt: post.createdAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof updatePostSchema> }>(
|
||||
"/boards/:boardSlug/posts/:id",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser, app.optionalAdmin], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
if (!post || post.boardId !== board.id) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
@@ -181,26 +584,136 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (post.isEditLocked) {
|
||||
reply.status(403).send({ error: "Editing is locked on this post" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = updatePostSchema.parse(req.body);
|
||||
|
||||
// admins skip ALTCHA, regular users must provide it
|
||||
if (!req.adminId) {
|
||||
if (!body.altcha) {
|
||||
reply.status(400).send({ error: "Challenge response required" });
|
||||
return;
|
||||
}
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (body.description && !post.templateId) {
|
||||
const dr = descriptionRecord.safeParse(body.description);
|
||||
if (!dr.success) {
|
||||
reply.status(400).send({ error: "Unknown description fields" });
|
||||
return;
|
||||
}
|
||||
const schema = post.type === PostType.BUG_REPORT ? bugReportSchema : featureRequestSchema;
|
||||
const result = schema.safeParse(body.description);
|
||||
if (!result.success) {
|
||||
reply.status(400).send({ error: "Invalid description for this post type" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (body.description && post.templateId) {
|
||||
const tr = templateDescRecord.safeParse(body.description);
|
||||
if (!tr.success) {
|
||||
reply.status(400).send({ error: "Too many description fields" });
|
||||
return;
|
||||
}
|
||||
const tmpl = await prisma.boardTemplate.findUnique({ where: { id: post.templateId } });
|
||||
if (!tmpl) {
|
||||
reply.status(400).send({ error: "Template no longer exists" });
|
||||
return;
|
||||
}
|
||||
const templateKeys = new Set((tmpl.fields as any[]).map((f: any) => f.key));
|
||||
const submitted = Object.keys(body.description);
|
||||
const unknown = submitted.filter((k) => !templateKeys.has(k));
|
||||
if (unknown.length > 0) {
|
||||
reply.status(400).send({ error: "Unknown template fields: " + unknown.join(", ") });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const titleChanged = body.title !== undefined && body.title !== post.title;
|
||||
const descChanged = body.description !== undefined && JSON.stringify(body.description) !== JSON.stringify(post.description);
|
||||
if (titleChanged || descChanged) {
|
||||
await prisma.editHistory.create({
|
||||
data: {
|
||||
postId: post.id,
|
||||
editedBy: req.user!.id,
|
||||
previousTitle: post.title,
|
||||
previousDescription: post.description as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await prisma.post.update({
|
||||
where: { id: post.id },
|
||||
data: {
|
||||
...(body.title !== undefined && { title: body.title }),
|
||||
...(body.title !== undefined && { title: body.title.replace(INVISIBLE_RE, '') }),
|
||||
...(body.description !== undefined && { description: body.description }),
|
||||
...(body.category !== undefined && { category: body.category }),
|
||||
},
|
||||
});
|
||||
|
||||
reply.send(updated);
|
||||
reply.send({
|
||||
id: updated.id, type: updated.type, title: updated.title,
|
||||
description: updated.description, status: updated.status,
|
||||
category: updated.category, updatedAt: updated.updatedAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { boardSlug: string; id: string } }>(
|
||||
"/boards/:boardSlug/posts/:id/edits",
|
||||
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post || post.boardId !== board.id) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const edits = await prisma.editHistory.findMany({
|
||||
where: { postId: post.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
include: {
|
||||
editor: { select: { id: true, displayName: true, avatarPath: true } },
|
||||
},
|
||||
});
|
||||
|
||||
reply.send(edits.map((e) => ({
|
||||
id: e.id,
|
||||
previousTitle: e.previousTitle,
|
||||
previousDescription: e.previousDescription,
|
||||
editedBy: cleanAuthor(e.editor),
|
||||
createdAt: e.createdAt,
|
||||
})));
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { boardSlug: string; id: string } }>(
|
||||
"/boards/:boardSlug/posts/:id",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post) {
|
||||
if (!post || post.boardId !== board.id) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
@@ -209,7 +722,36 @@ export default async function postRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commentIds = (await prisma.comment.findMany({
|
||||
where: { postId: post.id },
|
||||
select: { id: true },
|
||||
})).map((c) => c.id);
|
||||
|
||||
const attachments = await prisma.attachment.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ postId: post.id },
|
||||
...(commentIds.length ? [{ commentId: { in: commentIds } }] : []),
|
||||
],
|
||||
},
|
||||
select: { id: true, path: true },
|
||||
});
|
||||
|
||||
if (attachments.length) {
|
||||
await prisma.attachment.deleteMany({ where: { id: { in: attachments.map((a) => a.id) } } });
|
||||
}
|
||||
|
||||
await prisma.postMerge.deleteMany({
|
||||
where: { OR: [{ sourcePostId: post.id }, { targetPostId: post.id }] },
|
||||
});
|
||||
|
||||
await prisma.post.delete({ where: { id: post.id } });
|
||||
|
||||
const uploadDir = resolve(process.cwd(), "uploads");
|
||||
for (const att of attachments) {
|
||||
await unlink(resolve(uploadDir, att.path)).catch(() => {});
|
||||
}
|
||||
|
||||
reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,47 +1,69 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { generateChallenge } from "../services/altcha.js";
|
||||
import { config } from "../config.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const challengeQuery = z.object({
|
||||
difficulty: z.enum(["normal", "light"]).default("normal"),
|
||||
});
|
||||
|
||||
export default async function privacyRoutes(app: FastifyInstance) {
|
||||
app.get("/altcha/challenge", async (req, reply) => {
|
||||
const difficulty = req.query && (req.query as any).difficulty === "light" ? "light" : "normal";
|
||||
const challenge = await generateChallenge(difficulty as "normal" | "light");
|
||||
app.get("/altcha/challenge", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => {
|
||||
const { difficulty } = challengeQuery.parse(req.query);
|
||||
const challenge = await generateChallenge(difficulty);
|
||||
reply.send(challenge);
|
||||
});
|
||||
|
||||
app.get("/privacy/data-manifest", async (_req, reply) => {
|
||||
app.get("/privacy/data-manifest", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (_req, reply) => {
|
||||
reply.send({
|
||||
dataCollected: {
|
||||
anonymous: {
|
||||
cookieToken: "SHA-256 hashed, used for session identity",
|
||||
displayName: "AES-256-GCM encrypted, optional",
|
||||
posts: "Stored with author reference, deletable",
|
||||
comments: "Stored with author reference, deletable",
|
||||
votes: "Stored with voter reference, deletable",
|
||||
reactions: "Stored with user reference, deletable",
|
||||
},
|
||||
passkey: {
|
||||
username: "AES-256-GCM encrypted with blind index",
|
||||
credentialId: "AES-256-GCM encrypted with blind index",
|
||||
publicKey: "Encrypted at rest",
|
||||
},
|
||||
anonymousUser: [
|
||||
{ field: "Token hash", purpose: "SHA-256 hashed, used for session identity", retention: "Until user deletes", deletable: true },
|
||||
{ field: "Display name", purpose: "AES-256-GCM encrypted, optional cosmetic name", retention: "Until user deletes", deletable: true },
|
||||
{ field: "Dark mode preference", purpose: "Theme setting (system/light/dark)", retention: "Until user deletes", deletable: true },
|
||||
{ field: "Posts", purpose: "Stored with author reference", retention: "Until user deletes (anonymized)", deletable: true },
|
||||
{ field: "Comments", purpose: "Stored with author reference", retention: "Until user deletes (anonymized)", deletable: true },
|
||||
{ field: "Votes", purpose: "Stored with voter reference", retention: "Until user deletes", deletable: true },
|
||||
{ field: "Reactions", purpose: "Emoji reactions on comments", retention: "Until user deletes", deletable: true },
|
||||
],
|
||||
passkeyUser: [
|
||||
{ field: "Username", purpose: "AES-256-GCM encrypted with HMAC-SHA256 blind index", retention: "Until user deletes", deletable: true },
|
||||
{ field: "Display name", purpose: "AES-256-GCM encrypted", retention: "Until user deletes", deletable: true },
|
||||
{ field: "Avatar image", purpose: "Optional profile photo stored on disk", retention: "Until user deletes", deletable: true },
|
||||
{ field: "Credential ID", purpose: "AES-256-GCM encrypted with blind index", retention: "Until user deletes", deletable: true },
|
||||
{ field: "Public key", purpose: "AES-256-GCM encrypted", retention: "Until user deletes", deletable: true },
|
||||
{ field: "Counter", purpose: "Replay detection (not PII)", retention: "Until user deletes", deletable: true },
|
||||
{ field: "Device type", purpose: "singleDevice or multiDevice (not PII)", retention: "Until user deletes", deletable: true },
|
||||
{ field: "Backed up flag", purpose: "Whether passkey is synced (not PII)", retention: "Until user deletes", deletable: true },
|
||||
{ field: "Transports", purpose: "AES-256-GCM encrypted", retention: "Until user deletes", deletable: true },
|
||||
],
|
||||
cookieInfo: "This site uses exactly one cookie. It contains a random identifier used to connect you to your posts. It is not used for tracking, analytics, or advertising. No other cookies are set, ever.",
|
||||
dataLocation: "Self-hosted PostgreSQL, encrypted at rest",
|
||||
thirdParties: [],
|
||||
neverStored: [
|
||||
"Email address", "IP address", "Browser fingerprint", "User-agent string",
|
||||
"Referrer URL", "Geolocation", "Device identifiers", "Behavioral data",
|
||||
"Session replays", "Third-party tracking identifiers",
|
||||
],
|
||||
securityHeaders: {
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self'; frame-src 'none'; object-src 'none'",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
|
||||
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
"X-DNS-Prefetch-Control": "off",
|
||||
},
|
||||
retention: {
|
||||
activityEvents: `${config.DATA_RETENTION_ACTIVITY_DAYS} days`,
|
||||
orphanedUsers: `${config.DATA_RETENTION_ORPHAN_USER_DAYS} days`,
|
||||
},
|
||||
encryption: "AES-256-GCM with 96-bit random IV per value",
|
||||
indexing: "HMAC-SHA256 blind indexes for lookups",
|
||||
thirdParty: "None - fully self-hosted",
|
||||
export: "GET /api/v1/me/export",
|
||||
deletion: "DELETE /api/v1/me",
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/categories", async (_req, reply) => {
|
||||
app.get("/categories", { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, async (_req, reply) => {
|
||||
const cats = await prisma.category.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { encrypt, blindIndex } from "../services/encryption.js";
|
||||
import { masterKey, blindIndexKey } from "../config.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const PUSH_DOMAINS = [
|
||||
"fcm.googleapis.com",
|
||||
"updates.push.services.mozilla.com",
|
||||
"notify.windows.com",
|
||||
"push.apple.com",
|
||||
"web.push.apple.com",
|
||||
];
|
||||
|
||||
function isValidPushEndpoint(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
return PUSH_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith("." + d));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const subscribeBody = z.object({
|
||||
endpoint: z.string().url(),
|
||||
endpoint: z.string().url().refine(isValidPushEndpoint, "Must be a known push service endpoint"),
|
||||
keys: z.object({
|
||||
p256dh: z.string(),
|
||||
auth: z.string(),
|
||||
p256dh: z.string().max(200),
|
||||
auth: z.string().max(200),
|
||||
}),
|
||||
boardId: z.string().optional(),
|
||||
postId: z.string().optional(),
|
||||
@@ -21,15 +37,44 @@ const unsubscribeBody = z.object({
|
||||
});
|
||||
|
||||
export default async function pushRoutes(app: FastifyInstance) {
|
||||
app.get(
|
||||
"/push/vapid",
|
||||
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (_req, reply) => {
|
||||
const publicKey = process.env.VAPID_PUBLIC_KEY ?? "";
|
||||
reply.send({ publicKey });
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Body: z.infer<typeof subscribeBody> }>(
|
||||
"/push/subscribe",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = subscribeBody.parse(req.body);
|
||||
|
||||
if (body.boardId) {
|
||||
const board = await prisma.board.findUnique({ where: { id: body.boardId }, select: { id: true } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (body.postId) {
|
||||
const post = await prisma.post.findUnique({ where: { id: body.postId }, select: { id: true } });
|
||||
if (!post) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
|
||||
|
||||
const existing = await prisma.pushSubscription.findUnique({ where: { endpointIdx } });
|
||||
if (existing) {
|
||||
if (existing.userId !== req.user!.id) {
|
||||
reply.status(403).send({ error: "Not your subscription" });
|
||||
return;
|
||||
}
|
||||
await prisma.pushSubscription.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
@@ -41,6 +86,12 @@ export default async function pushRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subCount = await prisma.pushSubscription.count({ where: { userId: req.user!.id } });
|
||||
if (subCount >= 50) {
|
||||
reply.status(400).send({ error: "Maximum 50 push subscriptions" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.pushSubscription.create({
|
||||
data: {
|
||||
endpoint: encrypt(body.endpoint, masterKey),
|
||||
@@ -59,7 +110,7 @@ export default async function pushRoutes(app: FastifyInstance) {
|
||||
|
||||
app.delete<{ Body: z.infer<typeof unsubscribeBody> }>(
|
||||
"/push/subscribe",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const body = unsubscribeBody.parse(req.body);
|
||||
const endpointIdx = blindIndex(body.endpoint, blindIndexKey);
|
||||
@@ -79,7 +130,7 @@ export default async function pushRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get(
|
||||
"/push/subscriptions",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const subs = await prisma.pushSubscription.findMany({
|
||||
where: { userId: req.user!.id },
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
import prisma from "../lib/prisma.js";
|
||||
|
||||
const reactionBody = z.object({
|
||||
emoji: z.string().min(1).max(8),
|
||||
emoji: z.string().min(1).max(8).regex(/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u, "Invalid emoji"),
|
||||
});
|
||||
|
||||
export default async function reactionRoutes(app: FastifyInstance) {
|
||||
app.post<{ Params: { id: string }; Body: z.infer<typeof reactionBody> }>(
|
||||
"/comments/:id/reactions",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const comment = await prisma.comment.findUnique({ where: { id: req.params.id } });
|
||||
if (!comment) {
|
||||
@@ -19,6 +17,19 @@ export default async function reactionRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { id: comment.postId },
|
||||
select: { isThreadLocked: true, board: { select: { isArchived: true } } },
|
||||
});
|
||||
if (post?.board?.isArchived) {
|
||||
reply.status(403).send({ error: "Board is archived" });
|
||||
return;
|
||||
}
|
||||
if (post?.isThreadLocked) {
|
||||
reply.status(403).send({ error: "Thread is locked" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { emoji } = reactionBody.parse(req.body);
|
||||
|
||||
const existing = await prisma.reaction.findUnique({
|
||||
@@ -35,6 +46,14 @@ export default async function reactionRoutes(app: FastifyInstance) {
|
||||
await prisma.reaction.delete({ where: { id: existing.id } });
|
||||
reply.send({ toggled: false });
|
||||
} else {
|
||||
const distinctCount = await prisma.reaction.count({
|
||||
where: { commentId: comment.id, userId: req.user!.id },
|
||||
});
|
||||
if (distinctCount >= 10) {
|
||||
reply.status(400).send({ error: "Too many reactions" });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.reaction.create({
|
||||
data: {
|
||||
emoji,
|
||||
@@ -49,8 +68,33 @@ export default async function reactionRoutes(app: FastifyInstance) {
|
||||
|
||||
app.delete<{ Params: { id: string; emoji: string } }>(
|
||||
"/comments/:id/reactions/:emoji",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const emoji = req.params.emoji;
|
||||
if (!emoji || emoji.length > 8 || !/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u.test(emoji)) {
|
||||
reply.status(400).send({ error: "Invalid emoji" });
|
||||
return;
|
||||
}
|
||||
|
||||
const comment = await prisma.comment.findUnique({ where: { id: req.params.id }, select: { id: true, postId: true } });
|
||||
if (!comment) {
|
||||
reply.status(404).send({ error: "Comment not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { id: comment.postId },
|
||||
select: { isThreadLocked: true, board: { select: { isArchived: true } } },
|
||||
});
|
||||
if (post?.board?.isArchived) {
|
||||
reply.status(403).send({ error: "Board is archived" });
|
||||
return;
|
||||
}
|
||||
if (post?.isThreadLocked) {
|
||||
reply.status(403).send({ error: "Thread is locked" });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await prisma.reaction.deleteMany({
|
||||
where: {
|
||||
commentId: req.params.id,
|
||||
|
||||
142
packages/api/src/routes/recovery.ts
Normal file
142
packages/api/src/routes/recovery.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import bcrypt from "bcrypt";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { blindIndex, hashToken } from "../services/encryption.js";
|
||||
import { blindIndexKey } from "../config.js";
|
||||
import { verifyChallenge } from "../services/altcha.js";
|
||||
import { generateRecoveryPhrase } from "../lib/wordlist.js";
|
||||
|
||||
const EXPIRY_DAYS = 90;
|
||||
|
||||
const recoverBody = z.object({
|
||||
phrase: z.string().regex(/^[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+-[a-z]+$/),
|
||||
altcha: z.string(),
|
||||
});
|
||||
|
||||
async function getFailedAttempts(ip: string): Promise<number> {
|
||||
const cutoff = new Date(Date.now() - 15 * 60 * 1000);
|
||||
const count = await prisma.blockedToken.count({
|
||||
where: { tokenHash: { startsWith: `recovery:${ip}:` }, createdAt: { gt: cutoff } },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
async function recordFailedAttempt(ip: string): Promise<void> {
|
||||
await prisma.blockedToken.create({
|
||||
data: {
|
||||
tokenHash: `recovery:${ip}:${Date.now()}`,
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default async function recoveryRoutes(app: FastifyInstance) {
|
||||
// check if user has a recovery code
|
||||
app.get(
|
||||
"/me/recovery-code",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const code = await prisma.recoveryCode.findUnique({
|
||||
where: { userId: req.user!.id },
|
||||
select: { expiresAt: true },
|
||||
});
|
||||
if (code && code.expiresAt > new Date()) {
|
||||
reply.send({ hasCode: true, expiresAt: code.expiresAt });
|
||||
} else {
|
||||
reply.send({ hasCode: false });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// generate a new recovery code
|
||||
app.post(
|
||||
"/me/recovery-code",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 3, timeWindow: "1 hour" } } },
|
||||
async (req, reply) => {
|
||||
if (req.user!.authMethod === "PASSKEY") {
|
||||
reply.status(400).send({ error: "Passkey users don't need recovery codes" });
|
||||
return;
|
||||
}
|
||||
|
||||
const phrase = generateRecoveryPhrase();
|
||||
const codeHash = await bcrypt.hash(phrase, 12);
|
||||
const phraseIdx = blindIndex(phrase, blindIndexKey);
|
||||
const expiresAt = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
await prisma.recoveryCode.upsert({
|
||||
where: { userId: req.user!.id },
|
||||
create: { codeHash, phraseIdx, userId: req.user!.id, expiresAt },
|
||||
update: { codeHash, phraseIdx, expiresAt },
|
||||
});
|
||||
|
||||
reply.send({ phrase, expiresAt });
|
||||
}
|
||||
);
|
||||
|
||||
// recover identity using a code (unauthenticated)
|
||||
app.post(
|
||||
"/auth/recover",
|
||||
{ config: { rateLimit: { max: 3, timeWindow: "15 minutes" } } },
|
||||
async (req, reply) => {
|
||||
const body = recoverBody.parse(req.body);
|
||||
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
}
|
||||
|
||||
// exponential backoff per IP (persistent)
|
||||
const attempts = await getFailedAttempts(req.ip);
|
||||
if (attempts >= 3) {
|
||||
const delay = Math.min(1000 * Math.pow(2, attempts - 3), 30000);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
}
|
||||
|
||||
const phrase = body.phrase.toLowerCase().trim();
|
||||
const idx = blindIndex(phrase, blindIndexKey);
|
||||
|
||||
const record = await prisma.recoveryCode.findUnique({
|
||||
where: { phraseIdx: idx },
|
||||
include: { user: { select: { id: true } } },
|
||||
});
|
||||
|
||||
if (!record || record.expiresAt < new Date()) {
|
||||
await recordFailedAttempt(req.ip);
|
||||
reply.status(401).send({ error: "Invalid or expired recovery code" });
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = await bcrypt.compare(phrase, record.codeHash);
|
||||
if (!matches) {
|
||||
await recordFailedAttempt(req.ip);
|
||||
reply.status(401).send({ error: "Invalid or expired recovery code" });
|
||||
return;
|
||||
}
|
||||
|
||||
// success - issue new session token and delete used code
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const hash = hashToken(token);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: record.userId },
|
||||
data: { tokenHash: hash },
|
||||
}),
|
||||
prisma.recoveryCode.delete({ where: { id: record.id } }),
|
||||
]);
|
||||
|
||||
reply
|
||||
.setCookie("echoboard_token", token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 24 * 90,
|
||||
})
|
||||
.send({ recovered: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
54
packages/api/src/routes/roadmap.ts
Normal file
54
packages/api/src/routes/roadmap.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
|
||||
const ROADMAP_STATUSES = ["PLANNED", "IN_PROGRESS", "DONE"] as const;
|
||||
|
||||
export default async function roadmapRoutes(app: FastifyInstance) {
|
||||
const handler = async (req: any, reply: any) => {
|
||||
const boardSlug = req.params?.boardSlug || (req.query as any)?.board || null;
|
||||
|
||||
const where: any = {
|
||||
status: { in: [...ROADMAP_STATUSES] },
|
||||
board: { isArchived: false },
|
||||
};
|
||||
if (boardSlug) {
|
||||
where.board.slug = boardSlug;
|
||||
}
|
||||
|
||||
const posts = await prisma.post.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
status: true,
|
||||
category: true,
|
||||
voteCount: true,
|
||||
createdAt: true,
|
||||
board: { select: { slug: true, name: true } },
|
||||
_count: { select: { comments: true } },
|
||||
tags: { include: { tag: true } },
|
||||
},
|
||||
orderBy: [{ voteCount: "desc" }, { createdAt: "desc" }],
|
||||
take: 200,
|
||||
});
|
||||
|
||||
const mapped = posts.map((p) => ({
|
||||
...p,
|
||||
tags: p.tags.map((pt) => pt.tag),
|
||||
}));
|
||||
|
||||
const columns = {
|
||||
PLANNED: mapped.filter((p) => p.status === "PLANNED"),
|
||||
IN_PROGRESS: mapped.filter((p) => p.status === "IN_PROGRESS"),
|
||||
DONE: mapped.filter((p) => p.status === "DONE"),
|
||||
};
|
||||
|
||||
reply.send({ columns });
|
||||
};
|
||||
|
||||
const opts = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } };
|
||||
|
||||
app.get("/roadmap", opts, handler);
|
||||
app.get("/b/:boardSlug/roadmap", opts, handler);
|
||||
}
|
||||
109
packages/api/src/routes/search.ts
Normal file
109
packages/api/src/routes/search.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { z } from "zod";
|
||||
import { decrypt } from "../services/encryption.js";
|
||||
import { masterKey } from "../config.js";
|
||||
|
||||
const searchQuery = z.object({
|
||||
q: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
function decryptName(encrypted: string | null): string | null {
|
||||
if (!encrypted) return null;
|
||||
try { return decrypt(encrypted, masterKey); } catch { return null; }
|
||||
}
|
||||
|
||||
export default async function searchRoutes(app: FastifyInstance) {
|
||||
app.get<{ Querystring: { q: string } }>("/search", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => {
|
||||
const { q } = searchQuery.parse(req.query);
|
||||
const term = q.trim();
|
||||
const likeTerm = `%${term}%`;
|
||||
|
||||
const [boardMatches, postMatches] = await Promise.all([
|
||||
prisma.$queryRaw<{ id: string; slug: string; name: string; icon_name: string | null; icon_color: string | null; description: string | null; post_count: number }[]>`
|
||||
SELECT id, slug, name, "iconName" as icon_name, "iconColor" as icon_color, description,
|
||||
(SELECT COUNT(*)::int FROM "Post" WHERE "boardId" = "Board".id) as post_count
|
||||
FROM "Board"
|
||||
WHERE "isArchived" = false
|
||||
AND (
|
||||
word_similarity(${term}, name) > 0.15
|
||||
OR word_similarity(${term}, COALESCE(description, '')) > 0.15
|
||||
OR to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', ${term})
|
||||
)
|
||||
ORDER BY (
|
||||
word_similarity(${term}, name) +
|
||||
CASE WHEN to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', ${term})
|
||||
THEN 1.0 ELSE 0.0 END
|
||||
) DESC
|
||||
LIMIT 5
|
||||
`,
|
||||
|
||||
prisma.$queryRaw<{ id: string }[]>`
|
||||
SELECT p.id
|
||||
FROM "Post" p
|
||||
JOIN "Board" b ON b.id = p."boardId"
|
||||
WHERE
|
||||
word_similarity(${term}, p.title) > 0.15
|
||||
OR to_tsvector('english', p.title) @@ plainto_tsquery('english', ${term})
|
||||
OR p.description::text ILIKE ${likeTerm}
|
||||
ORDER BY (
|
||||
word_similarity(${term}, p.title) +
|
||||
CASE WHEN to_tsvector('english', p.title) @@ plainto_tsquery('english', ${term})
|
||||
THEN 1.0 ELSE 0.0 END
|
||||
) DESC, p."voteCount" DESC
|
||||
LIMIT 15
|
||||
`,
|
||||
]);
|
||||
|
||||
const postIds = postMatches.map((p) => p.id);
|
||||
const posts = postIds.length
|
||||
? await prisma.post.findMany({
|
||||
where: { id: { in: postIds } },
|
||||
include: {
|
||||
board: { select: { slug: true, name: true, iconName: true, iconColor: true } },
|
||||
author: { select: { id: true, displayName: true, avatarPath: true } },
|
||||
_count: { select: { comments: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
// preserve relevance ordering from raw SQL
|
||||
const postOrder = new Map(postIds.map((id, i) => [id, i]));
|
||||
posts.sort((a, b) => (postOrder.get(a.id) ?? 0) - (postOrder.get(b.id) ?? 0));
|
||||
|
||||
const boards = boardMatches.map((b) => ({
|
||||
type: "board" as const,
|
||||
id: b.id,
|
||||
title: b.name,
|
||||
slug: b.slug,
|
||||
iconName: b.icon_name,
|
||||
iconColor: b.icon_color,
|
||||
description: b.description,
|
||||
postCount: b.post_count,
|
||||
}));
|
||||
|
||||
const postResults = posts.map((p) => ({
|
||||
type: "post" as const,
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
postType: p.type,
|
||||
status: p.status,
|
||||
voteCount: p.voteCount,
|
||||
commentCount: p._count.comments,
|
||||
boardSlug: p.board.slug,
|
||||
boardName: p.board.name,
|
||||
boardIconName: p.board.iconName,
|
||||
boardIconColor: p.board.iconColor,
|
||||
author: p.author
|
||||
? {
|
||||
id: p.author.id,
|
||||
displayName: decryptName(p.author.displayName),
|
||||
avatarUrl: p.author.avatarPath ? `/api/v1/avatars/${p.author.id}` : null,
|
||||
}
|
||||
: null,
|
||||
createdAt: p.createdAt,
|
||||
}));
|
||||
|
||||
reply.send({ boards, posts: postResults });
|
||||
});
|
||||
}
|
||||
54
packages/api/src/routes/similar.ts
Normal file
54
packages/api/src/routes/similar.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
import { z } from "zod";
|
||||
|
||||
const similarQuery = z.object({
|
||||
title: z.string().min(5).max(200),
|
||||
boardId: z.string().min(1),
|
||||
});
|
||||
|
||||
export default async function similarRoutes(app: FastifyInstance) {
|
||||
app.get<{ Querystring: Record<string, string> }>(
|
||||
"/similar",
|
||||
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const q = similarQuery.safeParse(req.query);
|
||||
if (!q.success) {
|
||||
reply.send({ posts: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, boardId } = q.data;
|
||||
|
||||
const board = await prisma.board.findUnique({ where: { id: boardId }, select: { id: true } });
|
||||
if (!board) {
|
||||
reply.send({ posts: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
// use pg_trgm similarity to find posts with similar titles
|
||||
const similar = await prisma.$queryRaw<
|
||||
{ id: string; title: string; status: string; vote_count: number; similarity: number }[]
|
||||
>`
|
||||
SELECT id, title, status, "voteCount" as vote_count,
|
||||
similarity(title, ${title}) as similarity
|
||||
FROM "Post"
|
||||
WHERE "boardId" = ${boardId}
|
||||
AND similarity(title, ${title}) > 0.25
|
||||
AND status NOT IN ('DONE', 'DECLINED')
|
||||
ORDER BY similarity DESC
|
||||
LIMIT 5
|
||||
`;
|
||||
|
||||
reply.send({
|
||||
posts: similar.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
status: p.status,
|
||||
voteCount: p.vote_count,
|
||||
similarity: Math.round(p.similarity * 100),
|
||||
})),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
21
packages/api/src/routes/templates.ts
Normal file
21
packages/api/src/routes/templates.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { prisma } from "../lib/prisma.js";
|
||||
|
||||
export default async function templateRoutes(app: FastifyInstance) {
|
||||
app.get<{ Params: { boardSlug: string } }>(
|
||||
"/boards/:boardSlug/templates",
|
||||
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) return reply.status(404).send({ error: "Board not found" });
|
||||
|
||||
const templates = await prisma.boardTemplate.findMany({
|
||||
where: { boardId: board.id },
|
||||
orderBy: { position: "asc" },
|
||||
select: { id: true, name: true, fields: true, isDefault: true, position: true },
|
||||
});
|
||||
|
||||
reply.send({ templates });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,134 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { verifyChallenge } from "../services/altcha.js";
|
||||
import { getCurrentPeriod, getRemainingBudget, getNextResetDate } from "../lib/budget.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const voteBody = z.object({
|
||||
altcha: z.string(),
|
||||
});
|
||||
|
||||
const importanceBody = z.object({
|
||||
importance: z.enum(["critical", "important", "nice_to_have", "minor"]),
|
||||
});
|
||||
|
||||
export default async function voteRoutes(app: FastifyInstance) {
|
||||
app.post<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof voteBody> }>(
|
||||
"/boards/:boardSlug/posts/:id/vote",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 hour" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
reply.status(404).send({ error: "Board not found" });
|
||||
return;
|
||||
}
|
||||
if (board.isArchived) {
|
||||
reply.status(403).send({ error: "Board is archived" });
|
||||
return;
|
||||
}
|
||||
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post || post.boardId !== board.id) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (post.authorId === req.user!.id) {
|
||||
reply.status(403).send({ error: "Cannot vote on your own post" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (post.isVotingLocked) {
|
||||
reply.status(403).send({ error: "Voting is locked on this post" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = voteBody.parse(req.body);
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
return;
|
||||
}
|
||||
|
||||
const period = getCurrentPeriod(board.voteBudgetReset);
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.vote.findUnique({
|
||||
where: { postId_voterId: { postId: post.id, voterId: req.user!.id } },
|
||||
});
|
||||
|
||||
if (existing && !board.allowMultiVote) throw new Error("ALREADY_VOTED");
|
||||
|
||||
const remaining = await getRemainingBudget(req.user!.id, board.id, tx);
|
||||
if (remaining <= 0) throw new Error("BUDGET_EXHAUSTED");
|
||||
|
||||
if (existing && board.allowMultiVote) {
|
||||
if (existing.weight >= 3) throw new Error("MAX_VOTES");
|
||||
await tx.vote.update({
|
||||
where: { id: existing.id },
|
||||
data: { weight: { increment: 1 } },
|
||||
});
|
||||
} else {
|
||||
await tx.vote.create({
|
||||
data: {
|
||||
postId: post.id,
|
||||
voterId: req.user!.id,
|
||||
budgetPeriod: period,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.post.update({
|
||||
where: { id: post.id },
|
||||
data: { voteCount: { increment: 1 }, lastActivityAt: new Date() },
|
||||
});
|
||||
}, { isolationLevel: "Serializable" });
|
||||
} catch (err: any) {
|
||||
if (err.message === "ALREADY_VOTED") {
|
||||
reply.status(409).send({ error: "Already voted" });
|
||||
return;
|
||||
}
|
||||
if (err.message === "BUDGET_EXHAUSTED") {
|
||||
reply.status(429).send({ error: "Vote budget exhausted" });
|
||||
return;
|
||||
}
|
||||
if (err.message === "MAX_VOTES") {
|
||||
reply.status(409).send({ error: "Max 3 votes per post" });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
await prisma.activityEvent.create({
|
||||
data: {
|
||||
type: "vote_cast",
|
||||
boardId: board.id,
|
||||
postId: post.id,
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
|
||||
const newCount = post.voteCount + 1;
|
||||
const milestones = [10, 50, 100, 250, 500];
|
||||
if (milestones.includes(newCount)) {
|
||||
await prisma.activityEvent.create({
|
||||
data: {
|
||||
type: "vote_milestone",
|
||||
boardId: board.id,
|
||||
postId: post.id,
|
||||
metadata: { milestoneCount: newCount, title: post.title },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reply.send({ ok: true, voteCount: newCount });
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { boardSlug: string; id: string } }>(
|
||||
"/boards/:boardSlug/posts/:id/vote",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 hour" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
@@ -27,66 +142,35 @@ export default async function voteRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = voteBody.parse(req.body);
|
||||
const valid = await verifyChallenge(body.altcha);
|
||||
if (!valid) {
|
||||
reply.status(400).send({ error: "Invalid challenge response" });
|
||||
if (post.isVotingLocked) {
|
||||
reply.status(403).send({ error: "Voting is locked on this post" });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await prisma.vote.findUnique({
|
||||
where: { postId_voterId: { postId: post.id, voterId: req.user!.id } },
|
||||
});
|
||||
|
||||
if (existing && !board.allowMultiVote) {
|
||||
reply.status(409).send({ error: "Already voted" });
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = await getRemainingBudget(req.user!.id, board.id);
|
||||
if (remaining <= 0) {
|
||||
reply.status(429).send({ error: "Vote budget exhausted" });
|
||||
return;
|
||||
}
|
||||
|
||||
const period = getCurrentPeriod(board.voteBudgetReset);
|
||||
|
||||
if (existing && board.allowMultiVote) {
|
||||
await prisma.vote.update({
|
||||
where: { id: existing.id },
|
||||
data: { weight: existing.weight + 1 },
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const vote = await tx.vote.findUnique({
|
||||
where: { postId_voterId: { postId: req.params.id, voterId: req.user!.id } },
|
||||
});
|
||||
} else {
|
||||
await prisma.vote.create({
|
||||
data: {
|
||||
postId: post.id,
|
||||
voterId: req.user!.id,
|
||||
budgetPeriod: period,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!vote) throw new Error("NO_VOTE");
|
||||
|
||||
await prisma.post.update({
|
||||
where: { id: post.id },
|
||||
data: { voteCount: { increment: 1 } },
|
||||
await tx.vote.delete({ where: { id: vote.id } });
|
||||
await tx.$executeRaw`UPDATE "Post" SET "voteCount" = GREATEST(0, "voteCount" - ${vote.weight}) WHERE "id" = ${vote.postId}`;
|
||||
}).catch((err) => {
|
||||
if (err.message === "NO_VOTE") {
|
||||
reply.status(404).send({ error: "No vote found" });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
if (reply.sent) return;
|
||||
|
||||
await prisma.activityEvent.create({
|
||||
data: {
|
||||
type: "vote_cast",
|
||||
boardId: board.id,
|
||||
postId: post.id,
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ ok: true, voteCount: post.voteCount + 1 });
|
||||
reply.send({ ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { boardSlug: string; id: string } }>(
|
||||
"/boards/:boardSlug/posts/:id/vote",
|
||||
{ preHandler: [app.requireUser] },
|
||||
app.put<{ Params: { boardSlug: string; id: string }; Body: z.infer<typeof importanceBody> }>(
|
||||
"/boards/:boardSlug/posts/:id/vote/importance",
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
@@ -94,6 +178,18 @@ export default async function voteRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
|
||||
if (!post || post.boardId !== board.id) {
|
||||
reply.status(404).send({ error: "Post not found" });
|
||||
return;
|
||||
}
|
||||
if (post.isVotingLocked) {
|
||||
reply.status(403).send({ error: "Voting is locked" });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = importanceBody.parse(req.body);
|
||||
|
||||
const vote = await prisma.vote.findUnique({
|
||||
where: { postId_voterId: { postId: req.params.id, voterId: req.user!.id } },
|
||||
});
|
||||
@@ -103,11 +199,9 @@ export default async function voteRoutes(app: FastifyInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const weight = vote.weight;
|
||||
await prisma.vote.delete({ where: { id: vote.id } });
|
||||
await prisma.post.update({
|
||||
where: { id: req.params.id },
|
||||
data: { voteCount: { decrement: weight } },
|
||||
await prisma.vote.update({
|
||||
where: { id: vote.id },
|
||||
data: { importance: body.importance },
|
||||
});
|
||||
|
||||
reply.send({ ok: true });
|
||||
@@ -116,7 +210,7 @@ export default async function voteRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get<{ Params: { boardSlug: string } }>(
|
||||
"/boards/:boardSlug/budget",
|
||||
{ preHandler: [app.requireUser] },
|
||||
{ preHandler: [app.requireUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
|
||||
if (!board) {
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import Fastify from "fastify";
|
||||
import Fastify, { FastifyError } from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import cors from "@fastify/cors";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import multipart from "@fastify/multipart";
|
||||
import { resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
import { config } from "./config.js";
|
||||
import prisma from "./lib/prisma.js";
|
||||
import securityPlugin from "./middleware/security.js";
|
||||
import authPlugin from "./middleware/auth.js";
|
||||
import { loadPlugins } from "./plugins/loader.js";
|
||||
import { loadPlugins, startupPlugins, shutdownPlugins, getActivePluginInfo, getPluginAdminRoutes } from "./plugins/loader.js";
|
||||
import { seedAllBoardTemplates } from "./lib/default-templates.js";
|
||||
|
||||
import boardRoutes from "./routes/boards.js";
|
||||
import postRoutes from "./routes/posts.js";
|
||||
@@ -26,6 +31,25 @@ import adminPostRoutes from "./routes/admin/posts.js";
|
||||
import adminBoardRoutes from "./routes/admin/boards.js";
|
||||
import adminCategoryRoutes from "./routes/admin/categories.js";
|
||||
import adminStatsRoutes from "./routes/admin/stats.js";
|
||||
import searchRoutes from "./routes/search.js";
|
||||
import roadmapRoutes from "./routes/roadmap.js";
|
||||
import similarRoutes from "./routes/similar.js";
|
||||
import adminTagRoutes from "./routes/admin/tags.js";
|
||||
import adminNoteRoutes from "./routes/admin/notes.js";
|
||||
import adminChangelogRoutes from "./routes/admin/changelog.js";
|
||||
import adminWebhookRoutes from "./routes/admin/webhooks.js";
|
||||
import changelogRoutes from "./routes/changelog.js";
|
||||
import notificationRoutes from "./routes/notifications.js";
|
||||
import embedRoutes from "./routes/embed.js";
|
||||
import adminStatusRoutes from "./routes/admin/statuses.js";
|
||||
import adminExportRoutes from "./routes/admin/export.js";
|
||||
import adminTemplateRoutes from "./routes/admin/templates.js";
|
||||
import templateRoutes from "./routes/templates.js";
|
||||
import attachmentRoutes from "./routes/attachments.js";
|
||||
import avatarRoutes from "./routes/avatars.js";
|
||||
import recoveryRoutes from "./routes/recovery.js";
|
||||
import settingsRoutes from "./routes/admin/settings.js";
|
||||
import adminTeamRoutes from "./routes/admin/team.js";
|
||||
|
||||
export async function createServer() {
|
||||
const app = Fastify({
|
||||
@@ -35,25 +59,70 @@ export async function createServer() {
|
||||
return {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
remoteAddress: req.ip,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(cookie, { secret: process.env.TOKEN_SECRET });
|
||||
const cookieSecret = createHmac("sha256", config.TOKEN_SECRET).update("echoboard:cookie").digest("hex");
|
||||
await app.register(cookie, { secret: cookieSecret });
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
origin: process.env.NODE_ENV === "production"
|
||||
? config.WEBAUTHN_ORIGIN
|
||||
: ["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const allowedOrigins = new Set(
|
||||
process.env.NODE_ENV === "production"
|
||||
? [config.WEBAUTHN_ORIGIN]
|
||||
: [config.WEBAUTHN_ORIGIN, "http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"]
|
||||
);
|
||||
app.addHook("onRequest", async (req, reply) => {
|
||||
if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
|
||||
const origin = req.headers.origin;
|
||||
// Server-to-server webhook calls don't send Origin headers
|
||||
if (!origin && req.url.startsWith('/api/v1/plugins/') && req.url.includes('/webhook')) return;
|
||||
if (!origin || !allowedOrigins.has(origin)) {
|
||||
return reply.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await app.register(multipart, { limits: { fileSize: 5 * 1024 * 1024 } });
|
||||
await app.register(rateLimit, {
|
||||
max: 100,
|
||||
timeWindow: "1 minute",
|
||||
});
|
||||
|
||||
app.setErrorHandler((error: FastifyError, req, reply) => {
|
||||
req.log.error(error);
|
||||
|
||||
// zod validation errors
|
||||
if (error.validation) {
|
||||
reply.status(400).send({ error: "Validation failed" });
|
||||
return;
|
||||
}
|
||||
|
||||
// fastify rate limit
|
||||
if (error.statusCode === 429) {
|
||||
reply.status(429).send({ error: "Too many requests" });
|
||||
return;
|
||||
}
|
||||
|
||||
const status = error.statusCode ?? 500;
|
||||
reply.status(status).send({
|
||||
error: status >= 500 ? "Internal server error" : error.message,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(securityPlugin);
|
||||
await app.register(authPlugin);
|
||||
|
||||
app.decorate("prisma", prisma);
|
||||
|
||||
// api routes under /api/v1
|
||||
await app.register(async (api) => {
|
||||
await api.register(boardRoutes);
|
||||
@@ -72,6 +141,25 @@ export async function createServer() {
|
||||
await api.register(adminBoardRoutes);
|
||||
await api.register(adminCategoryRoutes);
|
||||
await api.register(adminStatsRoutes);
|
||||
await api.register(searchRoutes);
|
||||
await api.register(roadmapRoutes);
|
||||
await api.register(similarRoutes);
|
||||
await api.register(adminTagRoutes);
|
||||
await api.register(adminNoteRoutes);
|
||||
await api.register(adminChangelogRoutes);
|
||||
await api.register(adminWebhookRoutes);
|
||||
await api.register(changelogRoutes);
|
||||
await api.register(notificationRoutes);
|
||||
await api.register(embedRoutes);
|
||||
await api.register(adminStatusRoutes);
|
||||
await api.register(adminExportRoutes);
|
||||
await api.register(adminTemplateRoutes);
|
||||
await api.register(templateRoutes);
|
||||
await api.register(attachmentRoutes);
|
||||
await api.register(avatarRoutes);
|
||||
await api.register(recoveryRoutes);
|
||||
await api.register(settingsRoutes);
|
||||
await api.register(adminTeamRoutes);
|
||||
}, { prefix: "/api/v1" });
|
||||
|
||||
// serve static frontend build in production
|
||||
@@ -88,6 +176,29 @@ export async function createServer() {
|
||||
}
|
||||
|
||||
await loadPlugins(app);
|
||||
await startupPlugins();
|
||||
|
||||
// seed default templates for boards that have none
|
||||
await seedAllBoardTemplates(prisma);
|
||||
|
||||
// register plugin discovery endpoint and admin routes
|
||||
await app.register(async (api) => {
|
||||
api.get("/plugins/active", {
|
||||
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
|
||||
}, async () => getActivePluginInfo());
|
||||
|
||||
// register plugin-provided admin routes
|
||||
for (const route of getPluginAdminRoutes()) {
|
||||
api.get(`/plugins${route.path}`, { preHandler: [app.requireAdmin] }, async () => ({
|
||||
label: route.label,
|
||||
component: route.component,
|
||||
}));
|
||||
}
|
||||
}, { prefix: "/api/v1" });
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
await shutdownPlugins();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { createChallenge, verifySolution } from "altcha-lib";
|
||||
import { createHash } from "node:crypto";
|
||||
import { config } from "../config.js";
|
||||
|
||||
// replay protection: track consumed challenge hashes (fingerprint -> timestamp)
|
||||
const usedChallenges = new Map<string, number>();
|
||||
const EXPIRY_MS = 300000;
|
||||
|
||||
// clean up expired entries every 5 minutes
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - EXPIRY_MS;
|
||||
for (const [fp, ts] of usedChallenges) {
|
||||
if (ts < cutoff) usedChallenges.delete(fp);
|
||||
}
|
||||
}, EXPIRY_MS);
|
||||
|
||||
function challengeFingerprint(payload: string): string {
|
||||
return createHash("sha256").update(payload).digest("hex").slice(0, 32);
|
||||
}
|
||||
|
||||
export async function generateChallenge(difficulty: "normal" | "light" = "normal") {
|
||||
const maxNumber = difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER;
|
||||
const challenge = await createChallenge({
|
||||
@@ -13,8 +30,30 @@ export async function generateChallenge(difficulty: "normal" | "light" = "normal
|
||||
|
||||
export async function verifyChallenge(payload: string): Promise<boolean> {
|
||||
try {
|
||||
const fp = challengeFingerprint(payload);
|
||||
|
||||
// reject replayed challenges
|
||||
if (usedChallenges.has(fp)) return false;
|
||||
|
||||
const ok = await verifySolution(payload, config.ALTCHA_HMAC_KEY);
|
||||
return ok;
|
||||
if (!ok) return false;
|
||||
|
||||
// evict expired entries if map is large
|
||||
if (usedChallenges.size >= 50000) {
|
||||
const cutoff = Date.now() - EXPIRY_MS;
|
||||
for (const [key, ts] of usedChallenges) {
|
||||
if (ts < cutoff) usedChallenges.delete(key);
|
||||
}
|
||||
}
|
||||
// hard cap - drop oldest entries if still over limit
|
||||
if (usedChallenges.size >= 50000) {
|
||||
const sorted = [...usedChallenges.entries()].sort((a, b) => a[1] - b[1]);
|
||||
const toRemove = sorted.slice(0, sorted.length - 40000);
|
||||
for (const [key] of toRemove) usedChallenges.delete(key);
|
||||
}
|
||||
usedChallenges.set(fp, Date.now());
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ export function decrypt(encoded: string, key: Buffer): string {
|
||||
return decipher.update(ciphertext) + decipher.final("utf8");
|
||||
}
|
||||
|
||||
export function decryptWithFallback(encoded: string, currentKey: Buffer, previousKey: Buffer | null): string {
|
||||
try {
|
||||
return decrypt(encoded, currentKey);
|
||||
} catch {
|
||||
if (previousKey) return decrypt(encoded, previousKey);
|
||||
throw new Error("Decryption failed with all available keys");
|
||||
}
|
||||
}
|
||||
|
||||
export function blindIndex(value: string, key: Buffer): string {
|
||||
return createHmac("sha256", key).update(value.toLowerCase()).digest("hex");
|
||||
}
|
||||
|
||||
145
packages/api/src/services/key-rotation.ts
Normal file
145
packages/api/src/services/key-rotation.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { encrypt, decryptWithFallback } from "./encryption.js";
|
||||
import { masterKey, previousMasterKey } from "../config.js";
|
||||
|
||||
export async function reEncryptIfNeeded() {
|
||||
if (!previousMasterKey) return;
|
||||
|
||||
console.log("Key rotation: checking for records encrypted with previous key...");
|
||||
let reEncrypted = 0;
|
||||
let failures = 0;
|
||||
|
||||
// re-encrypt user fields
|
||||
const users = await prisma.user.findMany({
|
||||
where: { OR: [{ displayName: { not: null } }, { username: { not: null } }] },
|
||||
select: { id: true, displayName: true, username: true },
|
||||
});
|
||||
|
||||
for (const user of users) {
|
||||
const updates: Record<string, string | null> = {};
|
||||
let needsUpdate = false;
|
||||
|
||||
if (user.displayName) {
|
||||
try {
|
||||
const plain = decryptWithFallback(user.displayName, masterKey, previousMasterKey);
|
||||
const reEnc = encrypt(plain, masterKey);
|
||||
if (reEnc !== user.displayName) {
|
||||
updates.displayName = reEnc;
|
||||
needsUpdate = true;
|
||||
}
|
||||
} catch { failures++; }
|
||||
}
|
||||
|
||||
if (user.username) {
|
||||
try {
|
||||
const plain = decryptWithFallback(user.username, masterKey, previousMasterKey);
|
||||
const reEnc = encrypt(plain, masterKey);
|
||||
if (reEnc !== user.username) {
|
||||
updates.username = reEnc;
|
||||
needsUpdate = true;
|
||||
}
|
||||
} catch { failures++; }
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await prisma.user.update({ where: { id: user.id }, data: updates });
|
||||
reEncrypted++;
|
||||
}
|
||||
}
|
||||
|
||||
// re-encrypt passkey fields
|
||||
const passkeys = await prisma.passkey.findMany({
|
||||
select: { id: true, credentialId: true, credentialPublicKey: true, transports: true },
|
||||
});
|
||||
|
||||
for (const pk of passkeys) {
|
||||
const updates: Record<string, any> = {};
|
||||
let needsUpdate = false;
|
||||
|
||||
for (const field of ["credentialId", "transports"] as const) {
|
||||
const val = pk[field] as string;
|
||||
if (val) {
|
||||
try {
|
||||
const plain = decryptWithFallback(val, masterKey, previousMasterKey);
|
||||
const reEnc = encrypt(plain, masterKey);
|
||||
if (reEnc !== val) {
|
||||
updates[field] = reEnc;
|
||||
needsUpdate = true;
|
||||
}
|
||||
} catch { failures++; }
|
||||
}
|
||||
}
|
||||
|
||||
// re-encrypt public key (stored as encrypted string in Bytes field)
|
||||
if (pk.credentialPublicKey) {
|
||||
try {
|
||||
const pubKeyStr = pk.credentialPublicKey.toString();
|
||||
const plain = decryptWithFallback(pubKeyStr, masterKey, previousMasterKey);
|
||||
const reEnc = encrypt(plain, masterKey);
|
||||
if (reEnc !== pubKeyStr) {
|
||||
updates.credentialPublicKey = Buffer.from(reEnc);
|
||||
needsUpdate = true;
|
||||
}
|
||||
} catch { failures++; }
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await prisma.passkey.update({ where: { id: pk.id }, data: updates });
|
||||
reEncrypted++;
|
||||
}
|
||||
}
|
||||
|
||||
// re-encrypt push subscription fields
|
||||
const pushSubs = await prisma.pushSubscription.findMany({
|
||||
select: { id: true, endpoint: true, keysP256dh: true, keysAuth: true },
|
||||
});
|
||||
|
||||
for (const sub of pushSubs) {
|
||||
const updates: Record<string, string> = {};
|
||||
let needsUpdate = false;
|
||||
|
||||
for (const field of ["endpoint", "keysP256dh", "keysAuth"] as const) {
|
||||
const val = sub[field];
|
||||
if (val) {
|
||||
try {
|
||||
const plain = decryptWithFallback(val, masterKey, previousMasterKey);
|
||||
const reEnc = encrypt(plain, masterKey);
|
||||
if (reEnc !== val) {
|
||||
updates[field] = reEnc;
|
||||
needsUpdate = true;
|
||||
}
|
||||
} catch { failures++; }
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await prisma.pushSubscription.update({ where: { id: sub.id }, data: updates });
|
||||
reEncrypted++;
|
||||
}
|
||||
}
|
||||
|
||||
// re-encrypt webhook secrets
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
select: { id: true, secret: true },
|
||||
});
|
||||
|
||||
for (const wh of webhooks) {
|
||||
try {
|
||||
const plain = decryptWithFallback(wh.secret, masterKey, previousMasterKey);
|
||||
const reEnc = encrypt(plain, masterKey);
|
||||
if (reEnc !== wh.secret) {
|
||||
await prisma.webhook.update({ where: { id: wh.id }, data: { secret: reEnc } });
|
||||
reEncrypted++;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.warn(`Key rotation: ${failures} records failed to re-encrypt`);
|
||||
}
|
||||
if (reEncrypted > 0) {
|
||||
console.log(`Key rotation: re-encrypted ${reEncrypted} records`);
|
||||
} else {
|
||||
console.log("Key rotation: all records already use current key");
|
||||
}
|
||||
}
|
||||
40
packages/api/src/services/manifest-validator.ts
Normal file
40
packages/api/src/services/manifest-validator.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const TRACKED_MODELS = ["User", "Passkey", "PushSubscription"] as const;
|
||||
|
||||
const MANIFEST_FIELDS: Record<string, string[]> = {
|
||||
User: [
|
||||
"id", "authMethod", "tokenHash", "username", "usernameIdx",
|
||||
"displayName", "avatarPath", "darkMode", "createdAt", "updatedAt",
|
||||
"posts", "comments", "votes", "reactions", "passkeys", "notifications", "pushSubscriptions", "adminLink", "attachments", "edits", "recoveryCode",
|
||||
],
|
||||
Passkey: [
|
||||
"id", "credentialId", "credentialIdIdx", "credentialPublicKey",
|
||||
"counter", "credentialDeviceType", "credentialBackedUp", "transports",
|
||||
"userId", "user", "createdAt",
|
||||
],
|
||||
PushSubscription: [
|
||||
"id", "endpoint", "endpointIdx", "keysP256dh", "keysAuth",
|
||||
"userId", "user", "boardId", "board", "postId", "post", "failureCount", "createdAt",
|
||||
],
|
||||
};
|
||||
|
||||
export function validateManifest() {
|
||||
for (const modelName of TRACKED_MODELS) {
|
||||
const dmmfModel = Prisma.dmmf.datamodel.models.find((m) => m.name === modelName);
|
||||
if (!dmmfModel) continue;
|
||||
|
||||
const schemaFields = dmmfModel.fields.map((f) => f.name);
|
||||
const manifestFields = MANIFEST_FIELDS[modelName] || [];
|
||||
|
||||
for (const field of schemaFields) {
|
||||
if (!manifestFields.includes(field)) {
|
||||
console.error(
|
||||
`Data manifest violation: field "${modelName}.${field}" exists in schema but is not declared in the data manifest. ` +
|
||||
`Add it to the manifest or the app will not start.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import webpush from "web-push";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { config } from "../config.js";
|
||||
import { decrypt } from "./encryption.js";
|
||||
import { masterKey } from "../config.js";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
if (config.VAPID_PUBLIC_KEY && config.VAPID_PRIVATE_KEY && config.VAPID_CONTACT) {
|
||||
webpush.setVapidDetails(
|
||||
config.VAPID_CONTACT,
|
||||
@@ -21,7 +19,7 @@ interface PushPayload {
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export async function sendNotification(sub: { endpoint: string; keysP256dh: string; keysAuth: string }, payload: PushPayload) {
|
||||
export async function sendNotification(sub: { id: string; endpoint: string; keysP256dh: string; keysAuth: string }, payload: PushPayload): Promise<"ok" | "gone" | "failed"> {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
@@ -33,39 +31,58 @@ export async function sendNotification(sub: { endpoint: string; keysP256dh: stri
|
||||
},
|
||||
JSON.stringify(payload)
|
||||
);
|
||||
return true;
|
||||
return "ok";
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.statusCode === 410) {
|
||||
return false;
|
||||
return "gone";
|
||||
}
|
||||
throw err;
|
||||
// transient failure - increment counter
|
||||
await prisma.pushSubscription.update({
|
||||
where: { id: sub.id },
|
||||
data: { failureCount: { increment: 1 } },
|
||||
}).catch(() => {});
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_FANOUT = 5000;
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
async function processResults(subs: { id: string; endpoint: string; keysP256dh: string; keysAuth: string }[], event: PushPayload) {
|
||||
if (subs.length > MAX_FANOUT) {
|
||||
console.warn(`push fanout capped: ${subs.length} subscribers truncated to ${MAX_FANOUT}`);
|
||||
}
|
||||
const capped = subs.slice(0, MAX_FANOUT);
|
||||
const gone: string[] = [];
|
||||
|
||||
for (let i = 0; i < capped.length; i += BATCH_SIZE) {
|
||||
const batch = capped.slice(i, i + BATCH_SIZE);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map((sub) => sendNotification(sub, event).then((r) => ({ id: sub.id, result: r })))
|
||||
);
|
||||
for (const r of results) {
|
||||
if (r.status === "fulfilled" && r.value.result === "gone") {
|
||||
gone.push(r.value.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (gone.length > 0) {
|
||||
await prisma.pushSubscription.deleteMany({ where: { id: { in: gone } } });
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyPostSubscribers(postId: string, event: PushPayload) {
|
||||
const subs = await prisma.pushSubscription.findMany({ where: { postId } });
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const sub of subs) {
|
||||
const ok = await sendNotification(sub, event);
|
||||
if (!ok) failed.push(sub.id);
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
await prisma.pushSubscription.deleteMany({ where: { id: { in: failed } } });
|
||||
}
|
||||
await processResults(subs, event);
|
||||
}
|
||||
|
||||
export async function notifyBoardSubscribers(boardId: string, event: PushPayload) {
|
||||
const subs = await prisma.pushSubscription.findMany({ where: { boardId, postId: null } });
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const sub of subs) {
|
||||
const ok = await sendNotification(sub, event);
|
||||
if (!ok) failed.push(sub.id);
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
await prisma.pushSubscription.deleteMany({ where: { id: { in: failed } } });
|
||||
}
|
||||
await processResults(subs, event);
|
||||
}
|
||||
|
||||
export async function notifyUserReply(userId: string, event: PushPayload) {
|
||||
const subs = await prisma.pushSubscription.findMany({ where: { userId } });
|
||||
await processResults(subs, event);
|
||||
}
|
||||
|
||||
104
packages/api/src/services/webhooks.ts
Normal file
104
packages/api/src/services/webhooks.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { resolve as dnsResolve } from "node:dns/promises";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { decrypt } from "./encryption.js";
|
||||
import { masterKey } from "../config.js";
|
||||
|
||||
function isPrivateIp(ip: string): boolean {
|
||||
// normalize IPv6-mapped IPv4 (::ffff:127.0.0.1 -> 127.0.0.1)
|
||||
const normalized = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
|
||||
if (normalized === "127.0.0.1" || normalized === "::1") return true;
|
||||
if (normalized.startsWith("0.")) return true; // 0.0.0.0/8
|
||||
if (normalized.startsWith("10.") || normalized.startsWith("192.168.")) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(normalized)) return true;
|
||||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(normalized)) return true; // 100.64.0.0/10 CGNAT
|
||||
if (normalized.startsWith("198.18.") || normalized.startsWith("198.19.")) return true; // 198.18.0.0/15
|
||||
if (/^24[0-9]\./.test(normalized) || normalized.startsWith("255.")) return true; // 240.0.0.0/4
|
||||
if (normalized === "169.254.169.254" || normalized.startsWith("169.254.")) return true; // link-local
|
||||
if (normalized.startsWith("fe80:") || normalized.startsWith("fc00:") || normalized.startsWith("fd")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isAllowedUrl(raw: string): boolean {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
if (u.protocol !== "https:") return false;
|
||||
const host = u.hostname;
|
||||
if (host === "localhost" || isPrivateIp(host)) return false;
|
||||
if (host.endsWith(".internal") || host.endsWith(".local")) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolvedIpIsAllowed(hostname: string): Promise<boolean> {
|
||||
try {
|
||||
const addresses = await dnsResolve(hostname);
|
||||
for (const addr of addresses) {
|
||||
if (isPrivateIp(addr)) return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fireWebhook(event: string, payload: Record<string, unknown>) {
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: { active: true, events: { has: event } },
|
||||
});
|
||||
|
||||
for (const wh of webhooks) {
|
||||
if (!isAllowedUrl(wh.url)) continue;
|
||||
const url = new URL(wh.url);
|
||||
|
||||
let addresses: string[];
|
||||
try {
|
||||
addresses = await dnsResolve(url.hostname);
|
||||
if (addresses.some((addr) => isPrivateIp(addr))) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
let secret: string;
|
||||
try {
|
||||
secret = decrypt(wh.secret, masterKey);
|
||||
} catch (err: any) {
|
||||
console.warn(`webhook ${wh.id}: secret decryption failed - ${err?.message ?? "unknown error"}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = JSON.stringify({ event, timestamp: new Date().toISOString(), data: payload });
|
||||
const signature = createHmac("sha256", secret).update(body).digest("hex");
|
||||
|
||||
// connect directly to the resolved IP, use original hostname for TLS SNI
|
||||
// and Host header - this closes the DNS rebinding window
|
||||
const req = httpsRequest({
|
||||
hostname: addresses[0],
|
||||
port: url.port || 443,
|
||||
path: url.pathname + url.search,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Host": url.hostname,
|
||||
"Content-Type": "application/json",
|
||||
"X-Echoboard-Signature": signature,
|
||||
"X-Echoboard-Event": event,
|
||||
},
|
||||
servername: url.hostname,
|
||||
timeout: 10000,
|
||||
}, (res) => {
|
||||
res.resume(); // drain response
|
||||
});
|
||||
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy(new Error("timeout"));
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
console.warn(`webhook delivery failed for ${wh.id}: ${err.message}`);
|
||||
});
|
||||
req.write(body);
|
||||
req.end();
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,21 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/space-grotesk": "^5.0.0",
|
||||
"@fontsource/sora": "^5.0.0",
|
||||
"@fontsource/space-grotesk": "^5.0.0",
|
||||
"@simplewebauthn/browser": "^11.0.0",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-router-dom": "^7.0.0"
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
|
||||
92
packages/web/public/embed.js
Normal file
92
packages/web/public/embed.js
Normal file
@@ -0,0 +1,92 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
var scripts = document.querySelectorAll('script[data-echoboard]');
|
||||
for (var i = 0; i < scripts.length; i++) {
|
||||
var s = scripts[i];
|
||||
if (s.getAttribute('data-rendered')) continue;
|
||||
s.setAttribute('data-rendered', '1');
|
||||
|
||||
var board = s.getAttribute('data-board');
|
||||
var baseUrl = s.getAttribute('data-url') || s.src.replace(/\/embed\.js.*$/, '');
|
||||
var theme = s.getAttribute('data-theme') || 'dark';
|
||||
var limit = s.getAttribute('data-limit') || '10';
|
||||
var sort = s.getAttribute('data-sort') || 'top';
|
||||
var height = parseInt(s.getAttribute('data-height') || '500', 10) || 500;
|
||||
var mode = s.getAttribute('data-mode') || 'inline';
|
||||
var label = s.getAttribute('data-label') || 'Feedback';
|
||||
var position = s.getAttribute('data-position') || 'right';
|
||||
|
||||
if (!board) continue;
|
||||
|
||||
var params = '?theme=' + encodeURIComponent(theme) +
|
||||
'&limit=' + encodeURIComponent(limit) +
|
||||
'&sort=' + encodeURIComponent(sort);
|
||||
var src = baseUrl + '/embed/' + encodeURIComponent(board) + params;
|
||||
|
||||
if (mode === 'button') {
|
||||
// Floating feedback button mode
|
||||
var isRight = position !== 'left';
|
||||
var isDark = theme === 'dark';
|
||||
var accent = '#F59E0B';
|
||||
|
||||
// Button
|
||||
var btn = document.createElement('button');
|
||||
btn.textContent = label;
|
||||
btn.style.cssText = 'position:fixed;bottom:24px;' + (isRight ? 'right' : 'left') + ':24px;' +
|
||||
'z-index:999998;padding:10px 20px;border:none;border-radius:24px;cursor:pointer;' +
|
||||
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;font-weight:600;' +
|
||||
'background:' + accent + ';color:#161616;box-shadow:0 4px 16px rgba(0,0,0,0.3);' +
|
||||
'transition:transform 0.2s ease,box-shadow 0.2s ease;';
|
||||
btn.onmouseenter = function() { btn.style.transform = 'scale(1.05)'; btn.style.boxShadow = '0 6px 24px rgba(0,0,0,0.4)'; };
|
||||
btn.onmouseleave = function() { btn.style.transform = 'scale(1)'; btn.style.boxShadow = '0 4px 16px rgba(0,0,0,0.3)'; };
|
||||
|
||||
// Popup container
|
||||
var popup = document.createElement('div');
|
||||
popup.style.cssText = 'position:fixed;bottom:80px;' + (isRight ? 'right' : 'left') + ':24px;' +
|
||||
'z-index:999999;width:380px;max-width:calc(100vw - 48px);height:' + height + 'px;max-height:calc(100vh - 120px);' +
|
||||
'border-radius:12px;overflow:hidden;' +
|
||||
'box-shadow:0 8px 40px rgba(0,0,0,0.4);border:1px solid ' + (isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)') + ';' +
|
||||
'display:none;transform:translateY(12px);opacity:0;transition:transform 0.25s ease,opacity 0.25s ease;';
|
||||
|
||||
var popupIframe = document.createElement('iframe');
|
||||
popupIframe.style.cssText = 'width:100%;height:100%;border:none;';
|
||||
popupIframe.setAttribute('title', 'Echoboard - ' + board);
|
||||
popupIframe.setAttribute('sandbox', 'allow-scripts');
|
||||
popup.appendChild(popupIframe);
|
||||
|
||||
var open = false;
|
||||
btn.onclick = function() {
|
||||
if (!open) {
|
||||
if (!popupIframe.src) popupIframe.src = src;
|
||||
popup.style.display = 'block';
|
||||
// force reflow
|
||||
popup.offsetHeight;
|
||||
popup.style.transform = 'translateY(0)';
|
||||
popup.style.opacity = '1';
|
||||
open = true;
|
||||
} else {
|
||||
popup.style.transform = 'translateY(12px)';
|
||||
popup.style.opacity = '0';
|
||||
setTimeout(function() { popup.style.display = 'none'; }, 250);
|
||||
open = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.body.appendChild(btn);
|
||||
document.body.appendChild(popup);
|
||||
} else {
|
||||
// Inline embed mode (default)
|
||||
var iframe = document.createElement('iframe');
|
||||
iframe.src = src;
|
||||
iframe.style.width = '100%';
|
||||
iframe.style.height = height + 'px';
|
||||
iframe.style.border = 'none';
|
||||
iframe.style.borderRadius = '8px';
|
||||
iframe.style.overflow = 'hidden';
|
||||
iframe.setAttribute('loading', 'lazy');
|
||||
iframe.setAttribute('title', 'Echoboard - ' + board);
|
||||
iframe.setAttribute('sandbox', 'allow-scripts');
|
||||
s.parentNode.insertBefore(iframe, s.nextSibling);
|
||||
}
|
||||
}
|
||||
})();
|
||||
22
packages/web/public/sw.js
Normal file
22
packages/web/public/sw.js
Normal file
@@ -0,0 +1,22 @@
|
||||
self.addEventListener("push", (event) => {
|
||||
let data = { title: "Echoboard", body: "New activity on your watched content" };
|
||||
try {
|
||||
data = event.data.json();
|
||||
} catch {}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: data.icon || "/favicon.ico",
|
||||
data: { url: data.url },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const raw = event.notification.data?.url || "/";
|
||||
const safe = new URL(raw, self.location.origin);
|
||||
const url = safe.origin === self.location.origin ? safe.href : "/";
|
||||
event.waitUntil(clients.openWindow(url));
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { BrowserRouter, Routes, Route, useLocation, useNavigate, Link } from 'react-router-dom'
|
||||
import { IconShieldCheck, IconArrowRight, IconX, IconSearch, IconChevronUp, IconMessageCircle, IconBug, IconBulb, IconCommand, IconArrowNarrowRight } from '@tabler/icons-react'
|
||||
import { AuthProvider, useAuthState } from './hooks/useAuth'
|
||||
import { AdminProvider, useAdminState, useAdmin } from './hooks/useAdmin'
|
||||
import { ThemeProvider, useThemeState } from './hooks/useTheme'
|
||||
import { TranslationProvider, useTranslationState } from './i18n'
|
||||
import { BrandingProvider } from './hooks/useBranding'
|
||||
import { ConfirmProvider } from './hooks/useConfirm'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import AdminSidebar from './components/AdminSidebar'
|
||||
import MobileNav from './components/MobileNav'
|
||||
import ThemeToggle from './components/ThemeToggle'
|
||||
import IdentityBanner from './components/IdentityBanner'
|
||||
@@ -19,37 +25,495 @@ import AdminLogin from './pages/admin/AdminLogin'
|
||||
import AdminDashboard from './pages/admin/AdminDashboard'
|
||||
import AdminPosts from './pages/admin/AdminPosts'
|
||||
import AdminBoards from './pages/admin/AdminBoards'
|
||||
import AdminCategories from './pages/admin/AdminCategories'
|
||||
import AdminDataRetention from './pages/admin/AdminDataRetention'
|
||||
import AdminTags from './pages/admin/AdminTags'
|
||||
import RoadmapPage from './pages/RoadmapPage'
|
||||
import ChangelogPage from './pages/ChangelogPage'
|
||||
import AdminChangelog from './pages/admin/AdminChangelog'
|
||||
import AdminWebhooks from './pages/admin/AdminWebhooks'
|
||||
import AdminEmbed from './pages/admin/AdminEmbed'
|
||||
import AdminStatuses from './pages/admin/AdminStatuses'
|
||||
import AdminExport from './pages/admin/AdminExport'
|
||||
import AdminTemplates from './pages/admin/AdminTemplates'
|
||||
import AdminSettings from './pages/admin/AdminSettings'
|
||||
import AdminTeam from './pages/admin/AdminTeam'
|
||||
import AdminJoin from './pages/admin/AdminJoin'
|
||||
import ProfilePage from './pages/ProfilePage'
|
||||
import RecoverPage from './pages/RecoverPage'
|
||||
import EmbedBoard from './pages/EmbedBoard'
|
||||
import { api } from './lib/api'
|
||||
import BoardIcon from './components/BoardIcon'
|
||||
import StatusBadge from './components/StatusBadge'
|
||||
import Avatar from './components/Avatar'
|
||||
|
||||
interface SearchBoard {
|
||||
type: 'board'
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
iconName: string | null
|
||||
iconColor: string | null
|
||||
description: string | null
|
||||
postCount: number
|
||||
}
|
||||
|
||||
interface SearchPost {
|
||||
type: 'post'
|
||||
id: string
|
||||
title: string
|
||||
postType: 'FEATURE_REQUEST' | 'BUG_REPORT'
|
||||
status: string
|
||||
voteCount: number
|
||||
commentCount: number
|
||||
boardSlug: string
|
||||
boardName: string
|
||||
boardIconName: string | null
|
||||
boardIconColor: string | null
|
||||
author: { id: string; displayName: string | null; avatarUrl: string | null } | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function searchTimeAgo(date: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) return `${days}d ago`
|
||||
return `${Math.floor(days / 30)}mo ago`
|
||||
}
|
||||
|
||||
function SearchPage() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [boards, setBoards] = useState<SearchBoard[]>([])
|
||||
const [posts, setPosts] = useState<SearchPost[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) { setBoards([]); setPosts([]); setSearched(false); return }
|
||||
const t = setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.get<{ boards: SearchBoard[]; posts: SearchPost[] }>(`/search?q=${encodeURIComponent(query)}`)
|
||||
setBoards(res.boards)
|
||||
setPosts(res.posts)
|
||||
} catch { setBoards([]); setPosts([]) }
|
||||
setSearched(true)
|
||||
setLoading(false)
|
||||
}, 200)
|
||||
return () => clearTimeout(t)
|
||||
}, [query])
|
||||
|
||||
const totalResults = boards.length + posts.length
|
||||
const isMac = navigator.platform?.includes('Mac')
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 'var(--content-max)', margin: '0 auto', padding: '32px 24px' }}>
|
||||
<h1 className="sr-only">Search</h1>
|
||||
{/* Search input */}
|
||||
<div className="relative mb-6">
|
||||
<IconSearch
|
||||
size={18} stroke={2}
|
||||
style={{ position: 'absolute', left: 14, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-tertiary)', pointerEvents: 'none' }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
style={{ paddingLeft: 42, fontSize: 'var(--text-base)' }}
|
||||
placeholder="Search posts, feedback, and boards..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
aria-label="Search posts, feedback, and boards"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status line */}
|
||||
<div aria-live="polite" style={{ minHeight: 20 }}>
|
||||
{loading && <p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>Searching...</p>}
|
||||
{!loading && searched && (
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
{totalResults === 0 ? 'No results found - try different keywords or check for typos' : `${totalResults} result${totalResults === 1 ? '' : 's'} found`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty state - before searching */}
|
||||
{!query.trim() && !searched && (
|
||||
<div className="flex flex-col items-center py-16 fade-in" style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
className="flex items-center justify-center mb-5"
|
||||
style={{
|
||||
width: 72, height: 72,
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
background: 'var(--accent-subtle)',
|
||||
}}
|
||||
>
|
||||
<IconSearch size={32} stroke={1.5} style={{ color: 'var(--accent)' }} />
|
||||
</div>
|
||||
<h2
|
||||
className="font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-xl)' }}
|
||||
>
|
||||
Search across all boards
|
||||
</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6, maxWidth: 380 }}>
|
||||
Find posts, bug reports, feature requests, and boards. Typos are forgiven - the search is fuzzy.
|
||||
</p>
|
||||
<div
|
||||
className="flex items-center gap-2 mt-6"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-1"
|
||||
style={{
|
||||
background: 'var(--surface-hover)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}
|
||||
>
|
||||
{isMac ? <IconCommand size={11} stroke={2} /> : 'Ctrl+'}K
|
||||
</span>
|
||||
<span>for quick search anywhere</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results state */}
|
||||
{!loading && searched && totalResults === 0 && (
|
||||
<div className="flex flex-col items-center py-12 fade-in" style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
className="flex items-center justify-center mb-4"
|
||||
style={{
|
||||
width: 56, height: 56,
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
background: 'var(--surface-hover)',
|
||||
}}
|
||||
>
|
||||
<IconSearch size={24} stroke={1.5} style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
<p className="font-medium mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-base)' }}>
|
||||
Nothing matched "{query}"
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
Try broader terms or check the spelling
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Board results */}
|
||||
{boards.length > 0 && (
|
||||
<div className="mb-6 mt-4">
|
||||
<h3
|
||||
className="font-medium mb-3"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
|
||||
>
|
||||
Boards
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{boards.map((b, i) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
to={`/b/${b.slug}`}
|
||||
className="card card-interactive flex items-center gap-4 stagger-in"
|
||||
style={{ padding: '14px 16px', '--stagger': i } as React.CSSProperties}
|
||||
>
|
||||
<BoardIcon name={b.title} iconName={b.iconName} iconColor={b.iconColor} size={36} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{b.title}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{b.postCount} post{b.postCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{b.description && (
|
||||
<p className="truncate" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
|
||||
{b.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<IconArrowNarrowRight size={16} stroke={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Post results */}
|
||||
{posts.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3
|
||||
className="font-medium mb-3"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
|
||||
>
|
||||
Posts
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{posts.map((p, i) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
to={`/b/${p.boardSlug}/post/${p.id}`}
|
||||
className="card card-interactive stagger-in"
|
||||
style={{ padding: '14px 16px', '--stagger': i } as React.CSSProperties}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Vote count pill */}
|
||||
<div
|
||||
className="flex flex-col items-center shrink-0"
|
||||
style={{
|
||||
minWidth: 40,
|
||||
padding: '6px 8px',
|
||||
background: 'var(--surface-hover)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
<IconChevronUp size={14} stroke={2.5} style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span className="font-semibold" style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}>
|
||||
{p.voteCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: p.postType === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
|
||||
color: p.postType === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{p.postType === 'BUG_REPORT'
|
||||
? <IconBug size={11} stroke={2} aria-hidden="true" />
|
||||
: <IconBulb size={11} stroke={2} aria-hidden="true" />
|
||||
}
|
||||
{p.postType === 'BUG_REPORT' ? 'Bug' : 'Feature'}
|
||||
</span>
|
||||
<StatusBadge status={p.status} />
|
||||
</div>
|
||||
|
||||
<h4
|
||||
className="font-medium truncate"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
{p.title}
|
||||
</h4>
|
||||
|
||||
<div className="flex items-center gap-3 mt-2 flex-wrap" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
|
||||
{/* Author */}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Avatar
|
||||
userId={p.author?.id ?? '0000'}
|
||||
name={p.author?.displayName ?? null}
|
||||
avatarUrl={p.author?.avatarUrl}
|
||||
size={16}
|
||||
/>
|
||||
{p.author?.displayName ?? `Anonymous #${(p.author?.id ?? '0000').slice(-4)}`}
|
||||
</span>
|
||||
|
||||
{/* Board */}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<BoardIcon name={p.boardName} iconName={p.boardIconName} iconColor={p.boardIconColor} size={16} />
|
||||
{p.boardName}
|
||||
</span>
|
||||
|
||||
{/* Comments */}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<IconMessageCircle size={12} stroke={2} />
|
||||
{p.commentCount}
|
||||
</span>
|
||||
|
||||
{/* Time */}
|
||||
<time dateTime={p.createdAt}>{searchTimeAgo(p.createdAt)}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RequireAdmin({ children }: { children: React.ReactNode }) {
|
||||
const [ok, setOk] = useState<boolean | null>(null)
|
||||
const nav = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/admin/boards')
|
||||
.then(() => setOk(true))
|
||||
.catch(() => nav('/admin/login', { replace: true }))
|
||||
}, [nav])
|
||||
|
||||
if (!ok) return null
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function NewPostRedirect() {
|
||||
const nav = useNavigate()
|
||||
useEffect(() => { nav('/') }, [nav])
|
||||
return null
|
||||
}
|
||||
|
||||
function AdminBanner() {
|
||||
const admin = useAdmin()
|
||||
const location = useLocation()
|
||||
const isAdminPage = location.pathname.startsWith('/admin')
|
||||
|
||||
if (!admin.isAdmin || isAdminPage) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-2 slide-down"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 50,
|
||||
background: 'rgba(6, 182, 212, 0.08)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
borderBottom: '1px solid rgba(6, 182, 212, 0.15)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
}}
|
||||
>
|
||||
<IconShieldCheck size={14} stroke={2} style={{ color: 'var(--admin-accent)', flexShrink: 0 }} />
|
||||
<span style={{ color: 'var(--admin-accent)', fontWeight: 600 }}>Admin mode</span>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded"
|
||||
style={{
|
||||
color: 'var(--admin-accent)',
|
||||
background: 'rgba(6, 182, 212, 0.1)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
transition: 'background var(--duration-fast) ease-out',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.2)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.1)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.2)' }}
|
||||
onBlur={(e) => { e.currentTarget.style.background = 'rgba(6, 182, 212, 0.1)' }}
|
||||
>
|
||||
Admin panel <IconArrowRight size={11} stroke={2.5} />
|
||||
</Link>
|
||||
<button
|
||||
onClick={admin.exitAdminMode}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded"
|
||||
style={{
|
||||
color: 'var(--text-tertiary)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
transition: 'color var(--duration-fast) ease-out',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--text)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.color = 'var(--text)' }}
|
||||
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
|
||||
>
|
||||
<IconX size={11} stroke={2} /> Exit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RouteAnnouncer() {
|
||||
const location = useLocation()
|
||||
const [title, setTitle] = useState('')
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
setTitle(document.title || location.pathname)
|
||||
}, 300)
|
||||
return () => clearTimeout(timerRef.current)
|
||||
}, [location.pathname])
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
role="status"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
const location = useLocation()
|
||||
const admin = useAdmin()
|
||||
const [passkeyMode, setPasskeyMode] = useState<'register' | 'login' | null>(null)
|
||||
const isAdmin = location.pathname.startsWith('/admin')
|
||||
const isAdminPage = location.pathname.startsWith('/admin')
|
||||
const showAdminMode = admin.isAdmin && !isAdminPage
|
||||
|
||||
return (
|
||||
<>
|
||||
<a href="#main" className="sr-only">Skip to main content</a>
|
||||
<RouteAnnouncer />
|
||||
<CommandPalette />
|
||||
<div className="flex min-h-screen" style={{ background: 'var(--bg)' }}>
|
||||
{!isAdmin && <Sidebar />}
|
||||
<main className="flex-1 pb-20 md:pb-0">
|
||||
<div className={`flex min-h-screen ${showAdminMode ? 'admin-mode' : ''}`} style={{ background: 'var(--bg)' }}>
|
||||
{isAdminPage ? <AdminSidebar /> : <Sidebar />}
|
||||
<main id="main" className="flex-1 pb-20 md:pb-0">
|
||||
<AdminBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<BoardIndex />} />
|
||||
<Route path="/b/:boardSlug" element={<BoardFeed />} />
|
||||
<Route path="/b/:boardSlug/post/:postId" element={<PostDetail />} />
|
||||
<Route path="/b/:boardSlug/new" element={<BoardFeed />} />
|
||||
<Route path="/b/:boardSlug/roadmap" element={<RoadmapPage />} />
|
||||
<Route path="/b/:boardSlug/changelog" element={<ChangelogPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/new" element={<NewPostRedirect />} />
|
||||
<Route path="/activity" element={<ActivityFeed />} />
|
||||
<Route path="/settings" element={<IdentitySettings />} />
|
||||
<Route path="/my-posts" element={<MySubmissions />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/privacy" element={<PrivacyPage />} />
|
||||
<Route path="/recover" element={<RecoverPage />} />
|
||||
<Route path="/roadmap" element={<RoadmapPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route path="/admin" element={<AdminDashboard />} />
|
||||
<Route path="/admin/posts" element={<AdminPosts />} />
|
||||
<Route path="/admin/boards" element={<AdminBoards />} />
|
||||
<Route path="/admin" element={<RequireAdmin><AdminDashboard /></RequireAdmin>} />
|
||||
<Route path="/admin/posts" element={<RequireAdmin><AdminPosts /></RequireAdmin>} />
|
||||
<Route path="/admin/boards" element={<RequireAdmin><AdminBoards /></RequireAdmin>} />
|
||||
<Route path="/admin/categories" element={<RequireAdmin><AdminCategories /></RequireAdmin>} />
|
||||
<Route path="/admin/tags" element={<RequireAdmin><AdminTags /></RequireAdmin>} />
|
||||
<Route path="/admin/changelog" element={<RequireAdmin><AdminChangelog /></RequireAdmin>} />
|
||||
<Route path="/admin/webhooks" element={<RequireAdmin><AdminWebhooks /></RequireAdmin>} />
|
||||
<Route path="/admin/embed" element={<RequireAdmin><AdminEmbed /></RequireAdmin>} />
|
||||
<Route path="/admin/statuses" element={<RequireAdmin><AdminStatuses /></RequireAdmin>} />
|
||||
<Route path="/admin/export" element={<RequireAdmin><AdminExport /></RequireAdmin>} />
|
||||
<Route path="/admin/templates" element={<RequireAdmin><AdminTemplates /></RequireAdmin>} />
|
||||
<Route path="/admin/data-retention" element={<RequireAdmin><AdminDataRetention /></RequireAdmin>} />
|
||||
<Route path="/admin/team" element={<RequireAdmin><AdminTeam /></RequireAdmin>} />
|
||||
<Route path="/admin/join/:token" element={<AdminJoin />} />
|
||||
<Route path="/admin/settings" element={<RequireAdmin><AdminSettings /></RequireAdmin>} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
{!isAdmin && <MobileNav />}
|
||||
{!isAdminPage && <MobileNav />}
|
||||
<ThemeToggle />
|
||||
{!isAdmin && (
|
||||
{!isAdminPage && (
|
||||
<IdentityBanner onRegister={() => setPasskeyMode('register')} />
|
||||
)}
|
||||
<PasskeyModal
|
||||
@@ -63,15 +527,38 @@ function Layout() {
|
||||
|
||||
export default function App() {
|
||||
const auth = useAuthState()
|
||||
const theme = useThemeState()
|
||||
const admin = useAdminState()
|
||||
const i18n = useTranslationState()
|
||||
const theme = useThemeState((t) => {
|
||||
if (auth.user?.isPasskeyUser) {
|
||||
api.put('/me', { darkMode: t }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.user?.isPasskeyUser && auth.user.darkMode) {
|
||||
theme.set(auth.user.darkMode as 'dark' | 'light' | 'system')
|
||||
}
|
||||
}, [auth.user?.isPasskeyUser])
|
||||
|
||||
return (
|
||||
<ThemeProvider value={theme}>
|
||||
<AuthProvider value={auth}>
|
||||
<BrowserRouter>
|
||||
<Layout />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
<BrandingProvider>
|
||||
<ConfirmProvider>
|
||||
<TranslationProvider value={i18n}>
|
||||
<ThemeProvider value={theme}>
|
||||
<AuthProvider value={auth}>
|
||||
<AdminProvider value={admin}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/embed/:boardSlug" element={<EmbedBoard />} />
|
||||
<Route path="*" element={<Layout />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AdminProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</TranslationProvider>
|
||||
</ConfirmProvider>
|
||||
</BrandingProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,41 +2,154 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--bg: #141420;
|
||||
--surface: #1c1c2e;
|
||||
--surface-hover: #24243a;
|
||||
--border: rgba(245, 240, 235, 0.08);
|
||||
--border-hover: rgba(245, 240, 235, 0.15);
|
||||
--text: #f5f0eb;
|
||||
--text-secondary: rgba(245, 240, 235, 0.6);
|
||||
--text-tertiary: rgba(245, 240, 235, 0.35);
|
||||
/* Neutral dark palette */
|
||||
--bg: #161616;
|
||||
--surface: #1e1e1e;
|
||||
--surface-hover: #272727;
|
||||
--surface-raised: #2a2a2a;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--border-hover: rgba(255, 255, 255, 0.15);
|
||||
--border-accent: rgba(245, 158, 11, 0.3);
|
||||
|
||||
/* Text */
|
||||
--text: #f0f0f0;
|
||||
--text-secondary: rgba(240, 240, 240, 0.72);
|
||||
--text-tertiary: rgba(240, 240, 240, 0.71);
|
||||
|
||||
/* Accent - amber/gold */
|
||||
--accent: #F59E0B;
|
||||
--accent-hover: #D97706;
|
||||
--accent-subtle: rgba(245, 158, 11, 0.15);
|
||||
--admin-accent: #06B6D4;
|
||||
--admin-subtle: rgba(6, 182, 212, 0.15);
|
||||
--accent-hover: #FBBF24;
|
||||
--accent-dim: #D97706;
|
||||
--accent-subtle: rgba(245, 158, 11, 0.12);
|
||||
--accent-glow: rgba(245, 158, 11, 0.25);
|
||||
|
||||
/* Admin - cyan */
|
||||
--admin-accent: #08C4E4;
|
||||
--admin-subtle: rgba(8, 196, 228, 0.12);
|
||||
|
||||
/* Semantic */
|
||||
--success: #22C55E;
|
||||
--warning: #EAB308;
|
||||
--error: #EF4444;
|
||||
--info: #3B82F6;
|
||||
--error: #F98A8A;
|
||||
--info: #6DB5FC;
|
||||
|
||||
/* Status badge colors (bright for dark bg) */
|
||||
--status-open: #F59E0B;
|
||||
--status-review: #22D3EE;
|
||||
--status-planned: #6DB5FC;
|
||||
--status-progress: #EAB308;
|
||||
--status-done: #22C55E;
|
||||
--status-declined: #F98A8A;
|
||||
|
||||
/* Typography */
|
||||
--font-heading: 'Space Grotesk', system-ui, sans-serif;
|
||||
--font-body: 'Sora', system-ui, sans-serif;
|
||||
|
||||
/* Type scale - Perfect Fourth (1.333) */
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.313rem;
|
||||
--text-xl: 1.75rem;
|
||||
--text-2xl: 2.375rem;
|
||||
--text-3xl: 3.125rem;
|
||||
|
||||
/* Spacing scale */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
--space-2xl: 48px;
|
||||
--space-3xl: 64px;
|
||||
|
||||
/* Radii - soft friendly */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows - soft layered */
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.35), 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
--shadow-glow: 0 0 20px rgba(245, 158, 11, 0.15);
|
||||
|
||||
/* Transitions */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 250ms;
|
||||
--duration-slow: 400ms;
|
||||
|
||||
/* Layout */
|
||||
--sidebar-expanded: 300px;
|
||||
--sidebar-collapsed: 64px;
|
||||
--content-max: 920px;
|
||||
}
|
||||
|
||||
html.light {
|
||||
--bg: #faf9f6;
|
||||
--bg: #f7f8fa;
|
||||
--surface: #ffffff;
|
||||
--surface-hover: #f0eeea;
|
||||
--border: rgba(20, 20, 32, 0.08);
|
||||
--border-hover: rgba(20, 20, 32, 0.15);
|
||||
--text: #1a1a2e;
|
||||
--text-secondary: rgba(26, 26, 46, 0.6);
|
||||
--text-tertiary: rgba(26, 26, 46, 0.35);
|
||||
--accent: #D97706;
|
||||
--accent-hover: #B45309;
|
||||
--accent-subtle: rgba(217, 119, 6, 0.15);
|
||||
--admin-accent: #0891B2;
|
||||
--admin-subtle: rgba(8, 145, 178, 0.15);
|
||||
--surface-hover: #f0f1f3;
|
||||
--surface-raised: #f5f6f8;
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--border-hover: rgba(0, 0, 0, 0.15);
|
||||
--border-accent: rgba(112, 73, 9, 0.3);
|
||||
|
||||
--text: #1a1a1a;
|
||||
--text-secondary: #4a4a4a;
|
||||
--text-tertiary: #545454;
|
||||
|
||||
--accent: #704909;
|
||||
--accent-hover: #855609;
|
||||
--accent-dim: #5C3C06;
|
||||
--accent-subtle: rgba(112, 73, 9, 0.1);
|
||||
--accent-glow: rgba(112, 73, 9, 0.2);
|
||||
|
||||
--admin-accent: #0A5C73;
|
||||
--admin-subtle: rgba(10, 92, 115, 0.1);
|
||||
|
||||
--success: #166534;
|
||||
--warning: #74430C;
|
||||
--error: #991919;
|
||||
--info: #1A40B0;
|
||||
|
||||
/* Status badge colors (darker for light bg) */
|
||||
--status-open: #704909;
|
||||
--status-review: #0A5C73;
|
||||
--status-planned: #1A40B0;
|
||||
--status-progress: #74430C;
|
||||
--status-done: #166534;
|
||||
--status-declined: #991919;
|
||||
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1), 0 4px 8px rgba(0, 0, 0, 0.06);
|
||||
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.06);
|
||||
--shadow-glow: 0 0 20px rgba(112, 73, 9, 0.1);
|
||||
}
|
||||
|
||||
/* Admin mode - override accent with cyan */
|
||||
.admin-mode {
|
||||
--accent: #08C4E4;
|
||||
--accent-hover: #22D3EE;
|
||||
--accent-dim: #06A3BE;
|
||||
--accent-subtle: rgba(8, 196, 228, 0.12);
|
||||
--accent-glow: rgba(8, 196, 228, 0.25);
|
||||
--border-accent: rgba(8, 196, 228, 0.3);
|
||||
--shadow-glow: 0 0 20px rgba(8, 196, 228, 0.15);
|
||||
}
|
||||
html.light .admin-mode {
|
||||
--accent: #0A5C73;
|
||||
--accent-hover: #0C6D87;
|
||||
--accent-dim: #084B5E;
|
||||
--accent-subtle: rgba(10, 92, 115, 0.1);
|
||||
--accent-glow: rgba(10, 92, 115, 0.2);
|
||||
--border-accent: rgba(10, 92, 115, 0.3);
|
||||
--shadow-glow: 0 0 20px rgba(10, 92, 115, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -45,42 +158,75 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-padding-top: 48px;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background 200ms ease-out, color 200ms ease-out;
|
||||
transition: background var(--duration-normal) ease-out, color var(--duration-normal) ease-out;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-heading);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Focus ring - visible on all interactive elements */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ---------- Buttons ---------- */
|
||||
.btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
min-height: 44px;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-body);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
transition: all 200ms ease-out;
|
||||
font-size: var(--text-sm);
|
||||
transition: all var(--duration-normal) var(--ease-out);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent);
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--duration-slow) ease-out;
|
||||
}
|
||||
.btn:hover::before {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #141420;
|
||||
color: #161616;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: var(--shadow-glow), var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -92,6 +238,9 @@
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
.btn-secondary::before {
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
@@ -101,49 +250,296 @@
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-ghost::before { display: none; }
|
||||
|
||||
html.light .btn-primary { color: #fff; }
|
||||
html.light .btn-admin { color: #fff; }
|
||||
|
||||
.icon-picker-btn {
|
||||
transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out;
|
||||
}
|
||||
.icon-picker-btn:hover {
|
||||
background: var(--surface-hover) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.number-input-btn {
|
||||
transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out;
|
||||
}
|
||||
.number-input-btn:not(:disabled):hover {
|
||||
background: var(--surface-hover) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
.number-input-btn:not(:disabled):active {
|
||||
background: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Small inline action buttons (edit, delete, lock, etc.) */
|
||||
.action-btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out, opacity var(--duration-fast) ease-out;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: var(--surface-hover) !important;
|
||||
opacity: 0.85;
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.action-btn:active {
|
||||
background: var(--border) !important;
|
||||
}
|
||||
|
||||
/* Sidebar/nav links with hover feedback */
|
||||
.nav-link {
|
||||
transition: background var(--duration-fast) ease-out, color var(--duration-fast) ease-out;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background: var(--surface-hover) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.roadmap-card:hover {
|
||||
border-color: var(--border-hover) !important;
|
||||
box-shadow: var(--shadow-md) !important;
|
||||
}
|
||||
|
||||
.btn-admin {
|
||||
background: var(--admin-accent);
|
||||
color: #141420;
|
||||
color: #161616;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.btn-admin:hover {
|
||||
opacity: 0.9;
|
||||
box-shadow: var(--shadow-glow), var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ---------- Cards ---------- */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.75rem;
|
||||
transition: border-color 200ms ease-out, box-shadow 200ms ease-out;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--border-hover);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color var(--duration-normal) ease-out, box-shadow var(--duration-normal) ease-out, border-left-color var(--duration-normal) ease-out;
|
||||
}
|
||||
|
||||
/* Interactive cards get accent line on hover */
|
||||
.card-interactive {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.card-interactive:hover {
|
||||
border-color: var(--border-hover);
|
||||
border-left-color: var(--accent);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Static cards (forms, settings) have no hover effect */
|
||||
.card-static {
|
||||
cursor: default;
|
||||
}
|
||||
.card-static:hover {
|
||||
border-color: var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ---------- Inputs ---------- */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: var(--surface);
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 200ms ease-out;
|
||||
font-size: var(--text-sm);
|
||||
transition: border-color var(--duration-normal) ease-out, box-shadow var(--duration-normal) ease-out;
|
||||
outline: none;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-subtle);
|
||||
}
|
||||
.input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ---------- Markdown body ---------- */
|
||||
.markdown-body {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.7;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.markdown-body p { margin-bottom: 1em; }
|
||||
.markdown-body p:last-child { margin-bottom: 0; }
|
||||
.markdown-body strong { color: var(--text); font-weight: 600; }
|
||||
.markdown-body em { font-style: italic; }
|
||||
.markdown-body del { text-decoration: line-through; opacity: 0.7; }
|
||||
.markdown-body a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: opacity var(--duration-fast) ease-out;
|
||||
}
|
||||
.markdown-body a:hover { opacity: 0.8; }
|
||||
.markdown-body code {
|
||||
background: var(--bg);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.88em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.markdown-body pre {
|
||||
background: var(--bg);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.markdown-body pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.markdown-body ul, .markdown-body ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
.markdown-body ul { list-style: disc; }
|
||||
.markdown-body ol { list-style: decimal; }
|
||||
.markdown-body li { margin: 0.25em 0; }
|
||||
.markdown-body blockquote {
|
||||
border-left: 3px solid var(--border);
|
||||
padding-left: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3 {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
.markdown-body h1 {
|
||||
font-size: 1.5em;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0.8em 0 0.4em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.markdown-body h2 {
|
||||
font-size: 1.25em;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0.7em 0 0.3em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.markdown-body h3 {
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0.6em 0 0.25em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.markdown-body h1:first-child,
|
||||
.markdown-body h2:first-child,
|
||||
.markdown-body h3:first-child { margin-top: 0; }
|
||||
.markdown-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1em 0;
|
||||
}
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.markdown-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.5em 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.markdown-body th, .markdown-body td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.markdown-body th {
|
||||
background: var(--bg);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.markdown-body input[type="checkbox"] {
|
||||
margin-right: 6px;
|
||||
accent-color: var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.markdown-body li:has(> input[type="checkbox"]) {
|
||||
list-style: none;
|
||||
margin-left: -1.5em;
|
||||
}
|
||||
|
||||
/* ---------- Skeleton loading ---------- */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-hover) 50%, var(--surface) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.skeleton-text {
|
||||
height: 14px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.skeleton-title {
|
||||
height: 22px;
|
||||
margin-bottom: 12px;
|
||||
width: 60%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/* ---------- Progress bar ---------- */
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
overflow: hidden;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.progress-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 30%;
|
||||
background: var(--accent);
|
||||
border-radius: 1px;
|
||||
animation: progressSlide 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ---------- Animations ---------- */
|
||||
.slide-up {
|
||||
animation: slideUp 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation: slideUp 300ms var(--ease-out);
|
||||
}
|
||||
.fade-in {
|
||||
animation: fadeIn 200ms ease-out;
|
||||
animation: fadeIn var(--duration-normal) ease-out;
|
||||
}
|
||||
.slide-down {
|
||||
animation: slideDown var(--duration-normal) ease-out;
|
||||
}
|
||||
|
||||
/* Staggered entrance - use with style="--stagger: N" */
|
||||
.stagger-in {
|
||||
animation: staggerFadeIn var(--duration-slow) var(--ease-out) both;
|
||||
animation-delay: calc(var(--stagger, 0) * 50ms);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
@@ -158,4 +554,91 @@
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes checkDraw {
|
||||
from { stroke-dashoffset: 30; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
@keyframes paletteIn {
|
||||
from { transform: scale(0.97); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes slideDown {
|
||||
from { transform: translateY(-8px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes staggerFadeIn {
|
||||
from { transform: translateY(12px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
@keyframes progressSlide {
|
||||
0% { left: -30%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
@keyframes voteBounce {
|
||||
0% { transform: translateY(0); }
|
||||
30% { transform: translateY(-4px); }
|
||||
50% { transform: translateY(0); }
|
||||
70% { transform: translateY(-2px); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
@keyframes countTick {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.3); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@keyframes successCheck {
|
||||
from { stroke-dashoffset: 30; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
.vote-bounce {
|
||||
animation: voteBounce 400ms var(--ease-spring);
|
||||
}
|
||||
.count-tick {
|
||||
animation: countTick 300ms var(--ease-spring);
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
.sr-only:focus {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 12px 24px;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
clip: auto;
|
||||
white-space: normal;
|
||||
z-index: 9999;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
144
packages/web/src/components/AdminSidebar.tsx
Normal file
144
packages/web/src/components/AdminSidebar.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import { useAdmin } from '../hooks/useAdmin'
|
||||
import { IconHome, IconFileText, IconLayoutGrid, IconTag, IconTags, IconTrash, IconPlug, IconArrowLeft, IconNews, IconWebhook, IconCode, IconPalette, IconDownload, IconTemplate, IconSettings, IconUsers } from '@tabler/icons-react'
|
||||
import type { Icon } from '@tabler/icons-react'
|
||||
|
||||
interface PluginInfo {
|
||||
name: string
|
||||
version: string
|
||||
adminRoutes?: { path: string; label: string }[]
|
||||
}
|
||||
|
||||
const roleLevel: Record<string, number> = { SUPER_ADMIN: 3, ADMIN: 2, MODERATOR: 1 }
|
||||
|
||||
const links: { to: string; label: string; icon: Icon; minLevel: number }[] = [
|
||||
{ to: '/admin', label: 'Dashboard', icon: IconHome, minLevel: 1 },
|
||||
{ to: '/admin/posts', label: 'All Posts', icon: IconFileText, minLevel: 1 },
|
||||
{ to: '/admin/boards', label: 'Boards', icon: IconLayoutGrid, minLevel: 2 },
|
||||
{ to: '/admin/categories', label: 'Categories', icon: IconTag, minLevel: 1 },
|
||||
{ to: '/admin/tags', label: 'Tags', icon: IconTags, minLevel: 1 },
|
||||
{ to: '/admin/team', label: 'Team', icon: IconUsers, minLevel: 2 },
|
||||
{ to: '/admin/changelog', label: 'Changelog', icon: IconNews, minLevel: 2 },
|
||||
{ to: '/admin/webhooks', label: 'Webhooks', icon: IconWebhook, minLevel: 2 },
|
||||
{ to: '/admin/statuses', label: 'Custom Statuses', icon: IconPalette, minLevel: 2 },
|
||||
{ to: '/admin/templates', label: 'Templates', icon: IconTemplate, minLevel: 2 },
|
||||
{ to: '/admin/embed', label: 'Embed Widget', icon: IconCode, minLevel: 2 },
|
||||
{ to: '/admin/export', label: 'Export Data', icon: IconDownload, minLevel: 2 },
|
||||
{ to: '/admin/data-retention', label: 'Data Retention', icon: IconTrash, minLevel: 1 },
|
||||
{ to: '/admin/settings', label: 'Branding', icon: IconSettings, minLevel: 3 },
|
||||
]
|
||||
|
||||
export default function AdminSidebar() {
|
||||
const location = useLocation()
|
||||
const admin = useAdmin()
|
||||
const [plugins, setPlugins] = useState<PluginInfo[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
api.get<PluginInfo[]>('/plugins/active').then(setPlugins).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const pluginLinks = plugins.flatMap((p) =>
|
||||
(p.adminRoutes ?? []).map((r) => ({
|
||||
to: `/admin/plugins${r.path}`,
|
||||
label: r.label,
|
||||
}))
|
||||
)
|
||||
|
||||
const currentLevel = roleLevel[admin.role as string] ?? 0
|
||||
const visibleLinks = links.filter(l => currentLevel >= l.minLevel)
|
||||
|
||||
const isActive = (path: string) =>
|
||||
path === '/admin' ? location.pathname === '/admin' : location.pathname.startsWith(path)
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="Admin navigation"
|
||||
className="flex flex-col border-r h-screen sticky top-0"
|
||||
style={{
|
||||
width: 240,
|
||||
background: 'var(--surface)',
|
||||
borderColor: 'var(--border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="border-b"
|
||||
style={{ borderColor: 'var(--border)', padding: '14px 20px' }}
|
||||
>
|
||||
<Link
|
||||
to="/admin"
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-lg)' }}
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto" style={{ padding: '8px 10px' }}>
|
||||
{visibleLinks.map((link) => {
|
||||
const Icon = link.icon
|
||||
return (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="flex items-center gap-3 nav-link"
|
||||
aria-current={isActive(link.to) ? 'page' : undefined}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
marginBottom: 2,
|
||||
background: isActive(link.to) ? 'var(--admin-subtle)' : 'transparent',
|
||||
color: isActive(link.to) ? 'var(--admin-accent)' : 'var(--text-secondary)',
|
||||
fontWeight: isActive(link.to) ? 600 : 400,
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
<Icon size={18} stroke={2} aria-hidden="true" />
|
||||
{link.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{pluginLinks.length > 0 && (
|
||||
<>
|
||||
<div style={{ padding: '16px 12px 6px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em' }}>
|
||||
Plugins
|
||||
</div>
|
||||
{pluginLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="flex items-center gap-3 nav-link"
|
||||
aria-current={isActive(link.to) ? 'page' : undefined}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
marginBottom: 2,
|
||||
background: isActive(link.to) ? 'var(--admin-subtle)' : 'transparent',
|
||||
color: isActive(link.to) ? 'var(--admin-accent)' : 'var(--text-secondary)',
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
<IconPlug size={18} stroke={2} aria-hidden="true" />
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t" style={{ borderColor: 'var(--border)', padding: '12px 16px' }}>
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 nav-link"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', padding: '6px 4px', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
<IconArrowLeft size={14} stroke={2} />
|
||||
Back to public site
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
72
packages/web/src/components/Avatar.tsx
Normal file
72
packages/web/src/components/Avatar.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
function hashCode(str: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
|
||||
}
|
||||
return Math.abs(hash)
|
||||
}
|
||||
|
||||
interface AvatarProps {
|
||||
userId: string
|
||||
name?: string | null
|
||||
avatarUrl?: string | null
|
||||
size?: number
|
||||
}
|
||||
|
||||
export default function Avatar({ userId, name, avatarUrl, size = 28 }: AvatarProps) {
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={name ? `${name}'s avatar` : 'User avatar'}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 'var(--radius-full, 50%)',
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <GeneratedAvatar id={userId} size={size} />
|
||||
}
|
||||
|
||||
function GeneratedAvatar({ id, size }: { id: string; size: number }) {
|
||||
const { hue, shapes } = useMemo(() => {
|
||||
let hash = 0
|
||||
for (let i = 0; i < id.length; i++) hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0
|
||||
const h = Math.abs(hash % 360)
|
||||
const s: { x: number; y: number; r: number; op: number }[] = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const seed = Math.abs((hash >> (i * 4)) % 256)
|
||||
s.push({
|
||||
x: 4 + (seed % 24),
|
||||
y: 4 + ((seed * 7) % 24),
|
||||
r: 3 + (seed % 6),
|
||||
op: 0.3 + (seed % 5) * 0.15,
|
||||
})
|
||||
}
|
||||
return { hue: h, shapes: s }
|
||||
}, [id])
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
aria-hidden="true"
|
||||
style={{ borderRadius: 'var(--radius-full, 50%)', flexShrink: 0 }}
|
||||
>
|
||||
<rect width="32" height="32" fill={`hsl(${hue}, 55%, 45%)`} />
|
||||
{shapes.map((s, i) => (
|
||||
<circle key={i} cx={s.x} cy={s.y} r={s.r} fill={`hsla(${(hue + 60) % 360}, 60%, 70%, ${s.op})`} />
|
||||
))}
|
||||
<rect x="8" y="8" width="16" height="16" rx="3" fill={`hsla(${hue}, 40%, 90%, 0.15)`} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
50
packages/web/src/components/BoardIcon.tsx
Normal file
50
packages/web/src/components/BoardIcon.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { renderIcon } from './IconPicker'
|
||||
|
||||
export default function BoardIcon({
|
||||
name,
|
||||
iconName,
|
||||
iconColor,
|
||||
size = 32,
|
||||
}: {
|
||||
name: string
|
||||
iconName?: string | null
|
||||
iconColor?: string | null
|
||||
size?: number
|
||||
}) {
|
||||
const color = iconColor || 'var(--accent)'
|
||||
const fontSize = size * 0.45
|
||||
|
||||
if (iconName) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: iconColor ? `${iconColor}18` : 'var(--accent-subtle)',
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{renderIcon(iconName, size * 0.55, color)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center shrink-0 font-bold"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--accent-subtle)',
|
||||
color: 'var(--accent)',
|
||||
fontFamily: 'var(--font-heading)',
|
||||
fontSize,
|
||||
}}
|
||||
>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,60 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../lib/api'
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||
import { IconSearch, IconBug, IconBulb } from '@tabler/icons-react'
|
||||
import BoardIcon from './BoardIcon'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import Avatar from './Avatar'
|
||||
|
||||
interface SearchResult {
|
||||
type: 'post' | 'board'
|
||||
interface BoardResult {
|
||||
type: 'board'
|
||||
id: string
|
||||
title: string
|
||||
slug?: string
|
||||
boardSlug?: string
|
||||
slug: string
|
||||
iconName: string | null
|
||||
iconColor: string | null
|
||||
description: string | null
|
||||
postCount: number
|
||||
}
|
||||
|
||||
interface PostResult {
|
||||
type: 'post'
|
||||
id: string
|
||||
title: string
|
||||
postType: 'FEATURE_REQUEST' | 'BUG_REPORT'
|
||||
status: string
|
||||
voteCount: number
|
||||
commentCount: number
|
||||
boardSlug: string
|
||||
boardName: string
|
||||
boardIconName: string | null
|
||||
boardIconColor: string | null
|
||||
author: { id: string; displayName: string | null; avatarUrl: string | null } | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type FlatResult = (BoardResult | PostResult)
|
||||
|
||||
export default function CommandPalette() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [boards, setBoards] = useState<BoardResult[]>([])
|
||||
const [posts, setPosts] = useState<PostResult[]>([])
|
||||
const [selected, setSelected] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const trapRef = useFocusTrap(open)
|
||||
const nav = useNavigate()
|
||||
|
||||
const allResults: FlatResult[] = [...boards, ...posts]
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setOpen((v) => {
|
||||
if (!v) {
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setBoards([])
|
||||
setPosts([])
|
||||
setSelected(0)
|
||||
}
|
||||
return !v
|
||||
@@ -52,17 +83,20 @@ export default function CommandPalette() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setResults([])
|
||||
setBoards([])
|
||||
setPosts([])
|
||||
return
|
||||
}
|
||||
const t = setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.get<SearchResult[]>(`/search?q=${encodeURIComponent(query)}`)
|
||||
setResults(res)
|
||||
const res = await api.get<{ boards: BoardResult[]; posts: PostResult[] }>(`/search?q=${encodeURIComponent(query)}`)
|
||||
setBoards(res.boards)
|
||||
setPosts(res.posts)
|
||||
setSelected(0)
|
||||
} catch {
|
||||
setResults([])
|
||||
setBoards([])
|
||||
setPosts([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -70,7 +104,7 @@ export default function CommandPalette() {
|
||||
return () => clearTimeout(t)
|
||||
}, [query])
|
||||
|
||||
const navigate = (r: SearchResult) => {
|
||||
const go = (r: FlatResult) => {
|
||||
if (r.type === 'board') {
|
||||
nav(`/b/${r.slug}`)
|
||||
} else {
|
||||
@@ -82,19 +116,17 @@ export default function CommandPalette() {
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelected((s) => Math.min(s + 1, results.length - 1))
|
||||
setSelected((s) => Math.min(s + 1, allResults.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelected((s) => Math.max(s - 1, 0))
|
||||
} else if (e.key === 'Enter' && results[selected]) {
|
||||
navigate(results[selected])
|
||||
} else if (e.key === 'Enter' && allResults[selected]) {
|
||||
go(allResults[selected])
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const boards = results.filter((r) => r.type === 'board')
|
||||
const posts = results.filter((r) => r.type === 'post')
|
||||
let idx = -1
|
||||
|
||||
return (
|
||||
@@ -102,34 +134,49 @@ export default function CommandPalette() {
|
||||
className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 fade-in"
|
||||
style={{ background: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)' }}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className="relative w-full max-w-lg mx-4 rounded-xl overflow-hidden shadow-2xl slide-up"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="command-palette-title"
|
||||
className="relative w-full max-w-lg mx-4 overflow-hidden fade-in"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<h2 id="command-palette-title" className="sr-only">Search</h2>
|
||||
<div
|
||||
className="flex items-center gap-3 border-b"
|
||||
style={{ borderColor: 'var(--border)', padding: '12px 16px' }}
|
||||
>
|
||||
<IconSearch size={18} stroke={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Search posts and boards..."
|
||||
className="flex-1 bg-transparent outline-none text-sm"
|
||||
style={{ color: 'var(--text)', fontFamily: 'var(--font-body)' }}
|
||||
placeholder="Search posts, feedback, and boards..."
|
||||
className="flex-1 bg-transparent outline-none"
|
||||
style={{ color: 'var(--text)', fontFamily: 'var(--font-body)', fontSize: 'var(--text-sm)' }}
|
||||
aria-label="Search"
|
||||
/>
|
||||
<kbd
|
||||
className="text-[10px] px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
|
||||
className="px-1.5 py-0.5"
|
||||
style={{
|
||||
background: 'var(--surface-hover)',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
ESC
|
||||
</kbd>
|
||||
@@ -137,46 +184,52 @@ export default function CommandPalette() {
|
||||
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<div className="text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)', padding: '32px 16px' }}>
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && query && results.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{!loading && query && allResults.length === 0 && (
|
||||
<div className="text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)', padding: '32px 16px' }}>
|
||||
No results for "{query}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !query && (
|
||||
<div className="px-4 py-8 text-center text-sm" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<div className="text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)', padding: '32px 16px' }}>
|
||||
Start typing to search...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{boards.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<div style={{ padding: '8px 16px 4px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em', textTransform: 'uppercase' }}>
|
||||
Boards
|
||||
</div>
|
||||
{boards.map((r) => {
|
||||
{boards.map((b) => {
|
||||
idx++
|
||||
const i = idx
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => navigate(r)}
|
||||
className="w-full text-left px-4 py-2.5 flex items-center gap-3 text-sm"
|
||||
key={b.id}
|
||||
onClick={() => go(b)}
|
||||
className="w-full text-left flex items-center gap-3 action-btn"
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
background: selected === i ? 'var(--surface-hover)' : 'transparent',
|
||||
color: 'var(--text)',
|
||||
transition: 'background 100ms ease-out',
|
||||
fontSize: 'var(--text-sm)',
|
||||
transition: 'background var(--duration-fast) ease-out',
|
||||
}}
|
||||
aria-label={"Go to board: " + b.title}
|
||||
>
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--accent)', flexShrink: 0 }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
{r.title}
|
||||
<BoardIcon name={b.title} iconName={b.iconName} iconColor={b.iconColor} size={24} />
|
||||
<span className="flex-1 truncate">{b.title}</span>
|
||||
{b.description && (
|
||||
<span className="truncate" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', maxWidth: 180 }}>
|
||||
{b.description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -185,27 +238,50 @@ export default function CommandPalette() {
|
||||
|
||||
{posts.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-2 text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<div style={{ padding: '8px 16px 4px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em', textTransform: 'uppercase' }}>
|
||||
Posts
|
||||
</div>
|
||||
{posts.map((r) => {
|
||||
{posts.map((p) => {
|
||||
idx++
|
||||
const i = idx
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => navigate(r)}
|
||||
className="w-full text-left px-4 py-2.5 flex items-center gap-3 text-sm"
|
||||
key={p.id}
|
||||
onClick={() => go(p)}
|
||||
className="w-full text-left flex items-center gap-3 action-btn"
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
background: selected === i ? 'var(--surface-hover)' : 'transparent',
|
||||
color: 'var(--text)',
|
||||
transition: 'background 100ms ease-out',
|
||||
fontSize: 'var(--text-sm)',
|
||||
transition: 'background var(--duration-fast) ease-out',
|
||||
}}
|
||||
aria-label={"Go to post: " + p.title}
|
||||
>
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{r.title}
|
||||
<span
|
||||
className="flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
width: 24, height: 24,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: p.postType === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
|
||||
color: p.postType === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{p.postType === 'BUG_REPORT'
|
||||
? <IconBug size={13} stroke={2} />
|
||||
: <IconBulb size={13} stroke={2} />
|
||||
}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="truncate block">{p.title}</span>
|
||||
<span className="flex items-center gap-2" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', marginTop: 1 }}>
|
||||
<Avatar userId={p.author?.id ?? '0000'} avatarUrl={p.author?.avatarUrl} size={12} />
|
||||
{p.author?.displayName ?? `Anonymous #${(p.author?.id ?? '0000').slice(-4)}`}
|
||||
<span style={{ opacity: 0.5 }}>in</span>
|
||||
{p.boardName}
|
||||
</span>
|
||||
</div>
|
||||
<StatusBadge status={p.status} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -214,12 +290,12 @@ export default function CommandPalette() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="px-4 py-2 flex items-center gap-4 text-[10px] border-t"
|
||||
style={{ borderColor: 'var(--border)', color: 'var(--text-tertiary)' }}
|
||||
className="flex items-center gap-4 border-t"
|
||||
style={{ borderColor: 'var(--border)', padding: '8px 16px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
<span><kbd className="px-1 py-0.5 rounded" style={{ background: 'var(--border)' }}>↑↓</kbd> navigate</span>
|
||||
<span><kbd className="px-1 py-0.5 rounded" style={{ background: 'var(--border)' }}>Enter</kbd> open</span>
|
||||
<span><kbd className="px-1 py-0.5 rounded" style={{ background: 'var(--border)' }}>Esc</kbd> close</span>
|
||||
<span><kbd className="px-1 py-0.5" style={{ background: 'var(--surface-hover)', borderRadius: 'var(--radius-sm)' }}>↑↓</kbd> navigate</span>
|
||||
<span><kbd className="px-1 py-0.5" style={{ background: 'var(--surface-hover)', borderRadius: 'var(--radius-sm)' }}>Enter</kbd> open</span>
|
||||
<span><kbd className="px-1 py-0.5" style={{ background: 'var(--surface-hover)', borderRadius: 'var(--radius-sm)' }}>Esc</kbd> close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
253
packages/web/src/components/Dropdown.tsx
Normal file
253
packages/web/src/components/Dropdown.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { IconChevronDown, IconCheck, IconSearch } from '@tabler/icons-react'
|
||||
|
||||
interface Option {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
function fuzzyMatch(text: string, query: string): boolean {
|
||||
const lower = text.toLowerCase()
|
||||
const q = query.toLowerCase()
|
||||
let qi = 0
|
||||
for (let i = 0; i < lower.length && qi < q.length; i++) {
|
||||
if (lower[i] === q[qi]) qi++
|
||||
}
|
||||
return qi === q.length
|
||||
}
|
||||
|
||||
export default function Dropdown({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder = 'Select...',
|
||||
searchable = false,
|
||||
'aria-label': ariaLabel,
|
||||
}: {
|
||||
value: string
|
||||
options: Option[]
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
searchable?: boolean
|
||||
'aria-label'?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeIndex, setActiveIndex] = useState(-1)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const selected = options.find((o) => o.value === value)
|
||||
|
||||
const filtered = searchable && query
|
||||
? options.filter((o) => fuzzyMatch(o.label, query))
|
||||
: options
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && searchable) {
|
||||
setQuery('')
|
||||
setTimeout(() => searchRef.current?.focus(), 0)
|
||||
}
|
||||
if (open) {
|
||||
const idx = filtered.findIndex((o) => o.value === value)
|
||||
setActiveIndex(idx >= 0 ? idx : 0)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || activeIndex < 0) return
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const item = list.children[activeIndex] as HTMLElement | undefined
|
||||
item?.scrollIntoView({ block: 'nearest' })
|
||||
}, [activeIndex, open])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!open) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setActiveIndex((i) => Math.min(i + 1, filtered.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setActiveIndex((i) => Math.max(i - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if (!searchable || e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (activeIndex >= 0 && filtered[activeIndex]) {
|
||||
onChange(filtered[activeIndex].value)
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
break
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
setActiveIndex(0)
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
setActiveIndex(filtered.length - 1)
|
||||
break
|
||||
}
|
||||
}, [open, filtered, activeIndex, onChange, searchable])
|
||||
|
||||
const listboxId = 'dropdown-listbox'
|
||||
const activeId = activeIndex >= 0 ? `dropdown-opt-${activeIndex}` : undefined
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative" onKeyDown={handleKeyDown}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center justify-between"
|
||||
role="combobox"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={open ? listboxId : undefined}
|
||||
aria-activedescendant={open ? activeId : undefined}
|
||||
style={{
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--bg)',
|
||||
border: open ? '1px solid var(--accent)' : '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: selected ? 'var(--text)' : 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
fontFamily: 'var(--font-body)',
|
||||
transition: 'border-color var(--duration-normal) ease-out, box-shadow var(--duration-normal) ease-out',
|
||||
boxShadow: open ? '0 0 0 3px var(--accent-subtle)' : 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{selected?.label || placeholder}</span>
|
||||
<IconChevronDown
|
||||
size={14}
|
||||
stroke={2}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
color: 'var(--text-tertiary)',
|
||||
transition: 'transform var(--duration-fast) ease-out',
|
||||
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute left-0 right-0 z-50 mt-1 fade-in"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
maxHeight: 300,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{searchable && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 8px 4px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2" style={{
|
||||
padding: '6px 10px',
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
<IconSearch size={13} stroke={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} aria-hidden="true" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setActiveIndex(0) }}
|
||||
placeholder="Search..."
|
||||
aria-label="Search options"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: 'var(--text)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
width: '100%',
|
||||
fontFamily: 'var(--font-body)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={listRef} role="listbox" id={listboxId} style={{ overflowY: 'auto', padding: '4px 0' }}>
|
||||
{filtered.length === 0 && (
|
||||
<div
|
||||
className="px-3 py-2 text-center"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
No matches
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((opt, i) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
id={`dropdown-opt-${i}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={opt.value === value}
|
||||
onClick={() => { onChange(opt.value); setOpen(false) }}
|
||||
className="w-full flex items-center justify-between px-3 text-left"
|
||||
style={{
|
||||
fontSize: 'var(--text-sm)',
|
||||
minHeight: 44,
|
||||
color: opt.value === value ? 'var(--accent)' : 'var(--text)',
|
||||
background: i === activeIndex
|
||||
? 'var(--surface-hover)'
|
||||
: opt.value === value
|
||||
? 'var(--accent-subtle)'
|
||||
: 'transparent',
|
||||
transition: 'background var(--duration-fast) ease-out',
|
||||
}}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
onFocus={() => setActiveIndex(i)}
|
||||
>
|
||||
<span className="truncate">{opt.label}</span>
|
||||
{opt.value === value && <IconCheck size={14} stroke={2.5} style={{ flexShrink: 0 }} aria-hidden="true" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
packages/web/src/components/EditHistoryModal.tsx
Normal file
157
packages/web/src/components/EditHistoryModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { IconX, IconHistory } from '@tabler/icons-react'
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||
import Markdown from './Markdown'
|
||||
|
||||
interface EditEntry {
|
||||
id: string
|
||||
previousTitle?: string | null
|
||||
previousDescription?: Record<string, string> | null
|
||||
previousBody?: string | null
|
||||
editedBy?: { id: string; displayName: string | null } | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
entries: EditEntry[]
|
||||
type: 'post' | 'comment'
|
||||
isAdmin?: boolean
|
||||
onRollback?: (editHistoryId: string) => void
|
||||
}
|
||||
|
||||
export default function EditHistoryModal({ open, onClose, entries, type, isAdmin, onRollback }: Props) {
|
||||
const trapRef = useFocusTrap(open)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 fade-in"
|
||||
style={{ background: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)' }}
|
||||
/>
|
||||
<div
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edit-history-modal-title"
|
||||
className="relative w-full mx-4 fade-in"
|
||||
style={{
|
||||
maxWidth: 560,
|
||||
maxHeight: '80vh',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.key === 'Escape' && onClose()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4"
|
||||
style={{ borderBottom: '1px solid var(--border)' }}
|
||||
>
|
||||
<h2 id="edit-history-modal-title" className="font-semibold" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
Edit history
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center"
|
||||
style={{ width: 44, height: 44, color: 'var(--text-tertiary)', background: 'var(--surface-hover)', borderRadius: 'var(--radius-sm)' }}
|
||||
aria-label="Close"
|
||||
>
|
||||
<IconX size={14} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-4 overflow-y-auto" style={{ flex: 1 }}>
|
||||
{entries.length === 0 ? (
|
||||
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--text-tertiary)' }}>No edit history found.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{entries.map((edit) => (
|
||||
<div
|
||||
key={edit.id}
|
||||
className="p-4"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<time dateTime={edit.createdAt} style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
|
||||
{new Date(edit.createdAt).toLocaleString()}
|
||||
{edit.editedBy?.displayName && (
|
||||
<> - {edit.editedBy.displayName}</>
|
||||
)}
|
||||
</time>
|
||||
{isAdmin && onRollback && (
|
||||
<button
|
||||
onClick={() => onRollback(edit.id)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: 'var(--accent)',
|
||||
background: 'var(--accent-subtle)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
<IconHistory size={11} stroke={2} aria-hidden="true" /> Restore
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{type === 'post' && (
|
||||
<>
|
||||
{edit.previousTitle && (
|
||||
<div
|
||||
className="font-semibold mb-2"
|
||||
style={{ fontSize: 'var(--text-sm)', color: 'var(--text)' }}
|
||||
>
|
||||
{edit.previousTitle}
|
||||
</div>
|
||||
)}
|
||||
{edit.previousDescription && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(edit.previousDescription).map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', letterSpacing: '0.04em' }}
|
||||
>
|
||||
{k}
|
||||
</span>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--text-secondary)', marginTop: 2 }}>
|
||||
<Markdown>{typeof v === 'string' ? v : ''}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === 'comment' && edit.previousBody && (
|
||||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)' }}>
|
||||
<Markdown>{edit.previousBody}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,68 +1,54 @@
|
||||
import { IconSpeakerphone, IconSearch, IconActivity, IconFileText } from '@tabler/icons-react'
|
||||
import type { Icon } from '@tabler/icons-react'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
message?: string
|
||||
actionLabel?: string
|
||||
onAction?: () => void
|
||||
icon?: Icon
|
||||
}
|
||||
|
||||
export default function EmptyState({
|
||||
title = 'Nothing here yet',
|
||||
message = 'Be the first to share feedback',
|
||||
actionLabel = 'Create a post',
|
||||
message = 'Be the first to share your thoughts and ideas',
|
||||
actionLabel = 'Share feedback',
|
||||
onAction,
|
||||
icon: CustomIcon,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 fade-in">
|
||||
{/* Megaphone SVG */}
|
||||
<svg
|
||||
width="120"
|
||||
height="120"
|
||||
viewBox="0 0 120 120"
|
||||
fill="none"
|
||||
className="mb-6"
|
||||
>
|
||||
<circle cx="60" cy="60" r="55" stroke="var(--text-tertiary)" strokeWidth="1" strokeDasharray="4 4" />
|
||||
<path
|
||||
d="M75 35L45 50H35a5 5 0 00-5 5v10a5 5 0 005 5h10l30 15V35z"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M85 48a10 10 0 010 24"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<path
|
||||
d="M92 40a20 20 0 010 40"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
opacity="0.3"
|
||||
/>
|
||||
<path
|
||||
d="M42 70v10a5 5 0 005 5h5a5 5 0 005-5v-7"
|
||||
stroke="var(--text-tertiary)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
const Icon = CustomIcon || IconSpeakerphone
|
||||
|
||||
<h3
|
||||
className="text-lg font-semibold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 px-4 fade-in">
|
||||
<div
|
||||
className="w-24 h-24 rounded-full flex items-center justify-center mb-8"
|
||||
style={{
|
||||
background: 'var(--accent-subtle)',
|
||||
boxShadow: 'var(--shadow-glow)',
|
||||
}}
|
||||
>
|
||||
<Icon size={44} stroke={1.5} style={{ color: 'var(--accent)' }} />
|
||||
</div>
|
||||
|
||||
<h2
|
||||
className="font-bold mb-3"
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--text)',
|
||||
fontSize: 'var(--text-xl)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--text-tertiary)' }}>
|
||||
</h2>
|
||||
<p
|
||||
className="mb-8 text-center"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 'var(--text-base)',
|
||||
maxWidth: 360,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
{onAction && (
|
||||
|
||||
196
packages/web/src/components/FileUpload.tsx
Normal file
196
packages/web/src/components/FileUpload.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { IconUpload, IconX, IconLoader2 } from '@tabler/icons-react'
|
||||
|
||||
interface Attachment {
|
||||
id: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
attachmentIds: string[]
|
||||
onChange: (ids: string[]) => void
|
||||
}
|
||||
|
||||
const MAX_SIZE = 5 * 1024 * 1024
|
||||
const ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'
|
||||
|
||||
export default function FileUpload({ attachmentIds, onChange }: Props) {
|
||||
const [previews, setPreviews] = useState<Map<string, string>>(new Map())
|
||||
const [filenames, setFilenames] = useState<Map<string, string>>(new Map())
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const upload = useCallback(async (file: File) => {
|
||||
if (file.size > MAX_SIZE) {
|
||||
setError('File too large (max 5MB)')
|
||||
return null
|
||||
}
|
||||
if (!file.type.match(/^image\/(jpeg|png|gif|webp)$/)) {
|
||||
setError('Only jpg, png, gif, webp images are allowed')
|
||||
return null
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
||||
const res = await fetch('/api/v1/attachments', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: form,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error((body as any).error || 'Upload failed')
|
||||
}
|
||||
|
||||
return (await res.json()) as Attachment
|
||||
}, [])
|
||||
|
||||
const handleFiles = useCallback(async (files: FileList | File[]) => {
|
||||
setError('')
|
||||
const toUpload = Array.from(files).slice(0, 10 - attachmentIds.length)
|
||||
if (!toUpload.length) return
|
||||
|
||||
setUploading(true)
|
||||
const newIds: string[] = []
|
||||
const newPreviews = new Map(previews)
|
||||
const newFilenames = new Map(filenames)
|
||||
|
||||
for (const file of toUpload) {
|
||||
try {
|
||||
const att = await upload(file)
|
||||
if (att) {
|
||||
newIds.push(att.id)
|
||||
newPreviews.set(att.id, URL.createObjectURL(file))
|
||||
newFilenames.set(att.id, att.filename)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Upload failed')
|
||||
}
|
||||
}
|
||||
|
||||
setPreviews(newPreviews)
|
||||
setFilenames(newFilenames)
|
||||
onChange([...attachmentIds, ...newIds])
|
||||
setUploading(false)
|
||||
}, [attachmentIds, previews, onChange, upload])
|
||||
|
||||
const remove = useCallback(async (id: string) => {
|
||||
await fetch(`/api/v1/attachments/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
}).catch(() => {})
|
||||
|
||||
const url = previews.get(id)
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
|
||||
const next = new Map(previews)
|
||||
next.delete(id)
|
||||
setPreviews(next)
|
||||
const nextNames = new Map(filenames)
|
||||
nextNames.delete(id)
|
||||
setFilenames(nextNames)
|
||||
onChange(attachmentIds.filter((a) => a !== id))
|
||||
}, [attachmentIds, previews, filenames, onChange])
|
||||
|
||||
const onDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files)
|
||||
}, [handleFiles])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Upload files by clicking or dragging"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); inputRef.current?.click() } }}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={onDrop}
|
||||
style={{
|
||||
border: `2px dashed ${dragOver ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
background: dragOver ? 'var(--accent-subtle)' : 'transparent',
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPT}
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => { if (e.target.files) handleFiles(e.target.files); e.target.value = '' }}
|
||||
/>
|
||||
<div className="flex items-center justify-center gap-2" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{uploading ? (
|
||||
<IconLoader2 size={16} stroke={2} className="animate-spin" />
|
||||
) : (
|
||||
<IconUpload size={16} stroke={2} />
|
||||
)}
|
||||
<span>{uploading ? 'Uploading...' : 'Drop images or click to upload'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)', marginTop: 4 }}>{error}</div>
|
||||
)}
|
||||
|
||||
{attachmentIds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{attachmentIds.map((id) => (
|
||||
<div
|
||||
key={id}
|
||||
className="relative group"
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={previews.get(id) || `/api/v1/attachments/${id}`}
|
||||
alt={filenames.get(id) || 'Uploaded image'}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); remove(id) }}
|
||||
className="absolute top-0.5 right-0.5 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
color: '#fff',
|
||||
borderRadius: '50%',
|
||||
width: 44,
|
||||
height: 44,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'opacity var(--duration-fast) ease-out',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<IconX size={12} stroke={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
283
packages/web/src/components/IconPicker.tsx
Normal file
283
packages/web/src/components/IconPicker.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
||||
import * as tablerIcons from '@tabler/icons-react'
|
||||
import { IconSearch, IconX } from '@tabler/icons-react'
|
||||
|
||||
const ALL_ICON_NAMES: string[] = Object.keys(tablerIcons).filter(
|
||||
(k) => k.startsWith('Icon') && !k.includes('Filled') && k !== 'Icon'
|
||||
)
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#F59E0B', '#EF4444', '#22C55E', '#3B82F6', '#8B5CF6',
|
||||
'#EC4899', '#06B6D4', '#F97316', '#14B8A6', '#6366F1',
|
||||
'#A855F7', '#64748B', '#E11D48', '#0EA5E9', '#84CC16',
|
||||
'#D946EF', '#FB923C', '#2DD4BF', '#818CF8', '#F43F5E',
|
||||
'#34D399',
|
||||
]
|
||||
|
||||
const COLOR_NAMES: Record<string, string> = {
|
||||
'#F59E0B': 'Amber', '#EF4444': 'Red', '#22C55E': 'Green', '#3B82F6': 'Blue', '#8B5CF6': 'Violet',
|
||||
'#EC4899': 'Pink', '#06B6D4': 'Cyan', '#F97316': 'Orange', '#14B8A6': 'Teal', '#6366F1': 'Indigo',
|
||||
'#A855F7': 'Purple', '#64748B': 'Slate', '#E11D48': 'Rose', '#0EA5E9': 'Sky', '#84CC16': 'Lime',
|
||||
'#D946EF': 'Fuchsia', '#FB923C': 'Light orange', '#2DD4BF': 'Mint', '#818CF8': 'Periwinkle', '#F43F5E': 'Coral',
|
||||
'#34D399': 'Emerald',
|
||||
}
|
||||
|
||||
// common/popular icons shown first before search
|
||||
const POPULAR = [
|
||||
'IconHome', 'IconStar', 'IconHeart', 'IconMessage', 'IconBell',
|
||||
'IconSettings', 'IconUser', 'IconFolder', 'IconTag', 'IconFlag',
|
||||
'IconBulb', 'IconRocket', 'IconCode', 'IconBug', 'IconShield',
|
||||
'IconLock', 'IconEye', 'IconMusic', 'IconPhoto', 'IconBook',
|
||||
'IconCalendar', 'IconClock', 'IconMap', 'IconGlobe', 'IconCloud',
|
||||
'IconBolt', 'IconFlame', 'IconDiamond', 'IconCrown', 'IconTrophy',
|
||||
'IconPuzzle', 'IconBrush', 'IconPalette', 'IconWand', 'IconSparkles',
|
||||
'IconTarget', 'IconCompass', 'IconAnchor', 'IconFeather', 'IconLeaf',
|
||||
'IconDroplet', 'IconSun', 'IconMoon', 'IconZap', 'IconActivity',
|
||||
'IconChartBar', 'IconDatabase', 'IconServer', 'IconCpu', 'IconWifi',
|
||||
'IconBriefcase', 'IconGift', 'IconTruck', 'IconShoppingCart', 'IconCreditCard',
|
||||
'IconMicrophone', 'IconHeadphones', 'IconCamera', 'IconVideo', 'IconGamepad',
|
||||
]
|
||||
|
||||
function renderIcon(name: string, size: number, color?: string) {
|
||||
const Comp = (tablerIcons as Record<string, any>)[name]
|
||||
if (!Comp) return null
|
||||
return <Comp size={size} stroke={1.5} style={color ? { color } : undefined} />
|
||||
}
|
||||
|
||||
export { renderIcon }
|
||||
|
||||
function IconGrid({ names, value, currentColor, onPick }: { names: string[], value: string | null, currentColor: string, onPick: (name: string) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{names.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => onPick(name)}
|
||||
title={name.replace('Icon', '')}
|
||||
aria-label={name.replace('Icon', '')}
|
||||
className="flex items-center justify-center icon-picker-btn"
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
color: name === value ? currentColor : 'var(--text-secondary)',
|
||||
background: name === value ? 'var(--accent-subtle)' : 'transparent',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{renderIcon(name, 18)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function IconPicker({
|
||||
value,
|
||||
color,
|
||||
onChangeIcon,
|
||||
onChangeColor,
|
||||
}: {
|
||||
value: string | null
|
||||
color: string | null
|
||||
onChangeIcon: (name: string | null) => void
|
||||
onChangeColor: (color: string | null) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [visibleCount, setVisibleCount] = useState(120)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [open])
|
||||
|
||||
// reset scroll on search change
|
||||
useEffect(() => {
|
||||
setVisibleCount(120)
|
||||
if (gridRef.current) gridRef.current.scrollTop = 0
|
||||
}, [search])
|
||||
|
||||
const popular = useMemo(() => POPULAR.filter((n) => ALL_ICON_NAMES.includes(n)), [])
|
||||
const allExceptPopular = useMemo(() => ALL_ICON_NAMES.filter((n) => !POPULAR.includes(n)), [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return null
|
||||
const q = search.toLowerCase().replace(/\s+/g, '')
|
||||
return ALL_ICON_NAMES.filter((name) =>
|
||||
name.toLowerCase().replace('icon', '').includes(q)
|
||||
)
|
||||
}, [search])
|
||||
|
||||
const totalCount = filtered ? filtered.length : popular.length + allExceptPopular.length
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = gridRef.current
|
||||
if (!el) return
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
|
||||
setVisibleCount((c) => Math.min(c + 120, totalCount))
|
||||
}
|
||||
}, [totalCount])
|
||||
|
||||
const currentColor = color || 'var(--accent)'
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
background: 'var(--bg)',
|
||||
border: open ? '1px solid var(--accent)' : '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--text)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
transition: 'border-color var(--duration-normal) ease-out',
|
||||
cursor: 'pointer',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{value ? (
|
||||
<span style={{ color: currentColor }}>
|
||||
{renderIcon(value, 18, currentColor)}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>Choose icon</span>
|
||||
)}
|
||||
<span className="truncate" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)' }}>
|
||||
{value ? value.replace('Icon', '') : 'None'}
|
||||
</span>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onChangeIcon(null); onChangeColor(null) }}
|
||||
className="ml-auto"
|
||||
style={{ color: 'var(--text-tertiary)', padding: 2 }}
|
||||
aria-label="Clear icon"
|
||||
>
|
||||
<IconX size={12} stroke={2} />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute left-0 z-50 mt-1 fade-in"
|
||||
style={{
|
||||
width: 320,
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="p-2" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="flex items-center gap-2 px-2" style={{ background: 'var(--bg)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border)' }}>
|
||||
<IconSearch size={14} stroke={2} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search icons..."
|
||||
aria-label="Search icons"
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 0',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: 'var(--text)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
fontFamily: 'var(--font-body)',
|
||||
}}
|
||||
/>
|
||||
{search && (
|
||||
<button type="button" onClick={() => setSearch('')} aria-label="Clear search" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<IconX size={12} stroke={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color picker */}
|
||||
<div className="px-3 py-2 flex items-center gap-1.5 flex-wrap" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginRight: 4 }}>Color</span>
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onChangeColor(c)}
|
||||
className="rounded-full"
|
||||
aria-label={COLOR_NAMES[c] || c}
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
background: c,
|
||||
border: c === color ? '2px solid var(--text)' : '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color var(--duration-fast) ease-out',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Icon grid */}
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="p-2"
|
||||
style={{ maxHeight: 320, overflowY: 'auto' }}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{filtered && filtered.length === 0 ? (
|
||||
<div className="py-6 text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
No icons found
|
||||
</div>
|
||||
) : filtered ? (
|
||||
<>
|
||||
<div className="mb-1 px-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{filtered.length} results
|
||||
</div>
|
||||
<IconGrid names={filtered.slice(0, visibleCount)} value={value} currentColor={currentColor} onPick={(n) => { onChangeIcon(n); setOpen(false) }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 px-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
Popular
|
||||
</div>
|
||||
<IconGrid names={popular} value={value} currentColor={currentColor} onPick={(n) => { onChangeIcon(n); setOpen(false) }} />
|
||||
<div className="my-2 mx-1" style={{ borderTop: '1px solid var(--border)' }} />
|
||||
<div className="mb-1 px-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
All icons
|
||||
</div>
|
||||
<IconGrid names={allExceptPopular.slice(0, Math.max(0, visibleCount - popular.length))} value={value} currentColor={currentColor} onPick={(n) => { onChangeIcon(n); setOpen(false) }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +1,237 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||
import { IconLock, IconFingerprint, IconShieldCheck } from '@tabler/icons-react'
|
||||
|
||||
const DISMISSED_KEY = 'echoboard-identity-ack'
|
||||
const UPGRADE_DISMISSED_KEY = 'echoboard-upgrade-dismissed'
|
||||
|
||||
export default function IdentityBanner({ onRegister }: { onRegister: () => void }) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
const [upgradeNudge, setUpgradeNudge] = useState(false)
|
||||
const auth = useAuth()
|
||||
const trapRef = useFocusTrap(visible)
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem(DISMISSED_KEY)) {
|
||||
const t = setTimeout(() => setVisible(true), 800)
|
||||
const t = setTimeout(() => setVisible(true), 400)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!visible) return null
|
||||
if (!auth.isPasskeyUser && !auth.user?.hasRecoveryCode && auth.isAuthenticated) {
|
||||
const dismissed = localStorage.getItem(UPGRADE_DISMISSED_KEY)
|
||||
if (dismissed) {
|
||||
const ts = parseInt(dismissed, 10)
|
||||
if (Date.now() - ts < 7 * 24 * 60 * 60 * 1000) return
|
||||
}
|
||||
import('../lib/api').then(({ api }) => {
|
||||
api.get<{ length: number } & any[]>('/me/posts').then((posts) => {
|
||||
if (Array.isArray(posts) && posts.length >= 3) {
|
||||
setUpgradeNudge(true)
|
||||
}
|
||||
}).catch(() => {})
|
||||
})
|
||||
}
|
||||
}, [auth.isPasskeyUser, auth.isAuthenticated, auth.user?.hasRecoveryCode])
|
||||
|
||||
const dismiss = () => {
|
||||
localStorage.setItem(DISMISSED_KEY, '1')
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-50 md:left-[280px] slide-up"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
const dismissUpgrade = () => {
|
||||
localStorage.setItem(UPGRADE_DISMISSED_KEY, String(Date.now()))
|
||||
setUpgradeNudge(false)
|
||||
}
|
||||
|
||||
// Upgrade nudge - subtle toast
|
||||
if (upgradeNudge && !visible) {
|
||||
return (
|
||||
<div
|
||||
className="mx-4 mb-4 p-5 rounded-xl shadow-2xl md:max-w-lg md:mx-auto"
|
||||
className="fixed bottom-20 md:bottom-4 left-4 right-4 md:left-auto md:right-20 md:max-w-sm z-40 p-4 fade-in"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
pointerEvents: 'auto',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 mt-0.5"
|
||||
style={{ background: 'var(--accent-subtle)' }}
|
||||
<p className="mb-3" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
|
||||
You have been active! If you clear your cookies, you'll lose access to your posts. Save your identity with a passkey, or grab a recovery code.
|
||||
</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => { dismissUpgrade(); onRegister() }}
|
||||
className="btn btn-primary flex items-center gap-1.5"
|
||||
style={{ fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: 'var(--accent)' }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
className="text-base font-semibold mb-1"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
<IconFingerprint size={14} stroke={2} />
|
||||
Save identity
|
||||
</button>
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={dismissUpgrade}
|
||||
className="btn btn-secondary flex items-center gap-1.5"
|
||||
style={{ fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
<IconShieldCheck size={14} stroke={2} />
|
||||
Recovery code
|
||||
</Link>
|
||||
<button
|
||||
onClick={dismissUpgrade}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
// First-visit modal (centered)
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 fade-in"
|
||||
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="identity-banner-title"
|
||||
className="relative w-full max-w-xl mx-4 overflow-y-auto fade-in"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
maxHeight: '85vh',
|
||||
padding: '32px',
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Escape' && dismiss()}
|
||||
>
|
||||
<div
|
||||
className="w-12 h-12 flex items-center justify-center mb-5"
|
||||
style={{
|
||||
background: 'var(--accent-subtle)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}
|
||||
>
|
||||
<IconLock size={24} stroke={2} style={{ color: 'var(--accent)' }} />
|
||||
</div>
|
||||
|
||||
<h2
|
||||
id="identity-banner-title"
|
||||
className="font-bold mb-5"
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
fontSize: 'var(--text-xl)',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
Before you dive in
|
||||
</h2>
|
||||
|
||||
<div className="mb-4">
|
||||
<h3 className="font-semibold mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
How your identity works
|
||||
</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
|
||||
Your identity on this board starts as a cookie in your browser. It contains a random token - nothing personal. This token ties your posts, votes, and comments to you so you can manage them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mb-4 p-4"
|
||||
style={{
|
||||
background: 'rgba(234, 179, 8, 0.08)',
|
||||
border: '1px solid rgba(234, 179, 8, 0.25)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}
|
||||
>
|
||||
<h3 className="font-semibold mb-2" style={{ color: 'var(--warning)', fontSize: 'var(--text-sm)' }}>
|
||||
Your cookie expires when you close the browser
|
||||
</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
|
||||
Your identity is a session cookie. Close your browser, clear cookies, switch device - and you lose access to everything you created. Posts stay on the board but become uneditable by you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<h3 className="font-semibold mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
Want persistent access?
|
||||
</h3>
|
||||
<p className="mb-2" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
|
||||
Pick a username and save a passkey - no email, no account, no personal data. Your passkey syncs across your devices through your platform's credential manager, so you can always get back to your posts.
|
||||
</p>
|
||||
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', lineHeight: 1.5 }}>
|
||||
Browser doesn't support passkeys? You can generate a recovery code instead - a phrase you save that lets you get back to your posts. Visit Settings at any time to set one up.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateRows: showMore ? '1fr' : '0fr',
|
||||
transition: 'grid-template-rows var(--duration-slow) var(--ease-out)',
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<div
|
||||
className="mb-5 p-4"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
lineHeight: 1.6,
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
opacity: showMore ? 1 : 0,
|
||||
transition: 'opacity var(--duration-normal) ease-out',
|
||||
}}
|
||||
>
|
||||
Your identity is cookie-based
|
||||
</h3>
|
||||
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
You can post and vote right now - no signup needed. A cookie links your activity. Register a passkey to keep access across devices and browsers.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={dismiss} className="btn btn-primary text-sm">
|
||||
Continue anonymously
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { dismiss(); onRegister() }}
|
||||
className="btn btn-secondary text-sm"
|
||||
>
|
||||
Register with passkey
|
||||
</button>
|
||||
<Link to="/privacy" onClick={dismiss} className="btn btn-ghost text-sm">
|
||||
Learn more
|
||||
</Link>
|
||||
<p className="mb-2">
|
||||
<strong style={{ color: 'var(--text)' }}>What is stored:</strong> A random token (SHA-256 hashed on the server). The cookie is session-only and does not persist after you close your browser. If you register, a username and passkey public key (both encrypted at rest).
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
<strong style={{ color: 'var(--text)' }}>What is never stored:</strong> Your email, IP address, browser fingerprint, or any personal information. This site sets exactly one cookie containing your random identifier. No tracking, no analytics.
|
||||
</p>
|
||||
<p>
|
||||
<strong style={{ color: 'var(--text)' }}>Passkeys explained:</strong> Passkeys use the WebAuthn standard. Your device generates a key pair - the private key stays on your device (or syncs via iCloud Keychain, Google Password Manager, Windows Hello). The server only stores the public key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={() => { dismiss(); onRegister() }}
|
||||
className="btn btn-primary flex items-center justify-center gap-2"
|
||||
>
|
||||
<IconFingerprint size={16} stroke={2} />
|
||||
Save my identity
|
||||
</button>
|
||||
<button onClick={dismiss} className="btn btn-secondary">
|
||||
Continue anonymously
|
||||
</button>
|
||||
{!showMore && (
|
||||
<button
|
||||
onClick={() => setShowMore(true)}
|
||||
className="btn btn-ghost"
|
||||
>
|
||||
Learn more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
36
packages/web/src/components/Markdown.tsx
Normal file
36
packages/web/src/components/Markdown.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
|
||||
export default function Markdown({ children }: { children: string }) {
|
||||
return (
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={{
|
||||
a: ({ href, children }) => {
|
||||
const safe = href && /^https?:\/\//i.test(href)
|
||||
return safe
|
||||
? <a href={href} target="_blank" rel="noopener noreferrer">{children}<span className="sr-only"> (opens in new tab)</span></a>
|
||||
: <span>{children}</span>
|
||||
},
|
||||
img: ({ src, alt }) => {
|
||||
const safe = src && /^https?:\/\//i.test(src)
|
||||
return safe
|
||||
? <img src={src} alt={alt || ''} style={{ maxWidth: '100%', maxHeight: '400px', objectFit: 'contain' }} loading="lazy" referrerPolicy="no-referrer" />
|
||||
: <span>{alt || ''}</span>
|
||||
},
|
||||
script: () => null,
|
||||
iframe: () => null,
|
||||
form: () => null,
|
||||
input: () => null,
|
||||
object: () => null,
|
||||
embed: () => null,
|
||||
style: () => null,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
499
packages/web/src/components/MarkdownEditor.tsx
Normal file
499
packages/web/src/components/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
IconBold, IconItalic, IconStrikethrough, IconLink, IconCode,
|
||||
IconList, IconListNumbers, IconQuote, IconPhoto, IconTable,
|
||||
IconH1, IconH2, IconH3, IconMinus, IconCheckbox, IconSourceCode,
|
||||
IconEye, IconPencil,
|
||||
} from '@tabler/icons-react'
|
||||
import Markdown from './Markdown'
|
||||
import Avatar from './Avatar'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
interface MentionUser {
|
||||
id: string
|
||||
username: string
|
||||
avatarUrl: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
autoFocus?: boolean
|
||||
preview?: boolean
|
||||
ariaRequired?: boolean
|
||||
ariaLabel?: string
|
||||
mentions?: boolean
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { prefix: string; suffix: string }
|
||||
| { linePrefix: string }
|
||||
| { block: string }
|
||||
|
||||
interface ToolbarItem {
|
||||
icon: typeof IconBold
|
||||
title: string
|
||||
action: Action
|
||||
}
|
||||
|
||||
type ToolbarEntry = ToolbarItem | 'table'
|
||||
type ToolbarGroup = ToolbarEntry[]
|
||||
|
||||
const toolbar: ToolbarGroup[] = [
|
||||
[
|
||||
{ icon: IconH1, title: 'Heading 1', action: { linePrefix: '# ' } },
|
||||
{ icon: IconH2, title: 'Heading 2', action: { linePrefix: '## ' } },
|
||||
{ icon: IconH3, title: 'Heading 3', action: { linePrefix: '### ' } },
|
||||
],
|
||||
[
|
||||
{ icon: IconBold, title: 'Bold', action: { prefix: '**', suffix: '**' } },
|
||||
{ icon: IconItalic, title: 'Italic', action: { prefix: '*', suffix: '*' } },
|
||||
{ icon: IconStrikethrough, title: 'Strikethrough', action: { prefix: '~~', suffix: '~~' } },
|
||||
],
|
||||
[
|
||||
{ icon: IconCode, title: 'Inline code', action: { prefix: '`', suffix: '`' } },
|
||||
{ icon: IconSourceCode, title: 'Code block', action: { block: '```\n\n```' } },
|
||||
{ icon: IconQuote, title: 'Blockquote', action: { linePrefix: '> ' } },
|
||||
],
|
||||
[
|
||||
{ icon: IconList, title: 'Bullet list', action: { linePrefix: '- ' } },
|
||||
{ icon: IconListNumbers, title: 'Numbered list', action: { linePrefix: '1. ' } },
|
||||
{ icon: IconCheckbox, title: 'Task list', action: { linePrefix: '- [ ] ' } },
|
||||
],
|
||||
[
|
||||
{ icon: IconLink, title: 'Link', action: { prefix: '[', suffix: '](url)' } },
|
||||
{ icon: IconPhoto, title: 'Image', action: { prefix: '' } },
|
||||
{ icon: IconMinus, title: 'Horizontal rule', action: { block: '---' } },
|
||||
'table',
|
||||
],
|
||||
]
|
||||
|
||||
const GRID_COLS = 8
|
||||
const GRID_ROWS = 6
|
||||
const CELL = 44
|
||||
const GAP = 1
|
||||
|
||||
function buildTable(cols: number, rows: number): string {
|
||||
const header = '| ' + Array.from({ length: cols }, (_, i) => `Column ${i + 1}`).join(' | ') + ' |'
|
||||
const sep = '| ' + Array.from({ length: cols }, () => '---').join(' | ') + ' |'
|
||||
const dataRows = Array.from({ length: rows }, () =>
|
||||
'| ' + Array.from({ length: cols }, () => ' ').join(' | ') + ' |'
|
||||
)
|
||||
return [header, sep, ...dataRows].join('\n')
|
||||
}
|
||||
|
||||
function TablePicker({ onSelect, onClose }: { onSelect: (cols: number, rows: number) => void; onClose: () => void }) {
|
||||
const [hover, setHover] = useState<[number, number]>([0, 0])
|
||||
const popRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (popRef.current && !popRef.current.contains(e.target as Node)) onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popRef}
|
||||
className="absolute z-50"
|
||||
style={{
|
||||
top: '100%',
|
||||
left: 0,
|
||||
marginTop: 4,
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${GRID_COLS}, ${CELL}px)`,
|
||||
gap: GAP,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: GRID_ROWS * GRID_COLS }, (_, i) => {
|
||||
const col = i % GRID_COLS
|
||||
const row = Math.floor(i / GRID_COLS)
|
||||
const active = col < hover[0] && row < hover[1]
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
aria-label={`${col + 1} by ${row + 1} table`}
|
||||
onMouseEnter={() => setHover([col + 1, row + 1])}
|
||||
onFocus={() => setHover([col + 1, row + 1])}
|
||||
onClick={() => { onSelect(col + 1, row + 1); onClose() }}
|
||||
style={{
|
||||
width: CELL,
|
||||
height: CELL,
|
||||
borderRadius: 3,
|
||||
border: active ? '1px solid var(--accent)' : '1px solid var(--border)',
|
||||
background: active ? 'var(--accent-subtle)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
transition: 'background 60ms ease-out, border-color 60ms ease-out',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="text-center mt-1.5"
|
||||
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
{hover[0] > 0 && hover[1] > 0 ? `${hover[0]} x ${hover[1]}` : 'Select size'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MarkdownEditor({ value, onChange, placeholder, rows = 3, autoFocus, preview: enablePreview, ariaRequired, ariaLabel, mentions: enableMentions }: Props) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [tablePicker, setTablePicker] = useState(false)
|
||||
const [mentionUsers, setMentionUsers] = useState<MentionUser[]>([])
|
||||
const [mentionActive, setMentionActive] = useState(0)
|
||||
const mentionDebounce = useRef<ReturnType<typeof setTimeout>>()
|
||||
const mentionDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [mentionQuery, setMentionQuery] = useState('')
|
||||
|
||||
const getMentionQuery = useCallback((): string | null => {
|
||||
const ta = ref.current
|
||||
if (!ta || !enableMentions) return null
|
||||
const pos = ta.selectionStart
|
||||
const before = value.slice(0, pos)
|
||||
const match = before.match(/@([a-zA-Z0-9_]{2,30})$/)
|
||||
return match ? match[1] : null
|
||||
}, [value, enableMentions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableMentions) return
|
||||
const q = getMentionQuery()
|
||||
if (!q || q.length < 2) {
|
||||
setMentionUsers([])
|
||||
setMentionQuery('')
|
||||
return
|
||||
}
|
||||
if (q === mentionQuery) return
|
||||
setMentionQuery(q)
|
||||
if (mentionDebounce.current) clearTimeout(mentionDebounce.current)
|
||||
mentionDebounce.current = setTimeout(() => {
|
||||
api.get<{ users: MentionUser[] }>(`/users/search?q=${encodeURIComponent(q)}`)
|
||||
.then((r) => { setMentionUsers(r.users); setMentionActive(0) })
|
||||
.catch(() => setMentionUsers([]))
|
||||
}, 200)
|
||||
return () => { if (mentionDebounce.current) clearTimeout(mentionDebounce.current) }
|
||||
}, [value, getMentionQuery, enableMentions])
|
||||
|
||||
const insertMention = (username: string) => {
|
||||
const ta = ref.current
|
||||
if (!ta) return
|
||||
const pos = ta.selectionStart
|
||||
const before = value.slice(0, pos)
|
||||
const after = value.slice(pos)
|
||||
const atIdx = before.lastIndexOf('@')
|
||||
if (atIdx === -1) return
|
||||
const newVal = before.slice(0, atIdx) + '@' + username + ' ' + after
|
||||
onChange(newVal)
|
||||
setMentionUsers([])
|
||||
setMentionQuery('')
|
||||
const newPos = atIdx + username.length + 2
|
||||
requestAnimationFrame(() => { ta.focus(); ta.setSelectionRange(newPos, newPos) })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableMentions) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (mentionDropdownRef.current && !mentionDropdownRef.current.contains(e.target as Node)) {
|
||||
setMentionUsers([])
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [enableMentions])
|
||||
|
||||
const insertBlock = (block: string) => {
|
||||
const ta = ref.current
|
||||
if (!ta) return
|
||||
const start = ta.selectionStart
|
||||
const end = ta.selectionEnd
|
||||
const before = value.slice(0, start)
|
||||
const after = value.slice(end)
|
||||
const needsBefore = before.length > 0 && !before.endsWith('\n\n')
|
||||
const needsAfter = after.length > 0 && !after.startsWith('\n')
|
||||
const prefix = needsBefore ? (before.endsWith('\n') ? '\n' : '\n\n') : ''
|
||||
const suffix = needsAfter ? '\n' : ''
|
||||
const newValue = before + prefix + block + suffix + after
|
||||
const firstNewline = block.indexOf('\n')
|
||||
const blockStart = before.length + prefix.length
|
||||
const cursorPos = firstNewline > -1 ? blockStart + firstNewline + 1 : blockStart + block.length
|
||||
onChange(newValue)
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus()
|
||||
ta.setSelectionRange(cursorPos, cursorPos)
|
||||
})
|
||||
}
|
||||
|
||||
const apply = (action: Action) => {
|
||||
const ta = ref.current
|
||||
if (!ta) return
|
||||
|
||||
const start = ta.selectionStart
|
||||
const end = ta.selectionEnd
|
||||
const selected = value.slice(start, end)
|
||||
|
||||
let newValue: string
|
||||
let cursorPos: number
|
||||
|
||||
if ('block' in action) {
|
||||
insertBlock(action.block)
|
||||
return
|
||||
} else if ('linePrefix' in action) {
|
||||
const lines = selected ? selected.split('\n') : ['']
|
||||
const prefixed = lines.map((l) => action.linePrefix + l).join('\n')
|
||||
newValue = value.slice(0, start) + prefixed + value.slice(end)
|
||||
cursorPos = start + prefixed.length
|
||||
} else {
|
||||
const wrapped = action.prefix + (selected || 'text') + action.suffix
|
||||
newValue = value.slice(0, start) + wrapped + value.slice(end)
|
||||
if (selected) {
|
||||
cursorPos = start + wrapped.length
|
||||
} else {
|
||||
cursorPos = start + action.prefix.length + 4
|
||||
}
|
||||
}
|
||||
|
||||
onChange(newValue)
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus()
|
||||
if (!selected && 'prefix' in action && 'suffix' in action) {
|
||||
ta.setSelectionRange(start + action.prefix.length, start + action.prefix.length + 4)
|
||||
} else {
|
||||
ta.setSelectionRange(cursorPos, cursorPos)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const btnStyle = {
|
||||
color: 'var(--text-tertiary)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
transition: 'color var(--duration-fast) ease-out, background var(--duration-fast) ease-out',
|
||||
}
|
||||
|
||||
const hover = (e: React.MouseEvent<HTMLButtonElement> | React.FocusEvent<HTMLButtonElement>, enter: boolean) => {
|
||||
e.currentTarget.style.color = enter ? 'var(--text)' : 'var(--text-tertiary)'
|
||||
e.currentTarget.style.background = enter ? 'var(--surface-hover)' : 'transparent'
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-0.5 mb-1.5 px-1 flex-wrap"
|
||||
style={{ minHeight: 28 }}
|
||||
>
|
||||
{toolbar.map((group, gi) => (
|
||||
<div key={gi} className="flex items-center gap-0.5">
|
||||
{gi > 0 && (
|
||||
<div
|
||||
style={{
|
||||
width: 1,
|
||||
height: 16,
|
||||
background: 'var(--border)',
|
||||
margin: '0 4px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{group.map((entry) => {
|
||||
if (entry === 'table') {
|
||||
return (
|
||||
<div key="table" className="relative">
|
||||
<button
|
||||
type="button"
|
||||
title="Table"
|
||||
aria-label="Table"
|
||||
onClick={() => { setPreviewing(false); setTablePicker(!tablePicker) }}
|
||||
className="w-11 h-11 flex items-center justify-center"
|
||||
style={{
|
||||
...btnStyle,
|
||||
color: tablePicker ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
}}
|
||||
onMouseEnter={(e) => hover(e, true)}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = tablePicker ? 'var(--accent)' : 'var(--text-tertiary)'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
onFocus={(e) => hover(e, true)}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.color = tablePicker ? 'var(--accent)' : 'var(--text-tertiary)'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
<IconTable size={14} stroke={2} />
|
||||
</button>
|
||||
{tablePicker && (
|
||||
<TablePicker
|
||||
onSelect={(cols, rows) => {
|
||||
setPreviewing(false)
|
||||
insertBlock(buildTable(cols, rows))
|
||||
}}
|
||||
onClose={() => setTablePicker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const { icon: Icon, title, action } = entry
|
||||
return (
|
||||
<button
|
||||
key={title}
|
||||
type="button"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={() => { setPreviewing(false); apply(action) }}
|
||||
className="w-11 h-11 flex items-center justify-center"
|
||||
style={btnStyle}
|
||||
onMouseEnter={(e) => hover(e, true)}
|
||||
onMouseLeave={(e) => hover(e, false)}
|
||||
onFocus={(e) => hover(e, true)}
|
||||
onBlur={(e) => hover(e, false)}
|
||||
>
|
||||
<Icon size={14} stroke={2} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{enablePreview && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: 1,
|
||||
height: 16,
|
||||
background: 'var(--border)',
|
||||
margin: '0 4px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title={previewing ? 'Edit' : 'Preview'}
|
||||
aria-label={previewing ? 'Edit' : 'Preview'}
|
||||
onClick={() => setPreviewing(!previewing)}
|
||||
className="w-11 h-11 flex items-center justify-center"
|
||||
style={{
|
||||
...btnStyle,
|
||||
color: previewing ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
}}
|
||||
onMouseEnter={(e) => hover(e, true)}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = previewing ? 'var(--accent)' : 'var(--text-tertiary)'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
onFocus={(e) => hover(e, true)}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.color = previewing ? 'var(--accent)' : 'var(--text-tertiary)'
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
{previewing ? <IconPencil size={14} stroke={2} /> : <IconEye size={14} stroke={2} />}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewing ? (
|
||||
<div
|
||||
className="input w-full"
|
||||
style={{
|
||||
minHeight: rows * 24 + 24,
|
||||
padding: '12px 14px',
|
||||
overflow: 'auto',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
>
|
||||
{value.trim() ? (
|
||||
<Markdown>{value}</Markdown>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
|
||||
Nothing to preview
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<textarea
|
||||
ref={ref}
|
||||
className="input w-full"
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={enableMentions && mentionUsers.length > 0 ? (e) => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setMentionActive((a) => (a + 1) % mentionUsers.length) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setMentionActive((a) => (a - 1 + mentionUsers.length) % mentionUsers.length) }
|
||||
else if ((e.key === 'Enter' || e.key === 'Tab') && mentionUsers[mentionActive]) { e.preventDefault(); insertMention(mentionUsers[mentionActive].username) }
|
||||
else if (e.key === 'Escape') { setMentionUsers([]); setMentionQuery('') }
|
||||
} : undefined}
|
||||
style={{ resize: 'vertical' }}
|
||||
autoFocus={autoFocus}
|
||||
aria-label={ariaLabel || 'Markdown content'}
|
||||
aria-required={ariaRequired || undefined}
|
||||
/>
|
||||
{enableMentions && mentionUsers.length > 0 && (
|
||||
<div
|
||||
ref={mentionDropdownRef}
|
||||
role="listbox"
|
||||
aria-label="Mention suggestions"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: -4,
|
||||
transform: 'translateY(100%)',
|
||||
zIndex: 50,
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
minWidth: 200,
|
||||
maxHeight: 240,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{mentionUsers.map((u, i) => (
|
||||
<button
|
||||
key={u.id}
|
||||
role="option"
|
||||
aria-selected={i === mentionActive}
|
||||
onClick={() => insertMention(u.username)}
|
||||
className="flex items-center gap-2 w-full px-3"
|
||||
style={{
|
||||
minHeight: 44,
|
||||
background: i === mentionActive ? 'var(--surface-hover)' : 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: 'var(--text)',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={() => setMentionActive(i)}
|
||||
>
|
||||
<Avatar userId={u.id} name={u.username} avatarUrl={u.avatarUrl} size={22} />
|
||||
<span>@{u.username}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
packages/web/src/components/MentionInput.tsx
Normal file
164
packages/web/src/components/MentionInput.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
import Avatar from './Avatar'
|
||||
|
||||
interface MentionUser {
|
||||
id: string
|
||||
username: string
|
||||
avatarUrl: string | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export default function MentionInput({ value, onChange, placeholder, rows = 3, ariaLabel }: Props) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [users, setUsers] = useState<MentionUser[]>([])
|
||||
const [active, setActive] = useState(0)
|
||||
const [query, setQuery] = useState('')
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
const getMentionQuery = useCallback((): string | null => {
|
||||
const ta = ref.current
|
||||
if (!ta) return null
|
||||
const pos = ta.selectionStart
|
||||
const before = value.slice(0, pos)
|
||||
const match = before.match(/@([a-zA-Z0-9_]{2,30})$/)
|
||||
return match ? match[1] : null
|
||||
}, [value])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange(e.target.value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const q = getMentionQuery()
|
||||
if (!q || q.length < 2) {
|
||||
setUsers([])
|
||||
setQuery('')
|
||||
return
|
||||
}
|
||||
if (q === query) return
|
||||
setQuery(q)
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
api.get<{ users: MentionUser[] }>(`/users/search?q=${encodeURIComponent(q)}`)
|
||||
.then((r) => {
|
||||
setUsers(r.users)
|
||||
setActive(0)
|
||||
})
|
||||
.catch(() => setUsers([]))
|
||||
}, 200)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [value, getMentionQuery])
|
||||
|
||||
const insertMention = (username: string) => {
|
||||
const ta = ref.current
|
||||
if (!ta) return
|
||||
const pos = ta.selectionStart
|
||||
const before = value.slice(0, pos)
|
||||
const after = value.slice(pos)
|
||||
const atIdx = before.lastIndexOf('@')
|
||||
if (atIdx === -1) return
|
||||
const newValue = before.slice(0, atIdx) + '@' + username + ' ' + after
|
||||
onChange(newValue)
|
||||
setUsers([])
|
||||
setQuery('')
|
||||
const newPos = atIdx + username.length + 2
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus()
|
||||
ta.setSelectionRange(newPos, newPos)
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (users.length === 0) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActive((a) => (a + 1) % users.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActive((a) => (a - 1 + users.length) % users.length)
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (users[active]) {
|
||||
e.preventDefault()
|
||||
insertMention(users[active].username)
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setUsers([])
|
||||
setQuery('')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setUsers([])
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<textarea
|
||||
ref={ref}
|
||||
className="input w-full"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
style={{ resize: 'vertical' }}
|
||||
aria-label={ariaLabel || 'Comment'}
|
||||
/>
|
||||
{users.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: -4,
|
||||
transform: 'translateY(100%)',
|
||||
zIndex: 50,
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
minWidth: 200,
|
||||
maxHeight: 240,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{users.map((u, i) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => insertMention(u.username)}
|
||||
className="flex items-center gap-2 w-full px-3"
|
||||
style={{
|
||||
minHeight: 44,
|
||||
background: i === active ? 'var(--surface-hover)' : 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: 'var(--text)',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
>
|
||||
<Avatar userId={u.id} name={u.username} avatarUrl={u.avatarUrl} size={22} />
|
||||
<span>@{u.username}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,92 +1,63 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
path: '/',
|
||||
label: 'Home',
|
||||
icon: (
|
||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
label: 'Search',
|
||||
icon: (
|
||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/new',
|
||||
label: 'New',
|
||||
icon: (
|
||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
),
|
||||
accent: true,
|
||||
},
|
||||
{
|
||||
path: '/activity',
|
||||
label: 'Activity',
|
||||
icon: (
|
||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
label: 'Profile',
|
||||
icon: (
|
||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
import { Link, useLocation, useParams } from 'react-router-dom'
|
||||
import { IconHome, IconSearch, IconPlus, IconBell, IconUser, IconShieldCheck } from '@tabler/icons-react'
|
||||
import { useAdmin } from '../hooks/useAdmin'
|
||||
|
||||
export default function MobileNav() {
|
||||
const location = useLocation()
|
||||
const { boardSlug } = useParams()
|
||||
const admin = useAdmin()
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
const newPath = boardSlug ? `/b/${boardSlug}?new=1` : '/'
|
||||
|
||||
const tabs = [
|
||||
{ path: '/', label: 'Home', icon: IconHome },
|
||||
{ path: '/search', label: 'Search', icon: IconSearch },
|
||||
{ path: newPath, label: 'New', icon: IconPlus, accent: true },
|
||||
{ path: '/activity', label: 'Activity', icon: IconBell },
|
||||
{ path: '/settings', label: admin.isAdmin ? 'Admin' : 'Profile', icon: admin.isAdmin ? IconShieldCheck : IconUser },
|
||||
]
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="fixed bottom-0 left-0 right-0 md:hidden flex items-center justify-around py-2 border-t z-50"
|
||||
aria-label="Main navigation"
|
||||
className="fixed bottom-0 left-0 right-0 md:hidden flex items-center justify-around border-t z-50"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
borderColor: 'var(--border)',
|
||||
paddingBottom: 'max(0.5rem, env(safe-area-inset-bottom))',
|
||||
borderColor: admin.isAdmin ? 'rgba(6, 182, 212, 0.15)' : 'var(--border)',
|
||||
padding: '6px 0',
|
||||
paddingBottom: 'max(6px, env(safe-area-inset-bottom))',
|
||||
boxShadow: '0 -1px 8px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = location.pathname === tab.path ||
|
||||
(tab.path === '/' && location.pathname === '/')
|
||||
const active = isActive(tab.path)
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<Link
|
||||
key={tab.path}
|
||||
key={tab.label}
|
||||
to={tab.path}
|
||||
className="flex flex-col items-center gap-0.5 px-3 py-1"
|
||||
style={{ transition: 'color 200ms ease-out' }}
|
||||
className="flex flex-col items-center gap-0.5 px-3 py-1 nav-link"
|
||||
style={{ transition: 'color var(--duration-fast) ease-out', borderRadius: 'var(--radius-sm)' }}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{tab.accent ? (
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center -mt-4"
|
||||
style={{ background: 'var(--accent)', color: '#141420' }}
|
||||
className="w-11 h-11 rounded-full flex items-center justify-center -mt-4"
|
||||
style={{
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--bg)',
|
||||
boxShadow: 'var(--shadow-glow)',
|
||||
}}
|
||||
>
|
||||
{tab.icon}
|
||||
<Icon size={22} stroke={2.5} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)' }}>
|
||||
{tab.icon}
|
||||
<Icon size={22} stroke={2} />
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)' }}
|
||||
>
|
||||
<span style={{ color: active ? 'var(--accent)' : 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{tab.label}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
138
packages/web/src/components/NumberInput.tsx
Normal file
138
packages/web/src/components/NumberInput.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useRef, useCallback, useEffect } from 'react'
|
||||
import { IconMinus, IconPlus } from '@tabler/icons-react'
|
||||
|
||||
function SpinButton({
|
||||
direction,
|
||||
disabled,
|
||||
onTick,
|
||||
}: {
|
||||
direction: 'down' | 'up'
|
||||
disabled: boolean
|
||||
onTick: () => void
|
||||
}) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const tickRef = useRef(onTick)
|
||||
tickRef.current = onTick
|
||||
|
||||
const stopRepeat = useCallback(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
timerRef.current = null
|
||||
intervalRef.current = null
|
||||
}, [])
|
||||
|
||||
useEffect(() => stopRepeat, [stopRepeat])
|
||||
|
||||
const startRepeat = useCallback(() => {
|
||||
if (disabled) return
|
||||
tickRef.current()
|
||||
timerRef.current = setTimeout(() => {
|
||||
intervalRef.current = setInterval(() => tickRef.current(), 80)
|
||||
}, 400)
|
||||
}, [disabled])
|
||||
|
||||
const Icon = direction === 'down' ? IconMinus : IconPlus
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onMouseDown={startRepeat}
|
||||
onMouseUp={stopRepeat}
|
||||
onMouseLeave={stopRepeat}
|
||||
onTouchStart={startRepeat}
|
||||
onTouchEnd={stopRepeat}
|
||||
aria-label={direction === 'down' ? 'Decrease' : 'Increase'}
|
||||
className="number-input-btn"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 44,
|
||||
alignSelf: 'stretch',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRight: direction === 'down' ? '1px solid var(--border)' : 'none',
|
||||
borderLeft: direction === 'up' ? '1px solid var(--border)' : 'none',
|
||||
color: disabled ? 'var(--text-tertiary)' : 'var(--text-secondary)',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Icon size={13} stroke={2} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NumberInput({
|
||||
value,
|
||||
onChange,
|
||||
min = 1,
|
||||
max = 999,
|
||||
step = 1,
|
||||
style,
|
||||
'aria-label': ariaLabel,
|
||||
}: {
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
style?: React.CSSProperties
|
||||
'aria-label'?: string
|
||||
}) {
|
||||
const valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
|
||||
const clamp = useCallback((n: number) => Math.max(min, Math.min(max, n)), [min, max])
|
||||
|
||||
const tickDown = useCallback(() => {
|
||||
onChange(clamp(valueRef.current - step))
|
||||
}, [onChange, clamp, step])
|
||||
|
||||
const tickUp = useCallback(() => {
|
||||
onChange(clamp(valueRef.current + step))
|
||||
}, [onChange, clamp, step])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="number-input-wrapper"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<SpinButton direction="down" disabled={value <= min} onTick={tickDown} />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
aria-label={ariaLabel}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value)
|
||||
if (!isNaN(n)) onChange(clamp(n))
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
padding: '8px 4px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: 'var(--text)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
fontFamily: 'var(--font-body)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
/>
|
||||
<SpinButton direction="up" disabled={value >= max} onTick={tickUp} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||
import { api } from '../lib/api'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||
import { IconX, IconCheck, IconAlertTriangle, IconFingerprint } from '@tabler/icons-react'
|
||||
|
||||
interface Props {
|
||||
mode: 'register' | 'login'
|
||||
@@ -11,25 +14,48 @@ interface Props {
|
||||
|
||||
export default function PasskeyModal({ mode, open, onClose }: Props) {
|
||||
const auth = useAuth()
|
||||
const trapRef = useFocusTrap(open)
|
||||
const [username, setUsername] = useState('')
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [available, setAvailable] = useState<boolean | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [browserSupport, setBrowserSupport] = useState<'checking' | 'full' | 'basic' | 'none'>('checking')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const checkTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
useEffect(() => {
|
||||
async function check() {
|
||||
if (!window.PublicKeyCredential) {
|
||||
setBrowserSupport('none')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const platform = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
setBrowserSupport(platform ? 'full' : 'basic')
|
||||
} catch {
|
||||
setBrowserSupport('basic')
|
||||
}
|
||||
}
|
||||
check()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setUsername('')
|
||||
const prefill = mode === 'register' && auth.displayName && auth.displayName !== 'Anonymous'
|
||||
? auth.displayName
|
||||
: ''
|
||||
setUsername(prefill)
|
||||
setAvailable(null)
|
||||
setError('')
|
||||
setSuccess(false)
|
||||
setTimeout(() => inputRef.current?.focus(), 100)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'register' || !username.trim() || username.length < 3) {
|
||||
if (!username.trim() || username.length < 3) {
|
||||
setAvailable(null)
|
||||
return
|
||||
}
|
||||
@@ -37,16 +63,16 @@ export default function PasskeyModal({ mode, open, onClose }: Props) {
|
||||
checkTimer.current = setTimeout(async () => {
|
||||
setChecking(true)
|
||||
try {
|
||||
const res = await api.get<{ available: boolean }>(`/identity/check-username?name=${encodeURIComponent(username)}`)
|
||||
const res = await api.get<{ available: boolean }>(`/auth/passkey/check-username/${encodeURIComponent(username)}`)
|
||||
setAvailable(res.available)
|
||||
} catch {
|
||||
setAvailable(null)
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}, 400)
|
||||
}, 300)
|
||||
return () => clearTimeout(checkTimer.current)
|
||||
}, [username, mode])
|
||||
}, [username])
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username.trim()) {
|
||||
@@ -59,9 +85,10 @@ export default function PasskeyModal({ mode, open, onClose }: Props) {
|
||||
try {
|
||||
const opts = await api.post<any>('/auth/passkey/register/options', { username })
|
||||
const attestation = await startRegistration({ optionsJSON: opts })
|
||||
await api.post('/auth/passkey/register/verify', { username, attestation })
|
||||
await api.post('/auth/passkey/register/verify', { username, response: attestation })
|
||||
setSuccess(true)
|
||||
await auth.refresh()
|
||||
onClose()
|
||||
setTimeout(onClose, 2000)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Registration failed. Please try again.')
|
||||
} finally {
|
||||
@@ -74,11 +101,12 @@ export default function PasskeyModal({ mode, open, onClose }: Props) {
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const opts = await api.post<any>('/auth/passkey/login/options')
|
||||
const opts = await api.post<any>('/auth/passkey/login/options', {})
|
||||
const assertion = await startAuthentication({ optionsJSON: opts })
|
||||
await api.post('/auth/passkey/login/verify', { assertion })
|
||||
await api.post('/auth/passkey/login/verify', { response: assertion })
|
||||
setSuccess(true)
|
||||
await auth.refresh()
|
||||
onClose()
|
||||
setTimeout(onClose, 2000)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Authentication failed. Please try again.')
|
||||
} finally {
|
||||
@@ -98,87 +126,194 @@ export default function PasskeyModal({ mode, open, onClose }: Props) {
|
||||
style={{ background: 'rgba(0, 0, 0, 0.5)', backdropFilter: 'blur(4px)' }}
|
||||
/>
|
||||
<div
|
||||
className="relative w-full max-w-sm mx-4 rounded-xl p-6 shadow-2xl slide-up"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="passkey-modal-title"
|
||||
className="relative w-full max-w-sm mx-4 fade-in"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
padding: '24px',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.key === 'Escape' && onClose()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2
|
||||
className="text-lg font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
{mode === 'register' ? 'Register Passkey' : 'Login with Passkey'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="btn btn-ghost p-1">
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'register' ? (
|
||||
<>
|
||||
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
|
||||
Choose a display name and register a passkey to keep your identity across devices.
|
||||
{success ? (
|
||||
<div className="flex flex-col items-center py-6 fade-in">
|
||||
<div
|
||||
className="w-16 h-16 flex items-center justify-center mb-4"
|
||||
style={{ background: 'rgba(34, 197, 94, 0.12)', borderRadius: 'var(--radius-lg)' }}
|
||||
>
|
||||
<IconCheck size={32} stroke={2.5} color="var(--success)" />
|
||||
</div>
|
||||
<p className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-base)' }}>
|
||||
{mode === 'register' ? 'Identity saved' : 'Signed in'}
|
||||
</p>
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="input pr-8"
|
||||
placeholder="Display name"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
{checking && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 rounded-full" style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2
|
||||
id="passkey-modal-title"
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-lg)' }}
|
||||
>
|
||||
{mode === 'register' ? 'Save my identity' : 'Sign in with passkey'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-11 h-11 flex items-center justify-center"
|
||||
style={{
|
||||
color: 'var(--text-tertiary)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
transition: 'color var(--duration-fast) ease-out',
|
||||
}}
|
||||
aria-label="Close"
|
||||
>
|
||||
<IconX size={18} stroke={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'register' ? (
|
||||
<>
|
||||
{/* Passkey explainer */}
|
||||
<div
|
||||
className="mb-4 p-3 flex gap-3"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<IconFingerprint size={20} stroke={1.5} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 1 }} />
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: 'var(--text)', marginBottom: 4 }}>What's a passkey?</p>
|
||||
<p>
|
||||
A passkey is a modern replacement for passwords. It uses your device's built-in security
|
||||
(fingerprint, face, PIN, or security key) to prove it's you - no password to remember or type.
|
||||
Your passkey stays on your device and is never sent to our server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!checking && available !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{available ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--success)" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--error)" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
|
||||
{browserSupport === 'none' && (
|
||||
<div
|
||||
className="mb-4 p-3 flex items-start gap-3"
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.06)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.15)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<IconAlertTriangle size={16} stroke={2} style={{ color: 'var(--error)', flexShrink: 0, marginTop: 1 }} />
|
||||
<div>
|
||||
<p>Your browser doesn't support passkeys. Try a recent version of Chrome, Safari, Firefox, or Edge.</p>
|
||||
<p className="mt-1">
|
||||
You can still protect your identity with a{' '}
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={onClose}
|
||||
style={{ color: 'var(--accent)', textDecoration: 'underline', textUnderlineOffset: '2px' }}
|
||||
>
|
||||
recovery code
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mb-4" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
|
||||
Pick a username to save your identity. No email, no account - just a name so you can get back to your posts.
|
||||
</p>
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="input pr-8"
|
||||
placeholder="Username"
|
||||
aria-label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
maxLength={30}
|
||||
autoComplete="username"
|
||||
aria-describedby={error ? 'passkey-error' : undefined}
|
||||
/>
|
||||
{checking && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div
|
||||
className="w-4 h-4 border-2 rounded-full"
|
||||
style={{ borderColor: 'var(--border)', borderTopColor: 'var(--accent)', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!checking && available !== null && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{available ? (
|
||||
<IconCheck size={16} stroke={2.5} color="var(--success)" />
|
||||
) : (
|
||||
<IconX size={16} stroke={2.5} color="var(--error)" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!checking && available === false && (
|
||||
<p role="alert" className="mb-3" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>This username is taken. Try adding numbers or pick a different name.</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
|
||||
Use your registered passkey to sign in and restore your identity.
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="input"
|
||||
placeholder="Username (optional - helps find your passkey)"
|
||||
aria-label="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
aria-describedby={error ? 'passkey-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p id="passkey-error" role="alert" className="mb-3" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={mode === 'register' ? handleRegister : handleLogin}
|
||||
disabled={loading || browserSupport === 'none' || (mode === 'register' && (!username.trim() || available === false))}
|
||||
className="btn btn-primary w-full"
|
||||
style={{ opacity: loading ? 0.6 : 1 }}
|
||||
>
|
||||
{loading ? (
|
||||
<div
|
||||
className="w-4 h-4 border-2 rounded-full"
|
||||
style={{ borderColor: 'rgba(20,20,32,0.3)', borderTopColor: '#141420', animation: 'spin 0.6s linear infinite' }}
|
||||
/>
|
||||
) : mode === 'register' ? (
|
||||
'Save my identity'
|
||||
) : (
|
||||
'Sign in with passkey'
|
||||
)}
|
||||
</div>
|
||||
{!checking && available === false && (
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--error)' }}>This name is taken</p>
|
||||
</button>
|
||||
|
||||
{mode === 'register' && (
|
||||
<p className="mt-4 text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
Your passkey is stored on your device. No passwords involved.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
Use your registered passkey to sign in and restore your identity.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={mode === 'register' ? handleRegister : handleLogin}
|
||||
disabled={loading || (mode === 'register' && (!username.trim() || available === false))}
|
||||
className="btn btn-primary w-full"
|
||||
style={{ opacity: loading ? 0.6 : 1 }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-4 h-4 border-2 rounded-full" style={{ borderColor: 'rgba(20,20,32,0.3)', borderTopColor: '#141420', animation: 'spin 0.6s linear infinite' }} />
|
||||
) : mode === 'register' ? (
|
||||
'Register Passkey'
|
||||
) : (
|
||||
'Sign in with Passkey'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{mode === 'register' && (
|
||||
<p className="text-xs mt-4 text-center" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Your passkey is stored on your device. No passwords involved.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
51
packages/web/src/components/PluginSlot.tsx
Normal file
51
packages/web/src/components/PluginSlot.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
interface PluginInfo {
|
||||
name: string
|
||||
version: string
|
||||
components?: Record<string, string>
|
||||
}
|
||||
|
||||
let pluginCache: PluginInfo[] | null = null
|
||||
let pluginPromise: Promise<PluginInfo[]> | null = null
|
||||
|
||||
function fetchPlugins(): Promise<PluginInfo[]> {
|
||||
if (pluginCache) return Promise.resolve(pluginCache)
|
||||
if (!pluginPromise) {
|
||||
pluginPromise = api.get<PluginInfo[]>('/plugins/active')
|
||||
.then((data) => { pluginCache = data; return data })
|
||||
.catch(() => { pluginCache = []; return [] })
|
||||
}
|
||||
return pluginPromise
|
||||
}
|
||||
|
||||
export default function PluginSlot({ name }: { name: string }) {
|
||||
const [html, setHtml] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlugins().then((plugins) => {
|
||||
const parts: string[] = []
|
||||
for (const p of plugins) {
|
||||
if (p.components?.[name]) parts.push(p.components[name])
|
||||
}
|
||||
if (parts.length > 0) setHtml(parts)
|
||||
})
|
||||
}, [name])
|
||||
|
||||
if (html.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{html.map((h, i) => (
|
||||
<div key={i} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(h, {
|
||||
ALLOWED_TAGS: ['div', 'span', 'p', 'a', 'b', 'i', 'em', 'strong', 'ul', 'ol', 'li', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'img', 'code', 'pre'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'src', 'alt', 'class'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOWED_URI_REGEXP: /^(?:https?|mailto):/i,
|
||||
}) }} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +1,367 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import type { StatusConfig } from './StatusBadge'
|
||||
import { IconChevronUp, IconMessageCircle, IconBug, IconBulb, IconPin, IconClock, IconEye } from '@tabler/icons-react'
|
||||
import Avatar from './Avatar'
|
||||
|
||||
interface Post {
|
||||
id: string
|
||||
title: string
|
||||
excerpt?: string
|
||||
type: 'feature' | 'bug' | 'general'
|
||||
description?: Record<string, string>
|
||||
type: 'FEATURE_REQUEST' | 'BUG_REPORT'
|
||||
status: string
|
||||
statusReason?: string | null
|
||||
category?: string | null
|
||||
voteCount: number
|
||||
commentCount: number
|
||||
authorName: string
|
||||
viewCount?: number
|
||||
isPinned?: boolean
|
||||
isStale?: boolean
|
||||
onBehalfOf?: string | null
|
||||
author?: { id: string; displayName: string; avatarUrl?: string | null } | null
|
||||
createdAt: string
|
||||
boardSlug: string
|
||||
hasVoted?: boolean
|
||||
voted?: boolean
|
||||
voteWeight?: number
|
||||
}
|
||||
|
||||
function descriptionExcerpt(desc?: Record<string, string>, max = 120): string {
|
||||
if (!desc) return ''
|
||||
const text = Object.values(desc).join(' ').replace(/\s+/g, ' ').trim()
|
||||
return text.length > max ? text.slice(0, max) + '...' : text
|
||||
}
|
||||
|
||||
const IMPORTANCE_OPTIONS = [
|
||||
{ value: 'critical', label: 'Critical', color: '#ef4444' },
|
||||
{ value: 'important', label: 'Important', color: '#f59e0b' },
|
||||
{ value: 'nice_to_have', label: 'Nice to have', color: '#3b82f6' },
|
||||
{ value: 'minor', label: 'Minor', color: '#9ca3af' },
|
||||
] as const
|
||||
|
||||
export default function PostCard({
|
||||
post,
|
||||
onVote,
|
||||
onUnvote,
|
||||
onImportance,
|
||||
showImportancePopup,
|
||||
budgetDepleted,
|
||||
customStatuses,
|
||||
index = 0,
|
||||
}: {
|
||||
post: Post
|
||||
onVote?: (id: string) => void
|
||||
onUnvote?: (id: string) => void
|
||||
onImportance?: (id: string, importance: string) => void
|
||||
showImportancePopup?: boolean
|
||||
budgetDepleted?: boolean
|
||||
customStatuses?: StatusConfig[]
|
||||
index?: number
|
||||
}) {
|
||||
const [voteAnimating, setVoteAnimating] = useState(false)
|
||||
const [popupVisible, setPopupVisible] = useState(false)
|
||||
const popupTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const timeAgo = formatTimeAgo(post.createdAt)
|
||||
const typeLabel = post.type === 'BUG_REPORT' ? 'Bug' : 'Feature'
|
||||
|
||||
useEffect(() => {
|
||||
if (showImportancePopup) {
|
||||
setPopupVisible(true)
|
||||
popupTimer.current = setTimeout(() => setPopupVisible(false), 5000)
|
||||
return () => {
|
||||
if (popupTimer.current) clearTimeout(popupTimer.current)
|
||||
}
|
||||
} else {
|
||||
setPopupVisible(false)
|
||||
}
|
||||
}, [showImportancePopup])
|
||||
|
||||
const pickImportance = (val: string) => {
|
||||
if (popupTimer.current) clearTimeout(popupTimer.current)
|
||||
setPopupVisible(false)
|
||||
onImportance?.(post.id, val)
|
||||
}
|
||||
|
||||
const handleVoteClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
if (post.voted) {
|
||||
onUnvote?.(post.id)
|
||||
} else {
|
||||
setVoteAnimating(true)
|
||||
setTimeout(() => setVoteAnimating(false), 400)
|
||||
onVote?.(post.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card flex gap-0 overflow-hidden"
|
||||
style={{ transition: 'border-color 200ms ease-out' }}
|
||||
ref={cardRef}
|
||||
className="card card-interactive flex gap-0 overflow-hidden stagger-in"
|
||||
style={{ '--stagger': index, position: 'relative' } as React.CSSProperties}
|
||||
>
|
||||
{/* Vote column */}
|
||||
{/* Vote column - desktop */}
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); onVote?.(post.id) }}
|
||||
className="flex flex-col items-center justify-center px-3 py-4 shrink-0 gap-1"
|
||||
className="hidden md:flex flex-col items-center justify-center shrink-0 gap-1 action-btn"
|
||||
onClick={handleVoteClick}
|
||||
style={{
|
||||
width: 48,
|
||||
background: post.hasVoted ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: post.hasVoted ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
transition: 'all 200ms ease-out',
|
||||
width: 56,
|
||||
padding: '20px 12px',
|
||||
background: post.voted ? 'var(--accent-subtle)' : 'transparent',
|
||||
borderRight: '1px solid var(--border)',
|
||||
border: 'none',
|
||||
borderRadius: 0,
|
||||
cursor: 'pointer',
|
||||
transition: 'background var(--duration-normal) ease-out',
|
||||
}}
|
||||
title={post.voted ? 'Click to remove your vote' : 'Click to vote'}
|
||||
aria-label="Vote"
|
||||
>
|
||||
<IconChevronUp
|
||||
size={18}
|
||||
stroke={2.5}
|
||||
className={voteAnimating ? 'vote-bounce' : ''}
|
||||
style={{
|
||||
color: post.voted ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
transition: 'color var(--duration-fast) ease-out',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
|
||||
style={{
|
||||
color: post.voted ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
}}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{post.voteCount}
|
||||
</span>
|
||||
{budgetDepleted && !post.voted && (
|
||||
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', textAlign: 'center', lineHeight: 1.2, marginTop: 2 }}>
|
||||
0 left
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Mobile vote strip */}
|
||||
<div
|
||||
className="flex md:hidden items-center gap-2 px-4 py-2"
|
||||
style={{
|
||||
background: post.voted ? 'var(--accent-subtle)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
<span className="text-xs font-semibold">{post.voteCount}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleVoteClick}
|
||||
className="flex items-center gap-2"
|
||||
style={{ color: post.voted ? 'var(--accent)' : 'var(--text-tertiary)', cursor: 'pointer', background: 'none', border: 'none' }}
|
||||
aria-label="Vote"
|
||||
>
|
||||
<IconChevronUp size={14} stroke={2.5} className={voteAnimating ? 'vote-bounce' : ''} />
|
||||
<span className="font-semibold" style={{ fontSize: 'var(--text-xs)' }} aria-live="polite" aria-atomic="true">{post.voteCount}</span>
|
||||
</button>
|
||||
<StatusBadge status={post.status} customStatuses={customStatuses} />
|
||||
<div className="flex items-center gap-1 ml-auto" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<IconMessageCircle size={12} stroke={2} />
|
||||
<span style={{ fontSize: 'var(--text-xs)' }}>{post.commentCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content zone */}
|
||||
<Link
|
||||
to={`/b/${post.boardSlug}/post/${post.id}`}
|
||||
className="flex-1 py-3 px-4 min-w-0"
|
||||
className="flex-1 min-w-0"
|
||||
style={{ padding: '16px 20px' }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded"
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5"
|
||||
style={{
|
||||
background: post.type === 'bug' ? 'rgba(239, 68, 68, 0.15)' : 'var(--accent-subtle)',
|
||||
color: post.type === 'bug' ? 'var(--error)' : 'var(--accent)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: post.type === 'BUG_REPORT' ? 'rgba(239, 68, 68, 0.12)' : 'var(--accent-subtle)',
|
||||
color: post.type === 'BUG_REPORT' ? 'var(--error)' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{post.type}
|
||||
{post.type === 'BUG_REPORT' ? <IconBug size={11} stroke={2} aria-hidden="true" /> : <IconBulb size={11} stroke={2} aria-hidden="true" />}
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{post.authorName} - {timeAgo}
|
||||
{post.category && (
|
||||
<span
|
||||
className="px-2 py-0.5"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--surface-hover)',
|
||||
color: 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{post.category}
|
||||
</span>
|
||||
)}
|
||||
{post.isPinned && (
|
||||
<IconPin size={12} stroke={2} style={{ color: 'var(--accent)' }} />
|
||||
)}
|
||||
{post.isStale && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'rgba(245, 158, 11, 0.1)',
|
||||
color: 'rgb(245, 158, 11)',
|
||||
}}
|
||||
title="No recent activity"
|
||||
>
|
||||
<IconClock size={10} stroke={2} aria-hidden="true" />
|
||||
Stale
|
||||
<span className="sr-only"> - no recent activity</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5" style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>
|
||||
<Avatar
|
||||
userId={post.author?.id ?? '0000'}
|
||||
name={post.onBehalfOf ?? post.author?.displayName ?? null}
|
||||
avatarUrl={post.author?.avatarUrl}
|
||||
size={18}
|
||||
/>
|
||||
{post.onBehalfOf
|
||||
? <>on behalf of {post.onBehalfOf}</>
|
||||
: (post.author?.displayName ?? `Anonymous #${(post.author?.id ?? '0000').slice(-4)}`)
|
||||
} - <time dateTime={post.createdAt}>{timeAgo}</time>
|
||||
</span>
|
||||
</div>
|
||||
<h3
|
||||
className="text-sm font-medium mb-1 truncate"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
<h2
|
||||
className="font-medium mb-1 truncate"
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--text)',
|
||||
fontSize: 'var(--text-lg)',
|
||||
}}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
{post.excerpt && (
|
||||
</h2>
|
||||
{post.statusReason && (
|
||||
<p
|
||||
className="text-xs line-clamp-2"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
className="truncate mb-1"
|
||||
style={{
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
}}
|
||||
>
|
||||
{post.excerpt}
|
||||
Reason: {post.statusReason}
|
||||
</p>
|
||||
)}
|
||||
{post.description && (
|
||||
<p
|
||||
className="line-clamp-2"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.6,
|
||||
fontSize: 'var(--text-sm)',
|
||||
}}
|
||||
>
|
||||
{descriptionExcerpt(post.description)}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Status + comments */}
|
||||
<div className="flex flex-col items-end justify-center px-4 py-3 shrink-0 gap-2">
|
||||
<StatusBadge status={post.status} />
|
||||
<div className="flex items-center gap-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<span className="text-xs">{post.commentCount}</span>
|
||||
{/* Status + comments - desktop */}
|
||||
<div className="hidden md:flex flex-col items-end justify-center px-5 py-4 shrink-0 gap-2">
|
||||
<StatusBadge status={post.status} customStatuses={customStatuses} />
|
||||
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<IconMessageCircle size={14} stroke={2} aria-hidden="true" />
|
||||
<span style={{ fontSize: 'var(--text-xs)' }}>{post.commentCount}</span>
|
||||
<span className="sr-only">comments</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<IconEye size={14} stroke={2} aria-hidden="true" />
|
||||
<span style={{ fontSize: 'var(--text-xs)' }}>{post.viewCount ?? 0}</span>
|
||||
<span className="sr-only">views</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Importance popup */}
|
||||
{popupVisible && (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label="Rate importance"
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') setPopupVisible(false) }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 4,
|
||||
bottom: -4,
|
||||
transform: 'translateY(100%)',
|
||||
zIndex: 50,
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '8px 10px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
minWidth: 150,
|
||||
}}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: 4,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
How important is this?
|
||||
</span>
|
||||
{IMPORTANCE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
role="menuitem"
|
||||
onClick={(e) => { e.preventDefault(); pickImportance(opt.value) }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 8px',
|
||||
minHeight: 44,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: 'var(--text-secondary)',
|
||||
textAlign: 'left',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--surface-hover)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
|
||||
onFocus={(e) => { e.currentTarget.style.background = 'var(--surface-hover)' }}
|
||||
onBlur={(e) => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: opt.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { IMPORTANCE_OPTIONS }
|
||||
|
||||
function formatTimeAgo(date: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
|
||||
@@ -1,174 +1,495 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { solveAltcha } from '../lib/altcha'
|
||||
import { IconFingerprint } from '@tabler/icons-react'
|
||||
import Dropdown from './Dropdown'
|
||||
import MarkdownEditor from './MarkdownEditor'
|
||||
import FileUpload from './FileUpload'
|
||||
|
||||
interface SimilarPost {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
voteCount: number
|
||||
similarity: number
|
||||
}
|
||||
|
||||
interface TemplateField {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'textarea' | 'select'
|
||||
required: boolean
|
||||
placeholder?: string
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
interface Template {
|
||||
id: string
|
||||
name: string
|
||||
fields: TemplateField[]
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
boardSlug: string
|
||||
boardId?: string
|
||||
onSubmit?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
type PostType = 'feature' | 'bug' | 'general'
|
||||
type PostType = 'FEATURE_REQUEST' | 'BUG_REPORT'
|
||||
|
||||
export default function PostForm({ boardSlug, onSubmit }: Props) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [type, setType] = useState<PostType>('feature')
|
||||
interface FieldErrors {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Props) {
|
||||
const auth = useAuth()
|
||||
const [type, setType] = useState<PostType>('FEATURE_REQUEST')
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [expected, setExpected] = useState('')
|
||||
const [actual, setActual] = useState('')
|
||||
const [steps, setSteps] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const formRef = useRef<HTMLDivElement>(null)
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
|
||||
const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([])
|
||||
const [similar, setSimilar] = useState<SimilarPost[]>([])
|
||||
|
||||
// templates
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState('')
|
||||
const [templateValues, setTemplateValues] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ id: string; name: string; slug: string }[]>('/categories')
|
||||
.then(setCategories)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// fetch templates for this board
|
||||
useEffect(() => {
|
||||
if (!boardSlug) return
|
||||
api.get<{ templates: Template[] }>(`/boards/${boardSlug}/templates`)
|
||||
.then((r) => {
|
||||
setTemplates(r.templates)
|
||||
const def = r.templates.find((t) => t.isDefault)
|
||||
if (def) setSelectedTemplateId(def.id)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [boardSlug])
|
||||
|
||||
// debounced duplicate detection
|
||||
useEffect(() => {
|
||||
if (!boardId || title.trim().length < 5) { setSimilar([]); return }
|
||||
const t = setTimeout(() => {
|
||||
api.get<{ posts: SimilarPost[] }>(`/similar?title=${encodeURIComponent(title)}&boardId=${encodeURIComponent(boardId)}`)
|
||||
.then((r) => setSimilar(r.posts))
|
||||
.catch(() => setSimilar([]))
|
||||
}, 400)
|
||||
return () => clearTimeout(t)
|
||||
}, [title, boardId])
|
||||
|
||||
// bug report fields
|
||||
const [steps, setSteps] = useState('')
|
||||
const [expected, setExpected] = useState('')
|
||||
const [actual, setActual] = useState('')
|
||||
const [environment, setEnvironment] = useState('')
|
||||
const [bugContext, setBugContext] = useState('')
|
||||
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
|
||||
|
||||
// feature request fields
|
||||
const [useCase, setUseCase] = useState('')
|
||||
const [proposedSolution, setProposedSolution] = useState('')
|
||||
const [alternatives, setAlternatives] = useState('')
|
||||
const [featureContext, setFeatureContext] = useState('')
|
||||
|
||||
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) || null
|
||||
|
||||
const reset = () => {
|
||||
setTitle('')
|
||||
setBody('')
|
||||
setCategory('')
|
||||
setSteps('')
|
||||
setExpected('')
|
||||
setActual('')
|
||||
setSteps('')
|
||||
setEnvironment('')
|
||||
setBugContext('')
|
||||
setUseCase('')
|
||||
setProposedSolution('')
|
||||
setAlternatives('')
|
||||
setFeatureContext('')
|
||||
setAttachmentIds([])
|
||||
setTemplateValues({})
|
||||
setError('')
|
||||
setExpanded(false)
|
||||
setFieldErrors({})
|
||||
}
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errors: FieldErrors = {}
|
||||
|
||||
if (title.trim().length < 5) {
|
||||
errors.title = 'Title must be at least 5 characters'
|
||||
}
|
||||
|
||||
if (selectedTemplate) {
|
||||
for (const f of selectedTemplate.fields) {
|
||||
if (f.required && !templateValues[f.key]?.trim()) {
|
||||
errors[f.key] = `${f.label} is required`
|
||||
}
|
||||
}
|
||||
} else if (type === 'BUG_REPORT') {
|
||||
if (!steps.trim()) errors.steps = 'Steps to reproduce are required'
|
||||
if (!expected.trim()) errors.expected = 'Expected behavior is required'
|
||||
if (!actual.trim()) errors.actual = 'Actual behavior is required'
|
||||
} else {
|
||||
if (!useCase.trim()) errors.useCase = 'Use case is required'
|
||||
}
|
||||
|
||||
setFieldErrors(errors)
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!title.trim()) {
|
||||
setError('Title is required')
|
||||
return
|
||||
}
|
||||
if (!validate()) return
|
||||
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
|
||||
const payload: Record<string, string> = { title, type, body }
|
||||
if (type === 'bug') {
|
||||
payload.stepsToReproduce = steps
|
||||
payload.expected = expected
|
||||
payload.actual = actual
|
||||
let altcha: string
|
||||
try {
|
||||
altcha = await solveAltcha()
|
||||
} catch {
|
||||
setError('Verification failed. Please try again.')
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
let description: Record<string, string>
|
||||
if (selectedTemplate) {
|
||||
description = {}
|
||||
for (const f of selectedTemplate.fields) {
|
||||
description[f.key] = templateValues[f.key] || ''
|
||||
}
|
||||
} else if (type === 'BUG_REPORT') {
|
||||
description = {
|
||||
stepsToReproduce: steps,
|
||||
expectedBehavior: expected,
|
||||
actualBehavior: actual,
|
||||
environment: environment || '',
|
||||
additionalContext: bugContext || '',
|
||||
}
|
||||
} else {
|
||||
description = {
|
||||
useCase,
|
||||
proposedSolution: proposedSolution || '',
|
||||
alternativesConsidered: alternatives || '',
|
||||
additionalContext: featureContext || '',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/boards/${boardSlug}/posts`, payload)
|
||||
await api.post(`/boards/${boardSlug}/posts`, {
|
||||
title,
|
||||
type,
|
||||
description,
|
||||
category: category || undefined,
|
||||
templateId: selectedTemplate ? selectedTemplate.id : undefined,
|
||||
attachmentIds: attachmentIds.length ? attachmentIds : undefined,
|
||||
altcha,
|
||||
})
|
||||
reset()
|
||||
onSubmit?.()
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setError('Failed to submit. Please try again.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="card w-full px-4 py-3 text-left flex items-center gap-3"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||
>
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Share feedback...
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
const fieldError = (key: string) =>
|
||||
fieldErrors[key] ? (
|
||||
<span id={`err-${key}`} role="alert" className="mt-0.5 block" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{fieldErrors[key]}</span>
|
||||
) : null
|
||||
|
||||
const label = (text: string, required?: boolean, htmlFor?: string) => (
|
||||
<label htmlFor={htmlFor} className="block font-medium mb-1 uppercase tracking-wider" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{text}{required && <span style={{ color: 'var(--error)' }}> *</span>}
|
||||
</label>
|
||||
)
|
||||
|
||||
const handleTemplateChange = (id: string) => {
|
||||
setSelectedTemplateId(id)
|
||||
setTemplateValues({})
|
||||
setFieldErrors({})
|
||||
}
|
||||
|
||||
const renderTemplateFields = () => {
|
||||
if (!selectedTemplate) return null
|
||||
|
||||
return selectedTemplate.fields.map((f) => (
|
||||
<div key={f.key} className="mb-3">
|
||||
{label(f.label, f.required)}
|
||||
{f.type === 'text' && (
|
||||
<input
|
||||
className="input w-full"
|
||||
placeholder={f.placeholder || ''}
|
||||
value={templateValues[f.key] || ''}
|
||||
onChange={(e) => setTemplateValues((prev) => ({ ...prev, [f.key]: e.target.value }))}
|
||||
aria-required={f.required || undefined}
|
||||
aria-invalid={!!fieldErrors[f.key]}
|
||||
aria-describedby={fieldErrors[f.key] ? `err-${f.key}` : undefined}
|
||||
/>
|
||||
)}
|
||||
{f.type === 'textarea' && (
|
||||
<MarkdownEditor
|
||||
value={templateValues[f.key] || ''}
|
||||
onChange={(val) => setTemplateValues((prev) => ({ ...prev, [f.key]: val }))}
|
||||
placeholder={f.placeholder || ''}
|
||||
rows={3}
|
||||
ariaRequired={f.required}
|
||||
ariaLabel={f.label}
|
||||
/>
|
||||
)}
|
||||
{f.type === 'select' && f.options && (
|
||||
<Dropdown
|
||||
value={templateValues[f.key] || ''}
|
||||
onChange={(val) => setTemplateValues((prev) => ({ ...prev, [f.key]: val }))}
|
||||
placeholder={f.placeholder || 'Select...'}
|
||||
options={[
|
||||
{ value: '', label: f.placeholder || 'Select...' },
|
||||
...f.options.map((o) => ({ value: o, label: o })),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{fieldError(f.key)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={formRef} className="card p-4 slide-up">
|
||||
<div
|
||||
className="card card-static p-5"
|
||||
style={{ animation: 'slideUp var(--duration-normal) var(--ease-out)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||
New Post
|
||||
</h3>
|
||||
<button onClick={reset} className="btn btn-ghost text-xs">Cancel</button>
|
||||
</div>
|
||||
|
||||
{/* Type selector */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{(['feature', 'bug', 'general'] as PostType[]).map((t) => (
|
||||
<h2
|
||||
className="font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-base)' }}
|
||||
>
|
||||
Share feedback
|
||||
</h2>
|
||||
{onCancel && (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setType(t)}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-medium capitalize"
|
||||
style={{
|
||||
background: type === t ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: type === t ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
border: `1px solid ${type === t ? 'var(--accent)' : 'var(--border)'}`,
|
||||
transition: 'all 200ms ease-out',
|
||||
}}
|
||||
onClick={() => { reset(); onCancel() }}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
{t === 'feature' ? 'Feature Request' : t === 'bug' ? 'Bug Report' : 'General'}
|
||||
Cancel
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="input mb-3"
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
{/* Template selector */}
|
||||
{templates.length > 0 && (
|
||||
<div className="mb-4">
|
||||
{label('Template')}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => handleTemplateChange('')}
|
||||
className="px-3 py-1.5 font-medium"
|
||||
style={{
|
||||
background: !selectedTemplateId ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: !selectedTemplateId ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
border: `1px solid ${!selectedTemplateId ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
{templates.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => handleTemplateChange(t.id)}
|
||||
className="px-3 py-1.5 font-medium"
|
||||
style={{
|
||||
background: selectedTemplateId === t.id ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: selectedTemplateId === t.id ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
border: `1px solid ${selectedTemplateId === t.id ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
className="input mb-3"
|
||||
placeholder={type === 'bug' ? 'Describe the bug...' : type === 'feature' ? 'Describe the feature...' : 'What is on your mind?'}
|
||||
rows={3}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
{/* Type selector - only when not using a template */}
|
||||
{!selectedTemplate && (
|
||||
<div className="flex gap-2 mb-4">
|
||||
{([
|
||||
['FEATURE_REQUEST', 'Feature Request'],
|
||||
['BUG_REPORT', 'Bug Report'],
|
||||
] as const).map(([value, lbl]) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setType(value)}
|
||||
className="px-3 py-1.5 font-medium"
|
||||
style={{
|
||||
background: type === value ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: type === value ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
border: `1px solid ${type === value ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'bug' && (
|
||||
<>
|
||||
<textarea
|
||||
className="input mb-3"
|
||||
placeholder="Steps to reproduce"
|
||||
rows={2}
|
||||
value={steps}
|
||||
onChange={(e) => setSteps(e.target.value)}
|
||||
style={{ resize: 'vertical' }}
|
||||
{/* Title */}
|
||||
<div className="mb-3">
|
||||
{label('Title', true, 'post-title')}
|
||||
<input
|
||||
id="post-title"
|
||||
className="input w-full"
|
||||
placeholder="Brief summary"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
aria-required="true"
|
||||
aria-invalid={!!fieldErrors.title}
|
||||
aria-describedby={fieldErrors.title ? 'err-title' : undefined}
|
||||
/>
|
||||
{fieldError('title')}
|
||||
{similar.length > 0 && (
|
||||
<div
|
||||
className="mt-2 rounded-lg overflow-hidden"
|
||||
style={{ border: '1px solid var(--border-accent)', background: 'var(--surface)' }}
|
||||
>
|
||||
<div className="px-3 py-1.5" style={{ background: 'var(--accent-subtle)', color: 'var(--accent)', fontSize: 'var(--text-xs)', fontWeight: 500 }}>
|
||||
Similar posts already exist - vote instead?
|
||||
</div>
|
||||
{similar.map((p) => (
|
||||
<a
|
||||
key={p.id}
|
||||
href={`/b/${boardSlug}/post/${p.id}`}
|
||||
className="flex items-center justify-between px-3 py-2"
|
||||
style={{ borderTop: '1px solid var(--border)', fontSize: 'var(--text-xs)', color: 'var(--text)' }}
|
||||
>
|
||||
<span className="truncate mr-2">{p.title}</span>
|
||||
<span className="shrink-0 flex items-center gap-2" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span style={{ color: 'var(--accent)' }}>{p.voteCount} votes</span>
|
||||
<span>{p.similarity}% match</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
{categories.length > 0 && (
|
||||
<div className="mb-3">
|
||||
{label('Category')}
|
||||
<Dropdown
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
placeholder="No category"
|
||||
options={[
|
||||
{ value: '', label: 'No category' },
|
||||
...categories.map((c) => ({ value: c.slug, label: c.name })),
|
||||
]}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder="Expected behavior"
|
||||
rows={2}
|
||||
value={expected}
|
||||
onChange={(e) => setExpected(e.target.value)}
|
||||
style={{ resize: 'vertical' }}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template fields or default fields */}
|
||||
{selectedTemplate ? (
|
||||
renderTemplateFields()
|
||||
) : type === 'BUG_REPORT' ? (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
{label('Steps to reproduce', true)}
|
||||
<MarkdownEditor
|
||||
value={steps}
|
||||
onChange={setSteps}
|
||||
placeholder="1. Go to... 2. Click on... 3. See error"
|
||||
rows={3}
|
||||
ariaRequired
|
||||
ariaLabel="Steps to reproduce"
|
||||
/>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder="Actual behavior"
|
||||
rows={2}
|
||||
value={actual}
|
||||
onChange={(e) => setActual(e.target.value)}
|
||||
style={{ resize: 'vertical' }}
|
||||
{fieldError('steps')}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
{label('Expected behavior', true)}
|
||||
<MarkdownEditor value={expected} onChange={setExpected} placeholder="What should happen?" rows={2} ariaRequired ariaLabel="Expected behavior" />
|
||||
{fieldError('expected')}
|
||||
</div>
|
||||
<div>
|
||||
{label('Actual behavior', true)}
|
||||
<MarkdownEditor value={actual} onChange={setActual} placeholder="What actually happens?" rows={2} ariaRequired ariaLabel="Actual behavior" />
|
||||
{fieldError('actual')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
{label('Environment / version')}
|
||||
<input
|
||||
className="input w-full"
|
||||
placeholder="OS, browser, app version..."
|
||||
value={environment}
|
||||
onChange={(e) => setEnvironment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
{label('Additional context')}
|
||||
<MarkdownEditor value={bugContext} onChange={setBugContext} placeholder="Screenshots, logs, anything else..." rows={2} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
{label('Use case / problem statement', true)}
|
||||
<MarkdownEditor value={useCase} onChange={setUseCase} placeholder="What are you trying to accomplish?" rows={3} ariaRequired ariaLabel="Use case" />
|
||||
{fieldError('useCase')}
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
{label('Proposed solution')}
|
||||
<MarkdownEditor value={proposedSolution} onChange={setProposedSolution} placeholder="How do you think this should work?" rows={2} />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
{label('Alternatives considered')}
|
||||
<MarkdownEditor value={alternatives} onChange={setAlternatives} placeholder="Have you tried any workarounds?" rows={2} />
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
{label('Additional context')}
|
||||
<MarkdownEditor value={featureContext} onChange={setFeatureContext} placeholder="Anything else to add?" rows={2} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ALTCHA widget placeholder */}
|
||||
<div
|
||||
className="mb-4 p-3 rounded-lg text-xs flex items-center gap-2"
|
||||
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
ALTCHA verification
|
||||
<div className="mb-3">
|
||||
{label('Attachments')}
|
||||
<FileUpload attachmentIds={attachmentIds} onChange={setAttachmentIds} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</div>
|
||||
<div role="alert" className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
Your ability to edit this post depends on your browser cookie.
|
||||
</span>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={submitting}
|
||||
@@ -178,6 +499,18 @@ export default function PostForm({ boardSlug, onSubmit }: Props) {
|
||||
{submitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
{!auth.isPasskeyUser && (
|
||||
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<a
|
||||
href="/settings"
|
||||
className="flex items-center gap-2"
|
||||
style={{ color: 'var(--accent)', fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
<IconFingerprint size={14} stroke={2} />
|
||||
Save my identity to keep your posts across devices
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
191
packages/web/src/components/RecoveryCodeModal.tsx
Normal file
191
packages/web/src/components/RecoveryCodeModal.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useState } from 'react'
|
||||
import { IconShieldCheck, IconCopy, IconCheck, IconRefresh } from '@tabler/icons-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function RecoveryCodeModal({ open, onClose }: Props) {
|
||||
const auth = useAuth()
|
||||
const trapRef = useFocusTrap(open)
|
||||
const [phrase, setPhrase] = useState<string | null>(null)
|
||||
const [expiresAt, setExpiresAt] = useState<string | null>(null)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [copied, setCopied] = useState<'phrase' | 'link' | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const hasExisting = auth.user?.hasRecoveryCode
|
||||
|
||||
const generate = async () => {
|
||||
setGenerating(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.post<{ phrase: string; expiresAt: string }>('/me/recovery-code', {})
|
||||
setPhrase(res.phrase)
|
||||
setExpiresAt(res.expiresAt)
|
||||
auth.refresh()
|
||||
} catch {
|
||||
setError('Failed to generate recovery code')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyPhrase = () => {
|
||||
if (!phrase) return
|
||||
navigator.clipboard.writeText(phrase)
|
||||
setCopied('phrase')
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
}
|
||||
|
||||
const copyLink = () => {
|
||||
if (!phrase) return
|
||||
navigator.clipboard.writeText(`${window.location.origin}/recover#${phrase}`)
|
||||
setCopied('link')
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setPhrase(null)
|
||||
setExpiresAt(null)
|
||||
setError('')
|
||||
setCopied(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 fade-in"
|
||||
style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="recovery-modal-title"
|
||||
className="relative w-full max-w-md mx-4 fade-in"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-xl)',
|
||||
padding: '28px',
|
||||
maxHeight: '85vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Escape' && handleClose()}
|
||||
>
|
||||
<div
|
||||
className="w-10 h-10 flex items-center justify-center mb-4"
|
||||
style={{
|
||||
background: 'var(--accent-subtle)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}
|
||||
>
|
||||
<IconShieldCheck size={20} stroke={2} style={{ color: 'var(--accent)' }} />
|
||||
</div>
|
||||
|
||||
<h2
|
||||
id="recovery-modal-title"
|
||||
className="font-bold mb-2"
|
||||
style={{ fontFamily: 'var(--font-heading)', fontSize: 'var(--text-lg)', color: 'var(--text)' }}
|
||||
>
|
||||
Recovery Code
|
||||
</h2>
|
||||
|
||||
{phrase ? (
|
||||
<>
|
||||
<p className="mb-4" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
|
||||
Save this phrase somewhere safe. It will only be shown once. If you lose access to your cookies, enter this phrase to recover your identity.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="p-4 mb-3"
|
||||
style={{
|
||||
background: 'var(--bg)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="font-mono font-semibold text-center mb-3"
|
||||
style={{
|
||||
color: 'var(--accent)',
|
||||
fontSize: 'var(--text-lg)',
|
||||
wordBreak: 'break-all',
|
||||
letterSpacing: '0.02em',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{phrase}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={copyPhrase} className="btn btn-secondary flex-1 flex items-center justify-center gap-2" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{copied === 'phrase' ? <IconCheck size={14} stroke={2} /> : <IconCopy size={14} stroke={2} />}
|
||||
{copied === 'phrase' ? 'Copied' : 'Copy phrase'}
|
||||
</button>
|
||||
<button onClick={copyLink} className="btn btn-secondary flex-1 flex items-center justify-center gap-2" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{copied === 'link' ? <IconCheck size={14} stroke={2} /> : <IconCopy size={14} stroke={2} />}
|
||||
{copied === 'link' ? 'Copied' : 'Copy link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-3 mb-4"
|
||||
style={{
|
||||
background: 'rgba(234, 179, 8, 0.08)',
|
||||
border: '1px solid rgba(234, 179, 8, 0.25)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Expires on <strong style={{ color: 'var(--warning)' }}>{new Date(expiresAt!).toLocaleDateString()}</strong>.
|
||||
The code can only be used once - after recovery you'll need to generate a new one.
|
||||
</div>
|
||||
|
||||
<button onClick={handleClose} className="btn btn-primary w-full">
|
||||
I've saved it
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4" style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-sm)', lineHeight: 1.6 }}>
|
||||
{hasExisting
|
||||
? `You already have a recovery code (expires ${new Date(auth.user!.recoveryCodeExpiresAt!).toLocaleDateString()}). Generating a new one will replace it.`
|
||||
: 'Generate a 6-word phrase you can use to recover your identity if your cookies get cleared. Save it somewhere safe - a notes app, a password manager, or even a piece of paper.'}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mb-3" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={generate}
|
||||
disabled={generating}
|
||||
className="btn btn-primary flex-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
{hasExisting ? <IconRefresh size={16} stroke={2} /> : <IconShieldCheck size={16} stroke={2} />}
|
||||
{generating ? 'Generating...' : hasExisting ? 'Generate new code' : 'Generate recovery code'}
|
||||
</button>
|
||||
<button onClick={handleClose} className="btn btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,246 +1,668 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link, useLocation, useParams } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { useAdmin } from '../hooks/useAdmin'
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import { useBranding } from '../hooks/useBranding'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { api } from '../lib/api'
|
||||
import {
|
||||
IconHome, IconSearch, IconBell, IconActivity, IconFileText, IconSettings, IconUser,
|
||||
IconFingerprint, IconCookie, IconChevronLeft, IconChevronRight, IconChevronDown,
|
||||
IconSun, IconMoon, IconShieldLock, IconShieldCheck, IconLayoutKanban, IconNews,
|
||||
} from '@tabler/icons-react'
|
||||
import BoardIcon from './BoardIcon'
|
||||
import Avatar from './Avatar'
|
||||
|
||||
interface Board {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
iconName: string | null
|
||||
iconColor: string | null
|
||||
postCount: number
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
body: string
|
||||
postId: string | null
|
||||
post?: { id: string; board: { slug: string } } | null
|
||||
read: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function timeAgo(date: string): string {
|
||||
const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
|
||||
if (s < 60) return 'just now'
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h ago`
|
||||
const d = Math.floor(h / 24)
|
||||
if (d < 30) return `${d}d ago`
|
||||
return `${Math.floor(d / 30)}mo ago`
|
||||
}
|
||||
|
||||
function useMediaQuery(query: string) {
|
||||
const [matches, setMatches] = useState(() =>
|
||||
typeof window !== 'undefined' ? window.matchMedia(query).matches : false
|
||||
)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(query)
|
||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [query])
|
||||
return matches
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const { boardSlug } = useParams()
|
||||
const location = useLocation()
|
||||
const auth = useAuth()
|
||||
const admin = useAdmin()
|
||||
const { resolved, toggle: toggleTheme } = useTheme()
|
||||
const { appName, logoUrl } = useBranding()
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useMediaQuery('(max-width: 767px)')
|
||||
const isLarge = useMediaQuery('(min-width: 1024px)')
|
||||
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
const stored = localStorage.getItem('echoboard_sidebar')
|
||||
return stored === 'collapsed'
|
||||
})
|
||||
const userToggled = useRef(!!localStorage.getItem('echoboard_sidebar'))
|
||||
const [boards, setBoards] = useState<Board[]>([])
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [bellOpen, setBellOpen] = useState(false)
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Board[]>('/boards').then(setBoards).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
const isBoardActive = (slug: string) => boardSlug === slug
|
||||
// poll unread notification count
|
||||
useEffect(() => {
|
||||
const fetch = () => {
|
||||
api.get<{ notifications: Notification[]; unread: number }>('/notifications')
|
||||
.then((r) => {
|
||||
setNotifications(r.notifications.slice(0, 8))
|
||||
setUnreadCount(r.unread)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
fetch()
|
||||
const iv = setInterval(fetch, 30000)
|
||||
return () => clearInterval(iv)
|
||||
}, [])
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside
|
||||
className="hidden md:flex lg:hidden flex-col items-center py-4 gap-2 border-r"
|
||||
style={{
|
||||
width: 64,
|
||||
background: 'var(--surface)',
|
||||
borderColor: 'var(--border)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center mb-4"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
// Default collapsed on tablet, expanded on desktop - only if user hasn't toggled
|
||||
useEffect(() => {
|
||||
if (userToggled.current) return
|
||||
setCollapsed(!isLarge)
|
||||
}, [isLarge])
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{
|
||||
background: isActive('/') ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: isActive('/') ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
to={`/b/${b.slug}`}
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center text-xs font-semibold"
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
background: isBoardActive(b.slug) ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: isBoardActive(b.slug) ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{b.name.charAt(0).toUpperCase()}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="mt-auto">
|
||||
<Link
|
||||
to="/activity"
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{ color: isActive('/activity') ? 'var(--accent)' : 'var(--text-secondary)' }}
|
||||
>
|
||||
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
const toggleCollapse = () => {
|
||||
userToggled.current = true
|
||||
setCollapsed((c) => {
|
||||
const next = !c
|
||||
localStorage.setItem('echoboard_sidebar', next ? 'collapsed' : 'expanded')
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
const isBoardActive = (slug: string) => boardSlug === slug || location.pathname.startsWith(`/b/${slug}`)
|
||||
|
||||
if (isMobile) return null
|
||||
|
||||
const width = collapsed ? 64 : 300
|
||||
const isDark = resolved === 'dark'
|
||||
|
||||
const navItem = (to: string, icon: React.ReactNode, label: string, active: boolean) => (
|
||||
<Link
|
||||
to={to}
|
||||
className="flex items-center gap-3 rounded-lg nav-link"
|
||||
style={{
|
||||
padding: collapsed ? '10px' : '8px 12px',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
background: active ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: active ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}
|
||||
title={collapsed ? label : undefined}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{icon}
|
||||
{!collapsed && <span style={{ fontWeight: active ? 600 : 400 }}>{label}</span>}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="hidden lg:flex flex-col border-r h-screen sticky top-0"
|
||||
aria-label="Main navigation"
|
||||
className="relative flex flex-col border-r h-screen sticky top-0"
|
||||
style={{
|
||||
width: 280,
|
||||
width,
|
||||
minWidth: width,
|
||||
background: 'var(--surface)',
|
||||
borderColor: 'var(--border)',
|
||||
transition: 'width var(--duration-normal) var(--ease-out), min-width var(--duration-normal) var(--ease-out)',
|
||||
overflow: 'visible',
|
||||
zIndex: 20,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<Link
|
||||
to="/"
|
||||
className="text-xl font-bold"
|
||||
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}
|
||||
>
|
||||
Echoboard
|
||||
</Link>
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Board list */}
|
||||
<nav className="flex-1 overflow-y-auto py-3 px-3">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-1"
|
||||
style={{
|
||||
background: isActive('/') ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: isActive('/') ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
transition: 'all 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
|
||||
</svg>
|
||||
Home
|
||||
</Link>
|
||||
|
||||
<div className="mt-4 mb-2 px-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Boards
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{boards.map((b) => (
|
||||
<div
|
||||
className="flex items-center border-b"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
padding: collapsed ? '12px 12px' : '12px 16px',
|
||||
gap: 8,
|
||||
minHeight: 56,
|
||||
}}
|
||||
>
|
||||
{!collapsed && (
|
||||
<Link
|
||||
key={b.id}
|
||||
to={`/b/${b.slug}`}
|
||||
className="flex items-center justify-between px-3 py-2 rounded-lg text-sm mb-0.5"
|
||||
to="/"
|
||||
className="font-bold truncate flex items-center gap-2"
|
||||
style={{
|
||||
background: isBoardActive(b.slug) ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: isBoardActive(b.slug) ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
transition: 'all 200ms ease-out',
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--text)',
|
||||
fontSize: 'var(--text-lg)',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span>{b.name}</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded"
|
||||
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{b.postCount}
|
||||
</span>
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt={appName} style={{ height: 24, objectFit: 'contain' }} />
|
||||
) : (
|
||||
appName
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="mt-6 mb-2 px-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||
You
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/activity"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-0.5"
|
||||
style={{
|
||||
background: isActive('/activity') ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: isActive('/activity') ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
transition: 'all 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Activity
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/my-posts"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-0.5"
|
||||
style={{
|
||||
background: isActive('/my-posts') ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: isActive('/my-posts') ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
transition: 'all 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
My Posts
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/settings"
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm mb-0.5"
|
||||
style={{
|
||||
background: isActive('/settings') ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: isActive('/settings') ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
transition: 'all 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Settings
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Identity footer */}
|
||||
<div className="px-4 py-3 border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium"
|
||||
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||
)}
|
||||
{collapsed && (
|
||||
<Link
|
||||
to="/"
|
||||
className="font-bold flex items-center justify-center"
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--accent)',
|
||||
fontSize: 'var(--text-lg)',
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{auth.displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate" style={{ color: 'var(--text)' }}>
|
||||
{auth.displayName}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{auth.isPasskeyUser ? 'Passkey user' : 'Cookie identity'}
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt={appName} style={{ height: 20, objectFit: 'contain' }} />
|
||||
) : (
|
||||
appName.charAt(0)
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-11 h-11 rounded-lg flex items-center justify-center action-btn"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
transition: 'color var(--duration-fast) ease-out',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={isDark ? 'Light mode' : 'Dark mode'}
|
||||
>
|
||||
{isDark ? <IconSun size={16} stroke={2} /> : <IconMoon size={16} stroke={2} />}
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setBellOpen(!bellOpen)}
|
||||
className="w-11 h-11 rounded-lg flex items-center justify-center relative action-btn"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
aria-label={unreadCount > 0 ? `Notifications (${unreadCount} unread)` : 'Notifications'}
|
||||
aria-expanded={bellOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<IconBell size={16} stroke={2} />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: 2, right: 0,
|
||||
minWidth: 14, height: 14,
|
||||
borderRadius: 7,
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--bg)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
fontWeight: 700,
|
||||
padding: '0 3px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{bellOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setBellOpen(false)} />
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
className="absolute left-0 top-12 w-80 z-50 fade-in"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between"
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-xs)' }}>
|
||||
{t('notifications')}
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
className="action-btn"
|
||||
style={{ color: 'var(--accent)', fontSize: 'var(--text-xs)', padding: '4px 8px', borderRadius: 'var(--radius-sm)' }}
|
||||
onClick={() => {
|
||||
api.put('/notifications/read', {}).then(() => {
|
||||
setUnreadCount(0)
|
||||
setNotifications((n) => n.map((x) => ({ ...x, read: true })))
|
||||
}).catch(() => {})
|
||||
}}
|
||||
>
|
||||
{t('markAllRead')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto" aria-live="polite">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-4 text-center" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{t('noNotifications')}
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n) => (
|
||||
<Link
|
||||
key={n.id}
|
||||
to={n.post ? `/b/${n.post.board.slug}/post/${n.post.id}` : '/activity'}
|
||||
className="block nav-link"
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
borderLeft: n.read ? '3px solid transparent' : '3px solid var(--accent)',
|
||||
background: n.read ? 'transparent' : 'var(--accent-subtle)',
|
||||
}}
|
||||
onClick={() => setBellOpen(false)}
|
||||
>
|
||||
<div className="truncate" style={{ color: 'var(--text)', fontSize: 'var(--text-xs)', fontWeight: n.read ? 400 : 600 }}>
|
||||
{n.title}
|
||||
</div>
|
||||
<div className="truncate" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
|
||||
{n.body}
|
||||
</div>
|
||||
<time dateTime={n.createdAt} style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2, display: 'block' }}>
|
||||
{timeAgo(n.createdAt)}
|
||||
</time>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to="/activity"
|
||||
className="block text-center border-t nav-link"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--accent)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
padding: '8px',
|
||||
}}
|
||||
onClick={() => setBellOpen(false)}
|
||||
>
|
||||
{t('viewAllActivity')}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!auth.isPasskeyUser && (
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ padding: collapsed ? '8px 10px' : '8px 12px' }}>
|
||||
<Link
|
||||
to="/search"
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
padding: collapsed ? '8px' : '8px 12px',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
transition: 'border-color var(--duration-fast) ease-out',
|
||||
}}
|
||||
title={collapsed ? 'Search' : undefined}
|
||||
>
|
||||
<IconSearch size={15} stroke={2} />
|
||||
{!collapsed && <span>{t('searchPlaceholder')}</span>}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden" style={{ padding: collapsed ? '4px 8px 0' : '4px 12px 0' }}>
|
||||
{navItem('/', <IconHome size={18} stroke={2} aria-hidden="true" />, t('home'), isActive('/'))}
|
||||
{navItem('/activity', <IconActivity size={18} stroke={2} aria-hidden="true" />, t('activity'), isActive('/activity'))}
|
||||
|
||||
{!collapsed && (
|
||||
<div style={{ padding: '16px 12px 6px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em' }}>
|
||||
{t('boards')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collapsed && <div style={{ height: 12 }} />}
|
||||
|
||||
<nav className="flex-1 overflow-y-auto" style={{ paddingBottom: 4 }}>
|
||||
{boards.map((b) => {
|
||||
const active = isBoardActive(b.slug)
|
||||
const expanded = active && !collapsed
|
||||
const isBoardSubpage = active && (
|
||||
location.pathname === `/b/${b.slug}/roadmap` ||
|
||||
location.pathname === `/b/${b.slug}/changelog`
|
||||
)
|
||||
return (
|
||||
<div key={b.id} style={{ marginBottom: 2 }}>
|
||||
<Link
|
||||
to={`/b/${b.slug}`}
|
||||
className="flex items-center justify-between rounded-lg relative nav-link"
|
||||
style={{
|
||||
padding: collapsed ? '10px' : '8px 12px',
|
||||
justifyContent: collapsed ? 'center' : 'space-between',
|
||||
background: active ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: active ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
fontWeight: active ? 600 : 400,
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}
|
||||
title={collapsed ? `${b.name} (${b.postCount})` : undefined}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
className="absolute left-0 top-2 bottom-2 rounded-r"
|
||||
style={{ width: 3, background: 'var(--accent)' }}
|
||||
/>
|
||||
)}
|
||||
{collapsed ? (
|
||||
<BoardIcon name={b.name} iconName={b.iconName} iconColor={active ? undefined : b.iconColor} size={24} />
|
||||
) : (
|
||||
<>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
<BoardIcon name={b.name} iconName={b.iconName} iconColor={active ? undefined : b.iconColor} size={20} />
|
||||
<span className="truncate">{b.name}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="shrink-0"
|
||||
style={{
|
||||
padding: '1px 8px',
|
||||
background: 'var(--surface-hover)',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
aria-label={`${b.postCount} posts`}
|
||||
>
|
||||
{b.postCount}
|
||||
</span>
|
||||
{active && (
|
||||
<IconChevronDown
|
||||
size={12}
|
||||
stroke={2}
|
||||
style={{
|
||||
color: 'var(--text-tertiary)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
{expanded && (
|
||||
<div style={{ paddingLeft: 32, paddingTop: 2 }}>
|
||||
<Link
|
||||
to={`/b/${b.slug}`}
|
||||
className="flex items-center gap-2 rounded-md nav-link"
|
||||
style={{
|
||||
padding: '5px 10px',
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: !isBoardSubpage ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
fontWeight: !isBoardSubpage ? 500 : 400,
|
||||
transition: 'color var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
<IconFileText size={13} stroke={2} aria-hidden="true" />
|
||||
Posts
|
||||
</Link>
|
||||
<Link
|
||||
to={`/b/${b.slug}/roadmap`}
|
||||
className="flex items-center gap-2 rounded-md nav-link"
|
||||
style={{
|
||||
padding: '5px 10px',
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: location.pathname === `/b/${b.slug}/roadmap` ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
fontWeight: location.pathname === `/b/${b.slug}/roadmap` ? 500 : 400,
|
||||
transition: 'color var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
<IconLayoutKanban size={13} stroke={2} aria-hidden="true" />
|
||||
{t('roadmap')}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/b/${b.slug}/changelog`}
|
||||
className="flex items-center gap-2 rounded-md nav-link"
|
||||
style={{
|
||||
padding: '5px 10px',
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: location.pathname === `/b/${b.slug}/changelog` ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
fontWeight: location.pathname === `/b/${b.slug}/changelog` ? 500 : 400,
|
||||
transition: 'color var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
<IconNews size={13} stroke={2} aria-hidden="true" />
|
||||
{t('changelog')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* You section - pinned above profile footer */}
|
||||
<div className="border-t" style={{ borderColor: 'var(--border)', padding: collapsed ? '4px 8px' : '4px 12px' }}>
|
||||
{!collapsed && (
|
||||
<div style={{ padding: '8px 12px 6px', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', fontWeight: 500, letterSpacing: '0.03em' }}>
|
||||
{t('you')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collapsed && <div style={{ height: 4 }} />}
|
||||
|
||||
{admin.isAdmin
|
||||
? <>
|
||||
{navItem('/admin/team', <IconUser size={18} stroke={2} aria-hidden="true" />, t('profile'), isActive('/admin/team'))}
|
||||
{navItem('/admin/posts', <IconFileText size={18} stroke={2} aria-hidden="true" />, t('myPosts'), isActive('/admin/posts'))}
|
||||
{admin.isSuperAdmin && navItem('/admin/settings', <IconSettings size={18} stroke={2} aria-hidden="true" />, t('settings'), isActive('/admin/settings'))}
|
||||
</>
|
||||
: <>
|
||||
{navItem('/profile', <IconUser size={18} stroke={2} aria-hidden="true" />, t('profile'), isActive('/profile'))}
|
||||
{navItem('/my-posts', <IconFileText size={18} stroke={2} aria-hidden="true" />, t('myPosts'), isActive('/my-posts'))}
|
||||
{navItem('/settings', <IconSettings size={18} stroke={2} aria-hidden="true" />, t('settings'), isActive('/settings'))}
|
||||
</>
|
||||
}
|
||||
{navItem('/privacy', <IconShieldLock size={18} stroke={2} aria-hidden="true" />, t('privacy'), isActive('/privacy'))}
|
||||
</div>
|
||||
|
||||
{/* Profile footer */}
|
||||
<div
|
||||
className="border-t"
|
||||
style={{
|
||||
borderColor: admin.isAdmin ? 'rgba(6, 182, 212, 0.15)' : 'var(--border)',
|
||||
padding: collapsed ? '12px 8px' : '14px 16px',
|
||||
}}
|
||||
>
|
||||
{collapsed ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{admin.isAdmin ? (
|
||||
<div
|
||||
className="w-8 h-8 rounded flex items-center justify-center"
|
||||
style={{ background: 'var(--admin-subtle)', color: 'var(--admin-accent)', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
<IconShieldCheck size={16} stroke={2} />
|
||||
</div>
|
||||
) : auth.user?.id ? (
|
||||
<Avatar userId={auth.user.id} avatarUrl={auth.user.avatarUrl} size={32} />
|
||||
) : (
|
||||
<div
|
||||
className="w-8 h-8 rounded flex items-center justify-center"
|
||||
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
<IconCookie size={16} stroke={2} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-11 h-11 rounded-lg flex items-center justify-center action-btn"
|
||||
style={{ color: 'var(--text-secondary)', borderRadius: 'var(--radius-sm)' }}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{isDark ? <IconSun size={14} stroke={2} /> : <IconMoon size={14} stroke={2} />}
|
||||
</button>
|
||||
</div>
|
||||
) : admin.isAdmin ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-9 h-9 rounded flex items-center justify-center shrink-0"
|
||||
style={{ background: 'var(--admin-subtle)', color: 'var(--admin-accent)', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
<IconShieldCheck size={18} stroke={2} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate font-medium" style={{ color: 'var(--admin-accent)', fontSize: 'var(--text-sm)' }}>
|
||||
{admin.displayName || t('admin')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
|
||||
<IconShieldCheck size={11} stroke={2} aria-hidden="true" />
|
||||
<span>{admin.role === 'SUPER_ADMIN' ? 'Super Admin' : admin.role === 'MODERATOR' ? 'Moderator' : 'Admin'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
{auth.user?.id ? (
|
||||
<Avatar userId={auth.user.id} avatarUrl={auth.user.avatarUrl} size={36} />
|
||||
) : (
|
||||
<div
|
||||
className="w-9 h-9 rounded flex items-center justify-center shrink-0"
|
||||
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
<IconCookie size={18} stroke={2} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
{auth.displayName !== 'Anonymous'
|
||||
? auth.displayName
|
||||
: auth.isPasskeyUser && auth.user?.username
|
||||
? `@${auth.user.username}`
|
||||
: auth.user?.id
|
||||
? `Anonymous #${auth.user.id.slice(-4)}`
|
||||
: t('anonymous')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 2 }}>
|
||||
{auth.isPasskeyUser ? (
|
||||
<>
|
||||
<IconFingerprint size={11} stroke={2} aria-hidden="true" />
|
||||
<span>{t('passkeyUser')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCookie size={11} stroke={2} aria-hidden="true" />
|
||||
<span>{t('cookieIdentity')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && !admin.isAdmin && !auth.isPasskeyUser && (
|
||||
<Link
|
||||
to="/settings"
|
||||
className="block mt-2 text-xs text-center py-1.5 rounded-md"
|
||||
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||
className="block mt-3 text-center py-2 nav-link"
|
||||
style={{
|
||||
background: 'var(--accent-subtle)',
|
||||
color: 'var(--accent)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
fontWeight: 500,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
transition: 'opacity var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
Register passkey for persistence
|
||||
{t('saveIdentity')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<button
|
||||
onClick={toggleCollapse}
|
||||
className="absolute flex items-center justify-center action-btn"
|
||||
style={{
|
||||
top: '50%',
|
||||
right: -22,
|
||||
transform: 'translateY(-50%)',
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--text-tertiary)',
|
||||
cursor: 'pointer',
|
||||
zIndex: 30,
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
transition: 'color var(--duration-fast) ease-out',
|
||||
}}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? <IconChevronRight size={14} stroke={2} /> : <IconChevronLeft size={14} stroke={2} />}
|
||||
</button>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,64 @@
|
||||
const statusConfig: Record<string, { label: string; bg: string; color: string }> = {
|
||||
OPEN: { label: 'Open', bg: 'var(--accent-subtle)', color: 'var(--accent)' },
|
||||
UNDER_REVIEW: { label: 'Under Review', bg: 'var(--admin-subtle)', color: 'var(--admin-accent)' },
|
||||
PLANNED: { label: 'Planned', bg: 'rgba(59, 130, 246, 0.15)', color: 'var(--info)' },
|
||||
IN_PROGRESS: { label: 'In Progress', bg: 'rgba(234, 179, 8, 0.15)', color: 'var(--warning)' },
|
||||
DONE: { label: 'Done', bg: 'rgba(34, 197, 94, 0.15)', color: 'var(--success)' },
|
||||
DECLINED: { label: 'Declined', bg: 'rgba(239, 68, 68, 0.15)', color: 'var(--error)' },
|
||||
import { IconCircleDot, IconEye, IconCalendarEvent, IconLoader, IconCircleCheck, IconCircleX } from '@tabler/icons-react'
|
||||
import type { Icon } from '@tabler/icons-react'
|
||||
|
||||
export interface StatusConfig {
|
||||
status: string
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status }: { status: string }) {
|
||||
const cfg = statusConfig[status] || { label: status, bg: 'var(--border)', color: 'var(--text-secondary)' }
|
||||
const defaultConfig: Record<string, { label: string; colorVar: string; fallback: string; icon: Icon }> = {
|
||||
OPEN: { label: 'Open', colorVar: '--status-open', fallback: '#F59E0B', icon: IconCircleDot },
|
||||
UNDER_REVIEW: { label: 'Under Review', colorVar: '--status-review', fallback: '#06B6D4', icon: IconEye },
|
||||
PLANNED: { label: 'Planned', colorVar: '--status-planned', fallback: '#3B82F6', icon: IconCalendarEvent },
|
||||
IN_PROGRESS: { label: 'In Progress', colorVar: '--status-progress', fallback: '#EAB308', icon: IconLoader },
|
||||
DONE: { label: 'Done', colorVar: '--status-done', fallback: '#22C55E', icon: IconCircleCheck },
|
||||
DECLINED: { label: 'Declined', colorVar: '--status-declined', fallback: '#EF4444', icon: IconCircleX },
|
||||
}
|
||||
|
||||
const iconMap: Record<string, Icon> = {
|
||||
OPEN: IconCircleDot,
|
||||
UNDER_REVIEW: IconEye,
|
||||
PLANNED: IconCalendarEvent,
|
||||
IN_PROGRESS: IconLoader,
|
||||
DONE: IconCircleCheck,
|
||||
DECLINED: IconCircleX,
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status, customStatuses }: { status: string; customStatuses?: StatusConfig[] }) {
|
||||
let label: string
|
||||
let colorVar: string | null = null
|
||||
let fallbackColor: string
|
||||
let StatusIcon: Icon
|
||||
|
||||
const custom = customStatuses?.find((s) => s.status === status)
|
||||
if (custom) {
|
||||
label = custom.label
|
||||
fallbackColor = custom.color
|
||||
StatusIcon = iconMap[status] || IconCircleDot
|
||||
} else {
|
||||
const def = defaultConfig[status]
|
||||
label = def?.label || status
|
||||
colorVar = def?.colorVar || null
|
||||
fallbackColor = def?.fallback || '#64748B'
|
||||
StatusIcon = def?.icon || IconCircleDot
|
||||
}
|
||||
|
||||
const color = colorVar ? `var(${colorVar})` : fallbackColor
|
||||
const bgColor = colorVar ? `color-mix(in srgb, var(${colorVar}) 12%, transparent)` : `${fallbackColor}20`
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{ background: cfg.bg, color: cfg.color }}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 font-medium"
|
||||
style={{
|
||||
background: bgColor,
|
||||
color,
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{cfg.label}
|
||||
<StatusIcon size={12} stroke={2} aria-hidden="true" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import { IconSun, IconMoon } from '@tabler/icons-react'
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { resolved, toggle } = useTheme()
|
||||
@@ -7,30 +8,32 @@ export default function ThemeToggle() {
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="fixed bottom-6 right-6 w-11 h-11 rounded-full flex items-center justify-center z-40 md:bottom-6 md:right-6 bottom-20 shadow-lg"
|
||||
className="fixed z-40 rounded-full flex items-center justify-center md:hidden"
|
||||
style={{
|
||||
bottom: 80,
|
||||
right: 16,
|
||||
width: 44,
|
||||
height: 44,
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--accent)',
|
||||
transition: 'all 200ms ease-out',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform var(--duration-fast) var(--ease-out), background var(--duration-fast) ease-out',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.1)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.transform = 'scale(1.1)' }}
|
||||
onBlur={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transition: 'transform 300ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
transition: 'transform var(--duration-fast) var(--ease-spring)',
|
||||
transform: isDark ? 'rotate(0deg)' : 'rotate(180deg)',
|
||||
}}
|
||||
>
|
||||
{isDark ? (
|
||||
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
)}
|
||||
{isDark ? <IconSun size={18} stroke={2} /> : <IconMoon size={18} stroke={2} />}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -1,37 +1,84 @@
|
||||
import { useState } from 'react'
|
||||
import { IconTrendingUp, IconShieldCheck, IconMessageDots, IconEdit, IconTrash, IconArrowBackUp, IconLock, IconLockOpen } from '@tabler/icons-react'
|
||||
import Avatar from './Avatar'
|
||||
import Markdown from './Markdown'
|
||||
import MarkdownEditor from './MarkdownEditor'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
|
||||
interface ReplyTo {
|
||||
id: string
|
||||
body: string
|
||||
isAdmin: boolean
|
||||
authorName: string
|
||||
}
|
||||
|
||||
interface TimelineAttachment {
|
||||
id: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
interface TimelineEntry {
|
||||
id: string
|
||||
type: 'status_change' | 'admin_response' | 'comment'
|
||||
type: 'status_change' | 'comment'
|
||||
authorId?: string | null
|
||||
authorName: string
|
||||
authorAvatarUrl?: string | null
|
||||
content: string
|
||||
oldStatus?: string
|
||||
newStatus?: string
|
||||
reason?: string | null
|
||||
createdAt: string
|
||||
reactions?: { emoji: string; count: number; hasReacted: boolean }[]
|
||||
isAdmin?: boolean
|
||||
authorTitle?: string | null
|
||||
replyTo?: ReplyTo | null
|
||||
attachments?: TimelineAttachment[]
|
||||
editCount?: number
|
||||
isEditLocked?: boolean
|
||||
}
|
||||
|
||||
export type { TimelineEntry, ReplyTo }
|
||||
|
||||
export default function Timeline({
|
||||
entries,
|
||||
onReact,
|
||||
currentUserId,
|
||||
isCurrentAdmin,
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
onReply,
|
||||
onShowEditHistory,
|
||||
onLockComment,
|
||||
}: {
|
||||
entries: TimelineEntry[]
|
||||
onReact?: (entryId: string, emoji: string) => void
|
||||
currentUserId?: string
|
||||
isCurrentAdmin?: boolean
|
||||
onEditComment?: (entryId: string, body: string) => void
|
||||
onDeleteComment?: (entryId: string) => void
|
||||
onReply?: (entry: TimelineEntry) => void
|
||||
onShowEditHistory?: (entryId: string) => void
|
||||
onLockComment?: (entryId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Vertical line */}
|
||||
<div
|
||||
className="absolute left-4 top-0 bottom-0 w-px"
|
||||
style={{ background: 'var(--border)' }}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-0">
|
||||
{entries.map((entry) => (
|
||||
<TimelineItem key={entry.id} entry={entry} onReact={onReact} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{entries.map((entry, i) => (
|
||||
<TimelineItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onReact={onReact}
|
||||
isOwn={!!currentUserId && entry.type === 'comment' && entry.authorId === currentUserId}
|
||||
isCurrentAdmin={isCurrentAdmin}
|
||||
onEdit={onEditComment}
|
||||
onDelete={onDeleteComment}
|
||||
onReply={onReply}
|
||||
onShowEditHistory={onShowEditHistory}
|
||||
onLockComment={onLockComment}
|
||||
index={i}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -39,127 +86,421 @@ export default function Timeline({
|
||||
function TimelineItem({
|
||||
entry,
|
||||
onReact,
|
||||
isOwn,
|
||||
isCurrentAdmin,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
onShowEditHistory,
|
||||
onLockComment,
|
||||
index,
|
||||
}: {
|
||||
entry: TimelineEntry
|
||||
onReact?: (entryId: string, emoji: string) => void
|
||||
isOwn?: boolean
|
||||
isCurrentAdmin?: boolean
|
||||
onEdit?: (entryId: string, body: string) => void
|
||||
onDelete?: (entryId: string) => void
|
||||
onReply?: (entry: TimelineEntry) => void
|
||||
onShowEditHistory?: (entryId: string) => void
|
||||
onLockComment?: (entryId: string) => void
|
||||
index: number
|
||||
}) {
|
||||
const confirm = useConfirm()
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const quickEmojis = ['👍', '❤️', '🎉', '😄', '🤔', '👀']
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [editText, setEditText] = useState(entry.content)
|
||||
const quickEmojis = ['\uD83D\uDC4D', '\u2764\uFE0F', '\uD83C\uDF89', '\uD83D\uDE04', '\uD83D\uDE80']
|
||||
|
||||
const iconBg = entry.type === 'admin_response'
|
||||
? 'var(--admin-subtle)'
|
||||
: entry.type === 'status_change'
|
||||
? 'var(--accent-subtle)'
|
||||
: 'var(--border)'
|
||||
const isAdmin = entry.isAdmin === true
|
||||
const isStatusChange = entry.type === 'status_change'
|
||||
const canEdit = (isOwn || (isCurrentAdmin && isAdmin)) && !entry.isEditLocked
|
||||
const canDelete = isOwn || isCurrentAdmin
|
||||
|
||||
const iconColor = entry.type === 'admin_response'
|
||||
? 'var(--admin-accent)'
|
||||
: entry.type === 'status_change'
|
||||
? 'var(--accent)'
|
||||
: 'var(--text-tertiary)'
|
||||
|
||||
return (
|
||||
<div className="relative pl-10 pb-6">
|
||||
{/* Dot */}
|
||||
if (isStatusChange) {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-2 top-1 w-5 h-5 rounded-full flex items-center justify-center z-10"
|
||||
style={{ background: iconBg }}
|
||||
className="flex flex-col items-center py-2 stagger-in"
|
||||
style={{ '--stagger': index } as React.CSSProperties}
|
||||
>
|
||||
{entry.type === 'status_change' ? (
|
||||
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke={iconColor} strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
) : entry.type === 'admin_response' ? (
|
||||
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke={iconColor} strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="10" height="10" fill="none" viewBox="0 0 24 24" stroke={iconColor} strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 12h.01M12 12h.01M16 12h.01" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className="rounded-lg p-3"
|
||||
style={{
|
||||
background: entry.type === 'admin_response' ? 'var(--admin-subtle)' : 'var(--surface)',
|
||||
border: entry.type === 'admin_response' ? `1px solid rgba(6, 182, 212, 0.2)` : `1px solid var(--border)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium" style={{ color: entry.isAdmin ? 'var(--admin-accent)' : 'var(--text)' }}>
|
||||
{entry.authorName}
|
||||
{entry.isAdmin && (
|
||||
<span className="ml-1 px-1 py-0.5 rounded text-[10px]" style={{ background: 'var(--admin-subtle)', color: 'var(--admin-accent)' }}>
|
||||
admin
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center"
|
||||
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||
>
|
||||
<IconTrendingUp size={12} stroke={2.5} />
|
||||
</div>
|
||||
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
Status changed from <strong style={{ color: 'var(--text-secondary)' }}>{entry.oldStatus}</strong> to <strong style={{ color: 'var(--text-secondary)' }}>{entry.newStatus}</strong>
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<time dateTime={entry.createdAt} style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{entry.type === 'status_change' ? (
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Changed status from <strong>{entry.oldStatus}</strong> to <strong>{entry.newStatus}</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap" style={{ color: 'var(--text-secondary)' }}>
|
||||
{entry.content}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
{(entry.reactions?.length || entry.type === 'comment') && (
|
||||
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
|
||||
{entry.reactions?.map((r) => (
|
||||
<button
|
||||
key={r.emoji}
|
||||
onClick={() => onReact?.(entry.id, r.emoji)}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs"
|
||||
style={{
|
||||
background: r.hasReacted ? 'var(--accent-subtle)' : 'var(--border)',
|
||||
border: r.hasReacted ? '1px solid var(--accent)' : '1px solid transparent',
|
||||
color: 'var(--text-secondary)',
|
||||
transition: 'all 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
{r.emoji} {r.count}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowPicker(!showPicker)}
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
|
||||
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{showPicker && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-1 flex gap-1 p-1.5 rounded-lg z-20 fade-in"
|
||||
style={{ background: 'var(--surface)', border: '1px solid var(--border)', boxShadow: '0 4px 12px rgba(0,0,0,0.3)' }}
|
||||
>
|
||||
{quickEmojis.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
onClick={() => { onReact?.(entry.id, e); setShowPicker(false) }}
|
||||
className="w-7 h-7 rounded flex items-center justify-center hover:scale-110"
|
||||
style={{ transition: 'transform 200ms ease-out' }}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{entry.reason && (
|
||||
<div
|
||||
className="mt-1 px-3 py-1.5"
|
||||
style={{
|
||||
fontSize: 'var(--text-xs)',
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'var(--surface-hover)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
maxWidth: '80%',
|
||||
}}
|
||||
>
|
||||
Reason: {entry.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex stagger-in ${isAdmin ? 'justify-end' : 'justify-start'}`}
|
||||
style={{ '--stagger': index, maxWidth: '85%', marginLeft: isAdmin ? 'auto' : 0 } as React.CSSProperties}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => { setHovered(false); setShowPicker(false) }}
|
||||
onFocusCapture={() => setFocused(true)}
|
||||
onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) { setFocused(false); setShowPicker(false) } }}
|
||||
>
|
||||
<div className="flex-1">
|
||||
{/* Bubble */}
|
||||
<div
|
||||
className="p-4"
|
||||
style={{
|
||||
background: isAdmin ? 'var(--admin-subtle)' : 'var(--surface)',
|
||||
border: isAdmin ? '1px solid rgba(6, 182, 212, 0.15)' : '1px solid var(--border)',
|
||||
borderRadius: isAdmin
|
||||
? 'var(--radius-lg) var(--radius-lg) var(--radius-sm) var(--radius-lg)'
|
||||
: 'var(--radius-lg) var(--radius-lg) var(--radius-lg) var(--radius-sm)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Avatar
|
||||
userId={entry.authorId ?? '0000'}
|
||||
name={isAdmin ? null : entry.authorName}
|
||||
avatarUrl={entry.authorAvatarUrl}
|
||||
size={22}
|
||||
/>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
color: isAdmin ? 'var(--admin-accent)' : 'var(--text)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
}}
|
||||
>
|
||||
{entry.authorName}
|
||||
{entry.authorTitle && (
|
||||
<span style={{ color: 'var(--text-tertiary)', fontWeight: 400 }}> - {entry.authorTitle}</span>
|
||||
)}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded font-medium"
|
||||
style={{
|
||||
background: 'var(--admin-subtle)',
|
||||
color: 'var(--admin-accent)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
Official
|
||||
</span>
|
||||
)}
|
||||
<time dateTime={entry.createdAt} style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</time>
|
||||
{entry.isEditLocked && (
|
||||
<span title="Editing locked" style={{ color: 'var(--error)', display: 'inline-flex' }}>
|
||||
<IconLock size={11} stroke={2} aria-label="Editing locked" />
|
||||
</span>
|
||||
)}
|
||||
{entry.editCount != null && entry.editCount > 0 && (
|
||||
<button
|
||||
onClick={() => onShowEditHistory?.(entry.id)}
|
||||
style={{
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
fontStyle: 'italic',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '2px 4px',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
textDecorationStyle: 'dotted' as const,
|
||||
textUnderlineOffset: '2px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
transition: 'color var(--duration-fast) ease-out, background var(--duration-fast) ease-out',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)'; e.currentTarget.style.background = 'var(--accent-subtle)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)'; e.currentTarget.style.background = 'none' }}
|
||||
onFocus={(e) => { e.currentTarget.style.color = 'var(--accent)'; e.currentTarget.style.background = 'var(--accent-subtle)' }}
|
||||
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)'; e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
(edited)
|
||||
</button>
|
||||
)}
|
||||
{!editMode && (
|
||||
<div
|
||||
className="ml-auto flex items-center gap-1"
|
||||
style={{
|
||||
opacity: hovered || focused ? 1 : 0,
|
||||
transition: 'opacity var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
{onReply && (
|
||||
<button
|
||||
onClick={() => onReply(entry)}
|
||||
className="inline-flex items-center gap-1 px-2 rounded action-btn"
|
||||
style={{
|
||||
minHeight: 44,
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
transition: 'color var(--duration-fast) ease-out',
|
||||
background: 'var(--surface-hover)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.color = 'var(--accent)' }}
|
||||
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
|
||||
>
|
||||
<IconArrowBackUp size={11} stroke={2} aria-hidden="true" /> Reply
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => { setEditText(entry.content); setEditMode(true) }}
|
||||
className="inline-flex items-center gap-1 px-2 rounded action-btn"
|
||||
style={{
|
||||
minHeight: 44,
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
transition: 'color var(--duration-fast) ease-out',
|
||||
background: 'var(--surface-hover)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.color = 'var(--accent)' }}
|
||||
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
|
||||
>
|
||||
<IconEdit size={11} stroke={2} aria-hidden="true" /> Edit
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={async () => { if (await confirm('Delete this comment?')) onDelete?.(entry.id) }}
|
||||
className="inline-flex items-center gap-1 px-2 rounded action-btn"
|
||||
style={{
|
||||
minHeight: 44,
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
transition: 'color var(--duration-fast) ease-out',
|
||||
background: 'var(--surface-hover)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--error)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.color = 'var(--error)' }}
|
||||
onBlur={(e) => { e.currentTarget.style.color = 'var(--text-tertiary)' }}
|
||||
>
|
||||
<IconTrash size={11} stroke={2} aria-hidden="true" /> Delete
|
||||
</button>
|
||||
)}
|
||||
{onLockComment && isCurrentAdmin && (
|
||||
<button
|
||||
onClick={() => onLockComment(entry.id)}
|
||||
className="inline-flex items-center gap-1 px-2 rounded action-btn"
|
||||
style={{
|
||||
minHeight: 44,
|
||||
color: entry.isEditLocked ? 'var(--error)' : 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
transition: 'color var(--duration-fast) ease-out',
|
||||
background: entry.isEditLocked ? 'rgba(239, 68, 68, 0.1)' : 'var(--surface-hover)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
>
|
||||
{entry.isEditLocked
|
||||
? <><IconLock size={11} stroke={2} aria-hidden="true" /> Unlock</>
|
||||
: <><IconLockOpen size={11} stroke={2} aria-hidden="true" /> Lock</>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quoted message */}
|
||||
{entry.replyTo && (
|
||||
<div
|
||||
className="mb-3 px-3 py-2"
|
||||
style={{
|
||||
borderLeft: entry.replyTo.isAdmin
|
||||
? '2px solid var(--admin-accent)'
|
||||
: '2px solid var(--border-hover)',
|
||||
background: 'var(--bg)',
|
||||
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
|
||||
fontSize: 'var(--text-xs)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: entry.replyTo.isAdmin ? 'var(--admin-accent)' : 'var(--text-secondary)' }}
|
||||
>
|
||||
{entry.replyTo.authorName}
|
||||
</span>
|
||||
<p style={{ color: 'var(--text-tertiary)', marginTop: 2, lineHeight: 1.4 }}>
|
||||
{entry.replyTo.body.length > 150 ? entry.replyTo.body.slice(0, 150) + '...' : entry.replyTo.body}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content or edit form */}
|
||||
{editMode ? (
|
||||
<div>
|
||||
<MarkdownEditor
|
||||
value={editText}
|
||||
onChange={setEditText}
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => { onEdit?.(entry.id, editText); setEditMode(false) }}
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: 'var(--text-xs)', padding: '6px 12px' }}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditMode(false)}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 'var(--text-xs)', padding: '6px 12px' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Markdown>{entry.content}</Markdown>
|
||||
)}
|
||||
|
||||
{/* Comment attachments */}
|
||||
{entry.attachments && entry.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{entry.attachments.map((att) => (
|
||||
<a
|
||||
key={att.id}
|
||||
href={`/api/v1/attachments/${att.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--border)',
|
||||
transition: 'border-color var(--duration-fast) ease-out',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'var(--accent)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--accent)' }}
|
||||
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border)' }}
|
||||
>
|
||||
<img
|
||||
src={`/api/v1/attachments/${att.id}`}
|
||||
alt={att.filename}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
{!editMode && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 mt-3 flex-wrap"
|
||||
style={{
|
||||
opacity: hovered || focused || (entry.reactions && entry.reactions.length > 0) ? 1 : 0,
|
||||
transition: 'opacity var(--duration-normal) ease-out',
|
||||
}}
|
||||
>
|
||||
{entry.reactions?.map((r) => (
|
||||
<button
|
||||
key={r.emoji}
|
||||
onClick={() => onReact?.(entry.id, r.emoji)}
|
||||
className="inline-flex items-center gap-1 px-2.5 rounded-full"
|
||||
style={{
|
||||
minHeight: 44,
|
||||
fontSize: 'var(--text-xs)',
|
||||
background: r.hasReacted ? 'var(--accent-subtle)' : 'var(--surface-hover)',
|
||||
border: r.hasReacted ? '1px solid var(--border-accent)' : '1px solid transparent',
|
||||
color: 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
}}
|
||||
>
|
||||
{r.emoji} {r.count}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowPicker(!showPicker)}
|
||||
className="w-11 h-11 rounded-full flex items-center justify-center action-btn"
|
||||
style={{
|
||||
background: 'var(--surface-hover)',
|
||||
color: 'var(--text-tertiary)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
transition: 'all var(--duration-fast) ease-out',
|
||||
}}
|
||||
aria-label="Add reaction"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{showPicker && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-2 flex gap-1 p-2 z-20 fade-in"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
}}
|
||||
>
|
||||
{quickEmojis.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
onClick={() => { onReact?.(entry.id, e); setShowPicker(false) }}
|
||||
className="w-11 h-11 rounded flex items-center justify-center"
|
||||
style={{
|
||||
transition: 'transform var(--duration-fast) var(--ease-spring)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||
onFocus={(e) => { e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onBlur={(e) => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,42 +14,52 @@ export default function VoteBudget({ used, total, resetsAt }: Props) {
|
||||
<div className="relative inline-flex items-center gap-1">
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-help"
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-label={`Vote budget: ${used} of ${total} used, ${remaining} remaining`}
|
||||
onMouseEnter={() => setShowTip(true)}
|
||||
onMouseLeave={() => setShowTip(false)}
|
||||
onFocus={() => setShowTip(true)}
|
||||
onBlur={() => setShowTip(false)}
|
||||
>
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-2 h-2 rounded-full"
|
||||
className="rounded-full"
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
background: i < used ? 'var(--accent)' : 'var(--border-hover)',
|
||||
transition: 'background 200ms ease-out',
|
||||
transition: 'background var(--duration-fast) ease-out',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="text-xs ml-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span className="ml-1" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
|
||||
{remaining} left
|
||||
</span>
|
||||
|
||||
{showTip && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 mb-2 px-3 py-2 rounded-lg text-xs whitespace-nowrap z-50 fade-in"
|
||||
role="tooltip"
|
||||
className="absolute bottom-full left-0 mb-2 px-3 py-2 whitespace-nowrap z-50 fade-in"
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--text-secondary)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||
fontSize: 'var(--text-xs)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
}}
|
||||
>
|
||||
<div className="font-medium mb-1" style={{ color: 'var(--text)' }}>
|
||||
<div className="font-medium mb-1" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
|
||||
Vote Budget
|
||||
</div>
|
||||
<div>{used} of {total} votes used</div>
|
||||
{resetsAt && (
|
||||
<div style={{ color: 'var(--text-tertiary)' }}>
|
||||
Resets {new Date(resetsAt).toLocaleDateString()}
|
||||
Resets <time dateTime={resetsAt}>{new Date(resetsAt).toLocaleDateString()}</time>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
73
packages/web/src/hooks/useAdmin.ts
Normal file
73
packages/web/src/hooks/useAdmin.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
interface AdminState {
|
||||
isAdmin: boolean
|
||||
loading: boolean
|
||||
role: string | null
|
||||
displayName: string | null
|
||||
teamTitle: string | null
|
||||
isSuperAdmin: boolean
|
||||
canInvite: boolean
|
||||
canAccessSettings: boolean
|
||||
refresh: () => Promise<void>
|
||||
exitAdminMode: () => Promise<void>
|
||||
}
|
||||
|
||||
const AdminContext = createContext<AdminState>({
|
||||
isAdmin: false,
|
||||
loading: true,
|
||||
role: null,
|
||||
displayName: null,
|
||||
teamTitle: null,
|
||||
isSuperAdmin: false,
|
||||
canInvite: false,
|
||||
canAccessSettings: false,
|
||||
refresh: async () => {},
|
||||
exitAdminMode: async () => {},
|
||||
})
|
||||
|
||||
export const AdminProvider = AdminContext.Provider
|
||||
|
||||
export function useAdminState(): AdminState {
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [role, setRole] = useState<string | null>(null)
|
||||
const [displayName, setDisplayName] = useState<string | null>(null)
|
||||
const [teamTitle, setTeamTitle] = useState<string | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<{ isAdmin: boolean; role?: string; displayName?: string | null; teamTitle?: string | null }>('/admin/me')
|
||||
setIsAdmin(res.isAdmin)
|
||||
setRole(res.role ?? null)
|
||||
setDisplayName(res.displayName ?? null)
|
||||
setTeamTitle(res.teamTitle ?? null)
|
||||
} catch {
|
||||
setIsAdmin(false)
|
||||
setRole(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const exitAdminMode = useCallback(async () => {
|
||||
try {
|
||||
await api.post('/admin/exit')
|
||||
} catch {}
|
||||
setIsAdmin(false)
|
||||
setRole(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
const isSuperAdmin = role === 'SUPER_ADMIN'
|
||||
const canInvite = role === 'SUPER_ADMIN' || role === 'ADMIN'
|
||||
const canAccessSettings = role === 'SUPER_ADMIN'
|
||||
|
||||
return { isAdmin, loading, role, displayName, teamTitle, isSuperAdmin, canInvite, canAccessSettings, refresh, exitAdminMode }
|
||||
}
|
||||
|
||||
export function useAdmin(): AdminState {
|
||||
return useContext(AdminContext)
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { createContext, useContext, useState, useCallback, useEffect } from 'react'
|
||||
import { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
displayName: string
|
||||
username?: string
|
||||
isPasskeyUser: boolean
|
||||
avatarUrl?: string | null
|
||||
darkMode?: string
|
||||
hasRecoveryCode?: boolean
|
||||
recoveryCodeExpiresAt?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -15,8 +20,8 @@ interface AuthState {
|
||||
isPasskeyUser: boolean
|
||||
displayName: string
|
||||
initIdentity: () => Promise<void>
|
||||
updateProfile: (data: { displayName: string }) => Promise<void>
|
||||
deleteIdentity: () => Promise<void>
|
||||
updateProfile: (data: { displayName: string; altcha?: string }) => Promise<void>
|
||||
deleteIdentity: (altcha: string) => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -28,12 +33,23 @@ export function useAuthState(): AuthState {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const creating = useRef(false)
|
||||
|
||||
const fetchMe = useCallback(async () => {
|
||||
try {
|
||||
const u = await api.get<User>('/me')
|
||||
setUser(u)
|
||||
} catch {
|
||||
setUser(null)
|
||||
if (creating.current) return
|
||||
creating.current = true
|
||||
try {
|
||||
const res = await api.post<User>('/identity', {})
|
||||
setUser(res)
|
||||
} catch {
|
||||
setUser(null)
|
||||
} finally {
|
||||
creating.current = false
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -41,20 +57,20 @@ export function useAuthState(): AuthState {
|
||||
|
||||
const initIdentity = useCallback(async () => {
|
||||
try {
|
||||
const u = await api.post<User>('/identity')
|
||||
setUser(u)
|
||||
const res = await api.post<User>('/identity')
|
||||
setUser(res)
|
||||
} catch {
|
||||
await fetchMe()
|
||||
}
|
||||
}, [fetchMe])
|
||||
|
||||
const updateProfile = useCallback(async (data: { displayName: string }) => {
|
||||
const updateProfile = useCallback(async (data: { displayName: string; altcha?: string }) => {
|
||||
const u = await api.put<User>('/me', data)
|
||||
setUser(u)
|
||||
}, [])
|
||||
|
||||
const deleteIdentity = useCallback(async () => {
|
||||
await api.delete('/me')
|
||||
const deleteIdentity = useCallback(async (altcha: string) => {
|
||||
await api.delete('/me', { altcha })
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user