Compare commits
1 Commits
docs/updat
...
fix/multip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
175999c88c |
@@ -1,62 +1,18 @@
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Docker
|
||||
docker-compose*.yml
|
||||
docker-compose.override.yml
|
||||
|
||||
Dockerfile
|
||||
./**/*/Dockerfile
|
||||
|
||||
.dockerignore
|
||||
|
||||
# Dependencies (rebuilt in Docker)
|
||||
node_modules
|
||||
**/node_modules
|
||||
./**/*/node_modules
|
||||
|
||||
# Build outputs (rebuilt in Docker)
|
||||
**/.next
|
||||
**/dist
|
||||
**/out
|
||||
**/.turbo
|
||||
**/.cache
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.husky
|
||||
.changeset
|
||||
|
||||
# Documentation (not needed in build)
|
||||
*.md
|
||||
!packages/db/migrations/**
|
||||
LICENSE
|
||||
CONTRIBUTING.md
|
||||
AGENTS.md
|
||||
CHANGELOG.md
|
||||
|
||||
# Test files
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
**/__tests__
|
||||
**/coverage
|
||||
|
||||
# Logs
|
||||
pnpm-debug.log
|
||||
**/pnpm-debug.log
|
||||
./**/*/pnpm-debug.log
|
||||
|
||||
# Cloud compose (separate deployment)
|
||||
cloud/
|
||||
README.md
|
||||
.next
|
||||
# .git
|
||||
50
.github/workflows/docker-publish.yml
vendored
50
.github/workflows/docker-publish.yml
vendored
@@ -9,30 +9,20 @@ on:
|
||||
schedule:
|
||||
- cron: "21 21 * * *"
|
||||
push:
|
||||
# Only trigger on tags (releases), not main branch pushes
|
||||
# Main branch builds are handled by workflow_run after Translate completes
|
||||
branches: ["main"]
|
||||
# Publish semver tags as releases.
|
||||
tags: ["v*.*.*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
workflow_run:
|
||||
workflows: ["Translate"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
# Docker builds after Translate completes (success or failure)
|
||||
# This ensures Docker always has the latest translations
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
|
||||
|
||||
jobs:
|
||||
build:
|
||||
concurrency:
|
||||
group: docker-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -86,19 +76,6 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Extract Docker metadata (migrate)
|
||||
id: meta-migrate
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.MIGRATE_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
# Extract version from git tag or ref
|
||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||
- name: Extract version
|
||||
@@ -152,22 +129,6 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push migrate Docker image
|
||||
id: build-and-push-migrate
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
with:
|
||||
context: .
|
||||
file: apps/web/Dockerfile
|
||||
target: migrate
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta-migrate.outputs.tags }}
|
||||
labels: ${{ steps.meta-migrate.outputs.labels }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
@@ -182,10 +143,3 @@ jobs:
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
- name: Sign the published migrate Docker image
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
TAGS: ${{ steps.meta-migrate.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push-migrate.outputs.digest }}
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
56
.github/workflows/translate.yml
vendored
56
.github/workflows/translate.yml
vendored
@@ -1,56 +0,0 @@
|
||||
name: Translate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --filter @kan/web...
|
||||
|
||||
- name: Extract translations
|
||||
working-directory: apps/web
|
||||
run: pnpm lingui:extract
|
||||
|
||||
- name: Lingo.dev
|
||||
uses: lingodotdev/lingo.dev@main
|
||||
with:
|
||||
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
|
||||
commit-message: "chore: update translations"
|
||||
working-directory: apps/web
|
||||
commit-author-name: "${{ github.actor }}"
|
||||
commit-author-email: "${{ github.actor }}@users.noreply.github.com"
|
||||
parallel: true
|
||||
|
||||
- name: Compile translations
|
||||
working-directory: apps/web
|
||||
run: pnpm lingui:compile
|
||||
|
||||
- name: Commit compiled translations
|
||||
run: |
|
||||
git config --local user.email "${{ github.actor }}@users.noreply.github.com"
|
||||
git config --local user.name "${{ github.actor }}"
|
||||
if ls apps/web/src/locales/*/messages.ts 1> /dev/null 2>&1; then
|
||||
git add apps/web/src/locales/*/messages.ts
|
||||
git diff --staged --quiet || (git commit -m "chore: compile translations" && git push)
|
||||
fi
|
||||
56
README.md
56
README.md
@@ -57,61 +57,39 @@ The easiest way to deploy Kan is through Railway. We've partnered with Railway t
|
||||
|
||||
### Docker Compose
|
||||
|
||||
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database and automatically run migrations.
|
||||
Alternatively, you can self-host Kan with Docker Compose. This will set up everything for you including your postgres database.
|
||||
|
||||
1. Create a `.env` file with your environment variables (see [Environment Variables](#environment-variables-) section below)
|
||||
|
||||
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
|
||||
1. Create a new file called `docker-compose.yml` and paste the following configuration:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
migrate:
|
||||
image: ghcr.io/kanbn/kan-migrate:latest
|
||||
container_name: kan-migrate
|
||||
networks:
|
||||
- kan-network
|
||||
environment:
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
container_name: kan-web
|
||||
ports:
|
||||
- "${WEB_PORT:-3000}:3000"
|
||||
- "3000:3000"
|
||||
networks:
|
||||
- kan-network
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true
|
||||
NEXT_PUBLIC_BASE_URL: http://localhost:3000
|
||||
BETTER_AUTH_SECRET: your_auth_secret
|
||||
POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: true
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: kan-db
|
||||
environment:
|
||||
- POSTGRES_DB=kan_db
|
||||
- POSTGRES_USER=kan
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: kan_db
|
||||
POSTGRES_USER: kan
|
||||
POSTGRES_PASSWORD: your_postgres_password
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- kan_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- kan-network
|
||||
@@ -123,23 +101,23 @@ volumes:
|
||||
kan_postgres_data:
|
||||
```
|
||||
|
||||
3. Start the containers in detached mode:
|
||||
2. Start the containers in detached mode:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The `migrate` service will automatically run database migrations before the web service starts. The application will be available at http://localhost:3000 (or the port specified in `WEB_PORT`).
|
||||
3. Access Kan at http://localhost:3000
|
||||
|
||||
**Managing containers:**
|
||||
The application will be running in the background. You can manage the containers using these commands:
|
||||
|
||||
- To stop the containers: `docker compose down`
|
||||
- To view logs: `docker compose logs -f`
|
||||
- To view logs for a specific service: `docker compose logs -f web` or `docker compose logs -f migrate`
|
||||
- To restart the containers: `docker compose restart`
|
||||
- To rebuild after code changes: `docker compose up -d --build`
|
||||
|
||||
For the complete Docker Compose configuration with all optional features, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
||||
For the complete Docker Compose configuration, see [docker-compose.yml](./docker-compose.yml) in the repository.
|
||||
|
||||
> **Note**: The Docker Compose configuration shown above is a minimal example. For a complete setup with all features (email, OAuth, file uploads, etc.), you'll need to create a `.env` file with the required environment variables. See the Environment Variables section below for the full list of available options.
|
||||
|
||||
## Local Development 🧑💻
|
||||
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG NODE_VERSION=20
|
||||
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
|
||||
ARG APP_DIRNAME=web
|
||||
ARG PROJECT=@kan/web
|
||||
|
||||
# ============================================
|
||||
# Stage 1: Alpine base with pnpm and turbo
|
||||
# ============================================
|
||||
# 1. Alpine image
|
||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||
RUN apk update && \
|
||||
apk add --no-cache libc6-compat && \
|
||||
rm -rf /var/cache/apk/* /tmp/* || true && \
|
||||
corepack enable && \
|
||||
npm install turbo@2.3.1 --global && \
|
||||
pnpm config set store-dir ~/.pnpm-store
|
||||
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
|
||||
rm -rf /var/cache/apk/* /tmp/* || true
|
||||
|
||||
# ============================================
|
||||
# Stage 2: Prune the monorepo
|
||||
# ============================================
|
||||
FROM alpine AS pruner
|
||||
# Setup pnpm and turbo on the alpine base
|
||||
FROM alpine AS base
|
||||
RUN corepack enable
|
||||
# Replace <your-major-version> with the major version installed in your repository. For example:
|
||||
# RUN npm install turbo@2.1.3 --global
|
||||
RUN npm install turbo@2.3.1 --global
|
||||
|
||||
RUN pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
# 2. Prune projects
|
||||
FROM base AS pruner
|
||||
ARG PROJECT
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
@@ -33,89 +35,53 @@ RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null ||
|
||||
echo "unknown") && \
|
||||
echo "$AUTO_VERSION" > /app/AUTO_VERSION
|
||||
|
||||
RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
|
||||
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker
|
||||
|
||||
# 3. Build the project
|
||||
FROM base AS builder
|
||||
ARG PROJECT
|
||||
ARG APP_VERSION
|
||||
|
||||
# ============================================
|
||||
# Stage 3: Install dependencies
|
||||
# ============================================
|
||||
FROM alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
|
||||
ENV CI=true
|
||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||
|
||||
# ============================================
|
||||
# Stage 4: Build the web application
|
||||
# ============================================
|
||||
FROM alpine AS builder
|
||||
ARG APP_VERSION
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/ ./
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
|
||||
|
||||
# Force standalone output for production Docker image
|
||||
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true
|
||||
ENV CI=true
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
# Use provided APP_VERSION or auto-generated from pruner stage
|
||||
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
|
||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
||||
|
||||
# ============================================
|
||||
# Stage 5: Migration image (run-once container)
|
||||
# ============================================
|
||||
FROM node:${NODE_VERSION}-alpine AS migrate
|
||||
WORKDIR /db
|
||||
# # Copy static files to standalone directory
|
||||
# RUN mkdir -p apps/web/.next/standalone/.next && \
|
||||
# mv apps/web/public apps/web/.next/standalone/ && \
|
||||
# mv apps/web/.next/static apps/web/.next/standalone/.next/
|
||||
|
||||
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
|
||||
COPY packages/db/migrations/ ./migrations/
|
||||
# 4. Final image - runner stage to run the application
|
||||
FROM base AS runner
|
||||
ARG APP_DIRNAME
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
RUN npm init -y && \
|
||||
npm install drizzle-kit drizzle-orm pg --save-exact && \
|
||||
# Strip unnecessary files from node_modules
|
||||
find node_modules -type f \( \
|
||||
-name '*.d.ts' -o \
|
||||
-name '*.d.mts' -o \
|
||||
-name '*.d.cts' -o \
|
||||
-name '*.map' -o \
|
||||
-name '*.md' -o \
|
||||
-name '*.txt' -o \
|
||||
-name 'LICENSE*' -o \
|
||||
-name 'CHANGELOG*' -o \
|
||||
-name 'README*' -o \
|
||||
-name '.eslint*' -o \
|
||||
-name '.prettier*' -o \
|
||||
-name 'tsconfig*.json' \
|
||||
\) -delete && \
|
||||
find node_modules -type d -empty -delete && \
|
||||
rm -rf /root/.npm /tmp/*
|
||||
|
||||
CMD ["npx", "drizzle-kit", "migrate"]
|
||||
|
||||
# ============================================
|
||||
# Stage 6: Production web image (distroless)
|
||||
# ============================================
|
||||
FROM ${DISTROLESS_NODE_IMAGE} AS web
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Copy the standalone Next.js server
|
||||
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
||||
# Copy static assets and public files
|
||||
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/ ./
|
||||
WORKDIR /app/apps/${APP_DIRNAME}
|
||||
|
||||
# Copy bootstrap script for runtime env var injection
|
||||
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||
ARG PORT=3000
|
||||
ENV PORT=${PORT}
|
||||
EXPOSE ${PORT}
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["bootstrap.cjs"]
|
||||
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Bootstrap script for the distroless production image.
|
||||
*
|
||||
* Distroless images have no shell, so this Node.js script handles two tasks
|
||||
* that would normally be done in an entrypoint.sh:
|
||||
*
|
||||
* 1. Regenerate `public/__ENV.js` with the current runtime NEXT_PUBLIC_*
|
||||
* environment variables. The file was originally created at build time by
|
||||
* next-runtime-env's `configureRuntimeEnv()`, but in a Docker deployment the
|
||||
* env vars are provided at *run* time via docker-compose / docker run.
|
||||
*
|
||||
* 2. Start the Next.js standalone server.
|
||||
*/
|
||||
|
||||
const { writeFileSync, existsSync, mkdirSync } = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Inject runtime NEXT_PUBLIC_* env vars into __ENV.js
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const publicDir = path.join(__dirname, "apps", "web", "public");
|
||||
|
||||
if (!existsSync(publicDir)) {
|
||||
mkdirSync(publicDir, { recursive: true });
|
||||
}
|
||||
|
||||
const envVars = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (key.startsWith("NEXT_PUBLIC_")) {
|
||||
envVars[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
path.join(publicDir, "__ENV.js"),
|
||||
`self.__ENV = ${JSON.stringify(envVars)};`,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Start the Next.js standalone server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
require("./apps/web/server.js");
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": "1.10",
|
||||
"version": 0,
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
|
||||
},
|
||||
"buckets": {
|
||||
"json": {
|
||||
"include": ["src/locales/[locale]/messages.json"]
|
||||
"po": {
|
||||
"include": ["src/locales/[locale]/messages.po"]
|
||||
}
|
||||
},
|
||||
"$schema": "https://lingo.dev/schema/i18n.json"
|
||||
|
||||
3291
apps/web/i18n.lock
3291
apps/web/i18n.lock
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,7 @@
|
||||
import { defineConfig } from "@lingui/cli";
|
||||
import { formatter } from "@lingui/format-json";
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
|
||||
const config: LinguiConfig = {
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
|
||||
sourceLocale: "en",
|
||||
catalogs: [
|
||||
{
|
||||
@@ -12,5 +10,6 @@ export default defineConfig({
|
||||
exclude: ["**/node_modules/**"],
|
||||
},
|
||||
],
|
||||
format: formatter({ style: "lingui" }),
|
||||
});
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -16,18 +16,6 @@ const config = {
|
||||
: undefined,
|
||||
reactStrictMode: true,
|
||||
|
||||
/** Exclude build tools and dev-only packages from the standalone output */
|
||||
outputFileTracingExcludes: {
|
||||
"**/*": [
|
||||
"@esbuild/**",
|
||||
"esbuild/**",
|
||||
"typescript/**",
|
||||
"webpack/**",
|
||||
"uglify-js/**",
|
||||
"terser/**",
|
||||
],
|
||||
},
|
||||
|
||||
/** Enables hot reloading for local packages without a build step */
|
||||
transpilePackages: [
|
||||
"@kan/api",
|
||||
@@ -47,13 +35,49 @@ const config = {
|
||||
remotePatterns: (() => {
|
||||
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
||||
const patterns = [
|
||||
{ protocol: "https", hostname: "**" },
|
||||
{
|
||||
protocol: "https",
|
||||
hostname:
|
||||
env("S3_FORCE_PATH_STYLE") === "true"
|
||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.discordapp.com',
|
||||
},
|
||||
];
|
||||
|
||||
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
||||
const s3Endpoint = env("S3_ENDPOINT");
|
||||
if (s3Endpoint) {
|
||||
try {
|
||||
const url = new URL(s3Endpoint);
|
||||
const hostname = url.hostname;
|
||||
const protocol = url.protocol.replace(":", "");
|
||||
|
||||
// Extract root domain (last 2 parts: e.g. cloudflarestorage.com)
|
||||
const parts = hostname.split(".");
|
||||
if (parts.length >= 2) {
|
||||
const rootDomain = parts.slice(-2).join(".");
|
||||
patterns.push({
|
||||
protocol: protocol === "http" ? "http" : "https",
|
||||
hostname: `*.${rootDomain}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// If S3_ENDPOINT is not a valid URL, ignore it
|
||||
}
|
||||
}
|
||||
|
||||
return patterns;
|
||||
})(),
|
||||
},
|
||||
|
||||
@@ -79,7 +79,6 @@
|
||||
"@kan/tailwind-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"@lingui/cli": "^5.3.2",
|
||||
"@lingui/format-json": "^5.9.1",
|
||||
"@lingui/loader": "^5.3.2",
|
||||
"@lingui/swc-plugin": "^5.5.2",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
|
||||
@@ -387,54 +387,46 @@ export interface SlashNodeAttrs {
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => {
|
||||
const headingCommands: SlashCommandItem[] = disableHeadings
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: "Heading 1",
|
||||
icon: <HiH1 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
icon: <HiH2 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
icon: <HiH3 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||
},
|
||||
];
|
||||
|
||||
return [
|
||||
...headingCommands,
|
||||
{
|
||||
title: "Bullet List",
|
||||
icon: <HiOutlineListBullet />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
title: "Ordered List",
|
||||
icon: <HiOutlineNumberedList />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
title: "Blockquote",
|
||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
];
|
||||
};
|
||||
const CommandItems: SlashCommandItem[] = [
|
||||
{
|
||||
title: "Heading 1",
|
||||
icon: <HiH1 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
icon: <HiH2 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
icon: <HiH3 />,
|
||||
command: ({ editor }) =>
|
||||
editor.chain().focus().setHeading({ level: 3 }).run(),
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
icon: <HiOutlineListBullet />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
title: "Ordered List",
|
||||
icon: <HiOutlineNumberedList />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
title: "Blockquote",
|
||||
icon: <HiOutlineChatBubbleLeftEllipsis />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
icon: <HiOutlineCodeBracketSquare />,
|
||||
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
];
|
||||
|
||||
export default function Editor({
|
||||
content,
|
||||
@@ -443,8 +435,6 @@ export default function Editor({
|
||||
readOnly = false,
|
||||
workspaceMembers,
|
||||
enableYouTubeEmbed = true,
|
||||
placeholder,
|
||||
disableHeadings = false,
|
||||
}: {
|
||||
content: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
@@ -452,16 +442,18 @@ export default function Editor({
|
||||
readOnly?: boolean;
|
||||
workspaceMembers: WorkspaceMember[];
|
||||
enableYouTubeEmbed?: boolean;
|
||||
placeholder?: string;
|
||||
disableHeadings?: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: disableHeadings ? false : undefined,
|
||||
StarterKit,
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
@@ -472,20 +464,12 @@ export default function Editor({
|
||||
},
|
||||
validate: (href) => /^https?:\/\//.test(href),
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: placeholder ??
|
||||
t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
}),
|
||||
SlashCommands.configure({
|
||||
commandItems: getCommandItems(disableHeadings),
|
||||
commandItems: CommandItems,
|
||||
suggestion: {
|
||||
items: ({ query }: { query: string }) =>
|
||||
filterSlashCommandItems(getCommandItems(disableHeadings), query),
|
||||
filterSlashCommandItems(CommandItems, query),
|
||||
startOfLine: true,
|
||||
char: "/",
|
||||
},
|
||||
@@ -497,28 +481,20 @@ export default function Editor({
|
||||
suggestion: {
|
||||
char: "@",
|
||||
items: ({ query }: { query: string }) => {
|
||||
const withEmail = workspaceMembers.filter((member) => member.email);
|
||||
|
||||
const mapped = withEmail.map((member: WorkspaceMember) => ({
|
||||
id: member.publicId,
|
||||
label: member?.user?.name?.trim() || member.email || "",
|
||||
image: member?.user?.image ?? null,
|
||||
}));
|
||||
|
||||
const all: MentionItem[] = mapped.filter(
|
||||
(item) => item.label && item.label.length > 0,
|
||||
const all: MentionItem[] = workspaceMembers.map(
|
||||
(member: WorkspaceMember) => ({
|
||||
id: member.publicId,
|
||||
label: member?.user?.name ?? member.email,
|
||||
image: member?.user?.image ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
const q = query.toLowerCase().trim();
|
||||
|
||||
if (q === "") {
|
||||
return all;
|
||||
}
|
||||
|
||||
const filtered = all.filter((u) =>
|
||||
u.label.toLowerCase().includes(q),
|
||||
const q = query.toLowerCase();
|
||||
return all.filter(
|
||||
(u) =>
|
||||
u.label &&
|
||||
typeof u.label === "string" &&
|
||||
u.label.toLowerCase().includes(q),
|
||||
);
|
||||
return filtered;
|
||||
},
|
||||
command: ({ editor, range, props }) => {
|
||||
const id = props.id ?? "";
|
||||
|
||||
@@ -33,7 +33,7 @@ const schema = z.object({
|
||||
.min(3, {
|
||||
message: t`URL must be at least 3 characters long`,
|
||||
})
|
||||
.max(64, { message: t`URL cannot exceed 64 characters` })
|
||||
.max(24, { message: t`URL cannot exceed 24 characters` })
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||
})
|
||||
|
||||
@@ -19,7 +19,6 @@ interface UsePermissionsResult {
|
||||
canCreateBoard: boolean;
|
||||
canEditBoard: boolean;
|
||||
canDeleteBoard: boolean;
|
||||
canArchiveBoard: boolean;
|
||||
canViewComment: boolean;
|
||||
canCreateComment: boolean;
|
||||
canEditComment: boolean;
|
||||
@@ -34,7 +33,7 @@ interface UsePermissionsResult {
|
||||
export function usePermissions(): UsePermissionsResult {
|
||||
// Check if WorkspaceProvider is available (for public board views, it may not be)
|
||||
const workspaceContext = useContext(WorkspaceContext);
|
||||
|
||||
|
||||
// If WorkspaceProvider is not available, return safe defaults
|
||||
if (!workspaceContext) {
|
||||
const emptyPermissions: UsePermissionsResult = {
|
||||
@@ -52,7 +51,6 @@ export function usePermissions(): UsePermissionsResult {
|
||||
canCreateBoard: false,
|
||||
canEditBoard: false,
|
||||
canDeleteBoard: false,
|
||||
canArchiveBoard: false,
|
||||
canViewComment: false,
|
||||
canCreateComment: false,
|
||||
canEditComment: false,
|
||||
@@ -97,7 +95,6 @@ export function usePermissions(): UsePermissionsResult {
|
||||
canCreateBoard: hasPermission("board:create"),
|
||||
canEditBoard: hasPermission("board:edit"),
|
||||
canDeleteBoard: hasPermission("board:delete"),
|
||||
canArchiveBoard: hasPermission("board:edit"),
|
||||
canViewComment: hasPermission("comment:view"),
|
||||
canCreateComment: hasPermission("comment:create"),
|
||||
canEditComment: hasPermission("comment:edit"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/de/messages.po
Normal file
3090
apps/web/src/locales/de/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3275
apps/web/src/locales/en/messages.po
Normal file
3275
apps/web/src/locales/en/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/es/messages.po
Normal file
3090
apps/web/src/locales/es/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/fr/messages.po
Normal file
3090
apps/web/src/locales/fr/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ export const locales = [
|
||||
"nl",
|
||||
"ru",
|
||||
"pl",
|
||||
"ptbr"
|
||||
"pt-BR"
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
|
||||
nl: "Nederlands",
|
||||
ru: "Русский",
|
||||
pl: "Polski",
|
||||
ptbr: "Português",
|
||||
"pt-BR": "Português",
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/it/messages.po
Normal file
3090
apps/web/src/locales/it/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/nl/messages.po
Normal file
3090
apps/web/src/locales/nl/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3095
apps/web/src/locales/pl/messages.po
Normal file
3095
apps/web/src/locales/pl/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
3090
apps/web/src/locales/ru/messages.po
Normal file
3090
apps/web/src/locales/ru/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -13,23 +13,6 @@ const authHandler = toNodeHandler(auth.handler);
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req, res) => {
|
||||
/**
|
||||
* Better-auth behind proxies (Nginx/Cloudflare) can sometimes fail to parse the protocol
|
||||
* if headers are incorrectly set or if there are multiple values in X-Forwarded-Proto.
|
||||
* We sanitize these headers here to ensure better-auth gets a clean protocol and host.
|
||||
*/
|
||||
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||
if (forwardedProto) {
|
||||
const p = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto;
|
||||
req.headers["x-forwarded-proto"] = p?.split(",")[0]?.trim();
|
||||
}
|
||||
|
||||
const forwardedHost = req.headers["x-forwarded-host"];
|
||||
if (forwardedHost) {
|
||||
const h = Array.isArray(forwardedHost) ? forwardedHost[0] : forwardedHost;
|
||||
req.headers["host"] = h?.split(",")[0]?.trim();
|
||||
}
|
||||
|
||||
return await authHandler(req, res);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
const workspaceSlugSchema = z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(64)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
|
||||
|
||||
interface CheckoutSessionRequest {
|
||||
|
||||
@@ -2,16 +2,16 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { assertPermission } from "@kan/api/utils/permissions";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as cardAttachmentRepo from "@kan/db/repository/cardAttachment.repo";
|
||||
import { createS3Client, generateUID } from "@kan/shared/utils";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
import { assertPermission } from "@kan/api/utils/permissions";
|
||||
|
||||
// FIXME: Respect the environment variable: NEXT_API_BODY_SIZE_LIMIT
|
||||
const MAX_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
export const config = {
|
||||
@@ -115,15 +115,9 @@ export default withRateLimit(
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
return res.status(500).json({ error: "Failed to create attachment" });
|
||||
}
|
||||
|
||||
await cardActivityRepo.create(db, {
|
||||
type: "card.updated.attachment.added",
|
||||
cardId: card.id,
|
||||
attachmentId: attachment.id,
|
||||
toTitle: originalFilenameHeader,
|
||||
createdBy: user.id,
|
||||
});
|
||||
|
||||
@@ -134,3 +128,4 @@ export default withRateLimit(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(!isSignUpDisabled || redirect?.startsWith("/invite/")) && (
|
||||
{!isSignUpDisabled && (
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Don't have an account?{" "}
|
||||
|
||||
@@ -28,9 +28,7 @@ export default function SignUpPage() {
|
||||
setMagicLinkRecipient(recipient);
|
||||
};
|
||||
|
||||
const isInviteFlow = redirect?.startsWith("/invite/");
|
||||
|
||||
if (isSignUpDisabled && !isInviteFlow) {
|
||||
if (isSignUpDisabled) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Sign up | kan.bn`} />
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
HiOutlineStar,
|
||||
HiStar,
|
||||
} from "react-icons/hi2";
|
||||
import { IoArchiveOutline } from "react-icons/io5";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -17,7 +17,6 @@ import { api } from "~/utils/api";
|
||||
export default function BoardDropdown({
|
||||
isTemplate,
|
||||
isLoading,
|
||||
isArchived,
|
||||
boardPublicId,
|
||||
isFavorite,
|
||||
boardName,
|
||||
@@ -25,29 +24,28 @@ export default function BoardDropdown({
|
||||
isTemplate: boolean;
|
||||
isLoading: boolean;
|
||||
boardPublicId: string;
|
||||
isArchived?: boolean;
|
||||
isFavorite?: boolean;
|
||||
boardName?: string;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
|
||||
usePermissions();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
favorite: !isFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
const updateBoard = api.board.update.useMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
onSuccess: (data, variables) => {
|
||||
void utils.board.all.invalidate();
|
||||
void utils.board.byId.invalidate();
|
||||
if (variables.isArchived !== undefined) {
|
||||
showPopup({
|
||||
header: variables.isArchived ? t`Board archived` : t`Board unarchived`,
|
||||
message: variables.isArchived
|
||||
? t`The board has been archived.`
|
||||
: t`The board has been unarchived.`,
|
||||
icon: "success",
|
||||
});
|
||||
} else if (variables.favorite !== undefined) {
|
||||
|
||||
// Show popup notification
|
||||
if (variables.favorite !== undefined) {
|
||||
showPopup({
|
||||
header: variables.favorite
|
||||
? t`Added to favorites`
|
||||
@@ -67,54 +65,27 @@ export default function BoardDropdown({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
favorite: !isFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveOrUnarchive = () => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId,
|
||||
isArchived: !isArchived,
|
||||
});
|
||||
};
|
||||
|
||||
const isArchiveActionPending = updateBoard.isPending;
|
||||
|
||||
|
||||
const items = [
|
||||
...(isTemplate && canCreateBoard
|
||||
? [
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isTemplate && canEditBoard
|
||||
? [
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isTemplate && canArchiveBoard
|
||||
? [
|
||||
{
|
||||
label: isArchived ? t`Unarchive board` : t`Archive board`,
|
||||
action: handleArchiveOrUnarchive,
|
||||
icon: (
|
||||
<IoArchiveOutline className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: isFavorite
|
||||
@@ -129,26 +100,22 @@ export default function BoardDropdown({
|
||||
},
|
||||
...(canDeleteBoard
|
||||
? [
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: (
|
||||
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
]
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={isLoading || isArchiveActionPending}
|
||||
items={items}
|
||||
>
|
||||
<Dropdown disabled={isLoading} items={items}>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -66,8 +66,8 @@ const Card = ({
|
||||
const hasDueDate = !!dueDate;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
<span className="break-words">{title}</span>
|
||||
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
<span>{title}</span>
|
||||
{labels.length ||
|
||||
members.length ||
|
||||
checklists.length > 0 ||
|
||||
|
||||
@@ -58,14 +58,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const [selectedPublicListId, setSelectedPublicListId] =
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
});
|
||||
|
||||
const { canCreateList, canEditList, canEditCard, canEditBoard } =
|
||||
usePermissions();
|
||||
const { canCreateList, canEditList, canEditCard, canEditBoard } = usePermissions();
|
||||
|
||||
const { tooltipContent: createListShortcutTooltipContent } =
|
||||
useKeyboardShortcut({
|
||||
@@ -418,9 +417,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
{...register("name")}
|
||||
onBlur={canEditBoard ? handleSubmit(onSubmit) : undefined}
|
||||
readOnly={!canEditBoard}
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none disabled:cursor-not-allowed dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem] disabled:cursor-not-allowed"
|
||||
/>
|
||||
</form>
|
||||
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||
@@ -494,7 +494,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isTemplate={!!isTemplate}
|
||||
isLoading={!boardData}
|
||||
boardPublicId={boardId ?? ""}
|
||||
isArchived={boardData?.isArchived ?? false}
|
||||
workspacePublicId={workspace.publicId}
|
||||
isFavorite={boardData?.favorite}
|
||||
boardName={boardData?.name}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function BoardsList({ isTemplate, archived = false }: { isTemplate?: boolean; archived?: boolean }) {
|
||||
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const { canCreateBoard } = usePermissions();
|
||||
@@ -26,7 +26,6 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
|
||||
{
|
||||
workspacePublicId: workspace.publicId,
|
||||
type: isTemplate ? "template" : "regular",
|
||||
archived: archived,
|
||||
},
|
||||
{ enabled: workspace.publicId ? true : false },
|
||||
);
|
||||
@@ -60,10 +59,10 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
|
||||
<div className="flex flex-col items-center">
|
||||
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
||||
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
|
||||
{archived ? t`No archived boards` : t`No ${isTemplate ? "templates" : "boards"}`}
|
||||
{t`No ${isTemplate ? "templates" : "boards"}`}
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
{archived ? t`Boards you archive will appear here.` : t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip
|
||||
@@ -110,18 +109,18 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
|
||||
>
|
||||
<div className="group relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||
<PatternedBackground />
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
||||
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{board.favorite ? (
|
||||
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)}
|
||||
className={`absolute right-3 top-3 z-10 rounded p-1 transition-all hover:bg-light-300 dark:hover:bg-dark-200 ${board.favorite ? "" : "md:opacity-0 md:group-hover:opacity-100"
|
||||
}`}
|
||||
aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{board.favorite ? (
|
||||
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
|
||||
)}
|
||||
</button>
|
||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||
{board.name}
|
||||
</p>
|
||||
|
||||
@@ -23,18 +23,6 @@ export const getTemplates = (): Template[] => [
|
||||
lists: [t`Backlog`, t`To Do`, t`In Progress`, t`Code Review`, t`Done`],
|
||||
labels: [t`Bug`, t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "roadmap-basic",
|
||||
name: t`Basic Roadmap`,
|
||||
lists: [t`Requested`, t`Planned`, t`In Progress`, t`Done`],
|
||||
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "roadmap-extended",
|
||||
name: t`Extended Roadmap`,
|
||||
lists: [t`Requested`, t`Under Review`, t`Planned`, t`In Progress`, t`Done`, t`Rejected`],
|
||||
labels: [t`Feature`, t`Enhancement`, t`Critical`, t`Documentation`],
|
||||
},
|
||||
{
|
||||
id: "content-creation",
|
||||
name: t`Content Creation`,
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
import { useState } from "react";
|
||||
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
@@ -22,15 +15,9 @@ import { BoardsList } from "./components/BoardsList";
|
||||
import { ImportBoardsForm } from "./components/ImportBoardsForm";
|
||||
import { NewBoardForm } from "./components/NewBoardForm";
|
||||
|
||||
const boardsTabs = [
|
||||
{ key: "boards" as const, label: t`Active` },
|
||||
{ key: "archived" as const, label: t`Archived` },
|
||||
];
|
||||
|
||||
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { openModal, modalContentType, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
|
||||
const { canCreateBoard } = usePermissions();
|
||||
|
||||
const { tooltipContent: createModalShortcutTooltipContent } =
|
||||
@@ -47,7 +34,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<PageHead
|
||||
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? t`Workspace`}`}
|
||||
/>
|
||||
<div className="m-auto h-full max-w-[1100px] p-8 px-5 md:px-28 md:py-12">
|
||||
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
|
||||
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`${isTemplate ? "Templates" : "Boards"}`}
|
||||
@@ -128,79 +115,9 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
{!isTemplate ? (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="focus:outline-none">
|
||||
<div className="sm:hidden">
|
||||
<Listbox
|
||||
value={activeTab}
|
||||
onChange={(tab) => setActiveTab(tab)}
|
||||
>
|
||||
<div className="relative mb-4">
|
||||
<ListboxButton className="w-full appearance-none rounded-md border-0 bg-light-50 py-3 pl-3 pr-10 text-left text-sm font-semibold text-light-1000 shadow-sm ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:text-dark-1000 dark:ring-dark-300 dark:focus:ring-dark-500">
|
||||
{boardsTabs.find((tab) => tab.key === activeTab)?.label ??
|
||||
"Select a tab"}
|
||||
<HiChevronDown
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
</ListboxButton>
|
||||
<ListboxOptions className="absolute z-10 mt-1 w-full rounded-md bg-light-50 py-1 text-sm shadow-lg ring-1 ring-inset ring-light-300 dark:bg-dark-50 dark:ring-dark-300">
|
||||
{boardsTabs.map((tab) => (
|
||||
<ListboxOption
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className={({ selected }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-3 pr-9 ${selected
|
||||
? "font-bold text-light-1000 dark:text-dark-1000"
|
||||
: "font-normal text-light-1000 dark:text-dark-1000"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<div>
|
||||
<nav
|
||||
aria-label="Tabs"
|
||||
className="-mb-px flex space-x-8 focus:outline-none"
|
||||
>
|
||||
{boardsTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`whitespace-nowrap px-1 py-0 mt-2 mb-8 text-sm font-semibold transition-colors focus:outline-none ${activeTab === tab.key
|
||||
? "border-light-1000 text-light-1000 dark:border-dark-1000 dark:text-dark-1000"
|
||||
: "border-transparent text-light-900 hover:border-light-950 hover:text-light-950 dark:text-dark-900 dark:hover:border-white/20 dark:hover:text-dark-950"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full flex-row focus:outline-none">
|
||||
{activeTab === "boards" && (
|
||||
<BoardsList isTemplate={false} archived={false} />
|
||||
)}
|
||||
{activeTab === "archived" && (
|
||||
<BoardsList isTemplate={false} archived={true} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-row">
|
||||
<BoardsList isTemplate={!!isTemplate} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-full flex-row">
|
||||
<BoardsList isTemplate={!!isTemplate} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
HiOutlineArrowRight,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineClock,
|
||||
HiOutlinePaperClip,
|
||||
HiOutlinePencil,
|
||||
HiOutlinePlus,
|
||||
HiOutlineTag,
|
||||
@@ -32,16 +31,6 @@ import Comment from "./Comment";
|
||||
type ActivityType =
|
||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||
|
||||
type ActivityWithMergedLabels =
|
||||
GetCardActivitiesOutput["activities"][number] & {
|
||||
mergedLabels?: string[];
|
||||
attachment?: {
|
||||
publicId: string;
|
||||
filename: string;
|
||||
originalFilename: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const truncate = (value: string | null, maxLength = 50) => {
|
||||
if (!value) return value;
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||
@@ -68,7 +57,6 @@ const getActivityText = ({
|
||||
toDueDate,
|
||||
dateLocale,
|
||||
mergedLabels,
|
||||
attachmentName,
|
||||
}: {
|
||||
type: ActivityType;
|
||||
toTitle: string | null;
|
||||
@@ -83,7 +71,6 @@ const getActivityText = ({
|
||||
toDueDate?: Date | null;
|
||||
dateLocale: DateFnsLocale;
|
||||
mergedLabels?: string[];
|
||||
attachmentName?: string | null;
|
||||
}) => {
|
||||
const displayName = memberName ?? memberEmail ?? t`Member`;
|
||||
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
|
||||
@@ -137,8 +124,6 @@ const getActivityText = ({
|
||||
"card.updated.checklist.item.completed": t`completed a checklist item`,
|
||||
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
||||
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
||||
"card.updated.attachment.added": t`added an attachment`,
|
||||
"card.updated.attachment.removed": t`removed an attachment`,
|
||||
"card.updated.dueDate.added": t`set the due date`,
|
||||
"card.updated.dueDate.updated": t`updated the due date`,
|
||||
"card.updated.dueDate.removed": t`removed the due date`,
|
||||
@@ -271,27 +256,6 @@ const getActivityText = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.attachment.added") {
|
||||
const filename = attachmentName ?? toTitle;
|
||||
if (!filename) return baseText;
|
||||
return (
|
||||
<Trans>
|
||||
added an attachment <TextHighlight>{truncate(filename)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.attachment.removed") {
|
||||
const filename = attachmentName ?? fromTitle;
|
||||
if (!filename) return baseText;
|
||||
return (
|
||||
<Trans>
|
||||
removed an attachment{" "}
|
||||
<TextHighlight>{truncate(filename)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.dueDate.added" && toDueDate) {
|
||||
const showYear = !isSameYear(toDueDate, new Date());
|
||||
const formattedDate = format(
|
||||
@@ -344,8 +308,6 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
|
||||
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
||||
"card.updated.attachment.added": <HiOutlinePaperClip />,
|
||||
"card.updated.attachment.removed": <HiOutlinePaperClip />,
|
||||
"card.updated.dueDate.added": <HiOutlineClock />,
|
||||
"card.updated.dueDate.updated": <HiOutlineClock />,
|
||||
"card.updated.dueDate.removed": <HiOutlineClock />,
|
||||
@@ -379,7 +341,7 @@ const ActivityList = ({
|
||||
isAdmin?: boolean;
|
||||
isViewOnly?: boolean;
|
||||
}) => {
|
||||
const { dateLocale } = useLocalisation();
|
||||
const { dateLocale, locale } = useLocalisation();
|
||||
const { data: sessionData } = authClient.useSession();
|
||||
const utils = api.useUtils();
|
||||
const [allActivities, setAllActivities] = useState<
|
||||
@@ -429,21 +391,25 @@ const ActivityList = ({
|
||||
cursor: nextCursor,
|
||||
});
|
||||
|
||||
const existingIds = new Set(
|
||||
currentActivities.map((a) => a.publicId),
|
||||
);
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
currentActivities = [...currentActivities, ...newActivities];
|
||||
currentHasMore = nextPage.hasMore;
|
||||
if (nextPage) {
|
||||
const existingIds = new Set(
|
||||
currentActivities.map((a) => a.publicId),
|
||||
);
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
currentActivities = [...currentActivities, ...newActivities];
|
||||
currentHasMore = nextPage.hasMore;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setAllActivities(currentActivities);
|
||||
setHasMore(false);
|
||||
};
|
||||
|
||||
void fetchAllRemaining();
|
||||
fetchAllRemaining();
|
||||
} else {
|
||||
setAllActivities(firstPageData.activities);
|
||||
setHasMore(firstPageData.hasMore);
|
||||
@@ -470,15 +436,17 @@ const ActivityList = ({
|
||||
cursor: nextCursor,
|
||||
});
|
||||
|
||||
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
setAllActivities((prev) => [...prev, ...newActivities]);
|
||||
setHasMore(nextPage.hasMore);
|
||||
if (nextPage) {
|
||||
const existingIds = new Set(allActivities.map((a) => a.publicId));
|
||||
const newActivities = nextPage.activities.filter(
|
||||
(a: { publicId: string }) => !existingIds.has(a.publicId),
|
||||
);
|
||||
setAllActivities((prev) => [...prev, ...newActivities]);
|
||||
setHasMore(nextPage.hasMore);
|
||||
|
||||
if (!nextPage.hasMore) {
|
||||
isFullyExpandedRef.current = true;
|
||||
if (!nextPage.hasMore) {
|
||||
isFullyExpandedRef.current = true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
@@ -505,10 +473,7 @@ const ActivityList = ({
|
||||
fromDueDate: activity.fromDueDate ?? null,
|
||||
toDueDate: activity.toDueDate ?? null,
|
||||
dateLocale: dateLocale,
|
||||
mergedLabels: (activity as ActivityWithMergedLabels).mergedLabels,
|
||||
attachmentName:
|
||||
(activity as ActivityWithMergedLabels).attachment?.originalFilename ??
|
||||
null,
|
||||
mergedLabels: (activity as any).mergedLabels,
|
||||
});
|
||||
|
||||
if (activity.type === "card.updated.comment.added")
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { useState } from "react";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import Editor from "~/components/Editor";
|
||||
import type { WorkspaceMember } from "~/components/Editor";
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -30,6 +29,7 @@ const Comment = ({
|
||||
createdAt,
|
||||
comment,
|
||||
isAuthor,
|
||||
isAdmin,
|
||||
isEdited = false,
|
||||
isViewOnly = false,
|
||||
}: {
|
||||
@@ -42,6 +42,7 @@ const Comment = ({
|
||||
createdAt: string;
|
||||
comment: string | undefined;
|
||||
isAuthor: boolean;
|
||||
isAdmin: boolean;
|
||||
isEdited: boolean;
|
||||
isViewOnly: boolean;
|
||||
}) => {
|
||||
@@ -56,30 +57,6 @@ const Comment = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { data: cardData } = api.card.byId.useQuery(
|
||||
{
|
||||
cardPublicId,
|
||||
},
|
||||
{
|
||||
enabled: !!cardPublicId && cardPublicId.length >= 12,
|
||||
},
|
||||
);
|
||||
|
||||
const workspaceMembers: WorkspaceMember[] =
|
||||
cardData?.list.board.workspace.members
|
||||
.filter((member) => member.email)
|
||||
.map((member) => ({
|
||||
publicId: member.publicId,
|
||||
email: member.email,
|
||||
user: member.user
|
||||
? {
|
||||
id: member.user.id,
|
||||
name: member.user.name ?? null,
|
||||
image: member.user.image ?? null,
|
||||
}
|
||||
: null,
|
||||
})) ?? [];
|
||||
|
||||
if (!publicId) return null;
|
||||
|
||||
const updateCommentMutation = api.card.updateComment.useMutation({
|
||||
@@ -165,28 +142,21 @@ const Comment = ({
|
||||
)}
|
||||
</div>
|
||||
{!isEditing ? (
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={comment ?? null}
|
||||
readOnly={true}
|
||||
workspaceMembers={workspaceMembers}
|
||||
enableYouTubeEmbed={false}
|
||||
disableHeadings={true}
|
||||
/>
|
||||
</div>
|
||||
<ContentEditable
|
||||
html={comment ?? ""}
|
||||
disabled={true}
|
||||
className="break-anywhere mt-2 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={watch("comment")}
|
||||
onChange={(value) => setValue("comment", value)}
|
||||
workspaceMembers={workspaceMembers}
|
||||
enableYouTubeEmbed={false}
|
||||
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
|
||||
disableHeadings={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2 mt-2">
|
||||
<ContentEditable
|
||||
placeholder={t`Add a comment...`}
|
||||
html={watch("comment")}
|
||||
disabled={false}
|
||||
onChange={(e) => setValue("comment", e.target.value)}
|
||||
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-sm text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiOutlineArrowUp } from "react-icons/hi2";
|
||||
|
||||
import Editor from "~/components/Editor";
|
||||
import type { WorkspaceMember } from "~/components/Editor";
|
||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -14,13 +13,7 @@ interface FormValues {
|
||||
comment: string;
|
||||
}
|
||||
|
||||
const NewCommentForm = ({
|
||||
cardPublicId,
|
||||
workspaceMembers,
|
||||
}: {
|
||||
cardPublicId: string;
|
||||
workspaceMembers: WorkspaceMember[];
|
||||
}) => {
|
||||
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const { canCreateComment } = usePermissions();
|
||||
@@ -30,6 +23,10 @@ const NewCommentForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
const queryParams = {
|
||||
cardPublicId,
|
||||
};
|
||||
|
||||
const addCommentMutation = api.card.addComment.useMutation({
|
||||
onError: (_error, _newList) => {
|
||||
showPopup({
|
||||
@@ -60,13 +57,18 @@ const NewCommentForm = ({
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex w-full max-w-[800px] flex-col rounded-xl border border-light-600 bg-light-100 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
>
|
||||
<Editor
|
||||
content={watch("comment")}
|
||||
onChange={(value) => setValue("comment", value)}
|
||||
workspaceMembers={workspaceMembers}
|
||||
enableYouTubeEmbed={false}
|
||||
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
|
||||
disableHeadings={true}
|
||||
<ContentEditable
|
||||
placeholder={t`Add a comment...`}
|
||||
html={watch("comment")}
|
||||
disabled={false}
|
||||
onChange={(e) => setValue("comment", e.target.value)}
|
||||
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -4,7 +4,6 @@ import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { IoChevronForwardSharp } from "react-icons/io5";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
@@ -168,6 +167,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const {
|
||||
modalContentType,
|
||||
entityId,
|
||||
openModal,
|
||||
getModalState,
|
||||
clearModalState,
|
||||
isOpen,
|
||||
@@ -198,24 +198,8 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
};
|
||||
|
||||
const board = card?.list.board;
|
||||
const workspaceMembers = board?.workspace.members;
|
||||
const boardId = board?.publicId;
|
||||
|
||||
const editorWorkspaceMembers =
|
||||
workspaceMembers
|
||||
?.filter((member) => member.email)
|
||||
.map((member) => ({
|
||||
publicId: member.publicId,
|
||||
email: member.email,
|
||||
user: member.user
|
||||
? {
|
||||
id: member.user.id,
|
||||
name: member.user.name ?? null,
|
||||
image: member.user.image ?? null,
|
||||
}
|
||||
: null,
|
||||
})) ?? [];
|
||||
|
||||
const updateCard = api.card.update.useMutation({
|
||||
onError: () => {
|
||||
showPopup({
|
||||
@@ -302,7 +286,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
|
||||
if (!cardId) return <></>;
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
@@ -340,13 +323,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
boardPublicId={boardId}
|
||||
cardCreatedBy={card?.createdBy}
|
||||
/>
|
||||
<Link
|
||||
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[5px] text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200"
|
||||
aria-label={t`Close`}
|
||||
>
|
||||
<HiXMark className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -411,7 +387,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
onBlur={
|
||||
canEdit ? () => handleSubmit(onSubmit)() : undefined
|
||||
}
|
||||
workspaceMembers={workspaceMembers ?? []}
|
||||
workspaceMembers={board?.workspace.members ?? []}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -455,10 +431,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
<div className="mt-6">
|
||||
<NewCommentForm
|
||||
cardPublicId={cardId}
|
||||
workspaceMembers={editorWorkspaceMembers}
|
||||
/>
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,587 +1,293 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { HiCheckCircle, HiXCircle } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface Feature {
|
||||
key: string;
|
||||
label: string;
|
||||
kan: boolean | string;
|
||||
trello: boolean | string;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
name: string;
|
||||
features: Feature[];
|
||||
}
|
||||
|
||||
type FrequencyValue = "monthly" | "annually";
|
||||
|
||||
interface PlanFeature {
|
||||
key: string;
|
||||
label: string;
|
||||
free: string | boolean | { text: string; highlight?: string };
|
||||
teams: string | boolean | { text: string; highlight?: string };
|
||||
pro: string | boolean | { text: string; highlight?: string };
|
||||
enterprise: string | boolean | { text: string; highlight?: string };
|
||||
}
|
||||
|
||||
interface FeatureSection {
|
||||
name: string;
|
||||
features: PlanFeature[];
|
||||
}
|
||||
const CellValue = ({ value }: { value: boolean | string }) => {
|
||||
if (typeof value === "string") {
|
||||
return (
|
||||
<span className="text-sm text-dark-900 dark:text-dark-900">{value}</span>
|
||||
);
|
||||
}
|
||||
return value ? (
|
||||
<HiCheckCircle className="h-5 w-5 text-light-950 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiXCircle className="h-5 w-5 text-light-700 dark:text-dark-600" />
|
||||
);
|
||||
};
|
||||
|
||||
const FeatureComparisonTable = ({
|
||||
frequencyValue: _frequencyValue,
|
||||
frequencyValue,
|
||||
}: {
|
||||
frequencyValue: FrequencyValue;
|
||||
}) => {
|
||||
// Keep pricing column headers visible as you scroll
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isHeaderFixed, setIsHeaderFixed] = useState(false);
|
||||
const [headerRect, setHeaderRect] = useState<{ left: number; width: number }>({
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const HEADER_OFFSET = 64; // px; matches fixed top nav height
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Hide fixed header on mobile (screens smaller than 620px)
|
||||
if (window.innerWidth < 620) {
|
||||
setIsHeaderFixed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const pastHeader = rect.top <= HEADER_OFFSET;
|
||||
const aboveBottom = rect.bottom > HEADER_OFFSET + 40;
|
||||
|
||||
const shouldFix = pastHeader && aboveBottom;
|
||||
setIsHeaderFixed(shouldFix);
|
||||
|
||||
if (shouldFix) {
|
||||
setHeaderRect({
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleScroll();
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
window.addEventListener("resize", handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
window.removeEventListener("resize", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
const planSpecificLimits: PlanFeature[] = [
|
||||
{
|
||||
key: "boards",
|
||||
label: t`Boards`,
|
||||
free: { text: t`Unlimited boards`, highlight: "Unlimited" },
|
||||
teams: { text: t`Unlimited boards`, highlight: "Unlimited" },
|
||||
pro: { text: t`Unlimited boards`, highlight: "Unlimited" },
|
||||
enterprise: { text: t`Unlimited boards`, highlight: "Unlimited" },
|
||||
},
|
||||
{
|
||||
key: "members",
|
||||
label: t`Members`,
|
||||
free: { text: t`1 user`, highlight: "1" },
|
||||
teams: { text: t`Per-seat pricing`, highlight: "Per-seat" },
|
||||
pro: { text: t`Unlimited members`, highlight: "Unlimited" },
|
||||
enterprise: { text: t`Unlimited members`, highlight: "Unlimited" },
|
||||
},
|
||||
{
|
||||
key: "file-uploads",
|
||||
label: t`File uploads`,
|
||||
free: { text: t`10mb file uploads`, highlight: "10mb" },
|
||||
teams: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
|
||||
pro: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
|
||||
enterprise: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
|
||||
},
|
||||
{
|
||||
key: "workspace-username",
|
||||
label: t`Workspace username`,
|
||||
free: { text: t`Default username`, highlight: "Default" },
|
||||
teams: { text: t`Default username`, highlight: "Default" },
|
||||
pro: { text: t`Custom username`, highlight: "Custom" },
|
||||
enterprise: { text: t`Custom username`, highlight: "Custom" },
|
||||
},
|
||||
];
|
||||
|
||||
const coreFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "checklists",
|
||||
label: t`Checklists`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "custom-templates",
|
||||
label: t`Board templates`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "labels-filters",
|
||||
label: t`Labels & filters`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "board-visibility",
|
||||
label: t`Board visibility`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
label: t`Intelligent search`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "activity-log",
|
||||
label: t`Activity log`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "comments",
|
||||
label: t`Comments`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "roles-permissions",
|
||||
label: t`Roles & permissions`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "custom-branding",
|
||||
label: t`Custom branding`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const collaborationFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "mentions",
|
||||
label: t`Mentions`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "email-notifications",
|
||||
label: t`Email notifications`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "assignees",
|
||||
label: t`Assignees`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "invite-links",
|
||||
label: t`Invite links`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const importsFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "import-export",
|
||||
label: t`Trello`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const platformFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "open-source",
|
||||
label: t`Open source`,
|
||||
free: true,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "rest-api",
|
||||
label: t`API`,
|
||||
free: { text: t`Limited API access` },
|
||||
teams: { text: t`Full API access` },
|
||||
pro: { text: t`Full API access` },
|
||||
enterprise: { text: t`Full API access` },
|
||||
},
|
||||
{
|
||||
key: "self-hostable",
|
||||
label: t`On-premise`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: false,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const securityFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "sso",
|
||||
label: t`SSO`,
|
||||
free: { text: t`Google SSO` },
|
||||
teams: { text: t`Google SSO` },
|
||||
pro: { text: t`Google SSO` },
|
||||
enterprise: { text: t`Google SSO + SAML` },
|
||||
},
|
||||
{
|
||||
key: "admin-roles",
|
||||
label: t`Admin roles`,
|
||||
free: { text: t`Admin roles` },
|
||||
teams: { text: t`Admin roles` },
|
||||
pro: { text: t`Admin roles` },
|
||||
enterprise: { text: t`Advanced admin roles` },
|
||||
},
|
||||
];
|
||||
|
||||
const supportFeatures: PlanFeature[] = [
|
||||
{
|
||||
key: "priority-email-support",
|
||||
label: t`Priority email support`,
|
||||
free: false,
|
||||
teams: true,
|
||||
pro: true,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "account-manager",
|
||||
label: t`Account manager`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: false,
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
key: "sla",
|
||||
label: t`SLA`,
|
||||
free: false,
|
||||
teams: false,
|
||||
pro: false,
|
||||
enterprise: true,
|
||||
},
|
||||
];
|
||||
|
||||
const sections: FeatureSection[] = [
|
||||
{
|
||||
name: "",
|
||||
features: planSpecificLimits,
|
||||
},
|
||||
const sections: Section[] = [
|
||||
{
|
||||
name: t`Core features`,
|
||||
features: coreFeatures,
|
||||
features: [
|
||||
{
|
||||
key: "ulimited-workspaces",
|
||||
label: t`Unlimited workspaces`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "unlimited-boards",
|
||||
label: t`Unlimited boards`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "unlimited-lists",
|
||||
label: t`Unlimited lists`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "unlimited-cards",
|
||||
label: t`Unlimited cards`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "activity-log",
|
||||
label: t`Activity log`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: t`Custom templates`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{ key: "labels", label: t`Labels & filters`, kan: true, trello: true },
|
||||
{ key: "checklists", label: t`Checklists`, kan: true, trello: true },
|
||||
{
|
||||
key: "import",
|
||||
label: t`Import from Trello`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "visibility",
|
||||
label: t`Board visibility`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
label: t`Intelligent search`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "workspace-url",
|
||||
label: t`Custom workspace link`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t`Collaboration`,
|
||||
features: collaborationFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Imports`,
|
||||
features: importsFeatures,
|
||||
name: t`Teams`,
|
||||
features: [
|
||||
{
|
||||
key: "pricing",
|
||||
label: t`Price per user/month`,
|
||||
kan: frequencyValue === "monthly" ? `$10.00` : `$8.00`,
|
||||
trello: frequencyValue === "monthly" ? `$12.00` : `$10.00`,
|
||||
},
|
||||
{
|
||||
key: "comments",
|
||||
label: t`Comments & mentions`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "assignments",
|
||||
label: t`Assignees`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "sharing",
|
||||
label: t`Board sharing & invites`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "invite-links",
|
||||
label: t`Invite links`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t`Platform`,
|
||||
features: platformFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Security`,
|
||||
features: securityFeatures,
|
||||
},
|
||||
{
|
||||
name: t`Support`,
|
||||
features: supportFeatures,
|
||||
features: [
|
||||
{
|
||||
key: "api",
|
||||
label: t`REST API`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "open-source",
|
||||
label: t`Open source`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "self-hostable",
|
||||
label: t`Self-hostable`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "white-label",
|
||||
label: t`White label`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "performance",
|
||||
label: t`Fast & lightweight`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "this-is-a-joke",
|
||||
label: t`Owned by Atlassian`,
|
||||
kan: false,
|
||||
trello: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const plans = [
|
||||
{ id: "free", name: t`Free`, tierId: "tier-free" },
|
||||
{ id: "teams", name: t`Teams`, tierId: "tier-teams" },
|
||||
{ id: "pro", name: t`Pro`, tierId: "tier-pro" },
|
||||
{ id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" },
|
||||
const products = [
|
||||
{
|
||||
id: "kan",
|
||||
name: t`Kan`,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: "trello",
|
||||
name: t`Trello`,
|
||||
featured: false,
|
||||
},
|
||||
];
|
||||
|
||||
const isHighlightValue = (
|
||||
value: string | boolean | { text: string; highlight?: string },
|
||||
): value is { text: string; highlight?: string } => {
|
||||
return typeof value === "object" && value !== null && "text" in value;
|
||||
};
|
||||
|
||||
const shouldHighlight = (text: string): boolean => {
|
||||
const lowerText = text.toLowerCase();
|
||||
return ["unlimited", "multiple", "per-seat", "custom", "mentions", "full", "sso", "admin"].some(
|
||||
(keyword) => lowerText.includes(keyword),
|
||||
);
|
||||
};
|
||||
|
||||
const CellValue = ({
|
||||
value,
|
||||
label,
|
||||
}: {
|
||||
value: string | boolean | { text: string; highlight?: string };
|
||||
label: string;
|
||||
}) => {
|
||||
// Handle object with text and highlight
|
||||
if (isHighlightValue(value)) {
|
||||
const { text, highlight } = value;
|
||||
const isUnlimited = shouldHighlight(text);
|
||||
|
||||
let displayText;
|
||||
if (highlight) {
|
||||
const parts = text.split(new RegExp(`(${highlight})`, "gi"));
|
||||
displayText = (
|
||||
<>
|
||||
{parts.map((part, index) =>
|
||||
part.toLowerCase() === highlight.toLowerCase() ? (
|
||||
<span key={index} className="text-light-1000 dark:text-dark-1000">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={index} className="text-light-950 dark:text-dark-800">
|
||||
{part}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
displayText = text;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiCheckCircle
|
||||
className={twMerge(
|
||||
"mt-0.5 h-4 w-4 shrink-0",
|
||||
isUnlimited
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-400 dark:text-dark-600",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-sm font-medium",
|
||||
isUnlimited
|
||||
? "text-light-1000 dark:text-dark-950"
|
||||
: "text-light-950 dark:text-dark-800",
|
||||
)}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const isUnlimited = shouldHighlight(value);
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiCheckCircle
|
||||
className={twMerge(
|
||||
"mt-0.5 h-4 w-4 shrink-0",
|
||||
isUnlimited
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-400 dark:text-dark-600",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-sm font-medium",
|
||||
isUnlimited
|
||||
? "text-light-1000 dark:text-dark-950"
|
||||
: "text-light-950 dark:text-dark-800",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (value) {
|
||||
// Boolean true - show checkmark icon
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
|
||||
<span className="text-sm font-medium text-light-1000 dark:text-dark-950">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Boolean false - show cross icon
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<HiXCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-400 dark:text-dark-600" />
|
||||
<span className="text-sm font-medium text-light-800 dark:text-dark-800">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getFeatureValue = (
|
||||
feature: PlanFeature,
|
||||
planId: string,
|
||||
): string | boolean | { text: string; highlight?: string } => {
|
||||
switch (planId) {
|
||||
case "free":
|
||||
return feature.free;
|
||||
case "teams":
|
||||
return feature.teams;
|
||||
case "pro":
|
||||
return feature.pro;
|
||||
case "enterprise":
|
||||
return feature.enterprise;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-labelledby="comparison-heading" className="w-full">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-x-auto rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50"
|
||||
>
|
||||
{isHeaderFixed && (
|
||||
<div
|
||||
className="hidden sm:block fixed z-40 border-b border-light-300 bg-light-50/95 dark:border-dark-400 dark:bg-dark-50/95"
|
||||
style={{
|
||||
top: 64,
|
||||
left: headerRect.left,
|
||||
width: headerRect.width,
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-4 border-x border-light-300 dark:border-dark-300">
|
||||
{plans.map((plan) => {
|
||||
const isMostPopular = plan.id === "pro";
|
||||
return (
|
||||
<div
|
||||
key={`fixed-${plan.id}`}
|
||||
className={twMerge(
|
||||
"flex items-center px-6 py-3 text-left text-base font-semibold",
|
||||
isMostPopular
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
|
||||
: "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{plan.name}
|
||||
{plan.id === "pro" && <span className="ml-1 text-xl">∞</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<section aria-labelledby="comparison-heading" className="w-full px-4">
|
||||
<div className="mx-auto max-w-6xl py-8 lg:py-10">
|
||||
<div className="grid grid-cols-3 gap-x-8 border-t border-light-500 before:block dark:border-dark-300">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} aria-hidden="true" className="-mt-px">
|
||||
<div
|
||||
className={`${product.featured ? "border-dark-200 dark:border-dark-800" : "border-transparent"} border-t-2 pt-8`}
|
||||
>
|
||||
<p
|
||||
className={`${product.featured ? "text-dark-50 dark:text-dark-1000" : "text-light-1000 dark:text-dark-1000"} text-center text-sm font-semibold`}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr>
|
||||
{plans.map((plan) => {
|
||||
const isMostPopular = plan.id === "pro";
|
||||
return (
|
||||
<th
|
||||
key={plan.id}
|
||||
scope="col"
|
||||
className={twMerge(
|
||||
"w-1/4 px-6 py-4 text-left text-base font-semibold",
|
||||
isMostPopular
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
|
||||
: "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
{plan.name}
|
||||
{plan.id === "pro" && <span className="ml-1 text-xl">∞</span>}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sections.map((section, sectionIdx) => (
|
||||
<React.Fragment key={section.name || `section-${sectionIdx}`}>
|
||||
{section.name && (
|
||||
<tr>
|
||||
{plans.map((plan) => {
|
||||
const isPro = plan.id === "pro";
|
||||
return (
|
||||
<td
|
||||
key={plan.id}
|
||||
className={twMerge(
|
||||
"border-t px-6 py-3 text-left text-sm font-semibold text-dark-900 dark:text-dark-900",
|
||||
isPro
|
||||
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
|
||||
: "border-light-300 bg-light-50 dark:border-dark-400 dark:bg-dark-50",
|
||||
)}
|
||||
>
|
||||
{plan.id === "free" ? section.name : ""}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
)}
|
||||
{section.features.map((feature) => (
|
||||
<tr key={feature.key}>
|
||||
{plans.map((plan) => {
|
||||
const isPro = plan.id === "pro";
|
||||
return (
|
||||
<td
|
||||
key={plan.id}
|
||||
className={twMerge(
|
||||
"w-1/4 border-t px-6 py-3 text-left",
|
||||
isPro
|
||||
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
|
||||
: "border-light-300 dark:border-dark-400",
|
||||
)}
|
||||
>
|
||||
<CellValue
|
||||
value={getFeatureValue(feature, plan.id)}
|
||||
label={feature.label}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="-mt-6 space-y-12">
|
||||
{sections.map((section) => (
|
||||
<div key={section.name}>
|
||||
<h4 className="text-sm font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{section.name}
|
||||
</h4>
|
||||
<div className="relative -mx-8 mt-8">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block"
|
||||
>
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className={`size-full rounded-lg ${product.featured ? "bg-white shadow-sm dark:bg-dark-50 dark:shadow-none" : ""}`}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<table className="relative w-full border-separate border-spacing-x-8">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th scope="col">
|
||||
<span className="sr-only">{t`Feature`}</span>
|
||||
</th>
|
||||
{products.map((p) => (
|
||||
<th key={p.id} scope="col">
|
||||
<span className="sr-only">{p.name}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{section.features.map((feature, featureIdx) => (
|
||||
<tr key={feature.key}>
|
||||
<th
|
||||
scope="row"
|
||||
className="w-1/3 py-3 pr-4 text-left text-sm font-normal text-light-1000 dark:text-dark-1000"
|
||||
>
|
||||
{feature.label}
|
||||
{featureIdx !== section.features.length - 1 ? (
|
||||
<div className="absolute inset-x-8 mt-3 h-px bg-light-500 dark:bg-dark-300" />
|
||||
) : null}
|
||||
</th>
|
||||
{products.map((p) => (
|
||||
<td
|
||||
key={p.id}
|
||||
className="relative w-1/3 px-4 py-0 text-center"
|
||||
>
|
||||
<span className="relative inline-flex w-full items-center justify-center py-3">
|
||||
<CellValue
|
||||
value={
|
||||
p.id === "kan" ? feature.kan : feature.trello
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block"
|
||||
>
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className={`${product.featured ? "ring-1 ring-light-500 dark:ring-dark-800" : "ring-0"} rounded-2xl`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -23,115 +23,81 @@ const Pricing = ({
|
||||
}) => {
|
||||
const tiers = [
|
||||
{
|
||||
name: t`Free`,
|
||||
id: "tier-free",
|
||||
name: t`Individuals`,
|
||||
id: "tier-individuals",
|
||||
href: "signup",
|
||||
buttonText: t`Get started`,
|
||||
price: { monthly: t`$0`, annually: t`$0` },
|
||||
description: t`Free for everyone`,
|
||||
bestFor: t`Best for individuals and solo creators getting started`,
|
||||
featureHeader: undefined,
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: t`Free`, annually: t`Free` },
|
||||
description: t`Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.`,
|
||||
featureHeader: t`Free, forever`,
|
||||
features: [
|
||||
{ text: t`1 user` },
|
||||
{ text: t`Unlimited boards` },
|
||||
{ text: t`Unlimited cards` },
|
||||
{ text: t`Custom board templates` },
|
||||
{ text: t`Checklists` },
|
||||
t`1 user`,
|
||||
t`Unlimited boards`,
|
||||
t`Unlimited cards`,
|
||||
t`Custom board templates`,
|
||||
t`Checklists`,
|
||||
],
|
||||
showPrice: true,
|
||||
ctaSubtext: undefined,
|
||||
mostPopular: false,
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: t`Teams`,
|
||||
id: "tier-teams",
|
||||
href: "signup",
|
||||
buttonText: t`Get started`,
|
||||
price: { monthly: "$10", annually: "$8" },
|
||||
description: t`Perfect for small and growing teams looking to collaborate.`,
|
||||
bestFor: t`Best for small and growing teams that need collaboration`,
|
||||
featureHeader: undefined,
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: "$10.00", annually: "$8.00" },
|
||||
description: t`Kan is better with a team. Perfect for small and growing teams looking to collaborate.`,
|
||||
featureHeader: t`Everything in the free plan, plus:`,
|
||||
features: [
|
||||
{ text: t`All Free features +` },
|
||||
{ text: t`Workspace members` },
|
||||
{ text: t`Invite links` },
|
||||
{ text: t`Unlimited file uploads` },
|
||||
{ text: t`Email notifications for mentions` },
|
||||
{ text: t`Priority email support` },
|
||||
],
|
||||
highlighted: false,
|
||||
showPrice: true,
|
||||
showPriceSuffix: true,
|
||||
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
|
||||
mostPopular: false,
|
||||
},
|
||||
{
|
||||
name: t`Pro`,
|
||||
id: "tier-pro",
|
||||
href: "signup",
|
||||
buttonText: t`Get started`,
|
||||
price: { monthly: "$29", annually: "$24" },
|
||||
description: t`Unlimited seats and advanced features for growing teams.`,
|
||||
bestFor: t`Best for teams that want to scale without limits`,
|
||||
featureHeader: undefined,
|
||||
features: [
|
||||
{ text: t`All Teams features +` },
|
||||
{ text: t`Unlimited members` },
|
||||
{ text: t`Custom workspace username` },
|
||||
{ text: t`Configurable user roles and permissions` },
|
||||
{ text: t`Custom branding` },
|
||||
t`Workspace members`,
|
||||
t`Admin roles`,
|
||||
t`Priority email support`,
|
||||
t`Support the development of the project`,
|
||||
],
|
||||
highlighted: true,
|
||||
showPrice: true,
|
||||
showPriceSuffix: false,
|
||||
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
|
||||
mostPopular: true,
|
||||
showPriceSuffix: true,
|
||||
},
|
||||
{
|
||||
name: t`Enterprise`,
|
||||
id: "tier-enterprise",
|
||||
href: "mailto:support@kan.bn?subject=Enterprise Inquiry",
|
||||
buttonText: t`Contact Sales`,
|
||||
price: { monthly: t`Contact us`, annually: t`Contact us` },
|
||||
description: t`Advanced security, compliance, and dedicated support for large organizations.`,
|
||||
bestFor: t`Best for large organizations with advanced needs`,
|
||||
featureHeader: undefined,
|
||||
name: t`Self Host`,
|
||||
id: "tier-self-host",
|
||||
href: "https://github.com/kanbn/kan",
|
||||
buttonText: t`View docs`,
|
||||
price: { monthly: "-", annually: "-" },
|
||||
description: t`Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.`,
|
||||
featureHeader: t`Complete control and ownership:`,
|
||||
features: [
|
||||
{ text: t`All Pro features +` },
|
||||
{ text: t`SAML SSO` },
|
||||
{ text: t`Advanced admin controls` },
|
||||
{ text: t`Dedicated account manager` },
|
||||
{ text: t`Custom SLA` },
|
||||
{ text: t`On-premise deployment option` },
|
||||
t`Run on your own infrastructure`,
|
||||
t`Own your data`,
|
||||
t`Custom domain`,
|
||||
],
|
||||
highlighted: false,
|
||||
showPrice: true,
|
||||
showPriceSuffix: false,
|
||||
ctaSubtext: undefined,
|
||||
mostPopular: false,
|
||||
showPrice: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto max-w-7xl px-4">
|
||||
<div className="mb-8 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center px-4 pb-10">
|
||||
<div className="mt-14 flex flex-col items-center justify-center">
|
||||
<div className="mb-8 flex items-center gap-2 rounded-full border bg-white px-4 py-1.5 text-center text-xs font-bold text-gray-800 dark:border-dark-300 dark:bg-dark-1000 dark:text-gray-800 lg:text-sm">
|
||||
<HiBolt />
|
||||
<p>{t`Launch offer: unlimited seats for just $29/month with Pro`}</p>
|
||||
</div>
|
||||
<fieldset aria-label={t`Payment frequency`}>
|
||||
<RadioGroup
|
||||
value={frequency}
|
||||
onChange={(value) => setFrequency(value)}
|
||||
className="flex gap-1 rounded-lg border border-light-300 bg-light-50 p-1 dark:border-dark-300 dark:bg-dark-50"
|
||||
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs/5 font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600"
|
||||
>
|
||||
{frequencies.map((option) => (
|
||||
<Radio
|
||||
key={option.value}
|
||||
value={option}
|
||||
className={twMerge(
|
||||
"cursor-pointer rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
"cursor-pointer rounded-full px-2.5 py-1 text-xs transition-colors lg:text-sm",
|
||||
frequency?.value === option.value
|
||||
? "bg-white text-light-1000 shadow-sm dark:bg-dark-100 dark:text-dark-1000"
|
||||
: "text-light-600 dark:text-dark-700",
|
||||
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
|
||||
: "text-light-900 hover:bg-light-100 dark:hover:bg-dark-100",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
@@ -140,129 +106,103 @@ const Pricing = ({
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="isolate mx-auto mb-10 grid max-w-md grid-cols-1 gap-8 px-4 lg:mx-0 lg:max-w-none lg:grid-cols-3">
|
||||
{tiers.map((tier) => (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={twMerge(
|
||||
"relative flex h-full flex-col rounded-lg border p-6",
|
||||
tier.mostPopular
|
||||
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
|
||||
: "border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50",
|
||||
tier.highlighted
|
||||
? "bg-dark-50 ring-1 ring-dark-50 dark:ring-dark-800"
|
||||
: "bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-300",
|
||||
"rounded-3xl p-8 xl:p-10",
|
||||
)}
|
||||
>
|
||||
{tier.id === "tier-pro" && (
|
||||
<div className="absolute right-2 top-2 z-10">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-light-400 bg-light-100 px-3 py-1 text-center text-xs text-dark-600 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
|
||||
<HiBolt className="h-3 w-3 -ml-0.5" />
|
||||
{t`Launch offer`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h3
|
||||
id={tier.id}
|
||||
<div className="flex items-center justify-between gap-x-4">
|
||||
<h3
|
||||
id={tier.id}
|
||||
className={twMerge(
|
||||
tier.highlighted
|
||||
? "text-dark-1000 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
"text-lg/8 font-semibold",
|
||||
)}
|
||||
>
|
||||
{tier.name}
|
||||
</h3>
|
||||
{tier.highlighted && (
|
||||
<p className="rounded-full bg-light-50 px-2.5 py-1 text-[12px] font-semibold text-dark-500 dark:bg-dark-1000 dark:text-dark-50">
|
||||
{t`14 day free trial`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={twMerge(
|
||||
"mb-4 flex items-center text-base font-semibold",
|
||||
tier.mostPopular
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-1000 dark:text-dark-1000",
|
||||
"mt-4 text-sm/6 text-dark-950 dark:text-dark-900",
|
||||
tier.highlighted ? "text-light-100" : "text-dark-50",
|
||||
)}
|
||||
>
|
||||
{tier.name}
|
||||
{tier.id === "tier-pro" && <span className="ml-1 text-xl leading-none">∞</span>}
|
||||
</h3>
|
||||
<div className="mb-4">
|
||||
<p className="flex items-baseline gap-x-1">
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-2xl font-semibold",
|
||||
tier.mostPopular
|
||||
? "text-light-1000 dark:text-dark-1000"
|
||||
: "text-light-1000 dark:text-dark-1000",
|
||||
!tier.showPrice && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{tier.price[frequency?.value ?? "monthly"]}
|
||||
{tier.description}
|
||||
</p>
|
||||
<p className="mt-6 flex items-baseline gap-x-1">
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-3xl font-semibold tracking-tight text-light-100",
|
||||
tier.highlighted
|
||||
? "text-light-50 dark:text-dark-1000"
|
||||
: "text-gray-900 dark:text-dark-1000",
|
||||
!tier.showPrice && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{tier.price[frequency?.value ?? "monthly"]}
|
||||
</span>
|
||||
{tier.showPriceSuffix && (
|
||||
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">
|
||||
{frequency?.priceSuffix}
|
||||
</span>
|
||||
{tier.id === "tier-pro" && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-2xl font-semibold line-through",
|
||||
tier.mostPopular
|
||||
? "text-light-600 dark:text-dark-600"
|
||||
: "text-light-600 dark:text-dark-600",
|
||||
)}
|
||||
>
|
||||
{frequency?.value === "annually" ? "$79" : "$100"}
|
||||
</span>
|
||||
)}
|
||||
{tier.showPriceSuffix && (
|
||||
<span className="text-sm font-normal text-dark-400 dark:text-dark-700">
|
||||
{frequency?.priceSuffix}
|
||||
</span>
|
||||
)}
|
||||
{tier.id === "tier-pro" && (
|
||||
<span className="text-sm font-normal text-dark-400 dark:text-dark-700">
|
||||
{t`/month`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{tier.bestFor && (
|
||||
<p className="mb-6 text-sm text-dark-400 dark:text-dark-700">
|
||||
{tier.bestFor}
|
||||
</p>
|
||||
)}
|
||||
<div className="mb-6">
|
||||
{tier.id === "tier-enterprise" ? (
|
||||
<a
|
||||
href={tier.href}
|
||||
aria-describedby={tier.id}
|
||||
className="block w-full rounded-md border border-dark-200 bg-transparent px-4 py-2 text-center text-sm font-medium text-dark-50 transition-colors hover:bg-dark-50 hover:text-light-50 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-200"
|
||||
>
|
||||
{tier.buttonText}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href={tier.href}
|
||||
aria-describedby={tier.id}
|
||||
className={twMerge(
|
||||
"block w-full rounded-md px-4 py-2 text-center text-sm font-medium transition-colors",
|
||||
tier.mostPopular
|
||||
? "bg-dark-50 text-light-50 shadow-sm hover:bg-dark-100 dark:bg-dark-1000 dark:text-dark-50 dark:hover:bg-dark-900"
|
||||
: "border border-dark-200 bg-transparent text-dark-50 transition-colors hover:bg-dark-50 hover:text-light-50 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{tier.buttonText}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ul role="list" className="space-y-2.5">
|
||||
{tier.features.map((feature, index) => (
|
||||
<li
|
||||
key={`${tier.id}-feature-${index}`}
|
||||
className="flex items-start gap-x-2.5"
|
||||
>
|
||||
<HiCheckCircle
|
||||
className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000"
|
||||
/>
|
||||
<span className="text-sm text-dark-600 dark:text-dark-800">
|
||||
{feature.text}
|
||||
</span>
|
||||
</p>
|
||||
<Link
|
||||
href={tier.href}
|
||||
aria-describedby={tier.id}
|
||||
className={twMerge(
|
||||
tier.highlighted
|
||||
? "bg-light-50 text-dark-50 shadow-sm dark:bg-dark-1000 dark:text-dark-50"
|
||||
: "bg-dark-50 text-light-50 dark:bg-dark-200 dark:text-dark-1000",
|
||||
"mt-6 block rounded-md px-3 py-2 text-center text-sm/6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600",
|
||||
)}
|
||||
>
|
||||
{tier.buttonText}
|
||||
</Link>
|
||||
<p
|
||||
className={twMerge(
|
||||
"mt-8 text-sm/6 font-bold",
|
||||
tier.highlighted
|
||||
? "text-light-100 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{tier.featureHeader}
|
||||
</p>
|
||||
<ul
|
||||
role="list"
|
||||
className={twMerge(
|
||||
"mt-2 space-y-3 text-sm/6 text-light-600",
|
||||
tier.highlighted
|
||||
? "text-light-100 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-x-3">
|
||||
<HiCheckCircle className="h-5 w-5" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mx-auto mt-8 max-w-3xl px-4 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
||||
<p>{t`14 day free trial · Switch plans or cancel anytime`}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -43,8 +43,8 @@ export default function PricingView() {
|
||||
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
|
||||
{t`Simple pricing`}
|
||||
</p>
|
||||
<p className="text:md lg:text-md mt-6 max-w-[300px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Start free. Upgrade as your team grows. No hidden fees, no contracts.`}
|
||||
<p className="text:md lg:text-md mt-6 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
|
||||
</p>
|
||||
</div>
|
||||
<PricingTiers
|
||||
@@ -59,9 +59,23 @@ export default function PricingView() {
|
||||
</div>
|
||||
|
||||
<div className="pb-22 flex flex-col items-center justify-center px-4">
|
||||
|
||||
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
|
||||
<p>{t`Features`}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 w-full">
|
||||
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Feature breakdown`}
|
||||
</p>
|
||||
<p className="text-md lg:text-md my-4 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Compare our features to see why Kan is the best choice.`}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Button variant="primary" href="/signup">
|
||||
{t`Get started`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 w-full max-w-[900px]">
|
||||
<FeatureComparisonTable
|
||||
frequencyValue={frequency?.value ?? "annually"}
|
||||
/>
|
||||
|
||||
@@ -33,7 +33,7 @@ const UpdateWorkspaceUrlForm = ({
|
||||
.min(3, {
|
||||
message: t`URL must be at least 3 characters long`,
|
||||
})
|
||||
.max(64, { message: t`URL cannot exceed 64 characters` })
|
||||
.max(24, { message: t`URL cannot exceed 24 characters` })
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||
}),
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
services:
|
||||
migrator:
|
||||
image: ghcr.io/kanbn/kan-migrate:latest
|
||||
container_name: ${MIGRATOR_CONTAINER_NAME:-kan-migrate}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
target: migrate
|
||||
networks:
|
||||
- dokploy-network
|
||||
environment:
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
restart: "no"
|
||||
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
container_name: ${CONTAINER_NAME:-kan-web}
|
||||
@@ -22,7 +9,6 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/web/Dockerfile
|
||||
target: web
|
||||
args:
|
||||
APP_VERSION: ${APP_VERSION:-}
|
||||
env_file:
|
||||
@@ -93,9 +79,6 @@ services:
|
||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
|
||||
depends_on:
|
||||
migrator:
|
||||
condition: service_completed_successfully
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
services:
|
||||
migrate:
|
||||
image: ghcr.io/kanbn/kan-migrate:latest
|
||||
container_name: ${CONTAINER_NAME:-kan-migrate}
|
||||
networks:
|
||||
- kan-network
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
target: migrate
|
||||
environment:
|
||||
- POSTGRES_URL=${POSTGRES_URL}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
web:
|
||||
image: ghcr.io/kanbn/kan:latest
|
||||
container_name: ${CONTAINER_NAME:-kan-web}
|
||||
@@ -22,10 +6,10 @@ services:
|
||||
- "${WEB_PORT:-3000}:3000"
|
||||
networks:
|
||||
- kan-network
|
||||
- dokploy-network
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./apps/web/Dockerfile
|
||||
target: web
|
||||
dockerfile: ./apps/web/Dockerfile.new
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -123,8 +107,7 @@ services:
|
||||
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
|
||||
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
@@ -138,17 +121,14 @@ services:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- kan_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- kan-network
|
||||
|
||||
networks:
|
||||
kan-network:
|
||||
dokploy-network:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
kan_postgres_data:
|
||||
|
||||
@@ -142,18 +142,9 @@ export const attachmentRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new TRPCError({
|
||||
message: "Failed to create attachment",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.attachment.added",
|
||||
cardId: card.id,
|
||||
attachmentId: attachment.id,
|
||||
toTitle: input.originalFilename,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
@@ -215,8 +206,6 @@ export const attachmentRouter = createTRPCRouter({
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.attachment.removed",
|
||||
cardId: attachment.cardId,
|
||||
attachmentId: attachment.id,
|
||||
fromTitle: attachment.originalFilename,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ export const boardRouter = createTRPCRouter({
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
type: z.enum(["regular", "template"]).optional(),
|
||||
archived: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
@@ -66,10 +65,7 @@ export const boardRouter = createTRPCRouter({
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
userId,
|
||||
{
|
||||
type: input.type,
|
||||
archived: input.archived ?? false,
|
||||
}
|
||||
{ type: input.type }
|
||||
);
|
||||
|
||||
return result;
|
||||
@@ -157,51 +153,28 @@ export const boardRouter = createTRPCRouter({
|
||||
// Generate presigned URLs for workspace member avatars
|
||||
const workspaceWithAvatarUrls = result.workspace
|
||||
? {
|
||||
...result.workspace,
|
||||
members: await Promise.all(
|
||||
result.workspace.members.map(async (member) => {
|
||||
if (!member.user?.image) {
|
||||
return member;
|
||||
}
|
||||
...result.workspace,
|
||||
members: await Promise.all(
|
||||
result.workspace.members.map(async (member) => {
|
||||
if (!member.user?.image) {
|
||||
return member;
|
||||
}
|
||||
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: {
|
||||
...member.user,
|
||||
image: avatarUrl,
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
}
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: {
|
||||
...member.user,
|
||||
image: avatarUrl,
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
}
|
||||
: result.workspace;
|
||||
|
||||
// Generate presigned URLs for card member avatars
|
||||
const listsWithAvatarUrls = await Promise.all(
|
||||
result.lists.map(async (list) => ({
|
||||
...list,
|
||||
cards: await Promise.all(
|
||||
list.cards.map(async (card) => ({
|
||||
...card,
|
||||
members: await Promise.all(
|
||||
card.members.map(async (member) => {
|
||||
if (!member.user?.image) return member;
|
||||
const avatarUrl = await generateAvatarUrl(member.user.image);
|
||||
return {
|
||||
...member,
|
||||
user: { ...member.user, image: avatarUrl },
|
||||
};
|
||||
}),
|
||||
),
|
||||
})),
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
lists: listsWithAvatarUrls,
|
||||
workspace: workspaceWithAvatarUrls,
|
||||
};
|
||||
}),
|
||||
@@ -222,7 +195,7 @@ export const boardRouter = createTRPCRouter({
|
||||
workspaceSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(64)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
boardSlug: z
|
||||
.string()
|
||||
@@ -465,8 +438,7 @@ export const boardRouter = createTRPCRouter({
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||
.optional(),
|
||||
visibility: z.enum(["public", "private"]).optional(),
|
||||
favorite: z.boolean().optional(),
|
||||
isArchived: z.boolean().optional(),
|
||||
favorite: z.boolean().optional()
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
|
||||
@@ -508,7 +480,7 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Handle other updates (name, slug, visibility)
|
||||
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined || input.isArchived !== undefined;
|
||||
const hasOtherUpdates = input.name || input.slug || input.visibility !== undefined;
|
||||
|
||||
if (!hasOtherUpdates) {
|
||||
// Only favorite was updated, return success
|
||||
@@ -535,7 +507,6 @@ export const boardRouter = createTRPCRouter({
|
||||
slug: input.slug,
|
||||
boardPublicId: input.boardPublicId,
|
||||
visibility: input.visibility,
|
||||
isArchived: input.isArchived,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
|
||||
@@ -10,13 +10,8 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { mergeActivities } from "../utils/activities";
|
||||
import { sendMentionEmails } from "../utils/notifications";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
|
||||
import {
|
||||
createCardWebhookPayload,
|
||||
sendWebhooksForWorkspace,
|
||||
} from "../utils/webhook";
|
||||
|
||||
export const cardRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@@ -158,43 +153,6 @@ export const cardRouter = createTRPCRouter({
|
||||
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
|
||||
}
|
||||
|
||||
if (input.description) {
|
||||
sendMentionEmails({
|
||||
db: ctx.db,
|
||||
cardPublicId: newCard.publicId,
|
||||
commentHtml: input.description,
|
||||
commenterUserId: userId,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send mention emails:", error);
|
||||
});
|
||||
}
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
sendWebhooksForWorkspace(
|
||||
ctx.db,
|
||||
list.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: String(newCard.id),
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
dueDate: input.dueDate ?? null,
|
||||
listId: String(newCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: list.boardPublicId,
|
||||
boardName: list.boardName,
|
||||
listName: list.name,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
).catch((error) => {
|
||||
console.error("Webhook delivery failed:", error);
|
||||
});
|
||||
|
||||
return newCard;
|
||||
}),
|
||||
addComment: protectedProcedure
|
||||
@@ -257,16 +215,6 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
sendMentionEmails({
|
||||
db: ctx.db,
|
||||
cardPublicId: input.cardPublicId,
|
||||
commentHtml: input.comment,
|
||||
commenterUserId: userId,
|
||||
commentId: newComment.id,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send mention emails:", error);
|
||||
});
|
||||
|
||||
return newComment;
|
||||
}),
|
||||
updateComment: protectedProcedure
|
||||
@@ -347,16 +295,6 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
sendMentionEmails({
|
||||
db: ctx.db,
|
||||
cardPublicId: input.cardPublicId,
|
||||
commentHtml: input.comment,
|
||||
commenterUserId: userId,
|
||||
commentId: updatedComment.id,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send mention emails:", error);
|
||||
});
|
||||
|
||||
return updatedComment;
|
||||
}),
|
||||
deleteComment: protectedProcedure
|
||||
@@ -986,15 +924,6 @@ export const cardRouter = createTRPCRouter({
|
||||
fromDescription: existingCard.description ?? undefined,
|
||||
toDescription: input.description,
|
||||
});
|
||||
|
||||
sendMentionEmails({
|
||||
db: ctx.db,
|
||||
cardPublicId: input.cardPublicId,
|
||||
commentHtml: input.description,
|
||||
commenterUserId: userId,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to send mention emails:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1037,59 +966,6 @@ export const cardRouter = createTRPCRouter({
|
||||
await cardActivityRepo.bulkCreate(ctx.db, activities);
|
||||
}
|
||||
|
||||
// Build changes object for webhook
|
||||
const webhookChanges: Record<string, { from: unknown; to: unknown }> = {};
|
||||
if (input.title && existingCard.title !== input.title) {
|
||||
webhookChanges.title = { from: existingCard.title, to: input.title };
|
||||
}
|
||||
if (input.description && existingCard.description !== input.description) {
|
||||
webhookChanges.description = {
|
||||
from: existingCard.description,
|
||||
to: input.description,
|
||||
};
|
||||
}
|
||||
if (
|
||||
input.dueDate !== undefined &&
|
||||
previousDueDate?.getTime() !== input.dueDate?.getTime()
|
||||
) {
|
||||
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
|
||||
}
|
||||
if (newListId && existingCard.listId !== newListId) {
|
||||
webhookChanges.listId = { from: existingCard.listId, to: newListId };
|
||||
}
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
sendWebhooksForWorkspace(
|
||||
ctx.db,
|
||||
card.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
newListId && existingCard.listId !== newListId
|
||||
? "card.moved"
|
||||
: "card.updated",
|
||||
{
|
||||
id: String(result.id),
|
||||
title: result.title,
|
||||
description: result.description,
|
||||
dueDate: result.dueDate,
|
||||
listId: String(newListId ?? existingCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: card.listName,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
changes:
|
||||
Object.keys(webhookChanges).length > 0
|
||||
? webhookChanges
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
).catch((error) => {
|
||||
console.error("Webhook delivery failed:", error);
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
@@ -1137,9 +1013,6 @@ export const cardRouter = createTRPCRouter({
|
||||
card.createdBy,
|
||||
);
|
||||
|
||||
// Fetch full card data before delete for webhook
|
||||
const fullCard = await cardRepo.getByPublicId(ctx.db, input.cardPublicId);
|
||||
|
||||
const deletedAt = new Date();
|
||||
|
||||
await cardRepo.softDelete(ctx.db, {
|
||||
@@ -1154,34 +1027,6 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
if (fullCard) {
|
||||
sendWebhooksForWorkspace(
|
||||
ctx.db,
|
||||
card.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
"card.deleted",
|
||||
{
|
||||
id: String(fullCard.id),
|
||||
title: fullCard.title,
|
||||
description: fullCard.description,
|
||||
dueDate: fullCard.dueDate,
|
||||
listId: String(fullCard.listId),
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: card.listName,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
).catch((error) => {
|
||||
console.error("Webhook delivery failed:", error);
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -81,16 +81,6 @@ export const checklistRouter = createTRPCRouter({
|
||||
return newChecklist;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a checklist",
|
||||
method: "PUT",
|
||||
path: "/checklists/{checklistPublicId}",
|
||||
description: "Updates a checklist by its public ID",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
checklistPublicId: z.string().length(12),
|
||||
|
||||
@@ -210,12 +210,11 @@ export const memberRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
if (!member)
|
||||
throw new TRPCError({
|
||||
message: `Member with public ID ${input.memberPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const deletedMember = await memberRepo.softDelete(ctx.db, {
|
||||
memberId: member.id,
|
||||
@@ -721,7 +720,7 @@ export const memberRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
|
||||
@@ -133,7 +133,7 @@ export const permissionRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -209,7 +209,7 @@ export const permissionRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -274,7 +274,7 @@ export const permissionRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -339,7 +339,7 @@ export const permissionRouter = createTRPCRouter({
|
||||
input.memberPublicId,
|
||||
);
|
||||
|
||||
if (!member || member.workspaceId !== workspace.id) {
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
message: "Member not found",
|
||||
code: "NOT_FOUND",
|
||||
|
||||
@@ -160,7 +160,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
workspaceSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(64)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
}),
|
||||
)
|
||||
@@ -207,7 +207,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
slug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(64)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||
.optional(),
|
||||
}),
|
||||
@@ -290,7 +290,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
slug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(64)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||
.optional(),
|
||||
description: z.string().min(3).max(280).optional(),
|
||||
@@ -424,7 +424,7 @@ export const workspaceRouter = createTRPCRouter({
|
||||
workspaceSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(64)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as notificationRepo from "@kan/db/repository/notification.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { sendEmail } from "@kan/email";
|
||||
import { parseMentionsFromHTML } from "@kan/shared/utils";
|
||||
|
||||
/**
|
||||
* Sends mention notification emails to mentioned members
|
||||
* Only sends emails for new mentions (checks notification table to avoid duplicates)
|
||||
*/
|
||||
export async function sendMentionEmails({
|
||||
db,
|
||||
cardPublicId,
|
||||
commentHtml,
|
||||
commenterUserId,
|
||||
commentId,
|
||||
}: {
|
||||
db: dbClient;
|
||||
cardPublicId: string;
|
||||
commentHtml: string;
|
||||
commenterUserId: string;
|
||||
commentId?: number;
|
||||
}) {
|
||||
try {
|
||||
// Parse mentions from HTML
|
||||
const mentionPublicIds = parseMentionsFromHTML(commentHtml);
|
||||
if (mentionPublicIds.length === 0) return;
|
||||
|
||||
// Get card with board information
|
||||
const card = await cardRepo.getWithListAndMembersByPublicId(db, cardPublicId);
|
||||
if (!card?.list.board) return;
|
||||
|
||||
const board = card.list.board;
|
||||
const boardName = board.name;
|
||||
const cardTitle = card.title;
|
||||
const cardId = card.id;
|
||||
|
||||
// Get workspace ID from workspace publicId
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
board.workspace.publicId,
|
||||
);
|
||||
if (!workspace?.id) return;
|
||||
|
||||
const workspaceId = workspace.id;
|
||||
|
||||
// Get commenter information
|
||||
const commenter = await userRepo.getById(db, commenterUserId);
|
||||
if (!commenter) return;
|
||||
|
||||
const commenterName = commenter.name?.trim() || commenter.email;
|
||||
|
||||
// Get mentioned members with full details (filtered by workspace)
|
||||
const membersWithDetails = await memberRepo.getByPublicIdsWithUsers(
|
||||
db,
|
||||
mentionPublicIds,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
// Filter out the commenter
|
||||
const membersToNotify = membersWithDetails.filter(
|
||||
(member) => member.user?.id !== commenterUserId,
|
||||
);
|
||||
|
||||
if (membersToNotify.length === 0) return;
|
||||
|
||||
const baseUrl = env("NEXT_PUBLIC_BASE_URL");
|
||||
const cardUrl = `${baseUrl}/cards/${cardPublicId}`;
|
||||
|
||||
// Send emails to all mentioned members (only if notification doesn't exist)
|
||||
await Promise.all(
|
||||
membersToNotify.map(async (member) => {
|
||||
const userId = member.user?.id;
|
||||
const email = member.user?.email ?? member.email;
|
||||
|
||||
// Skip pending members (no userId) - they can be mentioned but won't receive emails
|
||||
if (!userId || !email) return;
|
||||
|
||||
try {
|
||||
// Check if notification already exists for this mention
|
||||
const notificationExists = await notificationRepo.exists(db, {
|
||||
userId,
|
||||
cardId,
|
||||
type: "mention",
|
||||
});
|
||||
|
||||
// If notification already exists, skip sending email
|
||||
if (notificationExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create notification record
|
||||
await notificationRepo.create(db, {
|
||||
type: "mention",
|
||||
userId,
|
||||
cardId,
|
||||
commentId,
|
||||
});
|
||||
|
||||
// Send email
|
||||
await sendEmail(
|
||||
email,
|
||||
`${commenterName} mentioned you in a comment on ${cardTitle}`,
|
||||
"MENTION",
|
||||
{
|
||||
commenterName,
|
||||
boardName,
|
||||
cardTitle,
|
||||
cardUrl,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send mention email:", {
|
||||
email,
|
||||
cardPublicId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error sending mention emails:", {
|
||||
cardPublicId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@kan/db/repository/webhook.repo", () => ({
|
||||
getActiveByWorkspaceId: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
import {
|
||||
sendWebhookToUrl,
|
||||
sendWebhooksForWorkspace,
|
||||
createCardWebhookPayload,
|
||||
webhookUrlSchema,
|
||||
type WebhookPayload,
|
||||
} from "./webhook";
|
||||
|
||||
const mockGetActiveByWorkspaceId = webhookRepo.getActiveByWorkspaceId as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("webhook utilities", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2024-01-15T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("createCardWebhookPayload", () => {
|
||||
it("creates a payload with required card fields", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.event).toBe("card.created");
|
||||
expect(payload.timestamp).toBe("2024-01-15T12:00:00.000Z");
|
||||
expect(payload.data.card).toEqual({
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
description: undefined,
|
||||
dueDate: null,
|
||||
listId: "list-456",
|
||||
boardId: "board-789",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes optional card fields when provided", () => {
|
||||
const dueDate = new Date("2024-02-01T10:00:00.000Z");
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.updated",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
description: "A description",
|
||||
dueDate,
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.card.description).toBe("A description");
|
||||
expect(payload.data.card.dueDate).toBe("2024-02-01T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("includes board context when provided", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
boardName: "My Board",
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.board).toEqual({
|
||||
id: "board-789",
|
||||
name: "My Board",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes list context when provided", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
listName: "To Do",
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.list).toEqual({
|
||||
id: "list-456",
|
||||
name: "To Do",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes user context when provided", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.created",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
user: {
|
||||
id: "user-111",
|
||||
name: "John Doe",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.user).toEqual({
|
||||
id: "user-111",
|
||||
name: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes changes for card.updated events", () => {
|
||||
const payload = createCardWebhookPayload(
|
||||
"card.updated",
|
||||
{
|
||||
id: "card-123",
|
||||
title: "Updated Title",
|
||||
listId: "list-456",
|
||||
},
|
||||
{
|
||||
boardId: "board-789",
|
||||
changes: {
|
||||
title: { from: "Old Title", to: "Updated Title" },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.data.changes).toEqual({
|
||||
title: { from: "Old Title", to: "Updated Title" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendWebhookToUrl", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const mockPayload: WebhookPayload = {
|
||||
event: "card.created",
|
||||
timestamp: "2024-01-15T12:00:00.000Z",
|
||||
data: {
|
||||
card: {
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
boardId: "board-789",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("SSRF protection", () => {
|
||||
it("blocks HTTP URLs", async () => {
|
||||
const result = await sendWebhookToUrl("http://example.com/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("HTTPS");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks localhost", async () => {
|
||||
const result = await sendWebhookToUrl("https://localhost/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Localhost");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks 127.0.0.1", async () => {
|
||||
const result = await sendWebhookToUrl("https://127.0.0.1/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks cloud metadata endpoint", async () => {
|
||||
const result = await sendWebhookToUrl("https://169.254.169.254/latest/meta-data/", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("metadata");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks private 10.x.x.x IPs", async () => {
|
||||
const result = await sendWebhookToUrl("https://10.0.0.1/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Private");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks private 192.168.x.x IPs", async () => {
|
||||
const result = await sendWebhookToUrl("https://192.168.1.1/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(false);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows valid HTTPS URLs", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
const result = await sendWebhookToUrl("https://example.com/webhook", undefined, mockPayload);
|
||||
expect(result.success).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("sends POST request with correct headers", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
await sendWebhookToUrl("https://example.com/webhook", undefined, mockPayload);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Event": "card.created",
|
||||
"X-Webhook-Timestamp": "2024-01-15T12:00:00.000Z",
|
||||
}),
|
||||
body: JSON.stringify(mockPayload),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes signature header when secret is provided", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
await sendWebhookToUrl("https://example.com/webhook", "my-secret", mockPayload);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Webhook-Signature": expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns success for 2xx responses", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const result = await sendWebhookToUrl(
|
||||
"https://example.com/webhook",
|
||||
undefined,
|
||||
mockPayload,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ success: true, statusCode: 200 });
|
||||
});
|
||||
|
||||
it("returns failure for non-2xx responses", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
|
||||
const result = await sendWebhookToUrl(
|
||||
"https://example.com/webhook",
|
||||
undefined,
|
||||
mockPayload,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
statusCode: 500,
|
||||
error: "500 Internal Server Error",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns failure on network error", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("Network error"),
|
||||
);
|
||||
|
||||
const result = await sendWebhookToUrl(
|
||||
"https://example.com/webhook",
|
||||
undefined,
|
||||
mockPayload,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Network error",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns timeout error when request takes too long", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
const error = new Error("Aborted");
|
||||
error.name = "AbortError";
|
||||
reject(error);
|
||||
}, 15000);
|
||||
}),
|
||||
);
|
||||
|
||||
const resultPromise = sendWebhookToUrl(
|
||||
"https://example.com/webhook",
|
||||
undefined,
|
||||
mockPayload,
|
||||
);
|
||||
|
||||
// Advance timers to trigger the abort
|
||||
vi.advanceTimersByTime(15000);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "Request timed out",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendWebhooksForWorkspace", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const mockDb = {} as Parameters<typeof sendWebhooksForWorkspace>[0];
|
||||
|
||||
const mockPayload: WebhookPayload = {
|
||||
event: "card.created",
|
||||
timestamp: "2024-01-15T12:00:00.000Z",
|
||||
data: {
|
||||
card: {
|
||||
id: "card-123",
|
||||
title: "Test Card",
|
||||
listId: "list-456",
|
||||
boardId: "board-789",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("sends to all active webhooks subscribed to the event", async () => {
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
publicId: "wh-1",
|
||||
url: "https://example.com/webhook1",
|
||||
secret: "secret1",
|
||||
events: ["card.created", "card.updated"],
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
publicId: "wh-2",
|
||||
url: "https://example.com/webhook2",
|
||||
secret: null,
|
||||
events: ["card.created"],
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
// Event filtering now happens at DB level
|
||||
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
1,
|
||||
"card.created",
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook1",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook2",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send when no webhooks match the event (DB-level filtering)", async () => {
|
||||
// DB-level event filter returns empty array when no webhooks match
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
expect(mockGetActiveByWorkspaceId).toHaveBeenCalledWith(
|
||||
mockDb,
|
||||
1,
|
||||
"card.created",
|
||||
);
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues sending to other webhooks when one fails", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([
|
||||
{
|
||||
id: 1,
|
||||
publicId: "wh-1",
|
||||
url: "https://example.com/webhook1",
|
||||
secret: null,
|
||||
events: ["card.created"],
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
publicId: "wh-2",
|
||||
url: "https://example.com/webhook2",
|
||||
secret: null,
|
||||
events: ["card.created"],
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Error" })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Webhook delivery failed"),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("handles empty webhook list", async () => {
|
||||
mockGetActiveByWorkspaceId.mockResolvedValueOnce([]);
|
||||
|
||||
await sendWebhooksForWorkspace(mockDb, 1, mockPayload);
|
||||
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("catches and logs DB errors without throwing", async () => {
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
mockGetActiveByWorkspaceId.mockRejectedValueOnce(
|
||||
new Error("DB connection failed"),
|
||||
);
|
||||
|
||||
// Should not throw — the try/catch absorbs the error
|
||||
await expect(
|
||||
sendWebhooksForWorkspace(mockDb, 1, mockPayload),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Failed to send webhooks for workspace:",
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("webhookUrlSchema", () => {
|
||||
it("accepts valid HTTPS URLs", () => {
|
||||
expect(webhookUrlSchema.safeParse("https://example.com/webhook").success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects HTTP URLs", () => {
|
||||
const result = webhookUrlSchema.safeParse("http://example.com/webhook");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects localhost", () => {
|
||||
const result = webhookUrlSchema.safeParse("https://localhost/webhook");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects private IPs", () => {
|
||||
expect(webhookUrlSchema.safeParse("https://10.0.0.1/webhook").success).toBe(false);
|
||||
expect(webhookUrlSchema.safeParse("https://192.168.1.1/webhook").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects cloud metadata endpoints", () => {
|
||||
expect(webhookUrlSchema.safeParse("https://169.254.169.254/latest").success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import type { WebhookEvent } from "@kan/db/schema";
|
||||
import * as webhookRepo from "@kan/db/repository/webhook.repo";
|
||||
|
||||
export type WebhookEventType = WebhookEvent;
|
||||
|
||||
export interface WebhookPayload {
|
||||
event: WebhookEventType;
|
||||
timestamp: string;
|
||||
data: {
|
||||
card: {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
dueDate?: string | null; // ISO string after JSON serialization
|
||||
listId: string;
|
||||
boardId: string;
|
||||
};
|
||||
board?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
list?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
user?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
};
|
||||
changes?: Record<string, { from: unknown; to: unknown }>;
|
||||
};
|
||||
}
|
||||
|
||||
function generateSignature(payload: string, secret: string): string {
|
||||
return crypto.createHmac("sha256", secret).update(payload).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema for webhook URLs with SSRF mitigation.
|
||||
* Requires HTTPS and blocks private/internal IP ranges, localhost,
|
||||
* and cloud metadata endpoints.
|
||||
*/
|
||||
export const webhookUrlSchema = z
|
||||
.string()
|
||||
.url()
|
||||
.max(2048)
|
||||
.refine(
|
||||
(url) => {
|
||||
try {
|
||||
return new URL(url).protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "Only HTTPS URLs are allowed" },
|
||||
)
|
||||
.refine(
|
||||
(url) => {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return !(
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname === "0.0.0.0"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "Localhost URLs are not allowed" },
|
||||
)
|
||||
.refine(
|
||||
(url) => {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
return !(
|
||||
hostname === "169.254.169.254" ||
|
||||
hostname === "metadata.google.internal"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "Cloud metadata endpoints are not allowed" },
|
||||
)
|
||||
.refine(
|
||||
(url) => {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
const ipv4Match = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(hostname);
|
||||
if (ipv4Match) {
|
||||
const [, a, b] = ipv4Match.map(Number);
|
||||
if (
|
||||
a === 10 ||
|
||||
(a === 172 && b! >= 16 && b! <= 31) ||
|
||||
(a === 192 && b === 168)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "Private IP addresses are not allowed" },
|
||||
);
|
||||
|
||||
/**
|
||||
* Send a webhook payload to a specific URL.
|
||||
*
|
||||
* SSRF note: URL validation (HTTPS-only, no private IPs, no cloud metadata)
|
||||
* is enforced both here at delivery time and at webhook creation via
|
||||
* webhookUrlSchema. This function is only reachable by workspace admins.
|
||||
*/
|
||||
export async function sendWebhookToUrl(
|
||||
url: string,
|
||||
secret: string | undefined,
|
||||
payload: WebhookPayload,
|
||||
): Promise<{ success: boolean; statusCode?: number; error?: string }> {
|
||||
const result = webhookUrlSchema.safeParse(url);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error.issues[0]?.message };
|
||||
}
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Webhook-Event": payload.event,
|
||||
"X-Webhook-Timestamp": payload.timestamp,
|
||||
};
|
||||
|
||||
if (secret) {
|
||||
headers["X-Webhook-Signature"] = generateSignature(body, secret);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
statusCode: response.status,
|
||||
error: `${response.status} ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, statusCode: response.status };
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return { success: false, error: "Request timed out" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send webhook to all active webhooks for a workspace that are subscribed to the event.
|
||||
* Wrapped in try/catch so callers using fire-and-forget don't risk unhandled rejections.
|
||||
*/
|
||||
export async function sendWebhooksForWorkspace(
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
payload: WebhookPayload,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get active webhooks for this workspace
|
||||
const webhooks = await webhookRepo.getActiveByWorkspaceId(db, workspaceId);
|
||||
|
||||
// Filter webhooks that are subscribed to this specific event
|
||||
const webhooksForEvent = webhooks.filter((webhook) =>
|
||||
webhook.events.includes(payload.event),
|
||||
);
|
||||
|
||||
// Send to all subscribed webhooks in parallel (fire and forget)
|
||||
const promises = webhooksForEvent.map((webhook) =>
|
||||
sendWebhookToUrl(webhook.url, webhook.secret ?? undefined, payload).then(
|
||||
(result) => {
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`Webhook delivery failed to ${webhook.url}: ${result.error}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for all to complete but don't block on failures
|
||||
await Promise.allSettled(promises);
|
||||
} catch (error) {
|
||||
console.error("Failed to send webhooks for workspace:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createCardWebhookPayload(
|
||||
event: WebhookEventType,
|
||||
card: {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
dueDate?: Date | null;
|
||||
listId: string;
|
||||
},
|
||||
context: {
|
||||
boardId: string;
|
||||
boardName?: string;
|
||||
listName?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
};
|
||||
changes?: Record<string, { from: unknown; to: unknown }>;
|
||||
},
|
||||
): WebhookPayload {
|
||||
return {
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
card: {
|
||||
id: card.id,
|
||||
title: card.title,
|
||||
description: card.description,
|
||||
dueDate: card.dueDate?.toISOString() ?? null,
|
||||
listId: card.listId,
|
||||
boardId: context.boardId,
|
||||
},
|
||||
board: context.boardName
|
||||
? { id: context.boardId, name: context.boardName }
|
||||
: undefined,
|
||||
list: context.listName
|
||||
? { id: card.listId, name: context.listName }
|
||||
: undefined,
|
||||
user: context.user,
|
||||
changes: context.changes,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,6 @@
|
||||
"dev": "tsc",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --emitDeclarationOnly false"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -11,14 +11,15 @@ import { createPlugins } from "./plugins";
|
||||
import { configuredProviders } from "./providers";
|
||||
|
||||
export const initAuth = (db: dbClient) => {
|
||||
const baseURL = env("NEXT_PUBLIC_BASE_URL") || env("BETTER_AUTH_URL");
|
||||
const trustedOrigins =
|
||||
env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",").filter(Boolean) ?? [];
|
||||
|
||||
return betterAuth({
|
||||
secret: env("BETTER_AUTH_SECRET"),
|
||||
baseURL,
|
||||
trustedOrigins: [...(baseURL ? [baseURL] : []), ...trustedOrigins],
|
||||
baseURL: env("NEXT_PUBLIC_BASE_URL"),
|
||||
trustedOrigins: env("BETTER_AUTH_TRUSTED_ORIGINS")
|
||||
? [
|
||||
env("NEXT_PUBLIC_BASE_URL") ?? "",
|
||||
...(env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? []),
|
||||
]
|
||||
: [env("NEXT_PUBLIC_BASE_URL") ?? ""],
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
@@ -33,10 +34,8 @@ export const initAuth = (db: dbClient) => {
|
||||
},
|
||||
emailAndPassword: {
|
||||
enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true",
|
||||
// Sign-up restriction is handled by the user.create.before database
|
||||
// hook which checks for pending invitations, allowing invited users
|
||||
// to register even when public sign-up is disabled.
|
||||
disableSignUp: false,
|
||||
disableSignUp:
|
||||
env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true",
|
||||
sendResetPassword: async (data) => {
|
||||
await sendEmail(data.user.email, "Reset Password", "RESET_PASSWORD", {
|
||||
resetPasswordUrl: data.url,
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("next-runtime-env", () => ({
|
||||
env: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/db/repository/member.repo", () => ({
|
||||
getByEmailAndStatus: vi.fn(),
|
||||
getByPublicId: vi.fn(),
|
||||
acceptInvite: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/db/repository/user.repo", () => ({
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@kan/email", () => ({
|
||||
notificationClient: null,
|
||||
}));
|
||||
|
||||
vi.mock("@kan/shared", () => ({
|
||||
createEmailUnsubscribeLink: vi.fn(),
|
||||
createS3Client: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aws-sdk/client-s3", () => ({
|
||||
PutObjectCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@novu/api/models/components", () => ({
|
||||
ChatOrPushProviderEnum: { Discord: "discord" },
|
||||
}));
|
||||
|
||||
import { env } from "next-runtime-env";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import { createDatabaseHooks } from "./hooks";
|
||||
|
||||
const mockEnv = env as ReturnType<typeof vi.fn>;
|
||||
const mockGetByEmailAndStatus =
|
||||
memberRepo.getByEmailAndStatus as ReturnType<typeof vi.fn>;
|
||||
|
||||
const db = {} as Parameters<typeof createDatabaseHooks>[0];
|
||||
|
||||
const fakeUser = {
|
||||
id: "user-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
email: "test@example.com",
|
||||
emailVerified: false,
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
describe("createDatabaseHooks", () => {
|
||||
const hooks = createDatabaseHooks(db);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("user.create.before", () => {
|
||||
it("allows sign-up when DISABLE_SIGN_UP is not set", async () => {
|
||||
mockEnv.mockReturnValue(undefined);
|
||||
|
||||
const result = await hooks.user.create.before(fakeUser, {});
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetByEmailAndStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows sign-up when DISABLE_SIGN_UP is false", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "false" : undefined,
|
||||
);
|
||||
|
||||
const result = await hooks.user.create.before(fakeUser, {});
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetByEmailAndStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks sign-up when disabled and user has no pending invitation", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
|
||||
);
|
||||
mockGetByEmailAndStatus.mockResolvedValue(undefined);
|
||||
|
||||
const result = await hooks.user.create.before(fakeUser, {});
|
||||
expect(result).toBe(false);
|
||||
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
|
||||
db,
|
||||
"test@example.com",
|
||||
"invited",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows sign-up when disabled but user has a pending invitation", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
|
||||
);
|
||||
mockGetByEmailAndStatus.mockResolvedValue({
|
||||
id: "member-1",
|
||||
email: "test@example.com",
|
||||
status: "invited",
|
||||
});
|
||||
|
||||
const result = await hooks.user.create.before(fakeUser, {});
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
|
||||
db,
|
||||
"test@example.com",
|
||||
"invited",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks sign-up when disabled and invitation exists but domain is not allowed", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
|
||||
);
|
||||
process.env.BETTER_AUTH_ALLOWED_DOMAINS = "acme.com";
|
||||
mockGetByEmailAndStatus.mockResolvedValue({
|
||||
id: "member-1",
|
||||
email: "test@example.com",
|
||||
status: "invited",
|
||||
});
|
||||
|
||||
const result = await hooks.user.create.before(fakeUser, {});
|
||||
expect(result).toBe(false);
|
||||
|
||||
delete process.env.BETTER_AUTH_ALLOWED_DOMAINS;
|
||||
});
|
||||
|
||||
// The user.create.before hook fires for ALL sign-up paths including
|
||||
// OIDC/social — verify invite bypass works regardless of auth method.
|
||||
it("allows OIDC/social sign-up when disabled but user has a pending invitation", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
|
||||
);
|
||||
const oidcUser = {
|
||||
...fakeUser,
|
||||
id: "user-oidc",
|
||||
email: "sso@corp.com",
|
||||
image: "https://provider.com/avatar.jpg",
|
||||
};
|
||||
mockGetByEmailAndStatus.mockResolvedValue({
|
||||
id: "member-2",
|
||||
email: "sso@corp.com",
|
||||
status: "invited",
|
||||
});
|
||||
|
||||
const result = await hooks.user.create.before(oidcUser, {});
|
||||
expect(result).toBe(true);
|
||||
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
|
||||
db,
|
||||
"sso@corp.com",
|
||||
"invited",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks OIDC/social sign-up when disabled and user has no pending invitation", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
|
||||
);
|
||||
const oidcUser = {
|
||||
...fakeUser,
|
||||
id: "user-oidc",
|
||||
email: "random@external.com",
|
||||
image: "https://provider.com/avatar.jpg",
|
||||
};
|
||||
mockGetByEmailAndStatus.mockResolvedValue(undefined);
|
||||
|
||||
const result = await hooks.user.create.before(oidcUser, {});
|
||||
expect(result).toBe(false);
|
||||
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
|
||||
db,
|
||||
"random@external.com",
|
||||
"invited",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows sign-up when disabled, invitation exists, and domain is allowed", async () => {
|
||||
mockEnv.mockImplementation((key: string) =>
|
||||
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
|
||||
);
|
||||
process.env.BETTER_AUTH_ALLOWED_DOMAINS = "example.com";
|
||||
mockGetByEmailAndStatus.mockResolvedValue({
|
||||
id: "member-1",
|
||||
email: "test@example.com",
|
||||
status: "invited",
|
||||
});
|
||||
|
||||
const result = await hooks.user.create.before(fakeUser, {});
|
||||
expect(result).toBe(true);
|
||||
|
||||
delete process.env.BETTER_AUTH_ALLOWED_DOMAINS;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,9 @@ import { magicLink } from "better-auth/plugins/magic-link";
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { sendEmail } from "@kan/email";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
import { sendEmail } from "@kan/email";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
import { socialProvidersPlugin } from "./providers";
|
||||
@@ -134,21 +133,20 @@ export function createPlugins(db: dbClient) {
|
||||
|
||||
if (workspace?.id) {
|
||||
await memberRepo.pauseAllMembers(db, workspace.id);
|
||||
|
||||
|
||||
// Reset slug to publicId, or generate a UID if publicId is taken
|
||||
let newSlug = workspace.publicId;
|
||||
|
||||
|
||||
if (workspace.slug !== workspace.publicId) {
|
||||
const isPublicIdAvailable =
|
||||
await workspaceRepo.isWorkspaceSlugAvailable(
|
||||
db,
|
||||
workspace.publicId,
|
||||
);
|
||||
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
|
||||
db,
|
||||
workspace.publicId,
|
||||
);
|
||||
if (!isPublicIdAvailable) {
|
||||
newSlug = generateUID();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
await workspaceRepo.update(db, subscription.referenceId, {
|
||||
plan: "free",
|
||||
slug: newSlug,
|
||||
@@ -173,76 +171,18 @@ export function createPlugins(db: dbClient) {
|
||||
magicLink({
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
console.log("Sending magic link to:", email, "URL:", url);
|
||||
console.log(
|
||||
"Magic link contains invite:",
|
||||
decodedUrl.includes("type=invite"),
|
||||
);
|
||||
if (decodedUrl.includes("type=invite")) {
|
||||
let inviterName = "";
|
||||
let workspaceName = "";
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const callbackUrl = urlObj.searchParams.get("callbackURL");
|
||||
if (callbackUrl) {
|
||||
const callbackParams = new URL(
|
||||
callbackUrl,
|
||||
process.env.NEXT_PUBLIC_BASE_URL,
|
||||
).searchParams;
|
||||
const memberPublicId = callbackParams.get("memberPublicId");
|
||||
|
||||
if (memberPublicId) {
|
||||
const member = await memberRepo.getByPublicId(
|
||||
db,
|
||||
memberPublicId,
|
||||
);
|
||||
if (member) {
|
||||
const [workspace, inviter] = await Promise.all([
|
||||
workspaceRepo.getById(db, member.workspaceId),
|
||||
userRepo.getById(db, member.createdBy),
|
||||
]);
|
||||
|
||||
if (workspace) workspaceName = workspace.name;
|
||||
if (inviter) inviterName = inviter.name ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch invite details:", error);
|
||||
}
|
||||
|
||||
await sendEmail(
|
||||
email,
|
||||
workspaceName
|
||||
? `Invitation to join the workspace ${workspaceName}`
|
||||
: "Invitation to join workspace",
|
||||
"JOIN_WORKSPACE",
|
||||
{
|
||||
magicLoginUrl: url,
|
||||
inviterName,
|
||||
workspaceName,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await sendEmail(
|
||||
email,
|
||||
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY === "true"
|
||||
? "Sign in to your account"
|
||||
: "Sign in to Kan",
|
||||
"MAGIC_LINK",
|
||||
{
|
||||
magicLoginUrl: url,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending magic link:", {
|
||||
if (url.includes("type=invite")) {
|
||||
await sendEmail(
|
||||
email,
|
||||
url,
|
||||
error,
|
||||
"Invitation to join workspace",
|
||||
"JOIN_WORKSPACE",
|
||||
{
|
||||
magicLoginUrl: url,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await sendEmail(email, "Sign in to kan.bn", "MAGIC_LINK", {
|
||||
magicLoginUrl: url,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { resolve } from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
root: __dirname,
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@kan/db": resolve(__dirname, "../db/src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS "workspace_webhooks" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"publicId" varchar(12) NOT NULL,
|
||||
"workspaceId" bigint NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"url" varchar(2048) NOT NULL,
|
||||
"secret" text,
|
||||
"events" text NOT NULL,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"createdBy" uuid NOT NULL,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp,
|
||||
CONSTRAINT "workspace_webhooks_publicId_unique" UNIQUE("publicId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace_webhooks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "workspace_webhooks_workspace_idx" ON "workspace_webhooks" USING btree ("workspaceId");--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -1,46 +0,0 @@
|
||||
CREATE TYPE "public"."notification_type" AS ENUM('mention', 'workspace.member.added', 'workspace.member.removed', 'workspace.role.changed');--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "notification" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"publicId" varchar(12) NOT NULL,
|
||||
"type" "notification_type" NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"cardId" bigint,
|
||||
"commentId" bigint,
|
||||
"workspaceId" bigint,
|
||||
"metadata" text,
|
||||
"readAt" timestamp,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp,
|
||||
CONSTRAINT "notification_publicId_unique" UNIQUE("publicId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_commentId_card_comments_id_fk" FOREIGN KEY ("commentId") REFERENCES "public"."card_comments"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_user_deleted_idx" ON "notification" USING btree ("userId","deletedAt");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_user_read_deleted_idx" ON "notification" USING btree ("userId","readAt","deletedAt");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_user_type_card_idx" ON "notification" USING btree ("userId","type","cardId");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_user_type_workspace_idx" ON "notification" USING btree ("userId","type","workspaceId");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "notification_user_created_idx" ON "notification" USING btree ("userId","createdAt");
|
||||
@@ -1,6 +0,0 @@
|
||||
ALTER TABLE "card_activity" ADD COLUMN "attachmentId" bigint;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_attachmentId_card_attachment_id_fk" FOREIGN KEY ("attachmentId") REFERENCES "public"."card_attachment"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "board" ADD COLUMN "isArchived" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "board_is_archived_idx" ON "board" USING btree ("isArchived");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -176,27 +176,6 @@
|
||||
"when": 1769983198190,
|
||||
"tag": "20260201215958_AddUserBoardFavouritesTable",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1770500457005,
|
||||
"tag": "20260207214056_AddNotificationsTable",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1771192000000,
|
||||
"tag": "20260129210000_AddWorkspaceWebhooks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1772049587901,
|
||||
"tag": "20260225195947_AddIsArchivedToBoard",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export const getAllByWorkspaceId = async (
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
userId: string,
|
||||
opts?: { type?: "regular" | "template"; archived?: boolean },
|
||||
opts?: { type?: "regular" | "template" },
|
||||
) => {
|
||||
const boardsData = await db.query.boards.findMany({
|
||||
columns: {
|
||||
@@ -78,7 +78,6 @@ export const getAllByWorkspaceId = async (
|
||||
eq(boards.workspaceId, workspaceId),
|
||||
isNull(boards.deletedAt),
|
||||
opts?.type ? eq(boards.type, opts.type) : undefined,
|
||||
opts?.archived !== undefined ? eq(boards.isArchived, opts.archived) : undefined,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -103,7 +102,6 @@ export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
|
||||
columns: {
|
||||
id: true,
|
||||
type: true,
|
||||
isArchived: true,
|
||||
},
|
||||
where: eq(boards.publicId, boardPublicId),
|
||||
});
|
||||
@@ -197,7 +195,6 @@ export const getByPublicId = async (
|
||||
name: true,
|
||||
slug: true,
|
||||
visibility: true,
|
||||
isArchived: true,
|
||||
},
|
||||
with: {
|
||||
userFavorites: {
|
||||
@@ -215,7 +212,6 @@ export const getByPublicId = async (
|
||||
columns: {
|
||||
publicId: true,
|
||||
email: true,
|
||||
status: true,
|
||||
},
|
||||
with: {
|
||||
user: {
|
||||
@@ -631,7 +627,6 @@ export const update = async (
|
||||
slug: string | undefined;
|
||||
visibility: BoardVisibilityStatus | undefined;
|
||||
boardPublicId: string;
|
||||
isArchived?: boolean;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
@@ -641,7 +636,6 @@ export const update = async (
|
||||
slug: boardInput.slug,
|
||||
visibility: boardInput.visibility,
|
||||
updatedAt: new Date(),
|
||||
...(boardInput.isArchived !== undefined && { isArchived: boardInput.isArchived })
|
||||
})
|
||||
.where(eq(boards.publicId, boardInput.boardPublicId))
|
||||
.returning({
|
||||
|
||||
@@ -420,7 +420,6 @@ export const getWithListAndMembersByPublicId = async (
|
||||
) => {
|
||||
const card = await db.query.cards.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
publicId: true,
|
||||
title: true,
|
||||
description: true,
|
||||
@@ -508,7 +507,6 @@ export const getWithListAndMembersByPublicId = async (
|
||||
columns: {
|
||||
publicId: true,
|
||||
email: true,
|
||||
status: true,
|
||||
},
|
||||
with: {
|
||||
user: {
|
||||
@@ -945,14 +943,12 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
||||
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
|
||||
with: {
|
||||
list: {
|
||||
columns: { name: true },
|
||||
columns: {},
|
||||
with: {
|
||||
board: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
workspaceId: true,
|
||||
visibility: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -966,9 +962,6 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
|
||||
createdBy: result.createdBy,
|
||||
workspaceId: result.list.board.workspaceId,
|
||||
workspaceVisibility: result.list.board.visibility,
|
||||
listName: result.list.name,
|
||||
boardPublicId: result.list.board.publicId,
|
||||
boardName: result.list.board.name,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
@@ -33,7 +33,6 @@ export const create = async (
|
||||
fromDueDate?: Date;
|
||||
toDueDate?: Date;
|
||||
sourceBoardId?: number;
|
||||
attachmentId?: number;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
@@ -59,7 +58,6 @@ export const create = async (
|
||||
fromDueDate: activityInput.fromDueDate,
|
||||
toDueDate: activityInput.toDueDate,
|
||||
sourceBoardId: activityInput.sourceBoardId,
|
||||
attachmentId: activityInput.attachmentId,
|
||||
})
|
||||
.returning({ id: cardActivities.id });
|
||||
|
||||
@@ -85,7 +83,6 @@ export const bulkCreate = async (
|
||||
fromDueDate?: Date;
|
||||
toDueDate?: Date;
|
||||
sourceBoardId?: number;
|
||||
attachmentId?: number;
|
||||
}[],
|
||||
) => {
|
||||
const activitiesWithPublicIds = activityInputs.map((activity) => ({
|
||||
@@ -194,13 +191,6 @@ export const getPaginatedActivities = async (
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
attachment: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
filename: true,
|
||||
originalFilename: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: asc(cardActivities.createdAt), // required for merging and pagination
|
||||
limit: limit + 1, // fetch one extra to check if there are more
|
||||
|
||||
@@ -419,14 +419,12 @@ export const getWorkspaceAndListIdByListPublicId = async (
|
||||
listPublicId: string,
|
||||
) => {
|
||||
const result = await db.query.lists.findFirst({
|
||||
columns: { id: true, name: true, createdBy: true },
|
||||
columns: { id: true, createdBy: true },
|
||||
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
|
||||
with: {
|
||||
board: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
workspaceId: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -435,11 +433,8 @@ export const getWorkspaceAndListIdByListPublicId = async (
|
||||
return result
|
||||
? {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
createdBy: result.createdBy,
|
||||
workspaceId: result.board.workspaceId,
|
||||
boardPublicId: result.board.publicId,
|
||||
boardName: result.board.name,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
@@ -63,36 +63,6 @@ export const getById = async (db: dbClient, memberId: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getByPublicIdsWithUsers = async (
|
||||
db: dbClient,
|
||||
memberPublicIds: string[],
|
||||
workspaceId?: number,
|
||||
) => {
|
||||
return db.query.workspaceMembers.findMany({
|
||||
where: (members, { inArray: inArrayFn, eq, and, isNull: isNullFn }) => {
|
||||
const conditions = [inArrayFn(members.publicId, memberPublicIds)];
|
||||
|
||||
if (workspaceId) {
|
||||
conditions.push(eq(members.workspaceId, workspaceId));
|
||||
}
|
||||
|
||||
conditions.push(eq(members.status, "active"));
|
||||
conditions.push(isNullFn(members.deletedAt));
|
||||
|
||||
return and(...conditions);
|
||||
},
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getByEmailAndStatus = async (
|
||||
db: dbClient,
|
||||
email: string,
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { and, count, eq, isNull } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import type { NotificationType } from "@kan/db/schema";
|
||||
import { notifications } from "@kan/db/schema";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
export const create = async (
|
||||
db: dbClient,
|
||||
notificationInput: {
|
||||
type: NotificationType;
|
||||
userId: string;
|
||||
cardId?: number;
|
||||
commentId?: number;
|
||||
workspaceId?: number;
|
||||
metadata?: string;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
.insert(notifications)
|
||||
.values({
|
||||
publicId: generateUID(),
|
||||
type: notificationInput.type,
|
||||
userId: notificationInput.userId,
|
||||
cardId: notificationInput.cardId,
|
||||
commentId: notificationInput.commentId,
|
||||
workspaceId: notificationInput.workspaceId,
|
||||
metadata: notificationInput.metadata,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const exists = async (
|
||||
db: dbClient,
|
||||
args: {
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
cardId?: number;
|
||||
workspaceId?: number;
|
||||
commentId?: number;
|
||||
},
|
||||
) => {
|
||||
const result = await db.query.notifications.findFirst({
|
||||
where: (notifications, { eq, and, isNull: isNullFn }) => {
|
||||
const conditions = [
|
||||
eq(notifications.userId, args.userId),
|
||||
eq(notifications.type, args.type),
|
||||
isNullFn(notifications.deletedAt),
|
||||
];
|
||||
|
||||
if (args.cardId) {
|
||||
conditions.push(eq(notifications.cardId, args.cardId));
|
||||
}
|
||||
|
||||
if (args.workspaceId) {
|
||||
conditions.push(eq(notifications.workspaceId, args.workspaceId));
|
||||
}
|
||||
|
||||
return and(...conditions);
|
||||
},
|
||||
});
|
||||
|
||||
return !!result;
|
||||
};
|
||||
|
||||
export const markAsRead = async (
|
||||
db: dbClient,
|
||||
notificationId: number,
|
||||
) => {
|
||||
const [result] = await db
|
||||
.update(notifications)
|
||||
.set({ readAt: new Date() })
|
||||
.where(eq(notifications.id, notificationId))
|
||||
.returning();
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getUnreadCount = async (
|
||||
db: dbClient,
|
||||
userId: string,
|
||||
) => {
|
||||
const result = await db
|
||||
.select({ count: count() })
|
||||
.from(notifications)
|
||||
.where(
|
||||
and(
|
||||
eq(notifications.userId, userId),
|
||||
isNull(notifications.readAt),
|
||||
isNull(notifications.deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
return result[0]?.count ?? 0;
|
||||
};
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { workspaceWebhooks } from "@kan/db/schema";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import type { WebhookEvent } from "../schema/webhooks";
|
||||
|
||||
/** Parse JSON-encoded events column into typed array */
|
||||
function parseEvents(raw: string): WebhookEvent[] {
|
||||
return JSON.parse(raw) as WebhookEvent[];
|
||||
}
|
||||
|
||||
export const create = async (
|
||||
db: dbClient,
|
||||
webhookInput: {
|
||||
workspaceId: number;
|
||||
name: string;
|
||||
url: string;
|
||||
secret?: string;
|
||||
events: WebhookEvent[];
|
||||
createdBy: string;
|
||||
},
|
||||
) => {
|
||||
const [webhook] = await db
|
||||
.insert(workspaceWebhooks)
|
||||
.values({
|
||||
publicId: generateUID(),
|
||||
workspaceId: webhookInput.workspaceId,
|
||||
name: webhookInput.name,
|
||||
url: webhookInput.url,
|
||||
secret: webhookInput.secret,
|
||||
events: JSON.stringify(webhookInput.events),
|
||||
createdBy: webhookInput.createdBy,
|
||||
})
|
||||
.returning({
|
||||
publicId: workspaceWebhooks.publicId,
|
||||
name: workspaceWebhooks.name,
|
||||
url: workspaceWebhooks.url,
|
||||
events: workspaceWebhooks.events,
|
||||
active: workspaceWebhooks.active,
|
||||
createdAt: workspaceWebhooks.createdAt,
|
||||
});
|
||||
|
||||
return webhook
|
||||
? {
|
||||
...webhook,
|
||||
events: parseEvents(webhook.events),
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
export const update = async (
|
||||
db: dbClient,
|
||||
webhookPublicId: string,
|
||||
webhookInput: {
|
||||
name?: string;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
events?: WebhookEvent[];
|
||||
active?: boolean;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
.update(workspaceWebhooks)
|
||||
.set({
|
||||
name: webhookInput.name,
|
||||
url: webhookInput.url,
|
||||
secret: webhookInput.secret,
|
||||
events: webhookInput.events
|
||||
? JSON.stringify(webhookInput.events)
|
||||
: undefined,
|
||||
active: webhookInput.active,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workspaceWebhooks.publicId, webhookPublicId))
|
||||
.returning({
|
||||
publicId: workspaceWebhooks.publicId,
|
||||
name: workspaceWebhooks.name,
|
||||
url: workspaceWebhooks.url,
|
||||
events: workspaceWebhooks.events,
|
||||
active: workspaceWebhooks.active,
|
||||
createdAt: workspaceWebhooks.createdAt,
|
||||
updatedAt: workspaceWebhooks.updatedAt,
|
||||
});
|
||||
|
||||
return result
|
||||
? {
|
||||
...result,
|
||||
events: parseEvents(result.events),
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
export const getByPublicId = async (db: dbClient, webhookPublicId: string) => {
|
||||
const result = await db.query.workspaceWebhooks.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
publicId: true,
|
||||
workspaceId: true,
|
||||
name: true,
|
||||
url: true,
|
||||
secret: true,
|
||||
events: true,
|
||||
active: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
where: eq(workspaceWebhooks.publicId, webhookPublicId),
|
||||
});
|
||||
|
||||
return result
|
||||
? {
|
||||
...result,
|
||||
events: parseEvents(result.events),
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
export const getAllByWorkspaceId = async (
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
) => {
|
||||
const results = await db.query.workspaceWebhooks.findMany({
|
||||
columns: {
|
||||
publicId: true,
|
||||
name: true,
|
||||
url: true,
|
||||
events: true,
|
||||
active: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
where: eq(workspaceWebhooks.workspaceId, workspaceId),
|
||||
});
|
||||
|
||||
return results.map((webhook) => ({
|
||||
...webhook,
|
||||
events: parseEvents(webhook.events),
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns active webhooks with secrets for server-side delivery (HMAC signing).
|
||||
* DO NOT expose this via tRPC or any client-facing endpoint.
|
||||
* Use getAllByWorkspaceId (which omits secrets) for the admin list endpoint.
|
||||
*/
|
||||
export const getActiveByWorkspaceId = async (
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
) => {
|
||||
const results = await db.query.workspaceWebhooks.findMany({
|
||||
columns: {
|
||||
publicId: true,
|
||||
url: true,
|
||||
secret: true,
|
||||
events: true,
|
||||
},
|
||||
where: and(
|
||||
eq(workspaceWebhooks.workspaceId, workspaceId),
|
||||
eq(workspaceWebhooks.active, true),
|
||||
),
|
||||
});
|
||||
|
||||
return results.map((webhook) => ({
|
||||
...webhook,
|
||||
events: parseEvents(webhook.events),
|
||||
}));
|
||||
};
|
||||
|
||||
export const hardDelete = (db: dbClient, webhookPublicId: string) => {
|
||||
return db
|
||||
.delete(workspaceWebhooks)
|
||||
.where(eq(workspaceWebhooks.publicId, webhookPublicId));
|
||||
};
|
||||
@@ -30,22 +30,22 @@ const SYSTEM_ROLES: {
|
||||
description: string;
|
||||
hierarchyLevel: number;
|
||||
}[] = [
|
||||
{
|
||||
name: "admin",
|
||||
description: "Full access to all workspace features",
|
||||
hierarchyLevel: 100,
|
||||
},
|
||||
{
|
||||
name: "member",
|
||||
description: "Standard member with create and edit permissions",
|
||||
hierarchyLevel: 50,
|
||||
},
|
||||
{
|
||||
name: "guest",
|
||||
description: "View-only access",
|
||||
hierarchyLevel: 10,
|
||||
},
|
||||
];
|
||||
{
|
||||
name: "admin",
|
||||
description: "Full access to all workspace features",
|
||||
hierarchyLevel: 100,
|
||||
},
|
||||
{
|
||||
name: "member",
|
||||
description: "Standard member with create and edit permissions",
|
||||
hierarchyLevel: 50,
|
||||
},
|
||||
{
|
||||
name: "guest",
|
||||
description: "View-only access",
|
||||
hierarchyLevel: 10,
|
||||
},
|
||||
];
|
||||
|
||||
export const getCount = async (db: dbClient) => {
|
||||
const result = await db
|
||||
@@ -250,7 +250,7 @@ export const getBySlugWithBoards = (db: dbClient, workspaceSlug: string) => {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
where: and(isNull(boards.deletedAt), eq(boards.visibility, "public"), eq(boards.isArchived, false)),
|
||||
where: and(isNull(boards.deletedAt), eq(boards.visibility, "public")),
|
||||
orderBy: [asc(boards.name)]
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
bigserial,
|
||||
boolean,
|
||||
index,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
@@ -11,8 +12,8 @@ import {
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
boolean,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { imports } from "./imports";
|
||||
import { labels } from "./labels";
|
||||
import { lists } from "./lists";
|
||||
@@ -55,11 +56,9 @@ export const boards = pgTable(
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
visibility: boardVisibilityEnum("visibility").notNull().default("private"),
|
||||
type: boardTypeEnum("type").notNull().default("regular"),
|
||||
isArchived: boolean("isArchived").notNull().default(false),
|
||||
sourceBoardId: bigint("sourceBoardId", { mode: "number" }),
|
||||
},
|
||||
(table) => [
|
||||
index("board_is_archived_idx").on(table.isArchived),
|
||||
index("board_visibility_idx").on(table.visibility),
|
||||
index("board_type_idx").on(table.type),
|
||||
index("board_source_idx").on(table.sourceBoardId),
|
||||
|
||||
@@ -147,10 +147,6 @@ export const cardActivities = pgTable("card_activity", {
|
||||
() => boards.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
attachmentId: bigint("attachmentId", { mode: "number" }).references(
|
||||
() => cardAttachments.id,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
}).enableRLS();
|
||||
|
||||
export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
||||
@@ -194,11 +190,6 @@ export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
||||
references: [comments.id],
|
||||
relationName: "cardActivitiesComment",
|
||||
}),
|
||||
attachment: one(cardAttachments, {
|
||||
fields: [cardActivities.attachmentId],
|
||||
references: [cardAttachments.id],
|
||||
relationName: "cardActivitiesAttachment",
|
||||
}),
|
||||
}));
|
||||
|
||||
export const cardsToLabels = pgTable(
|
||||
|
||||
@@ -13,5 +13,3 @@ export * from "./workspaces";
|
||||
export * from "./subscriptions";
|
||||
export * from "./workspaceInviteLinks";
|
||||
export * from "./permissions";
|
||||
export * from "./notifications";
|
||||
export * from "./webhooks";
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
bigserial,
|
||||
index,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { cards } from "./cards";
|
||||
import { comments } from "./cards";
|
||||
import { users } from "./users";
|
||||
import { workspaces } from "./workspaces";
|
||||
|
||||
export const notificationTypes = [
|
||||
"mention",
|
||||
"workspace.member.added",
|
||||
"workspace.member.removed",
|
||||
"workspace.role.changed",
|
||||
] as const;
|
||||
|
||||
export type NotificationType = (typeof notificationTypes)[number];
|
||||
|
||||
export const notificationTypeEnum = pgEnum("notification_type", notificationTypes);
|
||||
|
||||
export const notifications = pgTable(
|
||||
"notification",
|
||||
{
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
type: notificationTypeEnum("type").notNull(),
|
||||
userId: uuid("userId")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
cardId: bigint("cardId", { mode: "number" }).references(() => cards.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
commentId: bigint("commentId", { mode: "number" }).references(
|
||||
() => comments.id,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
workspaceId: bigint("workspaceId", { mode: "number" }).references(
|
||||
() => workspaces.id,
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
metadata: text("metadata"),
|
||||
readAt: timestamp("readAt"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
deletedAt: timestamp("deletedAt"),
|
||||
},
|
||||
(table) => [
|
||||
index("notification_user_deleted_idx").on(table.userId, table.deletedAt),
|
||||
index("notification_user_read_deleted_idx").on(
|
||||
table.userId,
|
||||
table.readAt,
|
||||
table.deletedAt,
|
||||
),
|
||||
index("notification_user_type_card_idx").on(
|
||||
table.userId,
|
||||
table.type,
|
||||
table.cardId,
|
||||
),
|
||||
index("notification_user_type_workspace_idx").on(
|
||||
table.userId,
|
||||
table.type,
|
||||
table.workspaceId,
|
||||
),
|
||||
index("notification_user_created_idx").on(table.userId, table.createdAt),
|
||||
],
|
||||
).enableRLS();
|
||||
|
||||
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [notifications.userId],
|
||||
references: [users.id],
|
||||
relationName: "notificationsUser",
|
||||
}),
|
||||
card: one(cards, {
|
||||
fields: [notifications.cardId],
|
||||
references: [cards.id],
|
||||
relationName: "notificationsCard",
|
||||
}),
|
||||
comment: one(comments, {
|
||||
fields: [notifications.commentId],
|
||||
references: [comments.id],
|
||||
relationName: "notificationsComment",
|
||||
}),
|
||||
workspace: one(workspaces, {
|
||||
fields: [notifications.workspaceId],
|
||||
references: [workspaces.id],
|
||||
relationName: "notificationsWorkspace",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
bigserial,
|
||||
boolean,
|
||||
index,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { users } from "./users";
|
||||
import { workspaces } from "./workspaces";
|
||||
|
||||
export const webhookEvents = [
|
||||
"card.created",
|
||||
"card.updated",
|
||||
"card.moved",
|
||||
"card.deleted",
|
||||
] as const;
|
||||
export type WebhookEvent = (typeof webhookEvents)[number];
|
||||
|
||||
export const workspaceWebhooks = pgTable("workspace_webhooks", {
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
workspaceId: bigint("workspaceId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
url: varchar("url", { length: 2048 }).notNull(),
|
||||
secret: text("secret"),
|
||||
events: text("events").notNull(), // JSON array of webhook events
|
||||
active: boolean("active").notNull().default(true),
|
||||
createdBy: uuid("createdBy")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
}, (table) => [
|
||||
index("workspace_webhooks_workspace_idx").on(table.workspaceId),
|
||||
]).enableRLS();
|
||||
|
||||
export const workspaceWebhooksRelations = relations(
|
||||
workspaceWebhooks,
|
||||
({ one }) => ({
|
||||
workspace: one(workspaces, {
|
||||
fields: [workspaceWebhooks.workspaceId],
|
||||
references: [workspaces.id],
|
||||
relationName: "workspaceWebhooksWorkspace",
|
||||
}),
|
||||
createdByUser: one(users, {
|
||||
fields: [workspaceWebhooks.createdBy],
|
||||
references: [users.id],
|
||||
relationName: "workspaceWebhooksCreatedByUser",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user