Compare commits

..

1 Commits

Author SHA1 Message Date
Henry
ac9bdaf316 feat: improve pricing tiers and add comparison table 2026-02-18 21:58:58 +00:00
80 changed files with 26816 additions and 68543 deletions

View File

@@ -1,62 +1,18 @@
# Environment
.env .env
.env.*
!.env.example
# Docker docker-compose.override.yml
docker-compose*.yml
Dockerfile
./**/*/Dockerfile
.dockerignore .dockerignore
# Dependencies (rebuilt in Docker)
node_modules 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 ./**/*/pnpm-debug.log
# Cloud compose (separate deployment) README.md
cloud/ .next
# .git

View File

@@ -9,30 +9,20 @@ on:
schedule: schedule:
- cron: "21 21 * * *" - cron: "21 21 * * *"
push: push:
# Only trigger on tags (releases), not main branch pushes branches: ["main"]
# Main branch builds are handled by workflow_run after Translate completes # Publish semver tags as releases.
tags: ["v*.*.*"] tags: ["v*.*.*"]
pull_request: pull_request:
branches: ["main"] 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: env:
# Use docker.io for Docker Hub if empty # Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io REGISTRY: ghcr.io
# github.repository as <account>/<repo> # github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
MIGRATE_IMAGE_NAME: ${{ github.repository }}-migrate
jobs: jobs:
build: build:
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
@@ -86,19 +76,6 @@ jobs:
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}} 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 # Extract version from git tag or ref
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234 # Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
- name: Extract version - name: Extract version
@@ -152,22 +129,6 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max 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. # Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker # 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 # 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 # This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance. # against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} 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}

View File

@@ -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

View File

@@ -57,61 +57,39 @@ The easiest way to deploy Kan is through Railway. We've partnered with Railway t
### Docker Compose ### 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) 1. Create a new file called `docker-compose.yml` and paste the following configuration:
2. Use the provided `docker-compose.yml` file or create your own with the following configuration:
```yaml ```yaml
services: 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: web:
image: ghcr.io/kanbn/kan:latest image: ghcr.io/kanbn/kan:latest
container_name: kan-web container_name: kan-web
ports: ports:
- "${WEB_PORT:-3000}:3000" - "3000:3000"
networks: networks:
- kan-network - kan-network
env_file:
- .env
environment: environment:
- NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL} NEXT_PUBLIC_BASE_URL: http://localhost:3000
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} BETTER_AUTH_SECRET: your_auth_secret
- POSTGRES_URL=${POSTGRES_URL} POSTGRES_URL: postgresql://kan:your_postgres_password@postgres:5432/kan_db
- NEXT_PUBLIC_ALLOW_CREDENTIALS=true NEXT_PUBLIC_ALLOW_CREDENTIALS: true
depends_on: depends_on:
migrate: - postgres
condition: service_completed_successfully
restart: unless-stopped restart: unless-stopped
postgres: postgres:
image: postgres:15 image: postgres:15
container_name: kan-db container_name: kan-db
environment: environment:
- POSTGRES_DB=kan_db POSTGRES_DB: kan_db
- POSTGRES_USER=kan POSTGRES_USER: kan
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD} POSTGRES_PASSWORD: your_postgres_password
ports: ports:
- 5432:5432 - 5432:5432
volumes: volumes:
- kan_postgres_data:/var/lib/postgresql/data - 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 restart: unless-stopped
networks: networks:
- kan-network - kan-network
@@ -123,23 +101,23 @@ volumes:
kan_postgres_data: kan_postgres_data:
``` ```
3. Start the containers in detached mode: 2. Start the containers in detached mode:
```bash ```bash
docker compose up -d 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 stop the containers: `docker compose down`
- To view logs: `docker compose logs -f` - 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 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 🧑‍💻 ## Local Development 🧑‍💻

View File

@@ -1,23 +1,25 @@
# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=20 ARG NODE_VERSION=20
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12 ARG APP_DIRNAME=web
ARG PROJECT=@kan/web
# ============================================ # 1. Alpine image
# Stage 1: Alpine base with pnpm and turbo
# ============================================
FROM node:${NODE_VERSION}-alpine AS alpine FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update && \ RUN apk update && \
apk add --no-cache libc6-compat && \ apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
rm -rf /var/cache/apk/* /tmp/* || true && \ rm -rf /var/cache/apk/* /tmp/* || true
corepack enable && \
npm install turbo@2.3.1 --global && \
pnpm config set store-dir ~/.pnpm-store
# ============================================ # Setup pnpm and turbo on the alpine base
# Stage 2: Prune the monorepo FROM alpine AS base
# ============================================ RUN corepack enable
FROM alpine AS pruner # 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 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 "unknown") && \
echo "$AUTO_VERSION" > /app/AUTO_VERSION 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 WORKDIR /app
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml 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/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=pruner /app/out/json/ . 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 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 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')}" && \ 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}
# ============================================ # # Copy static files to standalone directory
# Stage 5: Migration image (run-once container) # RUN mkdir -p apps/web/.next/standalone/.next && \
# ============================================ # mv apps/web/public apps/web/.next/standalone/ && \
FROM node:${NODE_VERSION}-alpine AS migrate # mv apps/web/.next/static apps/web/.next/standalone/.next/
WORKDIR /db
COPY packages/db/drizzle.config.ts ./drizzle.config.ts # 4. Final image - runner stage to run the application
COPY packages/db/migrations/ ./migrations/ 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 WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Copy the standalone Next.js server COPY --chown=nextjs:nodejs --from=builder /app/ ./
COPY --from=builder /app/apps/web/.next/standalone/ ./ WORKDIR /app/apps/${APP_DIRNAME}
# 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 bootstrap script for runtime env var injection ARG PORT=3000
COPY apps/web/bootstrap.cjs ./bootstrap.cjs ENV PORT=${PORT}
EXPOSE ${PORT}
EXPOSE 3000 CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
CMD ["bootstrap.cjs"]

View File

@@ -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");

View File

@@ -1,12 +1,12 @@
{ {
"version": "1.10", "version": 0,
"locale": { "locale": {
"source": "en", "source": "en",
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"] "targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
}, },
"buckets": { "buckets": {
"json": { "po": {
"include": ["src/locales/[locale]/messages.json"] "include": ["src/locales/[locale]/messages.po"]
} }
}, },
"$schema": "https://lingo.dev/schema/i18n.json" "$schema": "https://lingo.dev/schema/i18n.json"

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,7 @@
import { defineConfig } from "@lingui/cli"; import type { LinguiConfig } from "@lingui/conf";
import { formatter } from "@lingui/format-json";
const config: LinguiConfig = {
export default defineConfig({ locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"],
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
sourceLocale: "en", sourceLocale: "en",
catalogs: [ catalogs: [
{ {
@@ -12,5 +10,6 @@ export default defineConfig({
exclude: ["**/node_modules/**"], exclude: ["**/node_modules/**"],
}, },
], ],
format: formatter({ style: "lingui" }), };
});
export default config;

View File

@@ -16,18 +16,6 @@ const config = {
: undefined, : undefined,
reactStrictMode: true, 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 */ /** Enables hot reloading for local packages without a build step */
transpilePackages: [ transpilePackages: [
"@kan/api", "@kan/api",
@@ -47,13 +35,49 @@ const config = {
remotePatterns: (() => { remotePatterns: (() => {
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */ /** @type {Array<{protocol: "http" | "https", hostname: string}>} */
const patterns = [ 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", protocol: "http",
hostname: "localhost", 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; return patterns;
})(), })(),
}, },

View File

@@ -79,7 +79,6 @@
"@kan/tailwind-config": "workspace:*", "@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*", "@kan/tsconfig": "workspace:*",
"@lingui/cli": "^5.3.2", "@lingui/cli": "^5.3.2",
"@lingui/format-json": "^5.9.1",
"@lingui/loader": "^5.3.2", "@lingui/loader": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2", "@lingui/swc-plugin": "^5.5.2",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",

View File

@@ -463,6 +463,13 @@ export default function Editor({
StarterKit.configure({ StarterKit.configure({
heading: disableHeadings ? false : undefined, heading: disableHeadings ? false : undefined,
}), }),
Markdown,
Placeholder.configure({
placeholder: readOnly
? ""
: placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`,
}),
Link.configure({ Link.configure({
openOnClick: true, openOnClick: true,
HTMLAttributes: { HTMLAttributes: {
@@ -472,14 +479,6 @@ export default function Editor({
}, },
validate: (href) => /^https?:\/\//.test(href), validate: (href) => /^https?:\/\//.test(href),
autolink: true, autolink: true,
linkOnPaste: true,
}),
Markdown,
Placeholder.configure({
placeholder: readOnly
? ""
: placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`,
}), }),
SlashCommands.configure({ SlashCommands.configure({
commandItems: getCommandItems(disableHeadings), commandItems: getCommandItems(disableHeadings),

View File

@@ -33,7 +33,7 @@ const schema = z.object({
.min(3, { .min(3, {
message: t`URL must be at least 3 characters long`, 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-]+$/, { .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`, message: t`URL can only contain letters, numbers, and hyphens`,
}) })

View File

@@ -19,7 +19,6 @@ interface UsePermissionsResult {
canCreateBoard: boolean; canCreateBoard: boolean;
canEditBoard: boolean; canEditBoard: boolean;
canDeleteBoard: boolean; canDeleteBoard: boolean;
canArchiveBoard: boolean;
canViewComment: boolean; canViewComment: boolean;
canCreateComment: boolean; canCreateComment: boolean;
canEditComment: boolean; canEditComment: boolean;
@@ -34,7 +33,7 @@ interface UsePermissionsResult {
export function usePermissions(): UsePermissionsResult { export function usePermissions(): UsePermissionsResult {
// Check if WorkspaceProvider is available (for public board views, it may not be) // Check if WorkspaceProvider is available (for public board views, it may not be)
const workspaceContext = useContext(WorkspaceContext); const workspaceContext = useContext(WorkspaceContext);
// If WorkspaceProvider is not available, return safe defaults // If WorkspaceProvider is not available, return safe defaults
if (!workspaceContext) { if (!workspaceContext) {
const emptyPermissions: UsePermissionsResult = { const emptyPermissions: UsePermissionsResult = {
@@ -52,7 +51,6 @@ export function usePermissions(): UsePermissionsResult {
canCreateBoard: false, canCreateBoard: false,
canEditBoard: false, canEditBoard: false,
canDeleteBoard: false, canDeleteBoard: false,
canArchiveBoard: false,
canViewComment: false, canViewComment: false,
canCreateComment: false, canCreateComment: false,
canEditComment: false, canEditComment: false,
@@ -97,7 +95,6 @@ export function usePermissions(): UsePermissionsResult {
canCreateBoard: hasPermission("board:create"), canCreateBoard: hasPermission("board:create"),
canEditBoard: hasPermission("board:edit"), canEditBoard: hasPermission("board:edit"),
canDeleteBoard: hasPermission("board:delete"), canDeleteBoard: hasPermission("board:delete"),
canArchiveBoard: hasPermission("board:edit"),
canViewComment: hasPermission("comment:view"), canViewComment: hasPermission("comment:view"),
canCreateComment: hasPermission("comment:create"), canCreateComment: hasPermission("comment:create"),
canEditComment: hasPermission("comment:edit"), canEditComment: hasPermission("comment:edit"),

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 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 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 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

View File

@@ -7,7 +7,7 @@ export const locales = [
"nl", "nl",
"ru", "ru",
"pl", "pl",
"ptbr" "pt-BR"
] as const; ] as const;
export type Locale = (typeof locales)[number]; export type Locale = (typeof locales)[number];
@@ -23,5 +23,5 @@ export const localeNames: Record<Locale, string> = {
nl: "Nederlands", nl: "Nederlands",
ru: "Русский", ru: "Русский",
pl: "Polski", pl: "Polski",
ptbr: "Português", "pt-BR": "Português",
}; };

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 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 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 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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -11,7 +11,7 @@ import { withRateLimit } from "@kan/api/utils/rateLimit";
const workspaceSlugSchema = z const workspaceSlugSchema = z
.string() .string()
.min(3) .min(3)
.max(64) .max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/); .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
interface CheckoutSessionRequest { interface CheckoutSessionRequest {

View File

@@ -58,7 +58,7 @@ export default function LoginPage() {
</div> </div>
</div> </div>
)} )}
{(!isSignUpDisabled || redirect?.startsWith("/invite/")) && ( {!isSignUpDisabled && (
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000"> <p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
<Trans> <Trans>
Don't have an account?{" "} Don't have an account?{" "}

View File

@@ -28,9 +28,7 @@ export default function SignUpPage() {
setMagicLinkRecipient(recipient); setMagicLinkRecipient(recipient);
}; };
const isInviteFlow = redirect?.startsWith("/invite/"); if (isSignUpDisabled) {
if (isSignUpDisabled && !isInviteFlow) {
return ( return (
<> <>
<PageHead title={t`Sign up | kan.bn`} /> <PageHead title={t`Sign up | kan.bn`} />

View File

@@ -7,7 +7,7 @@ import {
HiOutlineStar, HiOutlineStar,
HiStar, HiStar,
} from "react-icons/hi2"; } from "react-icons/hi2";
import { IoArchiveOutline } from "react-icons/io5";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
@@ -17,7 +17,6 @@ import { api } from "~/utils/api";
export default function BoardDropdown({ export default function BoardDropdown({
isTemplate, isTemplate,
isLoading, isLoading,
isArchived,
boardPublicId, boardPublicId,
isFavorite, isFavorite,
boardName, boardName,
@@ -25,29 +24,28 @@ export default function BoardDropdown({
isTemplate: boolean; isTemplate: boolean;
isLoading: boolean; isLoading: boolean;
boardPublicId: string; boardPublicId: string;
isArchived?: boolean;
isFavorite?: boolean; isFavorite?: boolean;
boardName?: string; boardName?: string;
}) { }) {
const { openModal } = useModal(); const { openModal } = useModal();
const { canEditBoard, canDeleteBoard, canCreateBoard } = usePermissions();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
usePermissions();
const utils = api.useUtils(); const utils = api.useUtils();
const handleToggleFavorite = () => {
updateBoard.mutate({
boardPublicId,
favorite: !isFavorite,
});
};
const updateBoard = api.board.update.useMutation({ const updateBoard = api.board.update.useMutation({
onSuccess: (_data, variables) => { onSuccess: (data, variables) => {
void utils.board.all.invalidate(); void utils.board.all.invalidate();
void utils.board.byId.invalidate(); void utils.board.byId.invalidate();
if (variables.isArchived !== undefined) {
showPopup({ // Show popup notification
header: variables.isArchived ? t`Board archived` : t`Board unarchived`, if (variables.favorite !== undefined) {
message: variables.isArchived
? t`The board has been archived.`
: t`The board has been unarchived.`,
icon: "success",
});
} else if (variables.favorite !== undefined) {
showPopup({ showPopup({
header: variables.favorite header: variables.favorite
? t`Added to favorites` ? 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 = [ const items = [
...(isTemplate && canCreateBoard ...(isTemplate && canCreateBoard
? [ ? [
{ {
label: t`Make template`, label: t`Make template`,
action: () => openModal("CREATE_TEMPLATE"), action: () => openModal("CREATE_TEMPLATE"),
icon: ( icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" /> <HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
), ),
}, },
] ]
: []), : []),
...(!isTemplate && canEditBoard ...(!isTemplate && canEditBoard
? [ ? [
{ {
label: t`Edit board URL`, label: t`Edit board URL`,
action: () => openModal("UPDATE_BOARD_SLUG"), action: () => openModal("UPDATE_BOARD_SLUG"),
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />, 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: isFavorite label: isFavorite
@@ -129,26 +100,22 @@ export default function BoardDropdown({
}, },
...(canDeleteBoard ...(canDeleteBoard
? [ ? [
{ {
label: isTemplate ? t`Delete template` : t`Delete board`, label: isTemplate ? t`Delete template` : t`Delete board`,
action: () => openModal("DELETE_BOARD"), action: () => openModal("DELETE_BOARD"),
icon: ( icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
<HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" /> },
), ]
},
]
: []), : []),
]; ];
if (items.length === 0) { if (items.length === 0) {
return null; return null;
} }
return ( return (
<Dropdown <Dropdown disabled={isLoading} items={items}>
disabled={isLoading || isArchiveActionPending}
items={items}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" /> <HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown> </Dropdown>
); );

View File

@@ -494,7 +494,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
isTemplate={!!isTemplate} isTemplate={!!isTemplate}
isLoading={!boardData} isLoading={!boardData}
boardPublicId={boardId ?? ""} boardPublicId={boardId ?? ""}
isArchived={boardData?.isArchived ?? false} workspacePublicId={workspace.publicId}
isFavorite={boardData?.favorite} isFavorite={boardData?.favorite}
boardName={boardData?.name} boardName={boardData?.name}
/> />
@@ -598,12 +598,13 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
? `/templates/${boardId}/cards/${card.publicId}` ? `/templates/${boardId}/cards/${card.publicId}`
: `/cards/${card.publicId}` : `/cards/${card.publicId}`
} }
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith( className={`mb-2 flex !cursor-pointer flex-col ${
"PLACEHOLDER", card.publicId.startsWith(
) "PLACEHOLDER",
? "pointer-events-none" )
: "" ? "pointer-events-none"
}`} : ""
}`}
ref={provided.innerRef} ref={provided.innerRef}
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}

View File

@@ -10,7 +10,7 @@ import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; 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 { workspace } = useWorkspace();
const { openModal } = useModal(); const { openModal } = useModal();
const { canCreateBoard } = usePermissions(); const { canCreateBoard } = usePermissions();
@@ -26,7 +26,6 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
{ {
workspacePublicId: workspace.publicId, workspacePublicId: workspace.publicId,
type: isTemplate ? "template" : "regular", type: isTemplate ? "template" : "regular",
archived: archived,
}, },
{ enabled: workspace.publicId ? true : false }, { enabled: workspace.publicId ? true : false },
); );
@@ -60,10 +59,10 @@ export function BoardsList({ isTemplate, archived = false }: { isTemplate?: bool
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" /> <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"> <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>
<p className="text-[14px] text-light-900 dark:text-dark-900"> <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> </p>
</div> </div>
<Tooltip <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"> <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 /> <PatternedBackground />
<button <button
onClick={(e) => handleToggleFavorite(e, board.publicId, board.favorite)} 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" 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"} aria-label={board.favorite ? "Remove from favorites" : "Add to favorites"}
> >
{board.favorite ? ( {board.favorite ? (
<HiStar className="h-5 w-5 text-neutral-700 dark:text-dark-1000" /> <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" /> <HiOutlineStar className="h-5 w-5 text-neutral-700 dark:text-dark-800" />
)} )}
</button> </button>
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000"> <p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
{board.name} {board.name}
</p> </p>

View File

@@ -1,12 +1,5 @@
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/react";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { HiArrowDownTray, HiChevronDown, HiOutlinePlusSmall } from "react-icons/hi2"; import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
import { useState } from "react";
import Button from "~/components/Button"; import Button from "~/components/Button";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
@@ -22,15 +15,9 @@ import { BoardsList } from "./components/BoardsList";
import { ImportBoardsForm } from "./components/ImportBoardsForm"; import { ImportBoardsForm } from "./components/ImportBoardsForm";
import { NewBoardForm } from "./components/NewBoardForm"; 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 }) { export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
const { openModal, modalContentType, isOpen } = useModal(); const { openModal, modalContentType, isOpen } = useModal();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const [activeTab, setActiveTab] = useState<"boards" | "archived">("boards");
const { canCreateBoard } = usePermissions(); const { canCreateBoard } = usePermissions();
const { tooltipContent: createModalShortcutTooltipContent } = const { tooltipContent: createModalShortcutTooltipContent } =
@@ -47,7 +34,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
<PageHead <PageHead
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? t`Workspace`}`} 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"> <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]"> <h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`${isTemplate ? "Templates" : "Boards"}`} {t`${isTemplate ? "Templates" : "Boards"}`}
@@ -128,79 +115,9 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
</Modal> </Modal>
</> </>
{!isTemplate ? ( <div className="flex h-full flex-row">
<div className="flex h-full w-full flex-col"> <BoardsList isTemplate={!!isTemplate} />
<div className="focus:outline-none"> </div>
<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> </div>
</> </>
); );

View File

@@ -33,7 +33,7 @@ const UpdateWorkspaceUrlForm = ({
.min(3, { .min(3, {
message: t`URL must be at least 3 characters long`, 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-]+$/, { .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`, message: t`URL can only contain letters, numbers, and hyphens`,
}), }),

View File

@@ -1,17 +1,4 @@
services: 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: web:
image: ghcr.io/kanbn/kan:latest image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web} container_name: ${CONTAINER_NAME:-kan-web}
@@ -22,7 +9,6 @@ services:
build: build:
context: .. context: ..
dockerfile: apps/web/Dockerfile dockerfile: apps/web/Dockerfile
target: web
args: args:
APP_VERSION: ${APP_VERSION:-} APP_VERSION: ${APP_VERSION:-}
env_file: env_file:
@@ -93,9 +79,6 @@ services:
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID} - NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY} - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST} - NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
depends_on:
migrator:
condition: service_completed_successfully
networks: networks:
dokploy-network: dokploy-network:

View File

@@ -1,20 +1,4 @@
services: 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: web:
image: ghcr.io/kanbn/kan:latest image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web} container_name: ${CONTAINER_NAME:-kan-web}
@@ -22,10 +6,10 @@ services:
- "${WEB_PORT:-3000}:3000" - "${WEB_PORT:-3000}:3000"
networks: networks:
- kan-network - kan-network
- dokploy-network
build: build:
context: . context: .
dockerfile: ./apps/web/Dockerfile dockerfile: ./apps/web/Dockerfile.new
target: web
env_file: env_file:
- .env - .env
environment: environment:
@@ -123,8 +107,7 @@ services:
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET} - APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER} - APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
depends_on: depends_on:
migrate: - postgres
condition: service_completed_successfully
restart: unless-stopped restart: unless-stopped
postgres: postgres:
@@ -138,17 +121,14 @@ services:
- 5432:5432 - 5432:5432
volumes: volumes:
- kan_postgres_data:/var/lib/postgresql/data - 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 restart: unless-stopped
networks: networks:
- kan-network - kan-network
networks: networks:
kan-network: kan-network:
dokploy-network:
external: true
volumes: volumes:
kan_postgres_data: kan_postgres_data:

View File

@@ -34,7 +34,6 @@ export const boardRouter = createTRPCRouter({
z.object({ z.object({
workspacePublicId: z.string().min(12), workspacePublicId: z.string().min(12),
type: z.enum(["regular", "template"]).optional(), type: z.enum(["regular", "template"]).optional(),
archived: z.boolean().optional(),
}), }),
) )
.output( .output(
@@ -66,10 +65,7 @@ export const boardRouter = createTRPCRouter({
ctx.db, ctx.db,
workspace.id, workspace.id,
userId, userId,
{ { type: input.type }
type: input.type,
archived: input.archived ?? false,
}
); );
return result; return result;
@@ -157,24 +153,24 @@ export const boardRouter = createTRPCRouter({
// Generate presigned URLs for workspace member avatars // Generate presigned URLs for workspace member avatars
const workspaceWithAvatarUrls = result.workspace const workspaceWithAvatarUrls = result.workspace
? { ? {
...result.workspace, ...result.workspace,
members: await Promise.all( members: await Promise.all(
result.workspace.members.map(async (member) => { result.workspace.members.map(async (member) => {
if (!member.user?.image) { if (!member.user?.image) {
return member; return member;
} }
const avatarUrl = await generateAvatarUrl(member.user.image); const avatarUrl = await generateAvatarUrl(member.user.image);
return { return {
...member, ...member,
user: { user: {
...member.user, ...member.user,
image: avatarUrl, image: avatarUrl,
}, },
}; };
}), }),
), ),
} }
: result.workspace; : result.workspace;
// Generate presigned URLs for card member avatars // Generate presigned URLs for card member avatars
@@ -222,7 +218,7 @@ export const boardRouter = createTRPCRouter({
workspaceSlug: z workspaceSlug: z
.string() .string()
.min(3) .min(3)
.max(64) .max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/), .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
boardSlug: z boardSlug: z
.string() .string()
@@ -465,8 +461,7 @@ export const boardRouter = createTRPCRouter({
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/) .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(), .optional(),
visibility: z.enum(["public", "private"]).optional(), visibility: z.enum(["public", "private"]).optional(),
favorite: z.boolean().optional(), favorite: z.boolean().optional()
isArchived: z.boolean().optional(),
}), }),
) )
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>())) .output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
@@ -508,7 +503,7 @@ export const boardRouter = createTRPCRouter({
} }
// Handle other updates (name, slug, visibility) // 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) { if (!hasOtherUpdates) {
// Only favorite was updated, return success // Only favorite was updated, return success
@@ -535,7 +530,6 @@ export const boardRouter = createTRPCRouter({
slug: input.slug, slug: input.slug,
boardPublicId: input.boardPublicId, boardPublicId: input.boardPublicId,
visibility: input.visibility, visibility: input.visibility,
isArchived: input.isArchived,
}); });
if (!result) if (!result)

View File

@@ -13,10 +13,6 @@ import { mergeActivities } from "../utils/activities";
import { sendMentionEmails } from "../utils/notifications"; import { sendMentionEmails } from "../utils/notifications";
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions"; import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils"; import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
import {
createCardWebhookPayload,
sendWebhooksForWorkspace,
} from "../utils/webhook";
export const cardRouter = createTRPCRouter({ export const cardRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -169,32 +165,6 @@ export const cardRouter = createTRPCRouter({
}); });
} }
// 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; return newCard;
}), }),
addComment: protectedProcedure addComment: protectedProcedure
@@ -1037,59 +1007,6 @@ export const cardRouter = createTRPCRouter({
await cardActivityRepo.bulkCreate(ctx.db, activities); 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; return result;
}), }),
delete: protectedProcedure delete: protectedProcedure
@@ -1137,9 +1054,6 @@ export const cardRouter = createTRPCRouter({
card.createdBy, card.createdBy,
); );
// Fetch full card data before delete for webhook
const fullCard = await cardRepo.getByPublicId(ctx.db, input.cardPublicId);
const deletedAt = new Date(); const deletedAt = new Date();
await cardRepo.softDelete(ctx.db, { await cardRepo.softDelete(ctx.db, {
@@ -1154,34 +1068,6 @@ export const cardRouter = createTRPCRouter({
createdBy: userId, 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 }; return { success: true };
}), }),
}); });

View File

@@ -81,16 +81,6 @@ export const checklistRouter = createTRPCRouter({
return newChecklist; return newChecklist;
}), }),
update: protectedProcedure 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( .input(
z.object({ z.object({
checklistPublicId: z.string().length(12), checklistPublicId: z.string().length(12),

View File

@@ -210,12 +210,11 @@ export const memberRouter = createTRPCRouter({
input.memberPublicId, input.memberPublicId,
); );
if (!member || member.workspaceId !== workspace.id) { if (!member)
throw new TRPCError({ throw new TRPCError({
message: `Member with public ID ${input.memberPublicId} not found`, message: `Member with public ID ${input.memberPublicId} not found`,
code: "NOT_FOUND", code: "NOT_FOUND",
}); });
}
const deletedMember = await memberRepo.softDelete(ctx.db, { const deletedMember = await memberRepo.softDelete(ctx.db, {
memberId: member.id, memberId: member.id,
@@ -721,7 +720,7 @@ export const memberRouter = createTRPCRouter({
input.memberPublicId, input.memberPublicId,
); );
if (!member || member.workspaceId !== workspace.id) { if (!member) {
throw new TRPCError({ throw new TRPCError({
message: "Member not found", message: "Member not found",
code: "NOT_FOUND", code: "NOT_FOUND",

View File

@@ -133,7 +133,7 @@ export const permissionRouter = createTRPCRouter({
input.memberPublicId, input.memberPublicId,
); );
if (!member || member.workspaceId !== workspace.id) { if (!member) {
throw new TRPCError({ throw new TRPCError({
message: "Member not found", message: "Member not found",
code: "NOT_FOUND", code: "NOT_FOUND",
@@ -209,7 +209,7 @@ export const permissionRouter = createTRPCRouter({
input.memberPublicId, input.memberPublicId,
); );
if (!member || member.workspaceId !== workspace.id) { if (!member) {
throw new TRPCError({ throw new TRPCError({
message: "Member not found", message: "Member not found",
code: "NOT_FOUND", code: "NOT_FOUND",
@@ -274,7 +274,7 @@ export const permissionRouter = createTRPCRouter({
input.memberPublicId, input.memberPublicId,
); );
if (!member || member.workspaceId !== workspace.id) { if (!member) {
throw new TRPCError({ throw new TRPCError({
message: "Member not found", message: "Member not found",
code: "NOT_FOUND", code: "NOT_FOUND",
@@ -339,7 +339,7 @@ export const permissionRouter = createTRPCRouter({
input.memberPublicId, input.memberPublicId,
); );
if (!member || member.workspaceId !== workspace.id) { if (!member) {
throw new TRPCError({ throw new TRPCError({
message: "Member not found", message: "Member not found",
code: "NOT_FOUND", code: "NOT_FOUND",

View File

@@ -160,7 +160,7 @@ export const workspaceRouter = createTRPCRouter({
workspaceSlug: z workspaceSlug: z
.string() .string()
.min(3) .min(3)
.max(64) .max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/), .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}), }),
) )
@@ -207,7 +207,7 @@ export const workspaceRouter = createTRPCRouter({
slug: z slug: z
.string() .string()
.min(3) .min(3)
.max(64) .max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/) .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(), .optional(),
}), }),
@@ -290,7 +290,7 @@ export const workspaceRouter = createTRPCRouter({
slug: z slug: z
.string() .string()
.min(3) .min(3)
.max(64) .max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/) .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(), .optional(),
description: z.string().min(3).max(280).optional(), description: z.string().min(3).max(280).optional(),
@@ -424,7 +424,7 @@ export const workspaceRouter = createTRPCRouter({
workspaceSlug: z workspaceSlug: z
.string() .string()
.min(3) .min(3)
.max(64) .max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/), .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}), }),
) )

View File

@@ -53,7 +53,7 @@ export async function sendMentionEmails({
const commenter = await userRepo.getById(db, commenterUserId); const commenter = await userRepo.getById(db, commenterUserId);
if (!commenter) return; if (!commenter) return;
const commenterName = commenter.name?.trim() || commenter.email; const commenterName = commenter.name ?? commenter.email;
// Get mentioned members with full details (filtered by workspace) // Get mentioned members with full details (filtered by workspace)
const membersWithDetails = await memberRepo.getByPublicIdsWithUsers( const membersWithDetails = await memberRepo.getByPublicIdsWithUsers(

View File

@@ -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);
});
});
});

View File

@@ -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,
},
};
}

View File

@@ -21,7 +21,6 @@
"dev": "tsc", "dev": "tsc",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint", "lint": "eslint",
"test": "vitest run",
"typecheck": "tsc --noEmit --emitDeclarationOnly false" "typecheck": "tsc --noEmit --emitDeclarationOnly false"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -12,13 +12,15 @@ import { configuredProviders } from "./providers";
export const initAuth = (db: dbClient) => { export const initAuth = (db: dbClient) => {
const baseURL = env("NEXT_PUBLIC_BASE_URL") || env("BETTER_AUTH_URL"); const baseURL = env("NEXT_PUBLIC_BASE_URL") || env("BETTER_AUTH_URL");
const trustedOrigins = const trustedOrigins = env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? [];
env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",").filter(Boolean) ?? [];
return betterAuth({ return betterAuth({
secret: env("BETTER_AUTH_SECRET"), secret: env("BETTER_AUTH_SECRET"),
baseURL, baseURL,
trustedOrigins: [...(baseURL ? [baseURL] : []), ...trustedOrigins], trustedOrigins: [
...(baseURL ? [baseURL] : []),
...trustedOrigins,
],
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "pg", provider: "pg",
schema: { schema: {
@@ -33,10 +35,8 @@ export const initAuth = (db: dbClient) => {
}, },
emailAndPassword: { emailAndPassword: {
enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true", enabled: env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true",
// Sign-up restriction is handled by the user.create.before database disableSignUp:
// hook which checks for pending invitations, allowing invited users env("NEXT_PUBLIC_DISABLE_SIGN_UP")?.toLowerCase() === "true",
// to register even when public sign-up is disabled.
disableSignUp: false,
sendResetPassword: async (data) => { sendResetPassword: async (data) => {
await sendEmail(data.user.email, "Reset Password", "RESET_PASSWORD", { await sendEmail(data.user.email, "Reset Password", "RESET_PASSWORD", {
resetPasswordUrl: data.url, resetPasswordUrl: data.url,

View File

@@ -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;
});
});
});

View File

@@ -7,8 +7,8 @@ import * as memberRepo from "@kan/db/repository/member.repo";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo"; import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { sendEmail } from "@kan/email";
import { generateUID } from "@kan/shared/utils"; import { generateUID } from "@kan/shared/utils";
import { sendEmail } from "@kan/email";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
import { socialProvidersPlugin } from "./providers"; import { socialProvidersPlugin } from "./providers";
@@ -139,11 +139,10 @@ export function createPlugins(db: dbClient) {
let newSlug = workspace.publicId; let newSlug = workspace.publicId;
if (workspace.slug !== workspace.publicId) { if (workspace.slug !== workspace.publicId) {
const isPublicIdAvailable = const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable(
await workspaceRepo.isWorkspaceSlugAvailable( db,
db, workspace.publicId,
workspace.publicId, );
);
if (!isPublicIdAvailable) { if (!isPublicIdAvailable) {
newSlug = generateUID(); newSlug = generateUID();
} }
@@ -173,77 +172,69 @@ export function createPlugins(db: dbClient) {
magicLink({ magicLink({
expiresIn: 60 * 60 * 24 * 7, // 7 days expiresIn: 60 * 60 * 24 * 7, // 7 days
sendMagicLink: async ({ email, url }) => { sendMagicLink: async ({ email, url }) => {
try { const decodedUrl = decodeURIComponent(url);
const decodedUrl = decodeURIComponent(url); console.log("Sending magic link to:", email, "URL:", url);
console.log("Sending magic link to:", email, "URL:", url); console.log(
console.log( "Magic link contains invite:",
"Magic link contains invite:", decodedUrl.includes("type=invite"),
decodedUrl.includes("type=invite"), );
); if (decodedUrl.includes("type=invite")) {
if (decodedUrl.includes("type=invite")) { let inviterName = "";
let inviterName = ""; let workspaceName = "";
let workspaceName = "";
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
const callbackUrl = urlObj.searchParams.get("callbackURL"); const callbackUrl = urlObj.searchParams.get("callbackURL");
if (callbackUrl) { if (callbackUrl) {
const callbackParams = new URL( const callbackParams = new URL(
callbackUrl, callbackUrl,
process.env.NEXT_PUBLIC_BASE_URL, process.env.NEXT_PUBLIC_BASE_URL,
).searchParams; ).searchParams;
const memberPublicId = callbackParams.get("memberPublicId"); const memberPublicId = callbackParams.get("memberPublicId");
if (memberPublicId) { if (memberPublicId) {
const member = await memberRepo.getByPublicId( const member = await memberRepo.getByPublicId(
db, db,
memberPublicId, memberPublicId,
); );
if (member) { if (member) {
const [workspace, inviter] = await Promise.all([ const [workspace, inviter] = await Promise.all([
workspaceRepo.getById(db, member.workspaceId), workspaceRepo.getById(db, member.workspaceId),
userRepo.getById(db, member.createdBy), userRepo.getById(db, member.createdBy),
]); ]);
if (workspace) workspaceName = workspace.name; if (workspace) workspaceName = workspace.name;
if (inviter) inviterName = inviter.name ?? ""; if (inviter) inviterName = inviter.name ?? "";
}
} }
} }
} catch (error) {
console.error("Failed to fetch invite details:", error);
} }
} catch (error) {
await sendEmail( console.error("Failed to fetch invite details:", error);
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:", { await sendEmail(
email, email,
url, workspaceName
error, ? `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,
},
);
} }
}, },
}), }),

View File

@@ -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"),
},
},
});

View File

@@ -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 $$;

View File

@@ -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

View File

@@ -187,16 +187,9 @@
{ {
"idx": 26, "idx": 26,
"version": "7", "version": "7",
"when": 1771192000000, "when": 1770521594167,
"tag": "20260129210000_AddWorkspaceWebhooks", "tag": "20260208033314_AddAttachmentToActivity",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1772049587901,
"tag": "20260225195947_AddIsArchivedToBoard",
"breakpoints": true "breakpoints": true
} }
] ]
} }

View File

@@ -44,7 +44,7 @@ export const getAllByWorkspaceId = async (
db: dbClient, db: dbClient,
workspaceId: number, workspaceId: number,
userId: string, userId: string,
opts?: { type?: "regular" | "template"; archived?: boolean }, opts?: { type?: "regular" | "template" },
) => { ) => {
const boardsData = await db.query.boards.findMany({ const boardsData = await db.query.boards.findMany({
columns: { columns: {
@@ -78,7 +78,6 @@ export const getAllByWorkspaceId = async (
eq(boards.workspaceId, workspaceId), eq(boards.workspaceId, workspaceId),
isNull(boards.deletedAt), isNull(boards.deletedAt),
opts?.type ? eq(boards.type, opts.type) : undefined, 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: { columns: {
id: true, id: true,
type: true, type: true,
isArchived: true,
}, },
where: eq(boards.publicId, boardPublicId), where: eq(boards.publicId, boardPublicId),
}); });
@@ -197,7 +195,6 @@ export const getByPublicId = async (
name: true, name: true,
slug: true, slug: true,
visibility: true, visibility: true,
isArchived: true,
}, },
with: { with: {
userFavorites: { userFavorites: {
@@ -631,7 +628,6 @@ export const update = async (
slug: string | undefined; slug: string | undefined;
visibility: BoardVisibilityStatus | undefined; visibility: BoardVisibilityStatus | undefined;
boardPublicId: string; boardPublicId: string;
isArchived?: boolean;
}, },
) => { ) => {
const [result] = await db const [result] = await db
@@ -641,7 +637,6 @@ export const update = async (
slug: boardInput.slug, slug: boardInput.slug,
visibility: boardInput.visibility, visibility: boardInput.visibility,
updatedAt: new Date(), updatedAt: new Date(),
...(boardInput.isArchived !== undefined && { isArchived: boardInput.isArchived })
}) })
.where(eq(boards.publicId, boardInput.boardPublicId)) .where(eq(boards.publicId, boardInput.boardPublicId))
.returning({ .returning({

View File

@@ -945,14 +945,12 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)), where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
with: { with: {
list: { list: {
columns: { name: true }, columns: {},
with: { with: {
board: { board: {
columns: { columns: {
publicId: true,
workspaceId: true, workspaceId: true,
visibility: true, visibility: true,
name: true,
}, },
}, },
}, },
@@ -966,9 +964,6 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
createdBy: result.createdBy, createdBy: result.createdBy,
workspaceId: result.list.board.workspaceId, workspaceId: result.list.board.workspaceId,
workspaceVisibility: result.list.board.visibility, workspaceVisibility: result.list.board.visibility,
listName: result.list.name,
boardPublicId: result.list.board.publicId,
boardName: result.list.board.name,
} }
: null; : null;
}; };

View File

@@ -419,14 +419,12 @@ export const getWorkspaceAndListIdByListPublicId = async (
listPublicId: string, listPublicId: string,
) => { ) => {
const result = await db.query.lists.findFirst({ 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)), where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
with: { with: {
board: { board: {
columns: { columns: {
publicId: true,
workspaceId: true, workspaceId: true,
name: true,
}, },
}, },
}, },
@@ -435,11 +433,8 @@ export const getWorkspaceAndListIdByListPublicId = async (
return result return result
? { ? {
id: result.id, id: result.id,
name: result.name,
createdBy: result.createdBy, createdBy: result.createdBy,
workspaceId: result.board.workspaceId, workspaceId: result.board.workspaceId,
boardPublicId: result.board.publicId,
boardName: result.board.name,
} }
: null; : null;
}; };

View File

@@ -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));
};

View File

@@ -30,22 +30,22 @@ const SYSTEM_ROLES: {
description: string; description: string;
hierarchyLevel: number; hierarchyLevel: number;
}[] = [ }[] = [
{ {
name: "admin", name: "admin",
description: "Full access to all workspace features", description: "Full access to all workspace features",
hierarchyLevel: 100, hierarchyLevel: 100,
}, },
{ {
name: "member", name: "member",
description: "Standard member with create and edit permissions", description: "Standard member with create and edit permissions",
hierarchyLevel: 50, hierarchyLevel: 50,
}, },
{ {
name: "guest", name: "guest",
description: "View-only access", description: "View-only access",
hierarchyLevel: 10, hierarchyLevel: 10,
}, },
]; ];
export const getCount = async (db: dbClient) => { export const getCount = async (db: dbClient) => {
const result = await db const result = await db
@@ -250,7 +250,7 @@ export const getBySlugWithBoards = (db: dbClient, workspaceSlug: string) => {
slug: true, slug: true,
name: 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)] orderBy: [asc(boards.name)]
}, },
}, },

View File

@@ -2,6 +2,7 @@ import { relations, sql } from "drizzle-orm";
import { import {
bigint, bigint,
bigserial, bigserial,
boolean,
index, index,
pgEnum, pgEnum,
pgTable, pgTable,
@@ -11,8 +12,8 @@ import {
uniqueIndex, uniqueIndex,
uuid, uuid,
varchar, varchar,
boolean,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { imports } from "./imports"; import { imports } from "./imports";
import { labels } from "./labels"; import { labels } from "./labels";
import { lists } from "./lists"; import { lists } from "./lists";
@@ -55,11 +56,9 @@ export const boards = pgTable(
.references(() => workspaces.id, { onDelete: "cascade" }), .references(() => workspaces.id, { onDelete: "cascade" }),
visibility: boardVisibilityEnum("visibility").notNull().default("private"), visibility: boardVisibilityEnum("visibility").notNull().default("private"),
type: boardTypeEnum("type").notNull().default("regular"), type: boardTypeEnum("type").notNull().default("regular"),
isArchived: boolean("isArchived").notNull().default(false),
sourceBoardId: bigint("sourceBoardId", { mode: "number" }), sourceBoardId: bigint("sourceBoardId", { mode: "number" }),
}, },
(table) => [ (table) => [
index("board_is_archived_idx").on(table.isArchived),
index("board_visibility_idx").on(table.visibility), index("board_visibility_idx").on(table.visibility),
index("board_type_idx").on(table.type), index("board_type_idx").on(table.type),
index("board_source_idx").on(table.sourceBoardId), index("board_source_idx").on(table.sourceBoardId),

View File

@@ -14,4 +14,3 @@ export * from "./subscriptions";
export * from "./workspaceInviteLinks"; export * from "./workspaceInviteLinks";
export * from "./permissions"; export * from "./permissions";
export * from "./notifications"; export * from "./notifications";
export * from "./webhooks";

View File

@@ -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",
}),
}),
);

143
pnpm-lock.yaml generated
View File

@@ -254,9 +254,6 @@ importers:
'@lingui/cli': '@lingui/cli':
specifier: ^5.3.2 specifier: ^5.3.2
version: 5.4.1(typescript@5.9.2) version: 5.4.1(typescript@5.9.2)
'@lingui/format-json':
specifier: ^5.9.1
version: 5.9.1(typescript@5.9.2)
'@lingui/loader': '@lingui/loader':
specifier: ^5.3.2 specifier: ^5.3.2
version: 5.4.1(typescript@5.9.2)(webpack@5.101.3) version: 5.4.1(typescript@5.9.2)(webpack@5.101.3)
@@ -344,7 +341,7 @@ importers:
version: link:../../tooling/typescript version: link:../../tooling/typescript
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.6.1) version: 9.34.0(jiti@2.4.2)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -384,7 +381,7 @@ importers:
version: link:../../tooling/typescript version: link:../../tooling/typescript
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.6.1) version: 9.34.0(jiti@2.4.2)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -439,7 +436,7 @@ importers:
version: 0.28.1 version: 0.28.1
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.6.1) version: 9.34.0(jiti@2.4.2)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -476,7 +473,7 @@ importers:
version: 6.4.19 version: 6.4.19
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.6.1) version: 9.34.0(jiti@2.4.2)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -516,7 +513,7 @@ importers:
version: link:../../tooling/typescript version: link:../../tooling/typescript
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.6.1) version: 9.34.0(jiti@2.4.2)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -541,7 +538,7 @@ importers:
version: link:../../tooling/typescript version: link:../../tooling/typescript
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.6.1) version: 9.34.0(jiti@2.4.2)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -553,28 +550,28 @@ importers:
dependencies: dependencies:
'@eslint/compat': '@eslint/compat':
specifier: ^1.2.3 specifier: ^1.2.3
version: 1.3.2(eslint@9.34.0(jiti@2.6.1)) version: 1.3.2(eslint@9.34.0(jiti@2.4.2))
'@next/eslint-plugin-next': '@next/eslint-plugin-next':
specifier: ^14.2.15 specifier: ^14.2.15
version: 14.2.32 version: 14.2.32
eslint-plugin-import: eslint-plugin-import:
specifier: ^2.31.0 specifier: ^2.31.0
version: 2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)) version: 2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.34.0(jiti@2.4.2))
eslint-plugin-jsx-a11y: eslint-plugin-jsx-a11y:
specifier: ^6.10.2 specifier: ^6.10.2
version: 6.10.2(eslint@9.34.0(jiti@2.6.1)) version: 6.10.2(eslint@9.34.0(jiti@2.4.2))
eslint-plugin-react: eslint-plugin-react:
specifier: ^7.37.2 specifier: ^7.37.2
version: 7.37.5(eslint@9.34.0(jiti@2.6.1)) version: 7.37.5(eslint@9.34.0(jiti@2.4.2))
eslint-plugin-react-hooks: eslint-plugin-react-hooks:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.2.0(eslint@9.34.0(jiti@2.6.1)) version: 5.2.0(eslint@9.34.0(jiti@2.4.2))
eslint-plugin-turbo: eslint-plugin-turbo:
specifier: ^2.3.1 specifier: ^2.3.1
version: 2.5.6(eslint@9.34.0(jiti@2.6.1))(turbo@2.5.6) version: 2.5.6(eslint@9.34.0(jiti@2.4.2))(turbo@2.5.6)
typescript-eslint: typescript-eslint:
specifier: ^8.15.0 specifier: ^8.15.0
version: 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) version: 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)
devDependencies: devDependencies:
'@kan/prettier-config': '@kan/prettier-config':
specifier: workspace:* specifier: workspace:*
@@ -587,7 +584,7 @@ importers:
version: 8.42.3 version: 8.42.3
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.6.1) version: 9.34.0(jiti@2.4.2)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -640,7 +637,7 @@ importers:
version: link:../typescript version: link:../typescript
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.6.1) version: 9.34.0(jiti@2.4.2)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -2676,10 +2673,6 @@ packages:
resolution: {integrity: sha512-aDkj/bMSr/mCL8Nr1TS52v0GLCuVa4YqtRz+WvUCFZw/ovVInX0hKq1TClx/bSlhu60FzB/CbclxFMBw8aLVUg==} resolution: {integrity: sha512-aDkj/bMSr/mCL8Nr1TS52v0GLCuVa4YqtRz+WvUCFZw/ovVInX0hKq1TClx/bSlhu60FzB/CbclxFMBw8aLVUg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@lingui/conf@5.9.1':
resolution: {integrity: sha512-Dr1CV4P3INJZZzvLcksgML4kusHQS3pJyONcZt40l/T74LOETUSk2cxFW5epizWU46FpN2ThusvC2yb0L8o1jA==}
engines: {node: '>=20.0.0'}
'@lingui/core@5.4.1': '@lingui/core@5.4.1':
resolution: {integrity: sha512-4FeIh56PH5vziPg2BYo4XYWWOHE4XaY/XR8Jakwn0/qwtLpydWMNVpZOpGWi7nfPZtcLaJLmZKup6UNxEl1Pfw==} resolution: {integrity: sha512-4FeIh56PH5vziPg2BYo4XYWWOHE4XaY/XR8Jakwn0/qwtLpydWMNVpZOpGWi7nfPZtcLaJLmZKup6UNxEl1Pfw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -2692,10 +2685,6 @@ packages:
babel-plugin-macros: babel-plugin-macros:
optional: true optional: true
'@lingui/format-json@5.9.1':
resolution: {integrity: sha512-Kp/saH3jKJPlAzjcDJd+yqePoC6PZOc8cbO1mL/mN8mOz5bF9w+LnX4KntgZz1MjJwP12fqtGbr6AxDevwnmWw==}
engines: {node: '>=20.0.0'}
'@lingui/format-po@5.4.1': '@lingui/format-po@5.4.1':
resolution: {integrity: sha512-IBVq3RRLNEVRzNZcdEw0qpM5NKX4e9wDmvJMorkR2OYrgTbhWx5gDYhXpEZ9yqtuEVhILMdriVNjAAUnDAJibA==} resolution: {integrity: sha512-IBVq3RRLNEVRzNZcdEw0qpM5NKX4e9wDmvJMorkR2OYrgTbhWx5gDYhXpEZ9yqtuEVhILMdriVNjAAUnDAJibA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -6168,10 +6157,6 @@ packages:
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
hasBin: true hasBin: true
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@6.1.2: jose@6.1.2:
resolution: {integrity: sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==} resolution: {integrity: sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==}
@@ -10466,16 +10451,16 @@ snapshots:
eslint: 9.34.0(jiti@1.21.7) eslint: 9.34.0(jiti@1.21.7)
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.7.0(eslint@9.34.0(jiti@2.6.1))': '@eslint-community/eslint-utils@4.7.0(eslint@9.34.0(jiti@2.4.2))':
dependencies: dependencies:
eslint: 9.34.0(jiti@2.6.1) eslint: 9.34.0(jiti@2.4.2)
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1': {} '@eslint-community/regexpp@4.12.1': {}
'@eslint/compat@1.3.2(eslint@9.34.0(jiti@2.6.1))': '@eslint/compat@1.3.2(eslint@9.34.0(jiti@2.4.2))':
optionalDependencies: optionalDependencies:
eslint: 9.34.0(jiti@2.6.1) eslint: 9.34.0(jiti@2.4.2)
'@eslint/config-array@0.21.0': '@eslint/config-array@0.21.0':
dependencies: dependencies:
@@ -10886,16 +10871,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@lingui/conf@5.9.1(typescript@5.9.2)':
dependencies:
'@babel/runtime': 7.28.3
cosmiconfig: 8.3.6(typescript@5.9.2)
jest-validate: 29.7.0
jiti: 2.6.1
picocolors: 1.1.1
transitivePeerDependencies:
- typescript
'@lingui/core@5.4.1(@lingui/babel-plugin-lingui-macro@5.4.1(typescript@5.9.2))': '@lingui/core@5.4.1(@lingui/babel-plugin-lingui-macro@5.4.1(typescript@5.9.2))':
dependencies: dependencies:
'@babel/runtime': 7.28.3 '@babel/runtime': 7.28.3
@@ -10903,12 +10878,6 @@ snapshots:
optionalDependencies: optionalDependencies:
'@lingui/babel-plugin-lingui-macro': 5.4.1(typescript@5.9.2) '@lingui/babel-plugin-lingui-macro': 5.4.1(typescript@5.9.2)
'@lingui/format-json@5.9.1(typescript@5.9.2)':
dependencies:
'@lingui/conf': 5.9.1(typescript@5.9.2)
transitivePeerDependencies:
- typescript
'@lingui/format-po@5.4.1(typescript@5.9.2)': '@lingui/format-po@5.4.1(typescript@5.9.2)':
dependencies: dependencies:
'@lingui/conf': 5.4.1(typescript@5.9.2) '@lingui/conf': 5.4.1(typescript@5.9.2)
@@ -12570,15 +12539,15 @@ snapshots:
dependencies: dependencies:
'@types/yargs-parser': 21.0.3 '@types/yargs-parser': 21.0.3
'@typescript-eslint/eslint-plugin@8.41.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)': '@typescript-eslint/eslint-plugin@8.41.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.1 '@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) '@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)
'@typescript-eslint/scope-manager': 8.41.0 '@typescript-eslint/scope-manager': 8.41.0
'@typescript-eslint/type-utils': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) '@typescript-eslint/type-utils': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) '@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)
'@typescript-eslint/visitor-keys': 8.41.0 '@typescript-eslint/visitor-keys': 8.41.0
eslint: 9.34.0(jiti@2.6.1) eslint: 9.34.0(jiti@2.4.2)
graphemer: 1.4.0 graphemer: 1.4.0
ignore: 7.0.5 ignore: 7.0.5
natural-compare: 1.4.0 natural-compare: 1.4.0
@@ -12587,14 +12556,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)': '@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.41.0 '@typescript-eslint/scope-manager': 8.41.0
'@typescript-eslint/types': 8.41.0 '@typescript-eslint/types': 8.41.0
'@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2) '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2)
'@typescript-eslint/visitor-keys': 8.41.0 '@typescript-eslint/visitor-keys': 8.41.0
debug: 4.4.3 debug: 4.4.3
eslint: 9.34.0(jiti@2.6.1) eslint: 9.34.0(jiti@2.4.2)
typescript: 5.9.2 typescript: 5.9.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -12617,13 +12586,13 @@ snapshots:
dependencies: dependencies:
typescript: 5.9.2 typescript: 5.9.2
'@typescript-eslint/type-utils@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)': '@typescript-eslint/type-utils@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)':
dependencies: dependencies:
'@typescript-eslint/types': 8.41.0 '@typescript-eslint/types': 8.41.0
'@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2) '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2)
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) '@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)
debug: 4.4.3 debug: 4.4.3
eslint: 9.34.0(jiti@2.6.1) eslint: 9.34.0(jiti@2.4.2)
ts-api-utils: 2.1.0(typescript@5.9.2) ts-api-utils: 2.1.0(typescript@5.9.2)
typescript: 5.9.2 typescript: 5.9.2
transitivePeerDependencies: transitivePeerDependencies:
@@ -12647,13 +12616,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)': '@typescript-eslint/utils@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.4.2))
'@typescript-eslint/scope-manager': 8.41.0 '@typescript-eslint/scope-manager': 8.41.0
'@typescript-eslint/types': 8.41.0 '@typescript-eslint/types': 8.41.0
'@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2) '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2)
eslint: 9.34.0(jiti@2.6.1) eslint: 9.34.0(jiti@2.4.2)
typescript: 5.9.2 typescript: 5.9.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -13979,17 +13948,17 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.34.0(jiti@2.6.1)): eslint-module-utils@2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.34.0(jiti@2.4.2)):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) '@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)
eslint: 9.34.0(jiti@2.6.1) eslint: 9.34.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)): eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.34.0(jiti@2.4.2)):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
@@ -13998,9 +13967,9 @@ snapshots:
array.prototype.flatmap: 1.3.3 array.prototype.flatmap: 1.3.3
debug: 3.2.7 debug: 3.2.7
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 9.34.0(jiti@2.6.1) eslint: 9.34.0(jiti@2.4.2)
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.34.0(jiti@2.6.1)) eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.34.0(jiti@2.4.2))
hasown: 2.0.2 hasown: 2.0.2
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3
@@ -14012,13 +13981,13 @@ snapshots:
string.prototype.trimend: 1.0.9 string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0 tsconfig-paths: 3.15.0
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) '@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)
transitivePeerDependencies: transitivePeerDependencies:
- eslint-import-resolver-typescript - eslint-import-resolver-typescript
- eslint-import-resolver-webpack - eslint-import-resolver-webpack
- supports-color - supports-color
eslint-plugin-jsx-a11y@6.10.2(eslint@9.34.0(jiti@2.6.1)): eslint-plugin-jsx-a11y@6.10.2(eslint@9.34.0(jiti@2.4.2)):
dependencies: dependencies:
aria-query: 5.3.2 aria-query: 5.3.2
array-includes: 3.1.9 array-includes: 3.1.9
@@ -14028,7 +13997,7 @@ snapshots:
axobject-query: 4.1.0 axobject-query: 4.1.0
damerau-levenshtein: 1.0.8 damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2 emoji-regex: 9.2.2
eslint: 9.34.0(jiti@2.6.1) eslint: 9.34.0(jiti@2.4.2)
hasown: 2.0.2 hasown: 2.0.2
jsx-ast-utils: 3.3.5 jsx-ast-utils: 3.3.5
language-tags: 1.0.9 language-tags: 1.0.9
@@ -14037,11 +14006,11 @@ snapshots:
safe-regex-test: 1.1.0 safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1 string.prototype.includes: 2.0.1
eslint-plugin-react-hooks@5.2.0(eslint@9.34.0(jiti@2.6.1)): eslint-plugin-react-hooks@5.2.0(eslint@9.34.0(jiti@2.4.2)):
dependencies: dependencies:
eslint: 9.34.0(jiti@2.6.1) eslint: 9.34.0(jiti@2.4.2)
eslint-plugin-react@7.37.5(eslint@9.34.0(jiti@2.6.1)): eslint-plugin-react@7.37.5(eslint@9.34.0(jiti@2.4.2)):
dependencies: dependencies:
array-includes: 3.1.9 array-includes: 3.1.9
array.prototype.findlast: 1.2.5 array.prototype.findlast: 1.2.5
@@ -14049,7 +14018,7 @@ snapshots:
array.prototype.tosorted: 1.1.4 array.prototype.tosorted: 1.1.4
doctrine: 2.1.0 doctrine: 2.1.0
es-iterator-helpers: 1.2.1 es-iterator-helpers: 1.2.1
eslint: 9.34.0(jiti@2.6.1) eslint: 9.34.0(jiti@2.4.2)
estraverse: 5.3.0 estraverse: 5.3.0
hasown: 2.0.2 hasown: 2.0.2
jsx-ast-utils: 3.3.5 jsx-ast-utils: 3.3.5
@@ -14063,10 +14032,10 @@ snapshots:
string.prototype.matchall: 4.0.12 string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0 string.prototype.repeat: 1.0.0
eslint-plugin-turbo@2.5.6(eslint@9.34.0(jiti@2.6.1))(turbo@2.5.6): eslint-plugin-turbo@2.5.6(eslint@9.34.0(jiti@2.4.2))(turbo@2.5.6):
dependencies: dependencies:
dotenv: 16.0.3 dotenv: 16.0.3
eslint: 9.34.0(jiti@2.6.1) eslint: 9.34.0(jiti@2.4.2)
turbo: 2.5.6 turbo: 2.5.6
eslint-scope@5.1.1: eslint-scope@5.1.1:
@@ -14125,9 +14094,9 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint@9.34.0(jiti@2.6.1): eslint@9.34.0(jiti@2.4.2):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.4.2))
'@eslint-community/regexpp': 4.12.1 '@eslint-community/regexpp': 4.12.1
'@eslint/config-array': 0.21.0 '@eslint/config-array': 0.21.0
'@eslint/config-helpers': 0.3.1 '@eslint/config-helpers': 0.3.1
@@ -14163,7 +14132,7 @@ snapshots:
natural-compare: 1.4.0 natural-compare: 1.4.0
optionator: 0.9.4 optionator: 0.9.4
optionalDependencies: optionalDependencies:
jiti: 2.6.1 jiti: 2.4.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -14954,8 +14923,6 @@ snapshots:
jiti@2.4.2: {} jiti@2.4.2: {}
jiti@2.6.1: {}
jose@6.1.2: {} jose@6.1.2: {}
jose@6.1.3: {} jose@6.1.3: {}
@@ -17718,13 +17685,13 @@ snapshots:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10 reflect.getprototypeof: 1.0.10
typescript-eslint@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2): typescript-eslint@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.41.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) '@typescript-eslint/eslint-plugin': 8.41.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)
'@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) '@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)
'@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2) '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2)
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) '@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)
eslint: 9.34.0(jiti@2.6.1) eslint: 9.34.0(jiti@2.4.2)
typescript: 5.9.2 typescript: 5.9.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color